From c3b6a6cd5f93a601dc35de2eeb06f278db011502 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D0=BB=D0=B0=D0=B2=D0=B0?= Date: Thu, 6 Nov 2025 19:16:00 +0400 Subject: [PATCH 1/4] spring-cloud --- core/main-service/Dockerfile | 4 + core/main-service/pom.xml | 137 + .../src/main/java/ru/practicum/Main.java | 14 + .../controller/CategoryAdminController.java | 62 + .../controller/CategoryPublicController.java | 40 + .../practicum/category/dto/CategoryDto.java | 22 + .../category/mapper/CategoryMapper.java | 28 + .../ru/practicum/category/model/Category.java | 34 + .../repository/CategoryRepository.java | 10 + .../service/CategoryAdminService.java | 13 + .../service/CategoryAdminServiceImpl.java | 65 + .../service/CategoryPublicService.java | 13 + .../service/CategoryPublicServiceImpl.java | 45 + .../controller/CommentAdminController.java | 63 + .../controller/CommentPrivateController.java | 49 + .../controller/CommentPublicController.java | 45 + .../comment/dto/CommentCountDto.java | 17 + .../comment/dto/CommentCreateDto.java | 19 + .../ru/practicum/comment/dto/CommentDto.java | 35 + .../comment/dto/CommentShortDto.java | 25 + .../comment/mapper/CommentMapper.java | 49 + .../ru/practicum/comment/model/Comment.java | 47 + .../comment/repository/CommentRepository.java | 31 + .../comment/service/CommentAdminService.java | 18 + .../service/CommentAdminServiceImpl.java | 83 + .../service/CommentPrivateService.java | 13 + .../service/CommentPrivateServiceImpl.java | 91 + .../comment/service/CommentPublicService.java | 15 + .../service/CommentPublicServiceImpl.java | 84 + .../CompilationAdminController.java | 55 + .../CompilationPublicController.java | 44 + .../compilation/dto/CompilationDto.java | 25 + .../compilation/dto/NewCompilationDto.java | 29 + .../compilation/dto/UpdateCompilationDto.java | 30 + .../compilation/mapper/CompilationMapper.java | 33 + .../compilation/model/Compilation.java | 49 + .../repository/CompilationRepository.java | 19 + .../service/CompilationAdminService.java | 15 + .../service/CompilationAdminServiceImpl.java | 72 + .../service/CompilationPublicService.java | 13 + .../service/CompilationPublicServiceImpl.java | 44 + .../controller/EventAdminController.java | 66 + .../controller/EventPrivateController.java | 74 + .../controller/EventPublicController.java | 65 + .../practicum/event/dto/EventAdminParams.java | 31 + .../practicum/event/dto/EventCommentDto.java | 17 + .../ru/practicum/event/dto/EventFullDto.java | 50 + .../ru/practicum/event/dto/EventParams.java | 35 + .../dto/EventRequestStatusUpdateRequest.java | 14 + .../dto/EventRequestStatusUpdateResult.java | 15 + .../ru/practicum/event/dto/EventShortDto.java | 35 + .../ru/practicum/event/dto/EventSort.java | 5 + .../ru/practicum/event/dto/LocationDto.java | 17 + .../ru/practicum/event/dto/NewEventDto.java | 42 + .../java/ru/practicum/event/dto/State.java | 5 + .../ru/practicum/event/dto/StateAction.java | 8 + .../practicum/event/dto/UpdateEventDto.java | 51 + .../practicum/event/mapper/EventMapper.java | 88 + .../event/mapper/LocationMapper.java | 24 + .../java/ru/practicum/event/model/Event.java | 76 + .../ru/practicum/event/model/Location.java | 18 + .../java/ru/practicum/event/model/View.java | 27 + .../event/repository/EventRepository.java | 22 + .../event/repository/JpaSpecifications.java | 78 + .../event/repository/ViewRepository.java | 26 + .../event/service/EventAdminService.java | 13 + .../event/service/EventAdminServiceImpl.java | 116 + .../event/service/EventPrivateService.java | 20 + .../service/EventPrivateServiceImpl.java | 159 + .../event/service/EventPublicService.java | 16 + .../event/service/EventPublicServiceImpl.java | 118 + .../java/ru/practicum/exception/ApiError.java | 36 + .../exception/BadRequestException.java | 21 + .../exception/ConflictException.java | 21 + .../exception/ForbiddenException.java | 21 + .../exception/GlobalExceptionHandler.java | 167 + .../exception/NotFoundException.java | 21 + .../request/controller/RequestController.java | 74 + .../EventRequestStatusUpdateRequestDto.java | 27 + .../EventRequestStatusUpdateResultDto.java | 23 + .../request/dto/ParticipationRequestDto.java | 28 + .../dto/ParticipationRequestStatus.java | 7 + .../request/mapper/RequestMapper.java | 30 + .../ru/practicum/request/model/Request.java | 40 + .../request/repository/RequestRepository.java | 50 + .../request/service/RequestService.java | 198 ++ .../serialize/LocalDateTimeDeserializer.java | 34 + .../serialize/LocalDateTimeSerializer.java | 32 + .../user/controller/UserController.java | 53 + .../practicum/user/dto/NewUserRequestDto.java | 26 + .../java/ru/practicum/user/dto/UserDto.java | 20 + .../ru/practicum/user/dto/UserShortDto.java | 18 + .../ru/practicum/user/mapper/UserMapper.java | 32 + .../java/ru/practicum/user/model/User.java | 26 + .../user/repository/UserRepository.java | 10 + .../practicum/user/service/UserService.java | 62 + .../src/main/java/ru/practicum/util/Util.java | 23 + .../validation/AtLeastOneNotNull.java | 24 + .../AtLeastOneNotNullValidator.java | 28 + .../validation/CategoryCreateValidator.java | 26 + .../validation/CategoryUpdateValidator.java | 26 + .../validation/CreateOrUpdateValidator.java | 10 + .../NewCompilationCreateValidator.java | 26 + .../NewCompilationUpdateValidator.java | 26 + .../validation/NotBlankButNullAllowed.java | 22 + .../NotBlankButNullAllowedValidator.java | 13 + .../src/main/resources/application.yaml | 25 + .../src/main/resources/schema.sql | 76 + .../ru/practicum/client/StatClientTest.java | 48 + .../controller/UserControllerTest.java | 426 +++ .../service/UserServiceIntegrationTest.java | 365 +++ core/pom.xml | 19 + docker-compose.yml | 60 +- ewm-feature-comments-spec.json | 1004 ++++++ infra/config-server/pom.xml | 67 + .../ru/practicum/ConfigServerApplication.java | 15 + .../src/main/resources/application.yaml | 43 + .../config/gateway-server/application.yaml | 27 + .../config/main-service/application.yaml | 43 + .../config/stats-server/application.yaml | 40 + infra/discovery-server/pom.xml | 63 + .../practicum/DiscoveryServerApplication.java | 15 + .../src/main/resources/application.yaml | 30 + infra/gateway-server/pom.xml | 87 + .../practicum/GatewayServerApplication.java | 13 + .../src/main/resources/application.yaml | 25 + infra/pom.xml | 21 + pom.xml | 200 +- postman/feature.json | 2848 +++++++++++++++++ stats/pom.xml | 21 + stats/stats-client/pom.xml | 56 + .../practicum/ewm/client/RestStatClient.java | 142 + .../ru/practicum/ewm/client/StatClient.java | 21 + stats/stats-common/pom.xml | 65 + .../main/java/ru/practicum/EventHitDto.java | 31 + .../ru/practicum/EventStatsResponseDto.java | 20 + .../ru/practicum/exception/ErrorResponse.java | 23 + .../serialize/LocalDateTimeDeserializer.java | 35 + .../serialize/LocalDateTimeSerializer.java | 33 + .../validation/StringToBooleanConverter.java | 18 + .../StringToLocalDateTimeConverter.java | 32 + .../ru/practicum/validation/WebConfig.java | 20 + stats/stats-server/Dockerfile | 4 + stats/stats-server/pom.xml | 133 + .../ru/practicum/GlobalExceptionHandler.java | 97 + .../main/java/ru/practicum/StatServer.java | 13 + .../practicum/controller/StatsController.java | 42 + .../main/java/ru/practicum/model/Stat.java | 34 + .../ru/practicum/model/mapper/StatMapper.java | 17 + .../repository/StatServiceRepository.java | 40 + .../ru/practicum/service/StatsService.java | 21 + .../practicum/service/StatsServiceImpl.java | 57 + .../src/main/resources/application.yaml | 25 + .../src/main/resources/schema.sql | 8 + .../jsontest/EventHitDtoJsonTest.java | 182 ++ .../mockmvc/StatsControllerTest.java | 585 ++++ .../service/StatsServiceImplTest.java | 358 +++ 157 files changed, 11761 insertions(+), 163 deletions(-) create mode 100644 core/main-service/Dockerfile create mode 100644 core/main-service/pom.xml create mode 100644 core/main-service/src/main/java/ru/practicum/Main.java create mode 100644 core/main-service/src/main/java/ru/practicum/category/controller/CategoryAdminController.java create mode 100644 core/main-service/src/main/java/ru/practicum/category/controller/CategoryPublicController.java create mode 100644 core/main-service/src/main/java/ru/practicum/category/dto/CategoryDto.java create mode 100644 core/main-service/src/main/java/ru/practicum/category/mapper/CategoryMapper.java create mode 100644 core/main-service/src/main/java/ru/practicum/category/model/Category.java create mode 100644 core/main-service/src/main/java/ru/practicum/category/repository/CategoryRepository.java create mode 100644 core/main-service/src/main/java/ru/practicum/category/service/CategoryAdminService.java create mode 100644 core/main-service/src/main/java/ru/practicum/category/service/CategoryAdminServiceImpl.java create mode 100644 core/main-service/src/main/java/ru/practicum/category/service/CategoryPublicService.java create mode 100644 core/main-service/src/main/java/ru/practicum/category/service/CategoryPublicServiceImpl.java create mode 100644 core/main-service/src/main/java/ru/practicum/comment/controller/CommentAdminController.java create mode 100644 core/main-service/src/main/java/ru/practicum/comment/controller/CommentPrivateController.java create mode 100644 core/main-service/src/main/java/ru/practicum/comment/controller/CommentPublicController.java create mode 100644 core/main-service/src/main/java/ru/practicum/comment/dto/CommentCountDto.java create mode 100644 core/main-service/src/main/java/ru/practicum/comment/dto/CommentCreateDto.java create mode 100644 core/main-service/src/main/java/ru/practicum/comment/dto/CommentDto.java create mode 100644 core/main-service/src/main/java/ru/practicum/comment/dto/CommentShortDto.java create mode 100644 core/main-service/src/main/java/ru/practicum/comment/mapper/CommentMapper.java create mode 100644 core/main-service/src/main/java/ru/practicum/comment/model/Comment.java create mode 100644 core/main-service/src/main/java/ru/practicum/comment/repository/CommentRepository.java create mode 100644 core/main-service/src/main/java/ru/practicum/comment/service/CommentAdminService.java create mode 100644 core/main-service/src/main/java/ru/practicum/comment/service/CommentAdminServiceImpl.java create mode 100644 core/main-service/src/main/java/ru/practicum/comment/service/CommentPrivateService.java create mode 100644 core/main-service/src/main/java/ru/practicum/comment/service/CommentPrivateServiceImpl.java create mode 100644 core/main-service/src/main/java/ru/practicum/comment/service/CommentPublicService.java create mode 100644 core/main-service/src/main/java/ru/practicum/comment/service/CommentPublicServiceImpl.java create mode 100644 core/main-service/src/main/java/ru/practicum/compilation/controller/CompilationAdminController.java create mode 100644 core/main-service/src/main/java/ru/practicum/compilation/controller/CompilationPublicController.java create mode 100644 core/main-service/src/main/java/ru/practicum/compilation/dto/CompilationDto.java create mode 100644 core/main-service/src/main/java/ru/practicum/compilation/dto/NewCompilationDto.java create mode 100644 core/main-service/src/main/java/ru/practicum/compilation/dto/UpdateCompilationDto.java create mode 100644 core/main-service/src/main/java/ru/practicum/compilation/mapper/CompilationMapper.java create mode 100644 core/main-service/src/main/java/ru/practicum/compilation/model/Compilation.java create mode 100644 core/main-service/src/main/java/ru/practicum/compilation/repository/CompilationRepository.java create mode 100644 core/main-service/src/main/java/ru/practicum/compilation/service/CompilationAdminService.java create mode 100644 core/main-service/src/main/java/ru/practicum/compilation/service/CompilationAdminServiceImpl.java create mode 100644 core/main-service/src/main/java/ru/practicum/compilation/service/CompilationPublicService.java create mode 100644 core/main-service/src/main/java/ru/practicum/compilation/service/CompilationPublicServiceImpl.java create mode 100644 core/main-service/src/main/java/ru/practicum/event/controller/EventAdminController.java create mode 100644 core/main-service/src/main/java/ru/practicum/event/controller/EventPrivateController.java create mode 100644 core/main-service/src/main/java/ru/practicum/event/controller/EventPublicController.java create mode 100644 core/main-service/src/main/java/ru/practicum/event/dto/EventAdminParams.java create mode 100644 core/main-service/src/main/java/ru/practicum/event/dto/EventCommentDto.java create mode 100644 core/main-service/src/main/java/ru/practicum/event/dto/EventFullDto.java create mode 100644 core/main-service/src/main/java/ru/practicum/event/dto/EventParams.java create mode 100644 core/main-service/src/main/java/ru/practicum/event/dto/EventRequestStatusUpdateRequest.java create mode 100644 core/main-service/src/main/java/ru/practicum/event/dto/EventRequestStatusUpdateResult.java create mode 100644 core/main-service/src/main/java/ru/practicum/event/dto/EventShortDto.java create mode 100644 core/main-service/src/main/java/ru/practicum/event/dto/EventSort.java create mode 100644 core/main-service/src/main/java/ru/practicum/event/dto/LocationDto.java create mode 100644 core/main-service/src/main/java/ru/practicum/event/dto/NewEventDto.java create mode 100644 core/main-service/src/main/java/ru/practicum/event/dto/State.java create mode 100644 core/main-service/src/main/java/ru/practicum/event/dto/StateAction.java create mode 100644 core/main-service/src/main/java/ru/practicum/event/dto/UpdateEventDto.java create mode 100644 core/main-service/src/main/java/ru/practicum/event/mapper/EventMapper.java create mode 100644 core/main-service/src/main/java/ru/practicum/event/mapper/LocationMapper.java create mode 100644 core/main-service/src/main/java/ru/practicum/event/model/Event.java create mode 100644 core/main-service/src/main/java/ru/practicum/event/model/Location.java create mode 100644 core/main-service/src/main/java/ru/practicum/event/model/View.java create mode 100644 core/main-service/src/main/java/ru/practicum/event/repository/EventRepository.java create mode 100644 core/main-service/src/main/java/ru/practicum/event/repository/JpaSpecifications.java create mode 100644 core/main-service/src/main/java/ru/practicum/event/repository/ViewRepository.java create mode 100644 core/main-service/src/main/java/ru/practicum/event/service/EventAdminService.java create mode 100644 core/main-service/src/main/java/ru/practicum/event/service/EventAdminServiceImpl.java create mode 100644 core/main-service/src/main/java/ru/practicum/event/service/EventPrivateService.java create mode 100644 core/main-service/src/main/java/ru/practicum/event/service/EventPrivateServiceImpl.java create mode 100644 core/main-service/src/main/java/ru/practicum/event/service/EventPublicService.java create mode 100644 core/main-service/src/main/java/ru/practicum/event/service/EventPublicServiceImpl.java create mode 100644 core/main-service/src/main/java/ru/practicum/exception/ApiError.java create mode 100644 core/main-service/src/main/java/ru/practicum/exception/BadRequestException.java create mode 100644 core/main-service/src/main/java/ru/practicum/exception/ConflictException.java create mode 100644 core/main-service/src/main/java/ru/practicum/exception/ForbiddenException.java create mode 100644 core/main-service/src/main/java/ru/practicum/exception/GlobalExceptionHandler.java create mode 100644 core/main-service/src/main/java/ru/practicum/exception/NotFoundException.java create mode 100644 core/main-service/src/main/java/ru/practicum/request/controller/RequestController.java create mode 100644 core/main-service/src/main/java/ru/practicum/request/dto/EventRequestStatusUpdateRequestDto.java create mode 100644 core/main-service/src/main/java/ru/practicum/request/dto/EventRequestStatusUpdateResultDto.java create mode 100644 core/main-service/src/main/java/ru/practicum/request/dto/ParticipationRequestDto.java create mode 100644 core/main-service/src/main/java/ru/practicum/request/dto/ParticipationRequestStatus.java create mode 100644 core/main-service/src/main/java/ru/practicum/request/mapper/RequestMapper.java create mode 100644 core/main-service/src/main/java/ru/practicum/request/model/Request.java create mode 100644 core/main-service/src/main/java/ru/practicum/request/repository/RequestRepository.java create mode 100644 core/main-service/src/main/java/ru/practicum/request/service/RequestService.java create mode 100644 core/main-service/src/main/java/ru/practicum/serialize/LocalDateTimeDeserializer.java create mode 100644 core/main-service/src/main/java/ru/practicum/serialize/LocalDateTimeSerializer.java create mode 100644 core/main-service/src/main/java/ru/practicum/user/controller/UserController.java create mode 100644 core/main-service/src/main/java/ru/practicum/user/dto/NewUserRequestDto.java create mode 100644 core/main-service/src/main/java/ru/practicum/user/dto/UserDto.java create mode 100644 core/main-service/src/main/java/ru/practicum/user/dto/UserShortDto.java create mode 100644 core/main-service/src/main/java/ru/practicum/user/mapper/UserMapper.java create mode 100644 core/main-service/src/main/java/ru/practicum/user/model/User.java create mode 100644 core/main-service/src/main/java/ru/practicum/user/repository/UserRepository.java create mode 100644 core/main-service/src/main/java/ru/practicum/user/service/UserService.java create mode 100644 core/main-service/src/main/java/ru/practicum/util/Util.java create mode 100644 core/main-service/src/main/java/ru/practicum/validation/AtLeastOneNotNull.java create mode 100644 core/main-service/src/main/java/ru/practicum/validation/AtLeastOneNotNullValidator.java create mode 100644 core/main-service/src/main/java/ru/practicum/validation/CategoryCreateValidator.java create mode 100644 core/main-service/src/main/java/ru/practicum/validation/CategoryUpdateValidator.java create mode 100644 core/main-service/src/main/java/ru/practicum/validation/CreateOrUpdateValidator.java create mode 100644 core/main-service/src/main/java/ru/practicum/validation/NewCompilationCreateValidator.java create mode 100644 core/main-service/src/main/java/ru/practicum/validation/NewCompilationUpdateValidator.java create mode 100644 core/main-service/src/main/java/ru/practicum/validation/NotBlankButNullAllowed.java create mode 100644 core/main-service/src/main/java/ru/practicum/validation/NotBlankButNullAllowedValidator.java create mode 100644 core/main-service/src/main/resources/application.yaml create mode 100644 core/main-service/src/main/resources/schema.sql create mode 100644 core/main-service/src/test/java/ru/practicum/client/StatClientTest.java create mode 100644 core/main-service/src/test/java/ru/practicum/controller/UserControllerTest.java create mode 100644 core/main-service/src/test/java/ru/practicum/service/UserServiceIntegrationTest.java create mode 100644 core/pom.xml create mode 100644 ewm-feature-comments-spec.json create mode 100644 infra/config-server/pom.xml create mode 100644 infra/config-server/src/main/java/ru/practicum/ConfigServerApplication.java create mode 100644 infra/config-server/src/main/resources/application.yaml create mode 100644 infra/config-server/src/main/resources/config/gateway-server/application.yaml create mode 100644 infra/config-server/src/main/resources/config/main-service/application.yaml create mode 100644 infra/config-server/src/main/resources/config/stats-server/application.yaml create mode 100644 infra/discovery-server/pom.xml create mode 100644 infra/discovery-server/src/main/java/ru/practicum/DiscoveryServerApplication.java create mode 100644 infra/discovery-server/src/main/resources/application.yaml create mode 100644 infra/gateway-server/pom.xml create mode 100644 infra/gateway-server/src/main/java/ru/practicum/GatewayServerApplication.java create mode 100644 infra/gateway-server/src/main/resources/application.yaml create mode 100644 infra/pom.xml create mode 100644 postman/feature.json create mode 100644 stats/pom.xml create mode 100644 stats/stats-client/pom.xml create mode 100644 stats/stats-client/src/main/java/ru/practicum/ewm/client/RestStatClient.java create mode 100644 stats/stats-client/src/main/java/ru/practicum/ewm/client/StatClient.java create mode 100644 stats/stats-common/pom.xml create mode 100644 stats/stats-common/src/main/java/ru/practicum/EventHitDto.java create mode 100644 stats/stats-common/src/main/java/ru/practicum/EventStatsResponseDto.java create mode 100644 stats/stats-common/src/main/java/ru/practicum/exception/ErrorResponse.java create mode 100644 stats/stats-common/src/main/java/ru/practicum/serialize/LocalDateTimeDeserializer.java create mode 100644 stats/stats-common/src/main/java/ru/practicum/serialize/LocalDateTimeSerializer.java create mode 100644 stats/stats-common/src/main/java/ru/practicum/validation/StringToBooleanConverter.java create mode 100644 stats/stats-common/src/main/java/ru/practicum/validation/StringToLocalDateTimeConverter.java create mode 100644 stats/stats-common/src/main/java/ru/practicum/validation/WebConfig.java create mode 100644 stats/stats-server/Dockerfile create mode 100644 stats/stats-server/pom.xml create mode 100644 stats/stats-server/src/main/java/ru/practicum/GlobalExceptionHandler.java create mode 100644 stats/stats-server/src/main/java/ru/practicum/StatServer.java create mode 100644 stats/stats-server/src/main/java/ru/practicum/controller/StatsController.java create mode 100644 stats/stats-server/src/main/java/ru/practicum/model/Stat.java create mode 100644 stats/stats-server/src/main/java/ru/practicum/model/mapper/StatMapper.java create mode 100644 stats/stats-server/src/main/java/ru/practicum/repository/StatServiceRepository.java create mode 100644 stats/stats-server/src/main/java/ru/practicum/service/StatsService.java create mode 100644 stats/stats-server/src/main/java/ru/practicum/service/StatsServiceImpl.java create mode 100644 stats/stats-server/src/main/resources/application.yaml create mode 100644 stats/stats-server/src/main/resources/schema.sql create mode 100644 stats/stats-server/src/test/java/ru/practicum/jsontest/EventHitDtoJsonTest.java create mode 100644 stats/stats-server/src/test/java/ru/practicum/mockmvc/StatsControllerTest.java create mode 100644 stats/stats-server/src/test/java/ru/practicum/service/StatsServiceImplTest.java diff --git a/core/main-service/Dockerfile b/core/main-service/Dockerfile new file mode 100644 index 0000000..16eef7a --- /dev/null +++ b/core/main-service/Dockerfile @@ -0,0 +1,4 @@ +FROM amazoncorretto:21-alpine +LABEL authors="Слава" +COPY target/*.jar app.jar +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/core/main-service/pom.xml b/core/main-service/pom.xml new file mode 100644 index 0000000..dbb723f --- /dev/null +++ b/core/main-service/pom.xml @@ -0,0 +1,137 @@ + + + 4.0.0 + + + ru.practicum + core + 0.0.1-SNAPSHOT + + + main-service + + + + + + + ru.practicum + stats-client + + + + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + org.projectlombok + lombok + + + + org.springframework.boot + spring-boot-starter-actuator + + + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-validation + + + + javax.validation + validation-api + 2.0.1.Final + + + + + + org.postgresql + postgresql + + + + com.h2database + h2 + test + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + + org.springframework.cloud + spring-cloud-starter-netflix-eureka-client + + + + org.springframework.cloud + spring-cloud-starter-config + + + + org.springframework.retry + spring-retry + + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + + + + + + + + + diff --git a/core/main-service/src/main/java/ru/practicum/Main.java b/core/main-service/src/main/java/ru/practicum/Main.java new file mode 100644 index 0000000..52735de --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/Main.java @@ -0,0 +1,14 @@ +package ru.practicum; + + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; + +@EnableScheduling +@SpringBootApplication +public class Main { + public static void main(String[] args) { + SpringApplication.run(Main.class, args); + } +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/category/controller/CategoryAdminController.java b/core/main-service/src/main/java/ru/practicum/category/controller/CategoryAdminController.java new file mode 100644 index 0000000..a59d530 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/category/controller/CategoryAdminController.java @@ -0,0 +1,62 @@ +package ru.practicum.category.controller; + +import jakarta.validation.constraints.Positive; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.BindingResult; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import ru.practicum.category.dto.CategoryDto; +import ru.practicum.category.service.CategoryAdminService; +import ru.practicum.validation.CreateOrUpdateValidator; + +@RestController +@RequiredArgsConstructor +@Validated +@RequestMapping(path = "/admin/categories") +@Slf4j +public class CategoryAdminController { + + private final CategoryAdminService categoryAdminService; + + @PostMapping + public ResponseEntity addCategory( + @RequestBody @Validated(CreateOrUpdateValidator.Create.class) + CategoryDto requestCategory, + BindingResult bindingResult + ) { + log.info("Calling the POST request to /admin/categories endpoint"); + if (bindingResult.hasErrors()) { + log.error("Validation error with category name"); + return ResponseEntity.badRequest().body((requestCategory)); + } + return ResponseEntity + .status(HttpStatus.CREATED) + .body(categoryAdminService.createCategory(requestCategory)); + } + + @DeleteMapping("/{catId}") + public ResponseEntity deleteCategories( + @PathVariable @Positive Long catId + ) { + log.info("Calling the DELETE request to /admin/categories/{catId} endpoint"); + categoryAdminService.deleteCategory(catId); + return ResponseEntity + .status(HttpStatus.NO_CONTENT) + .body("Category deleted: " + catId); + } + + @PatchMapping("/{catId}") + public ResponseEntity updateCategory( + @PathVariable Long catId, + @RequestBody @Validated(CreateOrUpdateValidator.Update.class) CategoryDto categoryDto + ) { + log.info("Calling the PATCH request to /admin/categories/{catId} endpoint"); + return ResponseEntity + .status(HttpStatus.OK) + .body(categoryAdminService.updateCategory(catId, categoryDto)); + } + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/category/controller/CategoryPublicController.java b/core/main-service/src/main/java/ru/practicum/category/controller/CategoryPublicController.java new file mode 100644 index 0000000..8ee6349 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/category/controller/CategoryPublicController.java @@ -0,0 +1,40 @@ +package ru.practicum.category.controller; + +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import ru.practicum.category.dto.CategoryDto; +import ru.practicum.category.service.CategoryPublicService; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@Validated +@RequestMapping(path = "/categories") +@Slf4j +public class CategoryPublicController { + + private final CategoryPublicService service; + + @GetMapping + public ResponseEntity> readAllCategories( + @RequestParam(defaultValue = "0") @PositiveOrZero int from, + @RequestParam(defaultValue = "10") @Positive int size + ) { + log.info("Calling the POST request to - /categories - endpoint"); + return ResponseEntity.ok(service.readAllCategories(from, size)); + } + + @GetMapping("/{catId}") + public ResponseEntity readCategoryById( + @PathVariable Long catId + ) { + log.info("Calling the GET request to - /categories/{catId} - endpoint"); + return ResponseEntity.ok(service.readCategoryById(catId)); + } +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/category/dto/CategoryDto.java b/core/main-service/src/main/java/ru/practicum/category/dto/CategoryDto.java new file mode 100644 index 0000000..7fffa9e --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/category/dto/CategoryDto.java @@ -0,0 +1,22 @@ +package ru.practicum.category.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import ru.practicum.validation.CreateOrUpdateValidator; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CategoryDto { + + private Long id; + + @NotBlank(groups = {CreateOrUpdateValidator.Create.class, CreateOrUpdateValidator.Update.class}) + @Size(min = 1, max = 50, groups = {CreateOrUpdateValidator.Create.class, CreateOrUpdateValidator.Update.class}) + private String name; +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/category/mapper/CategoryMapper.java b/core/main-service/src/main/java/ru/practicum/category/mapper/CategoryMapper.java new file mode 100644 index 0000000..e675a57 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/category/mapper/CategoryMapper.java @@ -0,0 +1,28 @@ +package ru.practicum.category.mapper; + +import ru.practicum.category.dto.CategoryDto; +import ru.practicum.category.model.Category; + +import java.util.List; +import java.util.stream.Collectors; + +public class CategoryMapper { + + public static CategoryDto toCategoryDto(Category category) { + return CategoryDto.builder() + .id(category.getId()) + .name(category.getName()) + .build(); + } + + public static Category toCategories(CategoryDto categoryDto) { + return Category.builder() + .name(categoryDto.getName()) + .build(); + } + + public static List toListCategoriesDto(List list) { + return list.stream().map(CategoryMapper::toCategoryDto).collect(Collectors.toList()); + } + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/category/model/Category.java b/core/main-service/src/main/java/ru/practicum/category/model/Category.java new file mode 100644 index 0000000..6e4439c --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/category/model/Category.java @@ -0,0 +1,34 @@ +package ru.practicum.category.model; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; +import lombok.*; + +@Getter +@Setter +@Builder +@Entity +@AllArgsConstructor +@NoArgsConstructor +@Table(name = "categories") +public class Category { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "cat_name") + @Size(min = 1, max = 50) + @NotEmpty + private String name; + + @Override + public String toString() { + return "Categories{" + + "id=" + id + + ", name='" + name + '\'' + + '}'; + } +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/category/repository/CategoryRepository.java b/core/main-service/src/main/java/ru/practicum/category/repository/CategoryRepository.java new file mode 100644 index 0000000..539b9c8 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/category/repository/CategoryRepository.java @@ -0,0 +1,10 @@ +package ru.practicum.category.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import ru.practicum.category.model.Category; + +public interface CategoryRepository extends JpaRepository { + + boolean existsByName(String name); + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/category/service/CategoryAdminService.java b/core/main-service/src/main/java/ru/practicum/category/service/CategoryAdminService.java new file mode 100644 index 0000000..e5b1c18 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/category/service/CategoryAdminService.java @@ -0,0 +1,13 @@ +package ru.practicum.category.service; + +import ru.practicum.category.dto.CategoryDto; + +public interface CategoryAdminService { + + CategoryDto createCategory(CategoryDto requestCategory); + + void deleteCategory(Long catId); + + CategoryDto updateCategory(Long catId, CategoryDto categoryDto); + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/category/service/CategoryAdminServiceImpl.java b/core/main-service/src/main/java/ru/practicum/category/service/CategoryAdminServiceImpl.java new file mode 100644 index 0000000..4a9b694 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/category/service/CategoryAdminServiceImpl.java @@ -0,0 +1,65 @@ +package ru.practicum.category.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import ru.practicum.category.dto.CategoryDto; +import ru.practicum.category.mapper.CategoryMapper; +import ru.practicum.category.model.Category; +import ru.practicum.category.repository.CategoryRepository; +import ru.practicum.event.repository.EventRepository; +import ru.practicum.exception.ConflictException; +import ru.practicum.exception.NotFoundException; + +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional(readOnly = false) +public class CategoryAdminServiceImpl implements CategoryAdminService { + + private final CategoryRepository categoryRepository; + + private final EventRepository eventRepository; + + @Override + public CategoryDto createCategory(CategoryDto requestCategory) { + log.info("createCategories - invoked"); + if (categoryRepository.existsByName(requestCategory.getName())) { + log.error("Category name not unique {}", requestCategory.getName()); + throw new ConflictException("Category with this name already exists"); + } + Category result = categoryRepository.saveAndFlush(CategoryMapper.toCategories(requestCategory)); + log.info("Result: category - {} - saved", result.getName()); + return CategoryMapper.toCategoryDto(result); + } + + @Override + public void deleteCategory(Long catId) { + log.info("deleteCategories - invoked"); + if (!categoryRepository.existsById(catId)) { + log.error("Category with this id does not exist {}", catId); + throw new NotFoundException("Category with this id does not exist"); + } + if (eventRepository.existsByCategoryId(catId)) { + throw new ConflictException("Can't delete a category with associated events"); + } + log.info("Result: category with id - {} - deleted", catId); + categoryRepository.deleteById(catId); + } + + @Override + public CategoryDto updateCategory(Long catId, CategoryDto categoryDto) { + log.info("updateCategories - invoked"); + Category category = categoryRepository.findById(catId).orElseThrow(() + -> new NotFoundException("This Category not found")); + if (!category.getName().equals(categoryDto.getName()) && + categoryRepository.existsByName(categoryDto.getName())) { + log.error("Category with this name not unique: {}", categoryDto.getName()); + throw new ConflictException("Category with this name not unique: " + categoryDto.getName()); + } + category.setName(categoryDto.getName()); + log.info("Result: category - {} updated", category.getName()); + return CategoryMapper.toCategoryDto(category); + } +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/category/service/CategoryPublicService.java b/core/main-service/src/main/java/ru/practicum/category/service/CategoryPublicService.java new file mode 100644 index 0000000..de4cdd2 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/category/service/CategoryPublicService.java @@ -0,0 +1,13 @@ +package ru.practicum.category.service; + +import ru.practicum.category.dto.CategoryDto; + +import java.util.List; + +public interface CategoryPublicService { + + List readAllCategories(Integer from, Integer size); + + CategoryDto readCategoryById(Long catId); + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/category/service/CategoryPublicServiceImpl.java b/core/main-service/src/main/java/ru/practicum/category/service/CategoryPublicServiceImpl.java new file mode 100644 index 0000000..44374ab --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/category/service/CategoryPublicServiceImpl.java @@ -0,0 +1,45 @@ +package ru.practicum.category.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import ru.practicum.category.dto.CategoryDto; +import ru.practicum.category.mapper.CategoryMapper; +import ru.practicum.category.model.Category; +import ru.practicum.category.repository.CategoryRepository; +import ru.practicum.exception.NotFoundException; + +import java.util.List; + +import static ru.practicum.util.Util.createPageRequestAsc; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Slf4j +public class CategoryPublicServiceImpl implements CategoryPublicService { + + private final CategoryRepository repository; + + @Override + public List readAllCategories(Integer from, Integer size) { + log.info("readAllCategories - invoked"); + Page page = repository.findAll(createPageRequestAsc(from, size)); + List cat = page.getContent(); + log.info("Result: categories size = {}", cat.size()); + return CategoryMapper.toListCategoriesDto(cat); + } + + @Override + public CategoryDto readCategoryById(Long catId) { + log.info("readCategoryById - invoked"); + Category category = repository.findById(catId).orElseThrow(() -> { + log.error("Category with id = {} not exist", catId); + return new NotFoundException("Category not found"); + }); + log.info("Result: received a category - {}", category.getName()); + return CategoryMapper.toCategoryDto(category); + } +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/comment/controller/CommentAdminController.java b/core/main-service/src/main/java/ru/practicum/comment/controller/CommentAdminController.java new file mode 100644 index 0000000..1be0a1b --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/comment/controller/CommentAdminController.java @@ -0,0 +1,63 @@ +package ru.practicum.comment.controller; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Positive; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import ru.practicum.comment.dto.CommentDto; +import ru.practicum.comment.service.CommentAdminService; + +import java.util.List; + +@RestController +@RequestMapping(path = "/admin") +@RequiredArgsConstructor +@Slf4j +public class CommentAdminController { + + private final CommentAdminService service; + + @GetMapping("/comments/search") + public ResponseEntity> search(@RequestParam @NotBlank String text, + @RequestParam(defaultValue = "0") int from, + @RequestParam(defaultValue = "10") int size) { + log.info("Calling the GET request to /admin/comment/search endpoint"); + return ResponseEntity.ok(service.search(text, from, size)); + } + + @GetMapping("users/{userId}/comments") + public ResponseEntity> get(@PathVariable @Positive Long userId, + @RequestParam(defaultValue = "0") int from, + @RequestParam(defaultValue = "10") int size) { + log.info("Calling the GET request to admin/users/{userId}/comment endpoint"); + return ResponseEntity.ok(service.findAllByUserId(userId, from, size)); + } + + @DeleteMapping("comments/{comId}") + public ResponseEntity delete(@PathVariable @Positive Long comId) { + log.info("Calling the GET request to admin/comment/{comId} endpoint"); + service.delete(comId); + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } + + @PatchMapping("/comments/{comId}/approve") + public ResponseEntity approveComment(@PathVariable @Positive Long comId) { + log.info("Calling the PATCH request to /admin/comment/{comId}/approve endpoint"); + CommentDto commentDto = service.approveComment(comId); + return ResponseEntity + .status(HttpStatus.OK) + .body(commentDto); + } + + @PatchMapping("/comments/{comId}/reject") + public ResponseEntity rejectComment(@PathVariable @Positive Long comId) { + log.info("Calling the PATCH request to /admin/comment/{comId}/reject endpoint"); + CommentDto commentDto = service.rejectComment(comId); + return ResponseEntity + .status(HttpStatus.OK) + .body(commentDto); + } +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/comment/controller/CommentPrivateController.java b/core/main-service/src/main/java/ru/practicum/comment/controller/CommentPrivateController.java new file mode 100644 index 0000000..bac14f2 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/comment/controller/CommentPrivateController.java @@ -0,0 +1,49 @@ +package ru.practicum.comment.controller; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Positive; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import ru.practicum.comment.dto.CommentCreateDto; +import ru.practicum.comment.dto.CommentDto; +import ru.practicum.comment.service.CommentPrivateService; + +@RestController +@Validated +@RequiredArgsConstructor +@Slf4j +public class CommentPrivateController { + + private final CommentPrivateService service; + + @PostMapping("/users/{userId}/events/{eventId}/comments") + public ResponseEntity create(@PathVariable @Positive Long userId, + @PathVariable @Positive Long eventId, + @RequestBody @Valid CommentCreateDto commentCreateDto) { + log.info("Calling the GET request to /users/{userId}/events/{eventId}/comment endpoint"); + return ResponseEntity.status(HttpStatus.CREATED) + .body(service.createComment(userId, eventId, commentCreateDto)); + } + + @DeleteMapping("/users/{userId}/comments/{comId}") + public ResponseEntity delete(@PathVariable @Positive Long userId, + @PathVariable @Positive Long comId) { + log.info("Calling the GET request to /users/{userId}/comment/{comId} endpoint"); + service.deleteComment(userId, comId); + return ResponseEntity + .status(HttpStatus.NO_CONTENT) + .body("Comment deleted by user: " + comId); + } + + @PatchMapping("/users/{userId}/comments/{comId}") + public ResponseEntity patch(@PathVariable @Positive Long userId, + @PathVariable @Positive Long comId, + @RequestBody @Valid CommentCreateDto commentCreateDto) { + log.info("Calling the PATCH request to users/{userId}/comment/{comId} endpoint"); + return ResponseEntity.ok(service.patchComment(userId, comId, commentCreateDto)); + } +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/comment/controller/CommentPublicController.java b/core/main-service/src/main/java/ru/practicum/comment/controller/CommentPublicController.java new file mode 100644 index 0000000..5a97d7b --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/comment/controller/CommentPublicController.java @@ -0,0 +1,45 @@ +package ru.practicum.comment.controller; + +import jakarta.validation.constraints.Positive; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import ru.practicum.comment.dto.CommentDto; +import ru.practicum.comment.dto.CommentShortDto; +import ru.practicum.comment.service.CommentPublicService; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@Slf4j +public class CommentPublicController { + + private final CommentPublicService service; + + @GetMapping("/comments/{comId}") + public ResponseEntity getById(@PathVariable @Positive Long comId) { + log.info("Calling the GET request to /comments/{comId} endpoint"); + return ResponseEntity.ok(service.getComment(comId)); + } + + @GetMapping("/events/{eventId}/comments") + public ResponseEntity> getByEventId(@PathVariable @Positive Long eventId, + @RequestParam(defaultValue = "0") int from, + @RequestParam(defaultValue = "10") int size) { + log.info("Calling the GET request to /events/{eventId}/comments"); + return ResponseEntity.ok(service.getCommentsByEvent(eventId, from, size)); + } + + @GetMapping("/events/{eventId}/comments/{commentId}") + public ResponseEntity getByEventAndCommentId(@PathVariable @Positive Long eventId, + @PathVariable @Positive Long commentId) { + log.info("Calling the GET request to /events/{eventId}/comments/{commentId}"); + return ResponseEntity.ok(service.getCommentByEventAndCommentId(eventId, commentId)); + } + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/comment/dto/CommentCountDto.java b/core/main-service/src/main/java/ru/practicum/comment/dto/CommentCountDto.java new file mode 100644 index 0000000..fe16256 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/comment/dto/CommentCountDto.java @@ -0,0 +1,17 @@ +package ru.practicum.comment.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class CommentCountDto { + + private Long eventId; + private Long commentCount; + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/comment/dto/CommentCreateDto.java b/core/main-service/src/main/java/ru/practicum/comment/dto/CommentCreateDto.java new file mode 100644 index 0000000..4182509 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/comment/dto/CommentCreateDto.java @@ -0,0 +1,19 @@ +package ru.practicum.comment.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class CommentCreateDto { + + @NotBlank + @Size(min = 1, max = 1000) + private String text; +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/comment/dto/CommentDto.java b/core/main-service/src/main/java/ru/practicum/comment/dto/CommentDto.java new file mode 100644 index 0000000..1de7f27 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/comment/dto/CommentDto.java @@ -0,0 +1,35 @@ +package ru.practicum.comment.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import ru.practicum.event.dto.EventCommentDto; +import ru.practicum.user.dto.UserDto; + +import java.time.LocalDateTime; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class CommentDto { + + private Long id; + + private String text; + + private UserDto author; + + private EventCommentDto event; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime createTime; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime patchTime; + + private Boolean approved; + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/comment/dto/CommentShortDto.java b/core/main-service/src/main/java/ru/practicum/comment/dto/CommentShortDto.java new file mode 100644 index 0000000..a32bd4c --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/comment/dto/CommentShortDto.java @@ -0,0 +1,25 @@ +package ru.practicum.comment.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import ru.practicum.user.dto.UserDto; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class CommentShortDto { + + private Long id; + + private String text; + + private UserDto author; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private String createTime; + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/comment/mapper/CommentMapper.java b/core/main-service/src/main/java/ru/practicum/comment/mapper/CommentMapper.java new file mode 100644 index 0000000..b666a10 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/comment/mapper/CommentMapper.java @@ -0,0 +1,49 @@ +package ru.practicum.comment.mapper; + +import ru.practicum.comment.dto.CommentCreateDto; +import ru.practicum.comment.dto.CommentDto; +import ru.practicum.comment.dto.CommentShortDto; +import ru.practicum.comment.model.Comment; +import ru.practicum.event.mapper.EventMapper; +import ru.practicum.user.mapper.UserMapper; + +import java.util.List; +import java.util.stream.Collectors; + +public class CommentMapper { + + public static Comment toComment(CommentCreateDto commentDto) { + return Comment.builder() + .text(commentDto.getText()) + .build(); + } + + public static CommentDto toCommentDto(Comment comment) { + return CommentDto.builder() + .id(comment.getId()) + .author(UserMapper.toDto(comment.getAuthor())) + .event(EventMapper.toEventComment(comment.getEvent())) + .createTime(comment.getCreateTime()) + .text(comment.getText()) + .approved(comment.getApproved()) + .build(); + } + + public static List toListCommentDto(List list) { + return list.stream().map(CommentMapper::toCommentDto).collect(Collectors.toList()); + } + + public static CommentShortDto toCommentShortDto(Comment comment) { + return CommentShortDto.builder() + .author(UserMapper.toDto(comment.getAuthor())) + .createTime(comment.getText()) + .id(comment.getId()) + .text(comment.getText()) + .build(); + } + + public static List toListCommentShortDto(List list) { + return list.stream().map(CommentMapper::toCommentShortDto).collect(Collectors.toList()); + } + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/comment/model/Comment.java b/core/main-service/src/main/java/ru/practicum/comment/model/Comment.java new file mode 100644 index 0000000..76cdb2f --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/comment/model/Comment.java @@ -0,0 +1,47 @@ +package ru.practicum.comment.model; + +import jakarta.persistence.*; +import lombok.*; +import ru.practicum.event.model.Event; +import ru.practicum.user.model.User; + +import java.time.LocalDateTime; + +@Getter +@Setter +@Builder +@Entity +@AllArgsConstructor +@NoArgsConstructor +@Table(name = "comments") +public class Comment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "textual_content", length = 1000, nullable = false) + private String text; + + @ManyToOne + @JoinColumn(name = "author_id", nullable = false) + private User author; + + @ManyToOne + @JoinColumn(name = "event_id", nullable = false) + private Event event; + + @Column(name = "create_time", nullable = false) + private LocalDateTime createTime; + + @Column(name = "patch_time") + private LocalDateTime patchTime; + + @Column(name = "approved", nullable = false) + private Boolean approved; + + public boolean isApproved() { + return approved; + } + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/comment/repository/CommentRepository.java b/core/main-service/src/main/java/ru/practicum/comment/repository/CommentRepository.java new file mode 100644 index 0000000..21bbc6d --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/comment/repository/CommentRepository.java @@ -0,0 +1,31 @@ +package ru.practicum.comment.repository; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import ru.practicum.comment.dto.CommentCountDto; +import ru.practicum.comment.model.Comment; + +import java.util.List; +import java.util.Optional; + +public interface CommentRepository extends JpaRepository { + + Page findAllByEventId(Long eventId, Pageable pageable); + + @Query("select new ru.practicum.comment.dto.CommentCountDto(c.event.id, count(c.id)) " + + "from Comment as c " + + "where c.event.id in ?1 " + + "group by c.event.id") + List findAllCommentCount(List listEventId); + + @Query("select c " + + "from Comment as c " + + "where c.text ilike concat('%', ?1, '%')") + Page findAllByText(String text, Pageable pageable); + + Page findAllByAuthorId(Long userId, Pageable pageable); + + Optional findByEventIdAndId(Long eventId, Long commentId); +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/comment/service/CommentAdminService.java b/core/main-service/src/main/java/ru/practicum/comment/service/CommentAdminService.java new file mode 100644 index 0000000..707f1d1 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/comment/service/CommentAdminService.java @@ -0,0 +1,18 @@ +package ru.practicum.comment.service; + +import ru.practicum.comment.dto.CommentDto; + +import java.util.List; + +public interface CommentAdminService { + + void delete(Long comId); + + List search(String text, int from, int size); + + List findAllByUserId(Long userId, int from, int size); + + CommentDto approveComment(Long comId); + + CommentDto rejectComment(Long comId); +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/comment/service/CommentAdminServiceImpl.java b/core/main-service/src/main/java/ru/practicum/comment/service/CommentAdminServiceImpl.java new file mode 100644 index 0000000..af3018c --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/comment/service/CommentAdminServiceImpl.java @@ -0,0 +1,83 @@ +package ru.practicum.comment.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import ru.practicum.comment.dto.CommentDto; +import ru.practicum.comment.mapper.CommentMapper; +import ru.practicum.comment.model.Comment; +import ru.practicum.comment.repository.CommentRepository; +import ru.practicum.exception.NotFoundException; +import ru.practicum.user.repository.UserRepository; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional +public class CommentAdminServiceImpl implements CommentAdminService { + + private final CommentRepository repository; + private final UserRepository userRepository; + + @Override + public void delete(Long comId) { + log.info("admin delete - invoked"); + if (!repository.existsById(comId)) { + log.error("User with id = {} not exist", comId); + throw new NotFoundException("Comment not found"); + } + log.info("Result: comment with id = {} deleted", comId); + repository.deleteById(comId); + } + + @Override + public List search(String text, int from, int size) { + log.info("admin search - invoked"); + Pageable pageable = PageRequest.of(from / size, size); + Page page = repository.findAllByText(text, pageable); + List list = page.getContent(); + log.info("Result: list of comments size = {} ", list.size()); + return CommentMapper.toListCommentDto(list); + } + + @Override + public List findAllByUserId(Long userId, int from, int size) { + log.info("admin findAllByUserId - invoked"); + if (!userRepository.existsById(userId)) { + log.error("User with id = {} not exist", userId); + throw new NotFoundException("User not found"); + } + Pageable pageable = PageRequest.of(from / size, size); + Page page = repository.findAllByAuthorId(userId, pageable); + List list = page.getContent(); + log.info("Result: list of comments size = {} ", list.size()); + return CommentMapper.toListCommentDto(list); + } + + @Override + public CommentDto approveComment(Long comId) { + log.info("approveComment - invoked"); + Comment comment = repository.findById(comId) + .orElseThrow(() -> new NotFoundException("Comment not found")); + comment.setApproved(true); + repository.save(comment); + log.info("Result: comment with id = {} approved", comId); + return CommentMapper.toCommentDto(comment); + } + + @Override + public CommentDto rejectComment(Long comId) { + log.info("rejectComment - invoked"); + Comment comment = repository.findById(comId).orElseThrow(() -> new NotFoundException("Comment not found")); + comment.setApproved(false); + repository.save(comment); + log.info("Result: comment with id = {} rejected", comId); + return CommentMapper.toCommentDto(comment); + } +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/comment/service/CommentPrivateService.java b/core/main-service/src/main/java/ru/practicum/comment/service/CommentPrivateService.java new file mode 100644 index 0000000..1bf8dbd --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/comment/service/CommentPrivateService.java @@ -0,0 +1,13 @@ +package ru.practicum.comment.service; + +import ru.practicum.comment.dto.CommentCreateDto; +import ru.practicum.comment.dto.CommentDto; + +public interface CommentPrivateService { + + CommentDto createComment(Long userId, Long eventId, CommentCreateDto commentDto); + + void deleteComment(Long userId, Long comId); + + CommentDto patchComment(Long userId, Long comId, CommentCreateDto commentCreateDto); +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/comment/service/CommentPrivateServiceImpl.java b/core/main-service/src/main/java/ru/practicum/comment/service/CommentPrivateServiceImpl.java new file mode 100644 index 0000000..c0dce74 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/comment/service/CommentPrivateServiceImpl.java @@ -0,0 +1,91 @@ +package ru.practicum.comment.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import ru.practicum.comment.dto.CommentCreateDto; +import ru.practicum.comment.dto.CommentDto; +import ru.practicum.comment.mapper.CommentMapper; +import ru.practicum.comment.model.Comment; +import ru.practicum.comment.repository.CommentRepository; +import ru.practicum.event.dto.State; +import ru.practicum.event.model.Event; +import ru.practicum.event.repository.EventRepository; +import ru.practicum.exception.ConflictException; +import ru.practicum.exception.NotFoundException; +import ru.practicum.user.model.User; +import ru.practicum.user.repository.UserRepository; + +import java.time.LocalDateTime; + +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional +public class CommentPrivateServiceImpl implements CommentPrivateService { + + private final CommentRepository repository; + private final UserRepository userRepository; + private final EventRepository eventRepository; + + @Override + public CommentDto createComment(Long userId, Long eventId, CommentCreateDto commentDto) { + log.info("createComment - invoked"); + Comment comment = CommentMapper.toComment(commentDto); + User author = userRepository.findById(userId) + .orElseThrow(() -> { + log.error("User with id = {} - not registered", userId); + return new NotFoundException("Please register first then you can comment"); + }); + Event event = eventRepository.findById(eventId) + .orElseThrow(() -> { + log.error("Event with id = {} - not exist", eventId); + return new NotFoundException("Event not found"); + }); + if (!event.getState().equals(State.PUBLISHED)) { + log.error("Event state = {} - should be PUBLISHED", event.getState()); + throw new ConflictException("Event not published you cant comment it"); + } + comment.setAuthor(author); + comment.setEvent(event); + comment.setApproved(true); // по умолчанию комменты видны, но админ может удалить/вернуть + comment.setCreateTime(LocalDateTime.now().withNano(0)); + log.info("Result: new comment created"); + return CommentMapper.toCommentDto(repository.save(comment)); + } + + @Override + public void deleteComment(Long userId, Long comId) { + log.info("deleteComment - invoked"); + Comment comment = repository.findById(comId) + .orElseThrow(() -> { + log.error("Comment with id = {} - not exist", comId); + return new NotFoundException("Comment not found"); + }); + if (!comment.getAuthor().getId().equals(userId)) { + log.error("Unauthorized access by user"); + throw new ConflictException("you didn't write this comment and can't delete it"); + } + log.info("Result: comment with id = {} - deleted", comId); + repository.deleteById(comId); + } + + @Override + public CommentDto patchComment(Long userId, Long comId, CommentCreateDto commentCreateDto) { + log.info("patchComment - invoked"); + Comment comment = repository.findById(comId) + .orElseThrow(() -> { + log.error("Comment with id = {} - not exist", comId); + return new NotFoundException("Comment not found"); + }); + if (!comment.getAuthor().getId().equals(userId)) { + log.error("Unauthorized access by user"); + throw new ConflictException("you didn't write this comment and can't patch it"); + } + comment.setText(commentCreateDto.getText()); + comment.setPatchTime(LocalDateTime.now().withNano(0)); + log.info("Result: comment with id = {} - updated", comId); + return CommentMapper.toCommentDto(comment); + } +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/comment/service/CommentPublicService.java b/core/main-service/src/main/java/ru/practicum/comment/service/CommentPublicService.java new file mode 100644 index 0000000..2762467 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/comment/service/CommentPublicService.java @@ -0,0 +1,15 @@ +package ru.practicum.comment.service; + +import ru.practicum.comment.dto.CommentDto; +import ru.practicum.comment.dto.CommentShortDto; + +import java.util.List; + +public interface CommentPublicService { + + CommentDto getComment(Long comId); + + List getCommentsByEvent(Long eventId, int from, int size); + + CommentDto getCommentByEventAndCommentId(Long eventId, Long commentId); +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/comment/service/CommentPublicServiceImpl.java b/core/main-service/src/main/java/ru/practicum/comment/service/CommentPublicServiceImpl.java new file mode 100644 index 0000000..3464075 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/comment/service/CommentPublicServiceImpl.java @@ -0,0 +1,84 @@ +package ru.practicum.comment.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import ru.practicum.comment.dto.CommentDto; +import ru.practicum.comment.dto.CommentShortDto; +import ru.practicum.comment.mapper.CommentMapper; +import ru.practicum.comment.model.Comment; +import ru.practicum.comment.repository.CommentRepository; +import ru.practicum.event.repository.EventRepository; +import ru.practicum.exception.ForbiddenException; +import ru.practicum.exception.NotFoundException; + +import java.util.List; +import java.util.stream.Collectors; + +import static ru.practicum.util.Util.createPageRequestAsc; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +@Slf4j +public class CommentPublicServiceImpl implements CommentPublicService { + + private final CommentRepository repository; + private final EventRepository eventRepository; + + @Override + public CommentDto getComment(Long comId) { + log.info("getComment - invoked"); + Comment comment = repository.findById(comId) + .orElseThrow(() -> { + log.error("Comment with id = {} - not exist", comId); + return new NotFoundException("Comment not found"); + }); + if (!comment.isApproved()) { + log.warn("Comment with id = {} is not approved", comId); + throw new ForbiddenException("Comment is not approved"); + } + log.info("Result: comment with id= {}", comId); + return CommentMapper.toCommentDto(comment); + } + + @Override + public List getCommentsByEvent(Long eventId, int from, int size) { + log.info("getCommentsByEvent - invoked"); + if (!eventRepository.existsById(eventId)) { + log.error("Event with id = {} - not exist", eventId); + throw new NotFoundException("Event not found"); + } + Pageable pageable = createPageRequestAsc("createTime", from, size); + Page commentsPage = repository.findAllByEventId(eventId, pageable); + List comments = commentsPage.getContent(); + List approvedComments = comments.stream() + .filter(Comment::isApproved) + .collect(Collectors.toList()); + log.info("Result : list of approved comments size = {}", approvedComments.size()); + return CommentMapper.toListCommentShortDto(approvedComments); + } + + @Override + public CommentDto getCommentByEventAndCommentId(Long eventId, Long commentId) { + log.info("getCommentByEventAndCommentId - invoked"); + Comment comment = repository.findById(commentId) + .orElseThrow(() -> { + log.error("Comment with id = {} does not exist", commentId); + return new NotFoundException("Comment not found"); + }); + if (!comment.getEvent().getId().equals(eventId)) { + log.error("Comment with id = {} does not belong to event with id = {}", commentId, eventId); + throw new NotFoundException("Comment not found for the specified event"); + } + if (!comment.isApproved()) { + log.warn("Comment with id = {} is not approved", commentId); + throw new ForbiddenException("Comment is not approved"); + } + log.info("Result: comment with eventId= {} and commentId= {}", eventId, commentId); + return CommentMapper.toCommentDto(comment); + } +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/compilation/controller/CompilationAdminController.java b/core/main-service/src/main/java/ru/practicum/compilation/controller/CompilationAdminController.java new file mode 100644 index 0000000..1ecb2a9 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/compilation/controller/CompilationAdminController.java @@ -0,0 +1,55 @@ +package ru.practicum.compilation.controller; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import ru.practicum.compilation.dto.CompilationDto; +import ru.practicum.compilation.dto.NewCompilationDto; +import ru.practicum.compilation.dto.UpdateCompilationDto; +import ru.practicum.compilation.service.CompilationAdminService; + +@RestController +@Validated +@RequestMapping("/admin/compilations") +@RequiredArgsConstructor +@Slf4j +public class CompilationAdminController { + + private final CompilationAdminService compilationAdminService; + + @PostMapping + public ResponseEntity postCompilations( + @RequestBody @Valid NewCompilationDto newCompilationDto + ) { + log.info("Calling the POST request to /admin/compilations endpoint"); + return ResponseEntity + .status(HttpStatus.CREATED) + .body(compilationAdminService.createCompilation(newCompilationDto)); + } + + @DeleteMapping("/{compId}") + public ResponseEntity deleteCompilation( + @PathVariable Long compId + ) { + log.info("Calling the DELETE request to /admin/endpoint/{compId}"); + compilationAdminService.deleteCompilation(compId); + return ResponseEntity + .status(HttpStatus.NO_CONTENT) + .body("Compilation deleted: " + compId); + } + + @PatchMapping("/{compId}") + public ResponseEntity patchCompilation( + @PathVariable Long compId, + @RequestBody @Valid UpdateCompilationDto updateCompilationDto + ) { + log.info("Calling the PATCH request to /admin/compilations/{compId} endpoint"); + return ResponseEntity + .status(HttpStatus.OK) + .body(compilationAdminService.updateCompilation(compId, updateCompilationDto)); + } +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/compilation/controller/CompilationPublicController.java b/core/main-service/src/main/java/ru/practicum/compilation/controller/CompilationPublicController.java new file mode 100644 index 0000000..3021f0a --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/compilation/controller/CompilationPublicController.java @@ -0,0 +1,44 @@ +package ru.practicum.compilation.controller; + +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import ru.practicum.compilation.dto.CompilationDto; +import ru.practicum.compilation.service.CompilationPublicService; + +import java.util.List; + +@RestController +@Validated +@RequestMapping("/compilations") +@RequiredArgsConstructor +@Slf4j +public class CompilationPublicController { + + private final CompilationPublicService compilationPublicService; + + @GetMapping + public ResponseEntity> getCompilation( + @RequestParam(required = false) Boolean pinned, + @RequestParam(defaultValue = "0") @PositiveOrZero int from, + @RequestParam(defaultValue = "10") @Positive int size + ) { + log.info("Calling the GET request to /compilations endpoint"); + List list = compilationPublicService.readAllCompilations(pinned, from, size); + return ResponseEntity.ok(list); + } + + @GetMapping("/{compId}") + public ResponseEntity getCompilationById( + @PathVariable Long compId + ) { + log.info("Calling the GET request to /compilations/{compId} endpoint"); + CompilationDto response = compilationPublicService.readCompilationById(compId); + return ResponseEntity.ok(response); + } + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/compilation/dto/CompilationDto.java b/core/main-service/src/main/java/ru/practicum/compilation/dto/CompilationDto.java new file mode 100644 index 0000000..8078f28 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/compilation/dto/CompilationDto.java @@ -0,0 +1,25 @@ +package ru.practicum.compilation.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import ru.practicum.event.dto.EventShortDto; + +import java.util.List; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class CompilationDto { + + private List events; + + private Long id; + + private Boolean pinned; + + private String title; + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/compilation/dto/NewCompilationDto.java b/core/main-service/src/main/java/ru/practicum/compilation/dto/NewCompilationDto.java new file mode 100644 index 0000000..3dfbc8b --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/compilation/dto/NewCompilationDto.java @@ -0,0 +1,29 @@ +package ru.practicum.compilation.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.HashSet; +import java.util.Set; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class NewCompilationDto { + + @Builder.Default + private Set events = new HashSet<>(); + + @Builder.Default + private Boolean pinned = false; + + @NotBlank + @Size(min = 1, max = 50) + private String title; + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/compilation/dto/UpdateCompilationDto.java b/core/main-service/src/main/java/ru/practicum/compilation/dto/UpdateCompilationDto.java new file mode 100644 index 0000000..b655cf2 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/compilation/dto/UpdateCompilationDto.java @@ -0,0 +1,30 @@ +package ru.practicum.compilation.dto; + +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import ru.practicum.validation.AtLeastOneNotNull; +import ru.practicum.validation.NotBlankButNullAllowed; + +import java.util.HashSet; +import java.util.Set; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +@AtLeastOneNotNull(fields = {"events", "pinned", "title"}, message = "DTO has only null data fields") +public class UpdateCompilationDto { + + @Builder.Default + private Set events = new HashSet<>(); + + private Boolean pinned; + + @NotBlankButNullAllowed + @Size(min = 1, max = 50) + private String title; + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/compilation/mapper/CompilationMapper.java b/core/main-service/src/main/java/ru/practicum/compilation/mapper/CompilationMapper.java new file mode 100644 index 0000000..9635192 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/compilation/mapper/CompilationMapper.java @@ -0,0 +1,33 @@ +package ru.practicum.compilation.mapper; + +import ru.practicum.compilation.dto.CompilationDto; +import ru.practicum.compilation.model.Compilation; +import ru.practicum.event.dto.EventShortDto; +import ru.practicum.event.mapper.EventMapper; + +import java.util.List; +import java.util.stream.Collectors; + +public class CompilationMapper { + + public static CompilationDto toCompilationDto(Compilation compilation) { + List eventShortDtoList = compilation.getEvents().stream() + .map(event -> + EventMapper.toEventShortDto(event, 0L, 0L) + ).collect(Collectors.toList()); + + return CompilationDto.builder() + .id(compilation.getId()) + .pinned(compilation.getPinned()) + .title(compilation.getTitle()) + .events(eventShortDtoList) + .build(); + } + + public static List toCompilationDtoList(List compilations) { + return compilations.stream() + .map(CompilationMapper::toCompilationDto) + .collect(Collectors.toList()); + } + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/compilation/model/Compilation.java b/core/main-service/src/main/java/ru/practicum/compilation/model/Compilation.java new file mode 100644 index 0000000..e4e4004 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/compilation/model/Compilation.java @@ -0,0 +1,49 @@ +package ru.practicum.compilation.model; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; +import lombok.*; +import ru.practicum.event.model.Event; + +import java.util.Set; + +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "compilations") +public class Compilation { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "pinned") + private Boolean pinned; + + @Column(name = "title") + @Size(min = 1, max = 50) + @NotEmpty + private String title; + + @ManyToMany + @JoinTable(name = "compilations_events", + joinColumns = @JoinColumn(name = "compilations_id"), + inverseJoinColumns = @JoinColumn(name = "events_id")) + private Set events; + + @Override + public String toString() { + return "Compilations{" + + "id=" + id + + ", pinned=" + pinned + + ", title='" + title + '\'' + + ", events=" + events + + '}'; + } + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/compilation/repository/CompilationRepository.java b/core/main-service/src/main/java/ru/practicum/compilation/repository/CompilationRepository.java new file mode 100644 index 0000000..2d9e074 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/compilation/repository/CompilationRepository.java @@ -0,0 +1,19 @@ +package ru.practicum.compilation.repository; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import ru.practicum.compilation.model.Compilation; + +import java.util.List; + +public interface CompilationRepository extends JpaRepository { + + @Query("SELECT c " + + "FROM Compilation c " + + "WHERE c.pinned = ?1") + List findAllByPinned(Boolean pinned, Pageable pageable); + + boolean existsByTitle(String title); + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/compilation/service/CompilationAdminService.java b/core/main-service/src/main/java/ru/practicum/compilation/service/CompilationAdminService.java new file mode 100644 index 0000000..ad82c21 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/compilation/service/CompilationAdminService.java @@ -0,0 +1,15 @@ +package ru.practicum.compilation.service; + +import ru.practicum.compilation.dto.CompilationDto; +import ru.practicum.compilation.dto.NewCompilationDto; +import ru.practicum.compilation.dto.UpdateCompilationDto; + +public interface CompilationAdminService { + + CompilationDto createCompilation(NewCompilationDto request); + + void deleteCompilation(Long compId); + + CompilationDto updateCompilation(Long compId, UpdateCompilationDto updateCompilationDto); + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/compilation/service/CompilationAdminServiceImpl.java b/core/main-service/src/main/java/ru/practicum/compilation/service/CompilationAdminServiceImpl.java new file mode 100644 index 0000000..c8d89ad --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/compilation/service/CompilationAdminServiceImpl.java @@ -0,0 +1,72 @@ +package ru.practicum.compilation.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import ru.practicum.compilation.dto.CompilationDto; +import ru.practicum.compilation.dto.NewCompilationDto; +import ru.practicum.compilation.dto.UpdateCompilationDto; +import ru.practicum.compilation.mapper.CompilationMapper; +import ru.practicum.compilation.model.Compilation; +import ru.practicum.compilation.repository.CompilationRepository; +import ru.practicum.event.model.Event; +import ru.practicum.event.repository.EventRepository; +import ru.practicum.exception.NotFoundException; + +import java.util.HashSet; +import java.util.Set; + +@Service +@Transactional +@RequiredArgsConstructor +@Slf4j +public class CompilationAdminServiceImpl implements CompilationAdminService { + + private final CompilationRepository compilationRepository; + private final EventRepository eventRepository; + + @Override + public CompilationDto createCompilation(NewCompilationDto request) { + log.info("createCompilation - invoked"); + Set events; + events = (request.getEvents() != null && !request.getEvents().isEmpty()) ? + new HashSet<>(eventRepository.findAllById(request.getEvents())) : new HashSet<>(); + Compilation compilation = Compilation.builder() + .pinned(request.getPinned() != null && request.getPinned()) + .title(request.getTitle()) + .events(events) + .build(); + return CompilationMapper.toCompilationDto(compilationRepository.save(compilation)); + } + + @Override + public void deleteCompilation(Long compId) { + log.info("deleteCompilation(- invoked"); + if (!compilationRepository.existsById(compId)) { + throw new NotFoundException("Compilation Not Found"); + } + log.info("Result: compilation with id {} deleted ", compId); + compilationRepository.deleteById(compId); + } + + @Override + public CompilationDto updateCompilation(Long compId, UpdateCompilationDto updateCompilationDto) { + log.info("updateCompilation - invoked"); + Compilation compilation = compilationRepository.findById(compId) + .orElseThrow(() -> new NotFoundException("Compilation with id " + compId + " not found")); + if (updateCompilationDto.getTitle() != null) { + compilation.setTitle(updateCompilationDto.getTitle()); + } + if (updateCompilationDto.getPinned() != null) { + compilation.setPinned(updateCompilationDto.getPinned()); + } + if (updateCompilationDto.getEvents() != null && !updateCompilationDto.getEvents().isEmpty()) { + HashSet events = new HashSet<>(eventRepository.findAllById(updateCompilationDto.getEvents())); + compilation.setEvents(events); + } + Compilation updatedCompilation = compilationRepository.save(compilation); + log.info("Result: compilation with id {} updated ", compId); + return CompilationMapper.toCompilationDto(updatedCompilation); + } +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/compilation/service/CompilationPublicService.java b/core/main-service/src/main/java/ru/practicum/compilation/service/CompilationPublicService.java new file mode 100644 index 0000000..ab3283e --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/compilation/service/CompilationPublicService.java @@ -0,0 +1,13 @@ +package ru.practicum.compilation.service; + +import ru.practicum.compilation.dto.CompilationDto; + +import java.util.List; + +public interface CompilationPublicService { + + CompilationDto readCompilationById(Long compId); + + List readAllCompilations(Boolean pinned, int from, int size); + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/compilation/service/CompilationPublicServiceImpl.java b/core/main-service/src/main/java/ru/practicum/compilation/service/CompilationPublicServiceImpl.java new file mode 100644 index 0000000..58df11c --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/compilation/service/CompilationPublicServiceImpl.java @@ -0,0 +1,44 @@ +package ru.practicum.compilation.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import ru.practicum.compilation.dto.CompilationDto; +import ru.practicum.compilation.mapper.CompilationMapper; +import ru.practicum.compilation.model.Compilation; +import ru.practicum.compilation.repository.CompilationRepository; +import ru.practicum.exception.NotFoundException; + +import java.util.List; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +@Slf4j +public class CompilationPublicServiceImpl implements CompilationPublicService { + + private final CompilationRepository compilationRepository; + + @Override + public CompilationDto readCompilationById(Long compId) { + log.info("readCompilationById - invoked"); + Compilation compilation = compilationRepository.findById(compId).orElseThrow(() -> + new NotFoundException("Compilation not found")); + log.info("Result: {}", compilation); + return CompilationMapper.toCompilationDto(compilation); + } + + @Override + public List readAllCompilations(Boolean pinned, int from, int size) { + Pageable pageable = PageRequest.of(from, size, Sort.Direction.ASC, "id"); + List compilations; + compilations = (pinned == null) ? compilationRepository.findAll(pageable).getContent() : + compilationRepository.findAllByPinned(pinned, pageable); + log.info("Result: {}", compilations); + return CompilationMapper.toCompilationDtoList(compilations); + } +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/event/controller/EventAdminController.java b/core/main-service/src/main/java/ru/practicum/event/controller/EventAdminController.java new file mode 100644 index 0000000..6097494 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/event/controller/EventAdminController.java @@ -0,0 +1,66 @@ +package ru.practicum.event.controller; + +import com.fasterxml.jackson.annotation.JsonFormat; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import ru.practicum.event.dto.EventAdminParams; +import ru.practicum.event.dto.EventFullDto; +import ru.practicum.event.dto.State; +import ru.practicum.event.dto.UpdateEventDto; +import ru.practicum.event.service.EventAdminService; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; + +@RestController +@RequestMapping("/admin/events") +@RequiredArgsConstructor +@Slf4j +@Validated +public class EventAdminController { + + private final EventAdminService eventAdminService; + + // Поиск событий + @GetMapping + Collection getAllEventsByParams( + @RequestParam(required = false) List users, + @RequestParam(required = false) List states, + @RequestParam(required = false) List categories, + @RequestParam(required = false) @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime rangeStart, + @RequestParam(required = false) @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime rangeEnd, + @RequestParam(defaultValue = "0") @PositiveOrZero Long from, + @RequestParam(defaultValue = "10") @Positive Long size + ) { + EventAdminParams params = EventAdminParams.builder() + .users(users) + .states(states) + .categories(categories) + .rangeStart(rangeStart) + .rangeEnd(rangeEnd) + .from(from) + .size(size) + .build(); + + log.info("Calling to endpoint /admin/events GetMapping for params: " + params.toString()); + return eventAdminService.getAllEventsByParams(params); + } + + // Редактирование данных события и его статуса (отклонение/публикация). + @PatchMapping("/{eventId}") + EventFullDto updateEventByAdmin( + @PathVariable Long eventId, + @RequestBody @Valid UpdateEventDto updateEventDto + ) { + log.info("Calling to endpoint /admin/events/{eventId} PatchMapping for eventId: " + eventId + "." + + " UpdateEvent: " + updateEventDto.toString()); + return eventAdminService.updateEventByAdmin(eventId, updateEventDto); + } + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/event/controller/EventPrivateController.java b/core/main-service/src/main/java/ru/practicum/event/controller/EventPrivateController.java new file mode 100644 index 0000000..ac70da1 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/event/controller/EventPrivateController.java @@ -0,0 +1,74 @@ +package ru.practicum.event.controller; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Positive; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import ru.practicum.event.dto.EventFullDto; +import ru.practicum.event.dto.EventShortDto; +import ru.practicum.event.dto.NewEventDto; +import ru.practicum.event.dto.UpdateEventDto; +import ru.practicum.event.service.EventPrivateService; + +import java.util.Collection; + +@RestController +@RequestMapping("/users/{userId}/events") +@RequiredArgsConstructor +@Slf4j +@Validated +public class EventPrivateController { + + private final EventPrivateService eventPrivateService; + + // Добавление нового события + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + EventFullDto addNewEventByUser( + @PathVariable @Positive Long userId, + @Valid @RequestBody NewEventDto newEventDto + ) { + log.info("Calling to endpoint /users/{userId}/events PostMapping for userId: " + userId); + return eventPrivateService.addEvent(userId, newEventDto); + } + + // Получение событий, добавленных текущим пользователем + @GetMapping + Collection getAllEventsByUserId( + @PathVariable @Positive Long userId, + @RequestParam(defaultValue = "0") Long from, + @RequestParam(defaultValue = "10") Long size + ) { + log.info("Calling to endpoint /users/{userId}/events GetMapping for userId: " + userId); + return eventPrivateService.getEventsByUserId(userId, from, size); + } + + // Получение полной информации о событии добавленном текущим пользователем + @GetMapping("/{eventId}") + EventFullDto getEventByUserIdAndEventId( + @PathVariable @Positive Long userId, + @PathVariable @Positive Long eventId + ) { + log.info("Calling to endpoint /users/{userId}/events/{eventId} GetMapping for userId: " + + userId + " and eventId: " + eventId); + return eventPrivateService.getEventByUserIdAndEventId(userId, eventId); + } + + // Изменение события добавленного текущим пользователем + @PatchMapping("/{eventId}") + EventFullDto updateEventByUserIdAndEventId( + @PathVariable @Positive Long userId, + @PathVariable @Positive Long eventId, + @Valid @RequestBody UpdateEventDto updateEventDto + ) { + log.info("Calling to endpoint /users/{userId}/events/{eventId} PatchMapping for userId: " + userId + + " and eventId: " + eventId + "." + + "Information by eventDto: " + updateEventDto.toString()); + return eventPrivateService.updateEventByUserIdAndEventId(userId, eventId, updateEventDto); + } + + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/event/controller/EventPublicController.java b/core/main-service/src/main/java/ru/practicum/event/controller/EventPublicController.java new file mode 100644 index 0000000..7a6eeb9 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/event/controller/EventPublicController.java @@ -0,0 +1,65 @@ +package ru.practicum.event.controller; + +import com.fasterxml.jackson.annotation.JsonFormat; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.constraints.Positive; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; +import ru.practicum.event.dto.EventFullDto; +import ru.practicum.event.dto.EventParams; +import ru.practicum.event.dto.EventShortDto; +import ru.practicum.event.dto.EventSort; +import ru.practicum.event.service.EventPublicService; + +import java.time.LocalDateTime; +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/events") +@Slf4j +public class EventPublicController { + + private final EventPublicService eventPublicService; + + // Получение событий с возможностью фильтрации + @GetMapping + List getAllEventsByParams( + @RequestParam(required = false) String text, + @RequestParam(required = false) List categories, + @RequestParam(required = false) Boolean paid, + @RequestParam(required = false) @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime rangeStart, + @RequestParam(required = false) @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime rangeEnd, + @RequestParam(defaultValue = "false") Boolean onlyAvailable, + @RequestParam(defaultValue = "EVENT_DATE") EventSort eventSort, + @RequestParam(defaultValue = "0") Long from, + @RequestParam(defaultValue = "10") Long size, + HttpServletRequest request + ) { + EventParams params = EventParams.builder() + .text(text) + .categories(categories) + .paid(paid) + .rangeStart(rangeStart) + .rangeEnd(rangeEnd) + .onlyAvailable(onlyAvailable) + .eventSort(eventSort) + .from(from) + .size(size) + .build(); + log.info("Calling to endpoint /events GetMapping for params: " + params.toString()); + return eventPublicService.getAllEventsByParams(params, request); + } + + // Получение подробной информации об опубликованном событии по его идентификатору + @GetMapping("/{id}") + EventFullDto getInformationAboutEventByEventId( + @PathVariable @Positive Long id, + HttpServletRequest request + ) { + log.info("Calling to endpoint /events/{id} GetMapping for eventId: " + id); + return eventPublicService.getEventById(id, request); + } + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/event/dto/EventAdminParams.java b/core/main-service/src/main/java/ru/practicum/event/dto/EventAdminParams.java new file mode 100644 index 0000000..d09fe38 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/event/dto/EventAdminParams.java @@ -0,0 +1,31 @@ +package ru.practicum.event.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class EventAdminParams { + + private List users; + + private List states; + + private List categories; + + private LocalDateTime rangeStart; + + private LocalDateTime rangeEnd; + + private Long from; + + private Long size; + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/event/dto/EventCommentDto.java b/core/main-service/src/main/java/ru/practicum/event/dto/EventCommentDto.java new file mode 100644 index 0000000..d8aeb73 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/event/dto/EventCommentDto.java @@ -0,0 +1,17 @@ +package ru.practicum.event.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class EventCommentDto { + + private Long id; + + private String title; +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/event/dto/EventFullDto.java b/core/main-service/src/main/java/ru/practicum/event/dto/EventFullDto.java new file mode 100644 index 0000000..e892531 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/event/dto/EventFullDto.java @@ -0,0 +1,50 @@ +package ru.practicum.event.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import ru.practicum.category.dto.CategoryDto; +import ru.practicum.comment.dto.CommentShortDto; +import ru.practicum.user.dto.UserShortDto; + +import java.time.LocalDateTime; +import java.util.List; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class EventFullDto { + + private Long id; + + private UserShortDto initiator; + private CategoryDto category; + + private String title; + private String annotation; + private String description; + + private State state; + + private LocationDto location; + + private Long participantLimit; + private Boolean requestModeration; + private Boolean paid; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime eventDate; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime publishedOn; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime createdOn; + + private Long confirmedRequests; + private Long views; + + private List comments; + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/event/dto/EventParams.java b/core/main-service/src/main/java/ru/practicum/event/dto/EventParams.java new file mode 100644 index 0000000..411ce9c --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/event/dto/EventParams.java @@ -0,0 +1,35 @@ +package ru.practicum.event.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class EventParams { + + private String text; + + private List categories; + + private Boolean paid; + + private LocalDateTime rangeStart; + + private LocalDateTime rangeEnd; + + private Boolean onlyAvailable; + + private EventSort eventSort; + + private Long from; + + private Long size; + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/event/dto/EventRequestStatusUpdateRequest.java b/core/main-service/src/main/java/ru/practicum/event/dto/EventRequestStatusUpdateRequest.java new file mode 100644 index 0000000..3fbc1a3 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/event/dto/EventRequestStatusUpdateRequest.java @@ -0,0 +1,14 @@ +package ru.practicum.event.dto; + +import lombok.Data; + +import java.util.List; + +@Data +public class EventRequestStatusUpdateRequest { + + private List requestIds; + + private State state; + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/event/dto/EventRequestStatusUpdateResult.java b/core/main-service/src/main/java/ru/practicum/event/dto/EventRequestStatusUpdateResult.java new file mode 100644 index 0000000..4f5c254 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/event/dto/EventRequestStatusUpdateResult.java @@ -0,0 +1,15 @@ +package ru.practicum.event.dto; + +import lombok.Data; +import ru.practicum.request.dto.ParticipationRequestDto; + +import java.util.List; + +@Data +public class EventRequestStatusUpdateResult { + + private List confirmedRequests; + + private List rejectedRequests; + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/event/dto/EventShortDto.java b/core/main-service/src/main/java/ru/practicum/event/dto/EventShortDto.java new file mode 100644 index 0000000..8b3a31a --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/event/dto/EventShortDto.java @@ -0,0 +1,35 @@ +package ru.practicum.event.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import ru.practicum.category.dto.CategoryDto; +import ru.practicum.user.dto.UserShortDto; + +import java.time.LocalDateTime; + +@AllArgsConstructor +@Builder +@Data +@NoArgsConstructor +public class EventShortDto { + + private Long id; + + private UserShortDto initiator; + private CategoryDto category; + + private String title; + private String annotation; + + private Boolean paid; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime eventDate; + + private Long confirmedRequests; + private Long views; + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/event/dto/EventSort.java b/core/main-service/src/main/java/ru/practicum/event/dto/EventSort.java new file mode 100644 index 0000000..56e6dc6 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/event/dto/EventSort.java @@ -0,0 +1,5 @@ +package ru.practicum.event.dto; + +public enum EventSort { + EVENT_DATE, VIEWS +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/event/dto/LocationDto.java b/core/main-service/src/main/java/ru/practicum/event/dto/LocationDto.java new file mode 100644 index 0000000..687d4ee --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/event/dto/LocationDto.java @@ -0,0 +1,17 @@ +package ru.practicum.event.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class LocationDto { + + private Float lat; + private Float lon; + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/event/dto/NewEventDto.java b/core/main-service/src/main/java/ru/practicum/event/dto/NewEventDto.java new file mode 100644 index 0000000..09e385d --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/event/dto/NewEventDto.java @@ -0,0 +1,42 @@ +package ru.practicum.event.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import jakarta.validation.constraints.*; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +public class NewEventDto { + + @NotNull + @Positive + private Long category; + + @NotBlank + @Size(min = 3, max = 120) + private String title; + + @NotBlank + @Size(min = 20, max = 2000) + private String annotation; + + @NotBlank + @Size(min = 20, max = 7000) + private String description; + + private LocationDto location; + + private Boolean requestModeration = true; + + private Boolean paid = false; + + @PositiveOrZero + private Long participantLimit = 0L; + + @NotNull + @Future(message = "Event should be in future") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime eventDate; + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/event/dto/State.java b/core/main-service/src/main/java/ru/practicum/event/dto/State.java new file mode 100644 index 0000000..8d04843 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/event/dto/State.java @@ -0,0 +1,5 @@ +package ru.practicum.event.dto; + +public enum State { + PENDING, PUBLISHED, CANCELED +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/event/dto/StateAction.java b/core/main-service/src/main/java/ru/practicum/event/dto/StateAction.java new file mode 100644 index 0000000..b6ee34d --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/event/dto/StateAction.java @@ -0,0 +1,8 @@ +package ru.practicum.event.dto; + +public enum StateAction { + SEND_TO_REVIEW, + CANCEL_REVIEW, + PUBLISH_EVENT, + REJECT_EVENT +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/event/dto/UpdateEventDto.java b/core/main-service/src/main/java/ru/practicum/event/dto/UpdateEventDto.java new file mode 100644 index 0000000..f73ee20 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/event/dto/UpdateEventDto.java @@ -0,0 +1,51 @@ +package ru.practicum.event.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import jakarta.validation.constraints.Future; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import ru.practicum.validation.AtLeastOneNotNull; + +import java.time.LocalDateTime; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +@AtLeastOneNotNull(fields = {"category", "title", "annotation", "description", "location", "paid", + "participantLimit", "requestModeration", "stateAction", "eventDate"}) +public class UpdateEventDto { + + @Positive + private Long category; + + @Size(min = 3, max = 120) + private String title; + + @Size(min = 20, max = 2000) + private String annotation; + + @Size(min = 20, max = 7000) + private String description; + + private LocationDto location; + + private Boolean paid; + + @PositiveOrZero + private Long participantLimit; + + private Boolean requestModeration; + + private StateAction stateAction; + + @Future(message = "Event should be in future") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime eventDate; + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/event/mapper/EventMapper.java b/core/main-service/src/main/java/ru/practicum/event/mapper/EventMapper.java new file mode 100644 index 0000000..170356d --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/event/mapper/EventMapper.java @@ -0,0 +1,88 @@ +package ru.practicum.event.mapper; + +import ru.practicum.category.mapper.CategoryMapper; +import ru.practicum.category.model.Category; +import ru.practicum.event.dto.*; +import ru.practicum.event.model.Event; +import ru.practicum.user.mapper.UserMapper; +import ru.practicum.user.model.User; + +import java.time.LocalDateTime; + +public class EventMapper { + + + public static Event toEvent( + NewEventDto newEventDto, + User initiator, + Category category + ) { + return Event.builder() + .initiator(initiator) + .category(category) + .title(newEventDto.getTitle()) + .annotation(newEventDto.getAnnotation()) + .description(newEventDto.getDescription()) + .state(State.PENDING) + .location(LocationMapper.toEntity(newEventDto.getLocation())) + .participantLimit(newEventDto.getParticipantLimit()) + .requestModeration(newEventDto.getRequestModeration()) + .paid(newEventDto.getPaid()) + .eventDate(newEventDto.getEventDate()) + .createdOn(LocalDateTime.now()) + .build(); + } + + + public static EventFullDto toEventFullDto( + Event event, + Long confirmedRequests, + Long views + ) { + if (confirmedRequests == null) confirmedRequests = 0L; + return EventFullDto.builder() + .id(event.getId()) + .initiator(UserMapper.toUserShortDto(event.getInitiator())) + .category(CategoryMapper.toCategoryDto(event.getCategory())) + .title(event.getTitle()) + .annotation(event.getAnnotation()) + .description(event.getDescription()) + .state(event.getState()) + .location(LocationMapper.toDto(event.getLocation())) + .participantLimit(event.getParticipantLimit()) + .requestModeration(event.getRequestModeration()) + .paid(event.getPaid()) + .eventDate(event.getEventDate()) + .publishedOn(event.getPublishedOn()) + .createdOn(event.getCreatedOn()) + .confirmedRequests(confirmedRequests) + .views(views) + .build(); + } + + public static EventShortDto toEventShortDto( + Event event, + Long confirmedRequests, + Long views + ) { + if (confirmedRequests == null) confirmedRequests = 0L; + return EventShortDto.builder() + .id(event.getId()) + .initiator(UserMapper.toUserShortDto(event.getInitiator())) + .category(CategoryMapper.toCategoryDto(event.getCategory())) + .title(event.getTitle()) + .annotation(event.getAnnotation()) + .paid(event.getPaid()) + .eventDate(event.getEventDate()) + .confirmedRequests(confirmedRequests) + .views(views) + .build(); + } + + public static EventCommentDto toEventComment(Event event) { + return EventCommentDto.builder() + .id(event.getId()) + .title(event.getTitle()) + .build(); + } +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/event/mapper/LocationMapper.java b/core/main-service/src/main/java/ru/practicum/event/mapper/LocationMapper.java new file mode 100644 index 0000000..7d4f61a --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/event/mapper/LocationMapper.java @@ -0,0 +1,24 @@ +package ru.practicum.event.mapper; + +import ru.practicum.event.dto.LocationDto; +import ru.practicum.event.model.Location; + +public class LocationMapper { + + public static Location toEntity(LocationDto dto) { + if (dto == null) return null; + return Location.builder() + .lat(dto.getLat()) + .lon(dto.getLon()) + .build(); + } + + public static LocationDto toDto(Location location) { + if (location == null) return null; + return LocationDto.builder() + .lat(location.getLat()) + .lon(location.getLon()) + .build(); + } + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/event/model/Event.java b/core/main-service/src/main/java/ru/practicum/event/model/Event.java new file mode 100644 index 0000000..fb8ea7c --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/event/model/Event.java @@ -0,0 +1,76 @@ +package ru.practicum.event.model; + +import jakarta.persistence.*; +import lombok.*; +import ru.practicum.category.model.Category; +import ru.practicum.comment.model.Comment; +import ru.practicum.event.dto.State; +import ru.practicum.request.model.Request; +import ru.practicum.user.model.User; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "events") +public class Event { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @ManyToOne + @JoinColumn(name = "initiator", nullable = false) + private User initiator; + + @ManyToOne + @JoinColumn(name = "categories_id", nullable = false) + private Category category; + + @Column(name = "title", length = 120, nullable = false) + private String title; + + @Column(name = "annotation", length = 2000, nullable = false) + private String annotation; + + @Column(name = "description", length = 7000, nullable = false) + private String description; + + @Enumerated(EnumType.STRING) + @Column(name = "state", length = 20, nullable = false) + private State state; + + @Embedded + private Location location; + + @Column(name = "participant_limit", nullable = false) + private Long participantLimit; + + @Column(name = "request_moderation", nullable = false) + private Boolean requestModeration; + + @Column(name = "paid", nullable = false) + private Boolean paid; + + @Column(name = "event_date", nullable = false) + private LocalDateTime eventDate; + + @Column(name = "published_on") + private LocalDateTime publishedOn; + + @Column(name = "created_on", nullable = false) + private LocalDateTime createdOn; + + @OneToMany(mappedBy = "event", fetch = FetchType.LAZY) + private List requests; + + @OneToMany(mappedBy = "event", fetch = FetchType.LAZY) + private List comments; + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/event/model/Location.java b/core/main-service/src/main/java/ru/practicum/event/model/Location.java new file mode 100644 index 0000000..44cbad3 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/event/model/Location.java @@ -0,0 +1,18 @@ +package ru.practicum.event.model; + +import jakarta.persistence.Embeddable; +import lombok.*; + +@Getter +@Setter +@Embeddable +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class Location { + + private Float lat; + + private Float lon; + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/event/model/View.java b/core/main-service/src/main/java/ru/practicum/event/model/View.java new file mode 100644 index 0000000..0945701 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/event/model/View.java @@ -0,0 +1,27 @@ +package ru.practicum.event.model; + +import jakarta.persistence.*; +import lombok.*; + +@Getter +@Setter +@Builder +@AllArgsConstructor +@NoArgsConstructor +@Entity +@Table(name = "views") +public class View { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @ManyToOne + @JoinColumn(name = "event_id") + private Event event; + + @Column(name = "ip", length = 15, nullable = false) + private String ip; + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/event/repository/EventRepository.java b/core/main-service/src/main/java/ru/practicum/event/repository/EventRepository.java new file mode 100644 index 0000000..9ccec1d --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/event/repository/EventRepository.java @@ -0,0 +1,22 @@ +package ru.practicum.event.repository; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import ru.practicum.event.dto.State; +import ru.practicum.event.model.Event; + +import java.util.List; +import java.util.Optional; + +public interface EventRepository extends JpaRepository, JpaSpecificationExecutor { + + Optional findByIdAndInitiatorId(Long eventId, Long initiatorId); + + List findByInitiatorId(Long initiatorId, Pageable pageable); + + Optional findByIdAndState(Long id, State state); + + boolean existsByCategoryId(Long catId); + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/event/repository/JpaSpecifications.java b/core/main-service/src/main/java/ru/practicum/event/repository/JpaSpecifications.java new file mode 100644 index 0000000..4ca46d0 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/event/repository/JpaSpecifications.java @@ -0,0 +1,78 @@ +package ru.practicum.event.repository; + +import jakarta.persistence.criteria.Join; +import jakarta.persistence.criteria.JoinType; +import jakarta.persistence.criteria.Predicate; +import org.springframework.data.jpa.domain.Specification; +import ru.practicum.event.dto.EventAdminParams; +import ru.practicum.event.dto.EventParams; +import ru.practicum.event.model.Event; +import ru.practicum.request.dto.ParticipationRequestStatus; +import ru.practicum.request.model.Request; + +import java.util.ArrayList; +import java.util.List; + +public class JpaSpecifications { + + public static Specification adminFilters(EventAdminParams params) { + return (root, query, cb) -> { + List predicates = new ArrayList<>(); + + if (params.getUsers() != null && !params.getUsers().isEmpty()) + predicates.add(root.get("initiator").get("id").in(params.getUsers())); + + if (params.getStates() != null && !params.getStates().isEmpty()) + predicates.add(root.get("state").in(params.getStates())); + + if (params.getCategories() != null && !params.getCategories().isEmpty()) + predicates.add(root.get("category").get("id").in(params.getCategories())); + + if (params.getRangeStart() != null) + predicates.add(cb.greaterThanOrEqualTo(root.get("eventDate"), params.getRangeStart())); + + if (params.getRangeEnd() != null) + predicates.add(cb.lessThanOrEqualTo(root.get("eventDate"), params.getRangeEnd())); + + return cb.and(predicates.toArray(new Predicate[0])); + }; + } + + public static Specification publicFilters(EventParams params) { + return (root, query, cb) -> { + List predicates = new ArrayList<>(); + + if (params.getText() != null && !params.getText().isEmpty()) { + String searchPattern = "%" + params.getText().toLowerCase() + "%"; + Predicate annotationPredicate = cb.like(cb.lower(root.get("annotation")), searchPattern); + Predicate descriptionPredicate = cb.like(cb.lower(root.get("description")), searchPattern); + predicates.add(cb.or(annotationPredicate, descriptionPredicate)); + } + + if (params.getCategories() != null && !params.getCategories().isEmpty()) + predicates.add(root.get("category").get("id").in(params.getCategories())); + + if (params.getPaid() != null) predicates.add(cb.equal(root.get("paid"), params.getPaid())); + + if (params.getRangeStart() != null) + predicates.add(cb.greaterThanOrEqualTo(root.get("eventDate"), params.getRangeStart())); + + if (params.getRangeEnd() != null) + predicates.add(cb.lessThanOrEqualTo(root.get("eventDate"), params.getRangeEnd())); + + if (params.getOnlyAvailable() == true) { + Join requestJoin = root.join("requests", JoinType.LEFT); + requestJoin.on(cb.equal(requestJoin.get("status"), ParticipationRequestStatus.CONFIRMED)); + query.groupBy(root.get("id")); + + Predicate unlimitedPredicate = cb.equal(root.get("participantLimit"), 0); + Predicate hasFreeSeatsPredicate = cb.greaterThan(root.get("participantLimit"), cb.count(requestJoin)); + query.having(cb.or(unlimitedPredicate, hasFreeSeatsPredicate)); + } + + return cb.and(predicates.toArray(new Predicate[0])); + }; + } + + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/event/repository/ViewRepository.java b/core/main-service/src/main/java/ru/practicum/event/repository/ViewRepository.java new file mode 100644 index 0000000..f94c538 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/event/repository/ViewRepository.java @@ -0,0 +1,26 @@ +package ru.practicum.event.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import ru.practicum.event.model.View; + +import java.util.List; + +public interface ViewRepository extends JpaRepository { + + long countByEventId(Long eventId); + + @Query(""" + SELECT v.event.id, count(v) + FROM View v + WHERE v.event.id IN :eventIds + GROUP BY v.event.id + """) + List countsByEventIds( + @Param("eventIds") List eventIds + ); + + boolean existsByEventIdAndIp(Long eventId, String ip); + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/event/service/EventAdminService.java b/core/main-service/src/main/java/ru/practicum/event/service/EventAdminService.java new file mode 100644 index 0000000..b1d6495 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/event/service/EventAdminService.java @@ -0,0 +1,13 @@ +package ru.practicum.event.service; + +import ru.practicum.event.dto.EventAdminParams; +import ru.practicum.event.dto.EventFullDto; +import ru.practicum.event.dto.UpdateEventDto; + +import java.util.List; + +public interface EventAdminService { + List getAllEventsByParams(EventAdminParams eventAdminParams); + + EventFullDto updateEventByAdmin(Long eventId, UpdateEventDto updateEventDto); +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/event/service/EventAdminServiceImpl.java b/core/main-service/src/main/java/ru/practicum/event/service/EventAdminServiceImpl.java new file mode 100644 index 0000000..1ee7e93 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/event/service/EventAdminServiceImpl.java @@ -0,0 +1,116 @@ +package ru.practicum.event.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import ru.practicum.category.model.Category; +import ru.practicum.category.repository.CategoryRepository; +import ru.practicum.event.dto.*; +import ru.practicum.event.mapper.EventMapper; +import ru.practicum.event.mapper.LocationMapper; +import ru.practicum.event.model.Event; +import ru.practicum.event.repository.EventRepository; +import ru.practicum.event.repository.JpaSpecifications; +import ru.practicum.event.repository.ViewRepository; +import ru.practicum.exception.ConflictException; +import ru.practicum.exception.NotFoundException; +import ru.practicum.request.dto.ParticipationRequestStatus; +import ru.practicum.request.repository.RequestRepository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class EventAdminServiceImpl implements EventAdminService { + + private final EventRepository eventRepository; + private final CategoryRepository categoryRepository; + private final RequestRepository requestRepository; + private final ViewRepository viewRepository; + + // Поиск событий + @Override + public List getAllEventsByParams(EventAdminParams params) { + Pageable pageable = PageRequest.of( + params.getFrom().intValue() / params.getSize().intValue(), + params.getSize().intValue() + ); + List events = eventRepository.findAll(JpaSpecifications.adminFilters(params), pageable).getContent(); + + List eventIds = events.stream().map(Event::getId).toList(); + Map confirmedRequestsMap = requestRepository.getConfirmedRequestsByEventIds(eventIds) + .stream() + .collect(Collectors.toMap( + r -> (Long) r[0], + r -> (Long) r[1] + )); + Map viewsMap = viewRepository.countsByEventIds(eventIds) + .stream() + .collect(Collectors.toMap( + r -> (Long) r[0], + r -> (Long) r[1] + )); + + return events.stream() + .map(e -> EventMapper.toEventFullDto(e, confirmedRequestsMap.get(e.getId()), viewsMap.get(e.getId()))) + .toList(); + } + + // Редактирование данных события и его статуса (отклонение/публикация). + @Override + @Transactional(readOnly = false) + public EventFullDto updateEventByAdmin(Long eventId, UpdateEventDto updateEventDto) { + Event event = eventRepository.findById(eventId) + .orElseThrow(() -> new NotFoundException("Event with id=" + eventId + " was not found")); + + if (updateEventDto.getCategory() != null) { + Category category = categoryRepository.findById(updateEventDto.getCategory()) + .orElseThrow(() -> new NotFoundException("Category with id=" + updateEventDto.getCategory() + " not found")); + event.setCategory(category); + } + + if (updateEventDto.getTitle() != null) event.setTitle(updateEventDto.getTitle()); + if (updateEventDto.getAnnotation() != null) event.setAnnotation(updateEventDto.getAnnotation()); + if (updateEventDto.getDescription() != null) event.setDescription(updateEventDto.getDescription()); + if (updateEventDto.getLocation() != null) + event.setLocation(LocationMapper.toEntity(updateEventDto.getLocation())); + if (updateEventDto.getPaid() != null) event.setPaid(updateEventDto.getPaid()); + if (updateEventDto.getParticipantLimit() != null) + event.setParticipantLimit(updateEventDto.getParticipantLimit()); + if (updateEventDto.getRequestModeration() != null) + event.setRequestModeration(updateEventDto.getRequestModeration()); + if (updateEventDto.getEventDate() != null) event.setEventDate(updateEventDto.getEventDate()); + + if (Objects.equals(updateEventDto.getStateAction(), StateAction.REJECT_EVENT)) { + // событие можно отклонить, только если оно еще не опубликовано (Ожидается код ошибки 409) + if (Objects.equals(event.getState(), State.PUBLISHED)) { + throw new ConflictException("Event in PUBLISHED state can not be rejected"); + } + event.setState(State.CANCELED); + } else if (Objects.equals(updateEventDto.getStateAction(), StateAction.PUBLISH_EVENT)) { + // дата начала изменяемого события должна быть не ранее чем за час от даты публикации. (Ожидается код ошибки 409) + if (LocalDateTime.now().plusHours(1).isAfter(event.getEventDate())) { + throw new ConflictException("Event time must be at least 1 hours from publish time"); + } + // событие можно публиковать, только если оно в состоянии ожидания публикации (Ожидается код ошибки 409) + if (!Objects.equals(event.getState(), State.PENDING)) { + throw new ConflictException("Event should be in PENDING state"); + } + event.setState(State.PUBLISHED); + event.setPublishedOn(LocalDateTime.now()); + } + + eventRepository.save(event); + Long confirmedRequests = requestRepository.countByEventIdAndStatus(eventId, ParticipationRequestStatus.CONFIRMED); + Long views = viewRepository.countByEventId(eventId); + return EventMapper.toEventFullDto(event, confirmedRequests, views); + } + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/event/service/EventPrivateService.java b/core/main-service/src/main/java/ru/practicum/event/service/EventPrivateService.java new file mode 100644 index 0000000..1fc1b18 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/event/service/EventPrivateService.java @@ -0,0 +1,20 @@ +package ru.practicum.event.service; + +import ru.practicum.event.dto.EventFullDto; +import ru.practicum.event.dto.EventShortDto; +import ru.practicum.event.dto.NewEventDto; +import ru.practicum.event.dto.UpdateEventDto; + +import java.util.List; + +public interface EventPrivateService { + + EventFullDto addEvent(Long userId, NewEventDto newEventDto); + + EventFullDto getEventByUserIdAndEventId(Long userId, Long eventId); + + List getEventsByUserId(Long userId, Long from, Long size); + + EventFullDto updateEventByUserIdAndEventId(Long userId, Long eventId, UpdateEventDto newEventDto); + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/event/service/EventPrivateServiceImpl.java b/core/main-service/src/main/java/ru/practicum/event/service/EventPrivateServiceImpl.java new file mode 100644 index 0000000..32f5a7d --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/event/service/EventPrivateServiceImpl.java @@ -0,0 +1,159 @@ +package ru.practicum.event.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import ru.practicum.category.model.Category; +import ru.practicum.category.repository.CategoryRepository; +import ru.practicum.event.dto.*; +import ru.practicum.event.mapper.EventMapper; +import ru.practicum.event.mapper.LocationMapper; +import ru.practicum.event.model.Event; +import ru.practicum.event.repository.EventRepository; +import ru.practicum.event.repository.ViewRepository; +import ru.practicum.exception.ConflictException; +import ru.practicum.exception.NotFoundException; +import ru.practicum.request.dto.ParticipationRequestStatus; +import ru.practicum.request.repository.RequestRepository; +import ru.practicum.user.model.User; +import ru.practicum.user.repository.UserRepository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class EventPrivateServiceImpl implements EventPrivateService { + + private final UserRepository userRepository; + private final CategoryRepository categoryRepository; + private final EventRepository eventRepository; + private final RequestRepository requestRepository; + private final ViewRepository viewRepository; + + // Добавление нового события + @Override + @Transactional(readOnly = false) + public EventFullDto addEvent(Long userId, NewEventDto newEventDto) { + User initiator = userRepository.findById(userId) + .orElseThrow(() -> new NotFoundException("User with id=" + userId + " was not found")); + Category category = categoryRepository.findById(newEventDto.getCategory()) + .orElseThrow(() -> new NotFoundException("Category with id=" + newEventDto.getCategory() + " was not found")); + + Event newEvent = EventMapper.toEvent(newEventDto, initiator, category); + eventRepository.save(newEvent); + return EventMapper.toEventFullDto(newEvent, 0L, 0L); + } + + // Получение полной информации о событии добавленном текущим пользователем + @Override + public EventFullDto getEventByUserIdAndEventId(Long userId, Long eventId) { + User initiator = userRepository.findById(userId) + .orElseThrow(() -> new NotFoundException("User with id=" + userId + " was not found")); + Event event = eventRepository.findById(eventId) + .orElseThrow(() -> new NotFoundException("Event with id=" + eventId + " was not found")); + + if (!Objects.equals(initiator.getId(), event.getInitiator().getId())) { + throw new ConflictException("User " + userId + " is not an initiator of event " + eventId, "Forbidden action"); + } + + Long confirmedRequests = requestRepository.countByEventIdAndStatus(event.getId(), ParticipationRequestStatus.CONFIRMED); + Long views = viewRepository.countByEventId(eventId); + return EventMapper.toEventFullDto(event, confirmedRequests, views); + } + + // Получение событий, добавленных текущим пользователем + @Override + public List getEventsByUserId(Long userId, Long from, Long size) { + User initiator = userRepository.findById(userId) + .orElseThrow(() -> new NotFoundException("User with id=" + userId + " was not found")); + + Pageable pageable = PageRequest.of( + from.intValue() / size.intValue(), + size.intValue(), + Sort.by("eventDate").descending() + ); + + List events = eventRepository.findByInitiatorId(userId, pageable); + List eventIds = events.stream().map(Event::getId).toList(); + Map confirmedRequestsMap = requestRepository.getConfirmedRequestsByEventIds(eventIds) + .stream() + .collect(Collectors.toMap( + r -> (Long) r[0], + r -> (Long) r[1] + )); + Map viewsMap = viewRepository.countsByEventIds(eventIds) + .stream() + .collect(Collectors.toMap( + r -> (Long) r[0], + r -> (Long) r[1] + )); + + return events.stream() + .map(e -> EventMapper.toEventShortDto(e, confirmedRequestsMap.get(e.getId()), viewsMap.get(e.getId()))) + .toList(); + } + + // Изменение события добавленного текущим пользователем + @Override + @Transactional(readOnly = false) + public EventFullDto updateEventByUserIdAndEventId(Long userId, Long eventId, UpdateEventDto updateEventDto) { + User initiator = userRepository.findById(userId) + .orElseThrow(() -> new NotFoundException("User with id=" + userId + " was not found")); + Event event = eventRepository.findById(eventId) + .orElseThrow(() -> new NotFoundException("Event with id=" + eventId + " was not found")); + + if (!Objects.equals(initiator.getId(), event.getInitiator().getId())) { + throw new ConflictException("User " + userId + " is not an initiator of event " + eventId, "Forbidden action"); + } + + // изменить можно только отмененные события или события в состоянии ожидания модерации (Ожидается код ошибки 409) + if (event.getState() != State.PENDING && event.getState() != State.CANCELED) { + throw new ConflictException("Only pending or canceled events can be changed"); + } + + // дата и время на которые намечено событие не может быть раньше, чем через два часа от текущего момента (Ожидается код ошибки 409) + if (updateEventDto.getEventDate() != null && + updateEventDto.getEventDate().isBefore(LocalDateTime.now().plusHours(2))) { + throw new ConflictException("Event date must be at least 2 hours from now"); + } + + if (updateEventDto.getCategory() != null) { + Category category = categoryRepository.findById(updateEventDto.getCategory()) + .orElseThrow(() -> new NotFoundException("Category with id=" + updateEventDto.getCategory() + " not found")); + event.setCategory(category); + } + + if (updateEventDto.getTitle() != null) event.setTitle(updateEventDto.getTitle()); + if (updateEventDto.getAnnotation() != null) event.setAnnotation(updateEventDto.getAnnotation()); + if (updateEventDto.getDescription() != null) event.setDescription(updateEventDto.getDescription()); + if (updateEventDto.getLocation() != null) + event.setLocation(LocationMapper.toEntity(updateEventDto.getLocation())); + if (updateEventDto.getPaid() != null) event.setPaid(updateEventDto.getPaid()); + if (updateEventDto.getParticipantLimit() != null) + event.setParticipantLimit(updateEventDto.getParticipantLimit()); + if (updateEventDto.getRequestModeration() != null) + event.setRequestModeration(updateEventDto.getRequestModeration()); + if (updateEventDto.getEventDate() != null) event.setEventDate(updateEventDto.getEventDate()); + + if (Objects.equals(updateEventDto.getStateAction(), StateAction.CANCEL_REVIEW)) { + event.setState(State.CANCELED); + } else if (Objects.equals(updateEventDto.getStateAction(), StateAction.SEND_TO_REVIEW)) { + event.setState(State.PENDING); + } + + eventRepository.save(event); + Long confirmedRequests = requestRepository.countByEventIdAndStatus(event.getId(), ParticipationRequestStatus.CONFIRMED); + Long views = viewRepository.countByEventId(eventId); + return EventMapper.toEventFullDto(event, confirmedRequests, views); + } + + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/event/service/EventPublicService.java b/core/main-service/src/main/java/ru/practicum/event/service/EventPublicService.java new file mode 100644 index 0000000..08086bc --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/event/service/EventPublicService.java @@ -0,0 +1,16 @@ +package ru.practicum.event.service; + +import jakarta.servlet.http.HttpServletRequest; +import ru.practicum.event.dto.EventFullDto; +import ru.practicum.event.dto.EventParams; +import ru.practicum.event.dto.EventShortDto; + +import java.util.List; + +public interface EventPublicService { + + List getAllEventsByParams(EventParams eventParams, HttpServletRequest request); + + EventFullDto getEventById(Long id, HttpServletRequest request); + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/event/service/EventPublicServiceImpl.java b/core/main-service/src/main/java/ru/practicum/event/service/EventPublicServiceImpl.java new file mode 100644 index 0000000..fce8535 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/event/service/EventPublicServiceImpl.java @@ -0,0 +1,118 @@ +package ru.practicum.event.service; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import ru.practicum.EventHitDto; +import ru.practicum.event.dto.*; +import ru.practicum.event.mapper.EventMapper; +import ru.practicum.event.model.Event; +import ru.practicum.event.model.View; +import ru.practicum.event.repository.EventRepository; +import ru.practicum.event.repository.JpaSpecifications; +import ru.practicum.event.repository.ViewRepository; +import ru.practicum.ewm.client.StatClient; +import ru.practicum.exception.BadRequestException; +import ru.practicum.exception.NotFoundException; +import ru.practicum.request.dto.ParticipationRequestStatus; +import ru.practicum.request.repository.RequestRepository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class EventPublicServiceImpl implements EventPublicService { + + private final StatClient statClient; + private final EventRepository eventRepository; + private final RequestRepository requestRepository; + private final ViewRepository viewRepository; + + // Получение событий с возможностью фильтрации + @Override + public List getAllEventsByParams(EventParams params, HttpServletRequest request) { + + if (params.getRangeStart() != null && params.getRangeEnd() != null && params.getRangeEnd().isBefore(params.getRangeStart())) { + throw new BadRequestException("rangeStart should be before rangeEnd"); + } + + // если в запросе не указан диапазон дат [rangeStart-rangeEnd], то нужно выгружать события, которые произойдут позже текущей даты и времени + if (params.getRangeStart() == null) params.setRangeStart(LocalDateTime.now()); + + // сортировочка и пагинация + Sort sort = Sort.by(Sort.Direction.ASC, "eventDate"); + if (EventSort.VIEWS.equals(params.getEventSort())) sort = Sort.by(Sort.Direction.DESC, "views"); + PageRequest pageRequest = PageRequest.of(params.getFrom().intValue() / params.getSize().intValue(), + params.getSize().intValue(), sort); + + Page events = eventRepository.findAll(JpaSpecifications.publicFilters(params), pageRequest); + List eventIds = events.stream().map(Event::getId).toList(); + + // информация о каждом событии должна включать в себя количество просмотров и количество уже одобренных заявок на участие + Map confirmedRequestsMap = requestRepository.getConfirmedRequestsByEventIds(eventIds) + .stream() + .collect(Collectors.toMap( + r -> (Long) r[0], + r -> (Long) r[1] + )); + Map viewsMap = viewRepository.countsByEventIds(eventIds) + .stream() + .collect(Collectors.toMap( + r -> (Long) r[0], + r -> (Long) r[1] + )); + + // информацию о том, что по этому эндпоинту был осуществлен и обработан запрос, нужно сохранить в сервисе статистики + statClient.hit(EventHitDto.builder() + .ip(request.getRemoteAddr()) + .uri(request.getRequestURI()) + .app("ewm-main-service") + .timestamp(LocalDateTime.now()) + .build()); + + return events.stream() + .map(e -> EventMapper.toEventShortDto(e, confirmedRequestsMap.get(e.getId()), viewsMap.get(e.getId()))) + .toList(); + } + + // Получение подробной информации об опубликованном событии по его идентификатору + @Override + @Transactional(readOnly = false) + public EventFullDto getEventById(Long eventId, HttpServletRequest request) { + // событие должно быть опубликовано + Event event = eventRepository.findByIdAndState(eventId, State.PUBLISHED) + .orElseThrow(() -> new NotFoundException("Event not found")); + + // информация о событии должна включать в себя количество просмотров и количество подтвержденных запросов + Long confirmedRequests = requestRepository.countByEventIdAndStatus(eventId, ParticipationRequestStatus.CONFIRMED); + Long views = viewRepository.countByEventId(eventId); + + // делаем новый уникальный просмотр + if (!viewRepository.existsByEventIdAndIp(eventId, request.getRemoteAddr())) { + View view = View.builder() + .event(event) + .ip(request.getRemoteAddr()) + .build(); + viewRepository.save(view); + } + + // информацию о том, что по этому эндпоинту был осуществлен и обработан запрос, нужно сохранить в сервисе статистики + statClient.hit(EventHitDto.builder() + .ip(request.getRemoteAddr()) + .uri(request.getRequestURI()) + .app("ewm-main-service") + .timestamp(LocalDateTime.now()) + .build()); + + return EventMapper.toEventFullDto(event, confirmedRequests, views); + } + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/exception/ApiError.java b/core/main-service/src/main/java/ru/practicum/exception/ApiError.java new file mode 100644 index 0000000..b765a1d --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/exception/ApiError.java @@ -0,0 +1,36 @@ +package ru.practicum.exception; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.http.HttpStatus; +import ru.practicum.serialize.LocalDateTimeDeserializer; +import ru.practicum.serialize.LocalDateTimeSerializer; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ApiError { + + private HttpStatus status; + + private String reason; + + private String message; + + @Builder.Default + private List errors = new ArrayList<>(); + + @JsonSerialize(using = LocalDateTimeSerializer.class) + @JsonDeserialize(using = LocalDateTimeDeserializer.class) + private LocalDateTime timestamp; + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/exception/BadRequestException.java b/core/main-service/src/main/java/ru/practicum/exception/BadRequestException.java new file mode 100644 index 0000000..ed9e2b1 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/exception/BadRequestException.java @@ -0,0 +1,21 @@ +package ru.practicum.exception; + +public class BadRequestException extends RuntimeException { + + private final String reason; + + public BadRequestException(String message) { + super(message); + this.reason = "Incorrectly made request."; + } + + public BadRequestException(String message, String reason) { + super(message); + this.reason = reason; + } + + public String getReason() { + return reason; + } + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/exception/ConflictException.java b/core/main-service/src/main/java/ru/practicum/exception/ConflictException.java new file mode 100644 index 0000000..beb9dc0 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/exception/ConflictException.java @@ -0,0 +1,21 @@ +package ru.practicum.exception; + +public class ConflictException extends RuntimeException { + + private final String reason; + + public ConflictException(String message) { + super(message); + this.reason = "Integrity constraint has been violated."; + } + + public ConflictException(String message, String reason) { + super(message); + this.reason = reason; + } + + public String getReason() { + return reason; + } + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/exception/ForbiddenException.java b/core/main-service/src/main/java/ru/practicum/exception/ForbiddenException.java new file mode 100644 index 0000000..f7b6e0f --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/exception/ForbiddenException.java @@ -0,0 +1,21 @@ +package ru.practicum.exception; + +public class ForbiddenException extends RuntimeException { + + private final String reason; + + public ForbiddenException(String message) { + super(message); + this.reason = "For the requested operation the conditions are not met."; + } + + public ForbiddenException(String message, String reason) { + super(message); + this.reason = reason; + } + + public String getReason() { + return reason; + } + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/exception/GlobalExceptionHandler.java b/core/main-service/src/main/java/ru/practicum/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..c7ab630 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/exception/GlobalExceptionHandler.java @@ -0,0 +1,167 @@ +package ru.practicum.exception; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingRequestHeaderException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; + +import java.time.LocalDateTime; + +@Slf4j +@ControllerAdvice +public class GlobalExceptionHandler { + + // SPRING EXCEPTIONS ----------------------------------------------- + + @ExceptionHandler( + ConstraintViolationException.class // Custom annotation exceptions + ) + public ResponseEntity handleConstraintViolation(ConstraintViolationException e, HttpServletRequest request) { + String errorMessage = e.getConstraintViolations().stream() + .map(ConstraintViolation::getMessage) + .findFirst() + .orElse("Invalid input"); + + log.debug("VALIDATION FAILED: {}", e.getMessage()); + + ApiError apiError = ApiError.builder() + .status(HttpStatus.BAD_REQUEST) + .reason("Validation Failed") + .message(errorMessage) + .timestamp(LocalDateTime.now()) + .build(); + return new ResponseEntity<>(apiError, HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler( + MethodArgumentNotValidException.class // @Valid annotation exceptions + ) + public ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException e, HttpServletRequest request) { + String errorMessage = e.getBindingResult().getAllErrors().getFirst().getDefaultMessage(); + Object target = e.getBindingResult().getTarget(); + log.debug("VALIDATION FAILED: {} for {}", errorMessage, target); + ApiError apiError = ApiError.builder() + .status(HttpStatus.BAD_REQUEST) + .reason("Validation Failed") + .message(errorMessage) + .timestamp(LocalDateTime.now()) + .build(); + return new ResponseEntity<>(apiError, HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler({ + IllegalArgumentException.class, // wrong arguments like -1 + MethodArgumentTypeMismatchException.class, // argument type mismatch + HttpMessageNotReadableException.class, // wrong json in request body + MissingServletRequestParameterException.class // missing RequestParam + }) + public ResponseEntity handleIllegalArgument(Throwable e, HttpServletRequest request) { + log.debug("ILLEGAL ARGUMENT: {}", e.getMessage()); + ApiError apiError = ApiError.builder() + .status(HttpStatus.BAD_REQUEST) + .reason("Illegal Argument") + .message(e.getMessage()) + .timestamp(LocalDateTime.now()) + .build(); + return new ResponseEntity<>(apiError, HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler( + MissingRequestHeaderException.class // missing request header + ) + public ResponseEntity handleMissingRequestHeaderException(MissingRequestHeaderException e, HttpServletRequest request) { + log.debug("MISSING HEADER: {}", e.getMessage()); + ApiError apiError = ApiError.builder() + .status(HttpStatus.BAD_REQUEST) + .reason("Missing header") + .message(e.getMessage()) + .timestamp(LocalDateTime.now()) + .build(); + return new ResponseEntity<>(apiError, HttpStatus.BAD_REQUEST); + } + + // CUSTOM EXCEPTIONS -------------------------------------------------------- + + @ExceptionHandler( + BadRequestException.class // custom bad request + ) + public ResponseEntity handleBadRequestException(BadRequestException e, HttpServletRequest request) { + log.debug("BAD REQUEST: {}", e.getMessage()); + ApiError apiError = ApiError.builder() + .status(HttpStatus.BAD_REQUEST) + .reason(e.getReason()) + .message(e.getMessage()) + .timestamp(LocalDateTime.now()) + .build(); + return new ResponseEntity<>(apiError, HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler( + ConflictException.class // custom conflict exception + ) + public ResponseEntity handleConflictException(ConflictException e, HttpServletRequest request) { + log.debug("CONFLICT: {}", e.getMessage()); + ApiError apiError = ApiError.builder() + .status(HttpStatus.CONFLICT) + .reason(e.getReason()) + .message(e.getMessage()) + .timestamp(LocalDateTime.now()) + .build(); + return new ResponseEntity<>(apiError, HttpStatus.CONFLICT); + } + + @ExceptionHandler( + ForbiddenException.class // custom forbidden exception + ) + public ResponseEntity handleForbiddenException(ForbiddenException e, HttpServletRequest request) { + log.debug("FORBIDDEN: {}", e.getMessage()); + ApiError apiError = ApiError.builder() + .status(HttpStatus.FORBIDDEN) + .reason(e.getReason()) + .message(e.getMessage()) + .timestamp(LocalDateTime.now()) + .build(); + return new ResponseEntity<>(apiError, HttpStatus.FORBIDDEN); + } + + @ExceptionHandler( + NotFoundException.class // custom not_found exception + ) + public ResponseEntity handleNotFoundException(NotFoundException e, HttpServletRequest request) { + log.debug("NOT FOUND: {}", e.getMessage()); + ApiError apiError = ApiError.builder() + .status(HttpStatus.NOT_FOUND) + .reason(e.getReason()) + .message(e.getMessage()) + .timestamp(LocalDateTime.now()) + .build(); + return new ResponseEntity<>(apiError, HttpStatus.NOT_FOUND); + } + + // OTHER UNKNOWN EXCEPTIONS --------------------------------------------------- + + @ExceptionHandler( + RuntimeException.class // Internal Server Error + ) + public ResponseEntity handleRuntimeException(RuntimeException e, HttpServletRequest request) { + log.debug("INTERNAL SERVER ERROR: {}", e.getMessage()); + ApiError apiError = ApiError.builder() + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .reason("Internal Server Error") + .message(e.getMessage()) + .timestamp(LocalDateTime.now()) + .build(); + return new ResponseEntity<>(apiError, HttpStatus.INTERNAL_SERVER_ERROR); + } + + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/exception/NotFoundException.java b/core/main-service/src/main/java/ru/practicum/exception/NotFoundException.java new file mode 100644 index 0000000..57fdec0 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/exception/NotFoundException.java @@ -0,0 +1,21 @@ +package ru.practicum.exception; + +public class NotFoundException extends RuntimeException { + + private final String reason; + + public NotFoundException(String message) { + super(message); + this.reason = "The required object was not found."; + } + + public NotFoundException(String message, String reason) { + super(message); + this.reason = reason; + } + + public String getReason() { + return reason; + } + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/request/controller/RequestController.java b/core/main-service/src/main/java/ru/practicum/request/controller/RequestController.java new file mode 100644 index 0000000..ac51204 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/request/controller/RequestController.java @@ -0,0 +1,74 @@ +package ru.practicum.request.controller; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Positive; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import ru.practicum.request.dto.EventRequestStatusUpdateRequestDto; +import ru.practicum.request.dto.EventRequestStatusUpdateResultDto; +import ru.practicum.request.dto.ParticipationRequestDto; +import ru.practicum.request.service.RequestService; + +import java.util.Collection; + +@RestController +@RequiredArgsConstructor +@Validated +public class RequestController { + + private final RequestService requestService; + + // ЗАЯВКИ ТЕКУЩЕГО ПОЛЬЗОВАТЕЛЯ + + // Добавление запроса от текущего пользователя на участие в событии + @PostMapping("/users/{userId}/requests") + @ResponseStatus(HttpStatus.CREATED) + public ParticipationRequestDto addRequest( + @PathVariable @Positive(message = "User Id not valid") Long userId, + @RequestParam @Positive(message = "Event Id not valid") Long eventId + ) { + return requestService.addRequest(userId, eventId); + } + + // Отмена своего запроса на участие в событии + @PatchMapping("/users/{userId}/requests/{requestId}/cancel") + public ParticipationRequestDto cancelRequest( + @PathVariable @Positive(message = "User Id not valid") Long userId, + @PathVariable @Positive(message = "Request Id not valid") Long requestId + ) { + return requestService.cancelRequest(userId, requestId); + } + + // Получение информации о заявках текущего пользователя на участие в чужих событиях + @GetMapping("/users/{userId}/requests") + public Collection getRequesterRequests( + @PathVariable @Positive(message = "User Id not valid") Long userId + ) { + return requestService.findRequesterRequests(userId); + } + + // ЗАЯВКИ НА КОНКРЕТНОЕ СОБЫТИЕ + + // Изменение статуса (подтверждена, отменена) заявок на участие в событии текущего пользователя + @PatchMapping("/users/{userId}/events/{eventId}/requests") + public EventRequestStatusUpdateResultDto moderateRequest( + @PathVariable @Positive(message = "User Id not valid") Long userId, + @PathVariable @Positive(message = "Event Id not valid") Long eventId, + @RequestBody @Valid EventRequestStatusUpdateRequestDto updateRequestDto + ) { + return requestService.moderateRequest(userId, eventId, updateRequestDto); + } + + // Получение информации о запросах на участие в событии текущего пользователя + @GetMapping("/users/{userId}/events/{eventId}/requests") + public Collection getEventRequests( + @PathVariable @Positive(message = "User Id not valid") Long userId, + @PathVariable @Positive(message = "Event Id not valid") Long eventId + ) { + return requestService.findEventRequests(userId, eventId); + } + + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/request/dto/EventRequestStatusUpdateRequestDto.java b/core/main-service/src/main/java/ru/practicum/request/dto/EventRequestStatusUpdateRequestDto.java new file mode 100644 index 0000000..3b50544 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/request/dto/EventRequestStatusUpdateRequestDto.java @@ -0,0 +1,27 @@ +package ru.practicum.request.dto; + +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class EventRequestStatusUpdateRequestDto { + + @NotEmpty(message = "Field 'requestIds' shouldn't be empty") + private List requestIds; + + @Enumerated(EnumType.STRING) + @NotNull(message = "Field 'status' shouldn't be null") + private ParticipationRequestStatus status; + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/request/dto/EventRequestStatusUpdateResultDto.java b/core/main-service/src/main/java/ru/practicum/request/dto/EventRequestStatusUpdateResultDto.java new file mode 100644 index 0000000..4db9fc2 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/request/dto/EventRequestStatusUpdateResultDto.java @@ -0,0 +1,23 @@ +package ru.practicum.request.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class EventRequestStatusUpdateResultDto { + + @Builder.Default + private List confirmedRequests = new ArrayList<>(); + + @Builder.Default + private List rejectedRequests = new ArrayList<>(); + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/request/dto/ParticipationRequestDto.java b/core/main-service/src/main/java/ru/practicum/request/dto/ParticipationRequestDto.java new file mode 100644 index 0000000..6285a26 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/request/dto/ParticipationRequestDto.java @@ -0,0 +1,28 @@ +package ru.practicum.request.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ParticipationRequestDto { + + private Long id; + + private Long requester; + + private Long event; + + private ParticipationRequestStatus status; + + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS") + private LocalDateTime created; + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/request/dto/ParticipationRequestStatus.java b/core/main-service/src/main/java/ru/practicum/request/dto/ParticipationRequestStatus.java new file mode 100644 index 0000000..a1fcc71 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/request/dto/ParticipationRequestStatus.java @@ -0,0 +1,7 @@ +package ru.practicum.request.dto; + +public enum ParticipationRequestStatus { + + PENDING, CONFIRMED, CANCELED, REJECTED + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/request/mapper/RequestMapper.java b/core/main-service/src/main/java/ru/practicum/request/mapper/RequestMapper.java new file mode 100644 index 0000000..7a0540e --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/request/mapper/RequestMapper.java @@ -0,0 +1,30 @@ +package ru.practicum.request.mapper; + +import ru.practicum.event.model.Event; +import ru.practicum.request.dto.ParticipationRequestDto; +import ru.practicum.request.model.Request; +import ru.practicum.user.model.User; + +public class RequestMapper { + + public static ParticipationRequestDto toDto(Request request) { + ParticipationRequestDto dto = new ParticipationRequestDto(); + dto.setId(request.getId()); + dto.setRequester(request.getRequester().getId()); + dto.setEvent(request.getEvent().getId()); + dto.setStatus(request.getStatus()); + dto.setCreated(request.getCreated()); + return dto; + } + + public static Request toEntity(ParticipationRequestDto dto, User requester, Event event) { + Request request = new Request(); + request.setId(dto.getId()); + request.setRequester(requester); + request.setEvent(event); + request.setStatus(dto.getStatus()); + request.setCreated(dto.getCreated()); + return request; + } + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/request/model/Request.java b/core/main-service/src/main/java/ru/practicum/request/model/Request.java new file mode 100644 index 0000000..5b3ca06 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/request/model/Request.java @@ -0,0 +1,40 @@ +package ru.practicum.request.model; + +import jakarta.persistence.*; +import lombok.*; +import ru.practicum.event.model.Event; +import ru.practicum.request.dto.ParticipationRequestStatus; +import ru.practicum.user.model.User; + +import java.time.LocalDateTime; + +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "requests") +public class Request { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @ManyToOne + @JoinColumn(name = "requester_id") + private User requester; + + @ManyToOne + @JoinColumn(name = "event_id") + private Event event; + + @Enumerated(EnumType.STRING) + @Column(name = "status") + private ParticipationRequestStatus status; + + @Column(name = "created_at") + private LocalDateTime created; + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/request/repository/RequestRepository.java b/core/main-service/src/main/java/ru/practicum/request/repository/RequestRepository.java new file mode 100644 index 0000000..1926350 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/request/repository/RequestRepository.java @@ -0,0 +1,50 @@ +package ru.practicum.request.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import ru.practicum.request.dto.ParticipationRequestStatus; +import ru.practicum.request.model.Request; + +import java.util.List; + +public interface RequestRepository extends JpaRepository { + + boolean existsByRequesterIdAndEventId(Long userId, Long eventId); + + long countByEventIdAndStatus(Long eventId, ParticipationRequestStatus status); + + List findByRequesterId(Long userId); + + List findByEventId(Long eventId); + + @Modifying(clearAutomatically = true) + @Query("UPDATE Request r SET r.status = :status WHERE r.id IN :ids") + void updateStatusByIds( + @Param("ids") List ids, + @Param("status") ParticipationRequestStatus status + ); + + @Modifying(clearAutomatically = true) + @Query(""" + UPDATE Request r + SET r.status = 'REJECTED' + WHERE r.event.id = :eventId + AND r.status = 'PENDING' + """) + void setStatusToRejectForAllPending( + @Param("eventId") Long eventId + ); + + @Query(""" + SELECT r.event.id, count(r) + FROM Request r + WHERE r.event.id IN :eventIds + GROUP BY r.event.id + """) + List getConfirmedRequestsByEventIds( + @Param("eventIds") List eventIds + ); + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/request/service/RequestService.java b/core/main-service/src/main/java/ru/practicum/request/service/RequestService.java new file mode 100644 index 0000000..7278c48 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/request/service/RequestService.java @@ -0,0 +1,198 @@ +package ru.practicum.request.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import ru.practicum.event.dto.State; +import ru.practicum.event.model.Event; +import ru.practicum.event.repository.EventRepository; +import ru.practicum.exception.ConflictException; +import ru.practicum.exception.NotFoundException; +import ru.practicum.request.dto.EventRequestStatusUpdateRequestDto; +import ru.practicum.request.dto.EventRequestStatusUpdateResultDto; +import ru.practicum.request.dto.ParticipationRequestDto; +import ru.practicum.request.dto.ParticipationRequestStatus; +import ru.practicum.request.mapper.RequestMapper; +import ru.practicum.request.model.Request; +import ru.practicum.request.repository.RequestRepository; +import ru.practicum.user.model.User; +import ru.practicum.user.repository.UserRepository; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Objects; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class RequestService { + + private final RequestRepository requestRepository; + private final UserRepository userRepository; + private final EventRepository eventRepository; + + // ЗАЯВКИ ТЕКУЩЕГО ПОЛЬЗОВАТЕЛЯ + + // Добавление запроса от текущего пользователя на участие в событии + @Transactional(readOnly = false) + public ParticipationRequestDto addRequest(Long userId, Long eventId) { + User requester = userRepository.findById(userId) + .orElseThrow(() -> new NotFoundException("User with id=" + userId + " was not found")); + Event event = eventRepository.findById(eventId) + .orElseThrow(() -> new NotFoundException("Event with id=" + eventId + " was not found")); + + // нельзя добавить повторный запрос (Ожидается код ошибки 409) + if (requestRepository.existsByRequesterIdAndEventId(userId, eventId)) { + throw new ConflictException("User tries to make duplicate request", "Forbidden action"); + } + + // инициатор события не может добавить запрос на участие в своём событии (Ожидается код ошибки 409) + if (Objects.equals(requester.getId(), event.getInitiator().getId())) { + throw new ConflictException("User tries to request for his own event", "Forbidden action"); + } + + // нельзя участвовать в неопубликованном событии (Ожидается код ошибки 409) + if (event.getState() != State.PUBLISHED) { + throw new ConflictException("User tries to request for non-published event", "Forbidden action"); + } + + // если у события достигнут лимит запросов на участие - необходимо вернуть ошибку (Ожидается код ошибки 409) + long confirmedRequestCount = requestRepository.countByEventIdAndStatus(eventId, ParticipationRequestStatus.CONFIRMED); + if (event.getParticipantLimit() > 0 && confirmedRequestCount >= event.getParticipantLimit()) { + throw new ConflictException("Participants limit is already reached", "Forbidden action"); + } + + // если для события отключена пре-модерация запросов на участие, то запрос должен автоматически перейти в состояние подтвержденного + ParticipationRequestStatus newRequestStatus = ParticipationRequestStatus.PENDING; + if (!event.getRequestModeration()) newRequestStatus = ParticipationRequestStatus.CONFIRMED; + if (Objects.equals(event.getParticipantLimit(), 0L)) newRequestStatus = ParticipationRequestStatus.CONFIRMED; + + Request newRequest = Request.builder() + .requester(requester) + .event(event) + .status(newRequestStatus) + .created(LocalDateTime.now()) + .build(); + requestRepository.save(newRequest); + return RequestMapper.toDto(newRequest); + } + + // Отмена своего запроса на участие в событии + @Transactional(readOnly = false) + public ParticipationRequestDto cancelRequest(Long userId, Long requestId) { + User requester = userRepository.findById(userId) + .orElseThrow(() -> new NotFoundException("User with id=" + userId + " was not found")); + Request existingRequest = requestRepository.findById(requestId) + .orElseThrow(() -> new NotFoundException("Request with id=" + requestId + " was not found")); + + existingRequest.setStatus(ParticipationRequestStatus.CANCELED); + requestRepository.save(existingRequest); + return RequestMapper.toDto(existingRequest); + } + + // Получение информации о заявках текущего пользователя на участие в чужих событиях + public Collection findRequesterRequests(Long userId) { + return requestRepository.findByRequesterId(userId).stream() + .filter(Objects::nonNull) + .map(RequestMapper::toDto) + .toList(); + } + + // ЗАЯВКИ НА КОНКРЕТНОЕ СОБЫТИЕ + + // Получение информации о запросах на участие в событии текущего пользователя + public Collection findEventRequests(Long userId, Long eventId) { + User initiator = userRepository.findById(userId) + .orElseThrow(() -> new NotFoundException("User with id=" + userId + " was not found")); + Event event = eventRepository.findById(eventId) + .orElseThrow(() -> new NotFoundException("Event with id=" + eventId + " was not found")); + + // проверка что юзер - инициатор события + if (!Objects.equals(initiator.getId(), event.getInitiator().getId())) { + throw new ConflictException("User " + userId + " is not an initiator of event " + eventId, "Forbidden action"); + } + + return requestRepository.findByEventId(eventId).stream() + .filter(Objects::nonNull) + .map(RequestMapper::toDto) + .toList(); + } + + // Изменение статуса (подтверждена, отменена) заявок на участие в событии текущего пользователя + @Transactional(readOnly = false) + public EventRequestStatusUpdateResultDto moderateRequest( + Long userId, + Long eventId, + EventRequestStatusUpdateRequestDto updateRequestDto + ) { + User initiator = userRepository.findById(userId) + .orElseThrow(() -> new NotFoundException("User with id=" + userId + " was not found")); + Event event = eventRepository.findById(eventId) + .orElseThrow(() -> new NotFoundException("Event with id=" + eventId + " was not found")); + + // проверка что юзер - инициатор события + if (!Objects.equals(initiator.getId(), event.getInitiator().getId())) { + throw new ConflictException("User " + userId + " is not an initiator of event " + eventId, "Forbidden action"); + } + + // если для события лимит заявок равен 0 или отключена пре-модерация заявок, то подтверждение заявок не требуется + if (event.getParticipantLimit() < 1 || !event.getRequestModeration()) { + return new EventRequestStatusUpdateResultDto(); + } + + // статус можно изменить только у заявок, находящихся в состоянии ожидания (Ожидается код ошибки 409) + List requests = requestRepository.findAllById(updateRequestDto.getRequestIds()); + for (Request request : requests) { + if (request.getStatus() != ParticipationRequestStatus.PENDING) { + throw new ConflictException("Request " + request.getId() + " must have status PENDING", "Incorrectly made request"); + } + } + + List requestsToConfirm = new ArrayList<>(); + List requestsToReject = new ArrayList<>(); + + if (updateRequestDto.getStatus() == ParticipationRequestStatus.CONFIRMED) { + + long confirmedRequestCount = requestRepository.countByEventIdAndStatus(eventId, ParticipationRequestStatus.CONFIRMED); + + if (confirmedRequestCount >= event.getParticipantLimit()) { + // нельзя подтвердить заявку, если уже достигнут лимит по заявкам на данное событие (Ожидается код ошибки 409) + throw new ConflictException("The participant limit has been reached for event " + eventId, "Forbidden action"); + } else if (updateRequestDto.getRequestIds().size() < event.getParticipantLimit() - confirmedRequestCount) { + requestsToConfirm = updateRequestDto.getRequestIds(); + requestRepository.updateStatusByIds(requestsToConfirm, ParticipationRequestStatus.CONFIRMED); + } else { + long freeSeats = event.getParticipantLimit() - confirmedRequestCount; + requestsToConfirm = updateRequestDto.getRequestIds().stream() + .limit(freeSeats) + .toList(); + requestsToReject = updateRequestDto.getRequestIds().stream() + .skip(freeSeats) + .toList(); + requestRepository.updateStatusByIds(requestsToConfirm, ParticipationRequestStatus.CONFIRMED); + // если при подтверждении данной заявки, лимит заявок для события исчерпан, то все неподтверждённые заявки необходимо отклонить + requestRepository.setStatusToRejectForAllPending(eventId); + } + + } else if (updateRequestDto.getStatus() == ParticipationRequestStatus.REJECTED) { + requestsToReject = updateRequestDto.getRequestIds(); + requestRepository.updateStatusByIds(requestsToReject, ParticipationRequestStatus.REJECTED); + } else { + throw new ConflictException("Only CONFIRMED and REJECTED statuses are allowed", "Forbidden action"); + } + + EventRequestStatusUpdateResultDto resultDto = new EventRequestStatusUpdateResultDto(); + List confirmedRequests = requestRepository.findAllById(requestsToConfirm).stream() + .map(RequestMapper::toDto) + .toList(); + resultDto.setConfirmedRequests(confirmedRequests); + List rejectedRequests = requestRepository.findAllById(requestsToReject).stream() + .map(RequestMapper::toDto) + .toList(); + resultDto.setRejectedRequests(rejectedRequests); + return resultDto; + } + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/serialize/LocalDateTimeDeserializer.java b/core/main-service/src/main/java/ru/practicum/serialize/LocalDateTimeDeserializer.java new file mode 100644 index 0000000..099319d --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/serialize/LocalDateTimeDeserializer.java @@ -0,0 +1,34 @@ +package ru.practicum.serialize; + +import com.fasterxml.jackson.core.JacksonException; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +@Component +public class LocalDateTimeDeserializer extends StdDeserializer { + + private DateTimeFormatter formatter; + + public LocalDateTimeDeserializer() { + super(LocalDateTime.class); + } + + @Value("${explore-with-me.main.datetime.format}") + public void setFormatter(String dateTimeFormat) { + this.formatter = DateTimeFormatter.ofPattern(dateTimeFormat); + } + + @Override + public LocalDateTime deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException, JacksonException { + String date = jsonParser.getText(); + return LocalDateTime.parse(date, formatter); + } + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/serialize/LocalDateTimeSerializer.java b/core/main-service/src/main/java/ru/practicum/serialize/LocalDateTimeSerializer.java new file mode 100644 index 0000000..fd7b307 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/serialize/LocalDateTimeSerializer.java @@ -0,0 +1,32 @@ +package ru.practicum.serialize; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.ser.std.StdSerializer; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +@Component +public class LocalDateTimeSerializer extends StdSerializer { + + private DateTimeFormatter formatter; + + public LocalDateTimeSerializer() { + super(LocalDateTime.class); + } + + @Value("${explore-with-me.main.datetime.format}") + public void setFormatter(String dateTimeFormat) { + this.formatter = DateTimeFormatter.ofPattern(dateTimeFormat); + } + + @Override + public void serialize(LocalDateTime localDateTime, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException { + jsonGenerator.writeString(localDateTime.format(formatter)); + } + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/user/controller/UserController.java b/core/main-service/src/main/java/ru/practicum/user/controller/UserController.java new file mode 100644 index 0000000..31d2fce --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/user/controller/UserController.java @@ -0,0 +1,53 @@ +package ru.practicum.user.controller; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Positive; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import ru.practicum.user.dto.NewUserRequestDto; +import ru.practicum.user.dto.UserDto; +import ru.practicum.user.service.UserService; + +import java.util.Collection; +import java.util.List; + +@RestController +@RequiredArgsConstructor +@Validated +public class UserController { + + private final UserService userService; + + // MODIFY OPS + + @PostMapping("/admin/users") + @ResponseStatus(HttpStatus.CREATED) + public UserDto createUser( + @RequestBody @Valid NewUserRequestDto newUserRequestDto + ) { + return userService.create(newUserRequestDto); + } + + @DeleteMapping("/admin/users/{userId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deleteUser( + @PathVariable @Positive(message = "User Id not valid") Long userId + ) { + userService.delete(userId); + } + + // GET COLLECTION + + @GetMapping("/admin/users") + public Collection getUsers( + @RequestParam(required = false) List ids, + @RequestParam(defaultValue = "0") Integer from, + @RequestParam(defaultValue = "10") Integer size + ) { + return userService.findByIdListWithOffsetAndLimit(ids, from, size); + } + + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/user/dto/NewUserRequestDto.java b/core/main-service/src/main/java/ru/practicum/user/dto/NewUserRequestDto.java new file mode 100644 index 0000000..fb6bf52 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/user/dto/NewUserRequestDto.java @@ -0,0 +1,26 @@ +package ru.practicum.user.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class NewUserRequestDto { + + @NotBlank(message = "Field 'email' shouldn't be blank") + @Email(message = "Field 'email' should match email mask") + @Size(min = 6, max = 254, message = "Field 'email' should be from 6 to 254 characters") + private String email; + + @NotBlank(message = "Field 'name' shouldn't be blank") + @Size(min = 2, max = 250, message = "Field 'name' should be from 2 to 250 characters") + private String name; + +} diff --git a/core/main-service/src/main/java/ru/practicum/user/dto/UserDto.java b/core/main-service/src/main/java/ru/practicum/user/dto/UserDto.java new file mode 100644 index 0000000..ffd285f --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/user/dto/UserDto.java @@ -0,0 +1,20 @@ +package ru.practicum.user.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UserDto { + + private Long id; + + private String email; + + private String name; + +} diff --git a/core/main-service/src/main/java/ru/practicum/user/dto/UserShortDto.java b/core/main-service/src/main/java/ru/practicum/user/dto/UserShortDto.java new file mode 100644 index 0000000..fbb974a --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/user/dto/UserShortDto.java @@ -0,0 +1,18 @@ +package ru.practicum.user.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UserShortDto { + + private Long id; + + private String name; + +} diff --git a/core/main-service/src/main/java/ru/practicum/user/mapper/UserMapper.java b/core/main-service/src/main/java/ru/practicum/user/mapper/UserMapper.java new file mode 100644 index 0000000..116a50d --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/user/mapper/UserMapper.java @@ -0,0 +1,32 @@ +package ru.practicum.user.mapper; + +import ru.practicum.user.dto.NewUserRequestDto; +import ru.practicum.user.dto.UserDto; +import ru.practicum.user.dto.UserShortDto; +import ru.practicum.user.model.User; + +public class UserMapper { + + public static UserDto toDto(User user) { + return UserDto.builder() + .id(user.getId()) + .email(user.getEmail()) + .name(user.getName()) + .build(); + } + + public static User toEntity(NewUserRequestDto dto) { + return User.builder() + .email(dto.getEmail()) + .name(dto.getName()) + .build(); + } + + public static UserShortDto toUserShortDto(User user) { + return UserShortDto.builder() + .id(user.getId()) + .name(user.getName()) + .build(); + } + +} diff --git a/core/main-service/src/main/java/ru/practicum/user/model/User.java b/core/main-service/src/main/java/ru/practicum/user/model/User.java new file mode 100644 index 0000000..02bd49d --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/user/model/User.java @@ -0,0 +1,26 @@ +package ru.practicum.user.model; + +import jakarta.persistence.*; +import lombok.*; + +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "users") +public class User { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "email", length = 254, nullable = false, unique = true) + private String email; + + @Column(name = "name", length = 250, nullable = false) + private String name; + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/user/repository/UserRepository.java b/core/main-service/src/main/java/ru/practicum/user/repository/UserRepository.java new file mode 100644 index 0000000..9ccc466 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/user/repository/UserRepository.java @@ -0,0 +1,10 @@ +package ru.practicum.user.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import ru.practicum.user.model.User; + +public interface UserRepository extends JpaRepository { + + boolean existsByEmail(String email); + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/user/service/UserService.java b/core/main-service/src/main/java/ru/practicum/user/service/UserService.java new file mode 100644 index 0000000..d3644be --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/user/service/UserService.java @@ -0,0 +1,62 @@ +package ru.practicum.user.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import ru.practicum.exception.ConflictException; +import ru.practicum.exception.NotFoundException; +import ru.practicum.user.dto.NewUserRequestDto; +import ru.practicum.user.dto.UserDto; +import ru.practicum.user.mapper.UserMapper; +import ru.practicum.user.model.User; +import ru.practicum.user.repository.UserRepository; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserService { + + private final UserRepository userRepository; + + // MODIFY OPS + + @Transactional(readOnly = false) + public UserDto create(NewUserRequestDto newUserRequestDto) { + if (userRepository.existsByEmail(newUserRequestDto.getEmail())) { + throw new ConflictException("User with email " + newUserRequestDto.getEmail() + " already exists", + "Integrity constraint has been violated"); + } + User newUser = UserMapper.toEntity(newUserRequestDto); + userRepository.save(newUser); + return UserMapper.toDto(newUser); + } + + @Transactional(readOnly = false) + public void delete(Long userId) { + User userToDelete = userRepository.findById(userId) + .orElseThrow(() -> new NotFoundException("User with id=" + userId + " was not found")); + userRepository.delete(userToDelete); + } + + // GET COLLECTION + + public List findByIdListWithOffsetAndLimit(List idList, Integer from, Integer size) { + if (idList == null || idList.isEmpty()) { + Sort sort = Sort.by(Sort.Direction.ASC, "id"); + return userRepository.findAll(PageRequest.of(from / size, size, sort)) + .stream() + .map(UserMapper::toDto) + .toList(); + } else { + return userRepository.findAllById(idList) + .stream() + .map(UserMapper::toDto) + .toList(); + } + } + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/util/Util.java b/core/main-service/src/main/java/ru/practicum/util/Util.java new file mode 100644 index 0000000..a14e42f --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/util/Util.java @@ -0,0 +1,23 @@ +package ru.practicum.util; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; + +public class Util { + + private Util() { + } + + public static PageRequest createPageRequestAsc(int from, int size) { + return PageRequest.of(from, size, Sort.Direction.ASC, "id"); + } + + public static PageRequest createPageRequestDesc(String sortBy, int from, int size) { + return PageRequest.of(from > 0 ? from / size : 0, size, Sort.by(sortBy).descending()); + } + + public static PageRequest createPageRequestAsc(String sortBy, int from, int size) { + return PageRequest.of(from > 0 ? from / size : 0, size, Sort.by(sortBy).ascending()); + } + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/validation/AtLeastOneNotNull.java b/core/main-service/src/main/java/ru/practicum/validation/AtLeastOneNotNull.java new file mode 100644 index 0000000..3ea9448 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/validation/AtLeastOneNotNull.java @@ -0,0 +1,24 @@ +package ru.practicum.validation; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Constraint(validatedBy = AtLeastOneNotNullValidator.class) +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface AtLeastOneNotNull { + + String message() default "At least one field shouldn't be null"; + + Class[] groups() default {}; + + Class[] payload() default {}; + + String[] fields(); + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/validation/AtLeastOneNotNullValidator.java b/core/main-service/src/main/java/ru/practicum/validation/AtLeastOneNotNullValidator.java new file mode 100644 index 0000000..45faa2a --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/validation/AtLeastOneNotNullValidator.java @@ -0,0 +1,28 @@ +package ru.practicum.validation; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import org.springframework.beans.BeanWrapperImpl; + +public class AtLeastOneNotNullValidator implements ConstraintValidator { + + private String[] fields; + + @Override + public void initialize(AtLeastOneNotNull constraintAnnotation) { + this.fields = constraintAnnotation.fields(); + } + + @Override + public boolean isValid(Object o, ConstraintValidatorContext constraintValidatorContext) { + if (o == null) return false; + + BeanWrapperImpl beanWrapper = new BeanWrapperImpl(o); + for (String field : fields) { + if (beanWrapper.getPropertyValue(field) != null) return true; + } + + return false; + } + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/validation/CategoryCreateValidator.java b/core/main-service/src/main/java/ru/practicum/validation/CategoryCreateValidator.java new file mode 100644 index 0000000..a68e80a --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/validation/CategoryCreateValidator.java @@ -0,0 +1,26 @@ +package ru.practicum.validation; + +import ru.practicum.category.dto.CategoryDto; + +import javax.validation.Validator; +import java.util.Set; +import java.util.stream.Collectors; + +public class CategoryCreateValidator implements CreateOrUpdateValidator.Create { + private final Validator validator; + + public CategoryCreateValidator(Validator validator) { + this.validator = validator; + } + + public void validate(CategoryDto categoryDto) { + Set errors = validator.validate(categoryDto, CreateOrUpdateValidator.Create.class) + .stream() + .map(constraintViolation -> constraintViolation.getPropertyPath().toString() + ": " + constraintViolation.getMessage()) + .collect(Collectors.toSet()); + + if (!errors.isEmpty()) { + throw new IllegalArgumentException("Validation errors: " + errors); + } + } +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/validation/CategoryUpdateValidator.java b/core/main-service/src/main/java/ru/practicum/validation/CategoryUpdateValidator.java new file mode 100644 index 0000000..aa6fdc5 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/validation/CategoryUpdateValidator.java @@ -0,0 +1,26 @@ +package ru.practicum.validation; + +import ru.practicum.category.dto.CategoryDto; + +import javax.validation.Validator; +import java.util.Set; +import java.util.stream.Collectors; + +public class CategoryUpdateValidator implements CreateOrUpdateValidator.Update { + private final Validator validator; + + public CategoryUpdateValidator(Validator validator) { + this.validator = validator; + } + + public void validate(CategoryDto categoryDto) { + Set errors = validator.validate(categoryDto, CreateOrUpdateValidator.Create.class) + .stream() + .map(constraintViolation -> constraintViolation.getPropertyPath().toString() + ": " + constraintViolation.getMessage()) + .collect(Collectors.toSet()); + + if (!errors.isEmpty()) { + throw new IllegalArgumentException("Validation errors: " + errors); + } + } +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/validation/CreateOrUpdateValidator.java b/core/main-service/src/main/java/ru/practicum/validation/CreateOrUpdateValidator.java new file mode 100644 index 0000000..cce3996 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/validation/CreateOrUpdateValidator.java @@ -0,0 +1,10 @@ +package ru.practicum.validation; + +public interface CreateOrUpdateValidator { + + interface Create { + } + + interface Update { + } +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/validation/NewCompilationCreateValidator.java b/core/main-service/src/main/java/ru/practicum/validation/NewCompilationCreateValidator.java new file mode 100644 index 0000000..6bf46df --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/validation/NewCompilationCreateValidator.java @@ -0,0 +1,26 @@ +package ru.practicum.validation; + +import ru.practicum.compilation.dto.NewCompilationDto; + +import javax.validation.Validator; +import java.util.Set; +import java.util.stream.Collectors; + +public class NewCompilationCreateValidator implements CreateOrUpdateValidator.Create { + private final Validator validator; + + public NewCompilationCreateValidator(Validator validator) { + this.validator = validator; + } + + public void validate(NewCompilationDto newCompilationDto) { + Set errors = validator.validate(newCompilationDto, CreateOrUpdateValidator.Create.class) + .stream() + .map(constraintViolation -> constraintViolation.getPropertyPath().toString() + ": " + constraintViolation.getMessage()) + .collect(Collectors.toSet()); + + if (!errors.isEmpty()) { + throw new IllegalArgumentException("Validation errors: " + errors); + } + } +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/validation/NewCompilationUpdateValidator.java b/core/main-service/src/main/java/ru/practicum/validation/NewCompilationUpdateValidator.java new file mode 100644 index 0000000..995a4b8 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/validation/NewCompilationUpdateValidator.java @@ -0,0 +1,26 @@ +package ru.practicum.validation; + +import ru.practicum.compilation.dto.NewCompilationDto; + +import javax.validation.Validator; +import java.util.Set; +import java.util.stream.Collectors; + +public class NewCompilationUpdateValidator implements CreateOrUpdateValidator.Update { + private final Validator validator; + + public NewCompilationUpdateValidator(Validator validator) { + this.validator = validator; + } + + public void validate(NewCompilationDto newCompilationDto) { + Set errors = validator.validate(newCompilationDto, CreateOrUpdateValidator.Create.class) + .stream() + .map(constraintViolation -> constraintViolation.getPropertyPath().toString() + ": " + constraintViolation.getMessage()) + .collect(Collectors.toSet()); + + if (!errors.isEmpty()) { + throw new IllegalArgumentException("Validation errors: " + errors); + } + } +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/validation/NotBlankButNullAllowed.java b/core/main-service/src/main/java/ru/practicum/validation/NotBlankButNullAllowed.java new file mode 100644 index 0000000..7735d40 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/validation/NotBlankButNullAllowed.java @@ -0,0 +1,22 @@ +package ru.practicum.validation; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Constraint(validatedBy = NotBlankButNullAllowedValidator.class) +@Target({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +public @interface NotBlankButNullAllowed { + + String message() default "Shouldn't be blank (but can be null)"; + + Class[] groups() default {}; + + Class[] payload() default {}; + +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/validation/NotBlankButNullAllowedValidator.java b/core/main-service/src/main/java/ru/practicum/validation/NotBlankButNullAllowedValidator.java new file mode 100644 index 0000000..13a8a30 --- /dev/null +++ b/core/main-service/src/main/java/ru/practicum/validation/NotBlankButNullAllowedValidator.java @@ -0,0 +1,13 @@ +package ru.practicum.validation; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +public class NotBlankButNullAllowedValidator implements ConstraintValidator { + + @Override + public boolean isValid(String s, ConstraintValidatorContext constraintValidatorContext) { + return s == null || !s.isBlank(); + } + +} \ No newline at end of file diff --git a/core/main-service/src/main/resources/application.yaml b/core/main-service/src/main/resources/application.yaml new file mode 100644 index 0000000..d2b045b --- /dev/null +++ b/core/main-service/src/main/resources/application.yaml @@ -0,0 +1,25 @@ +spring: + application: + name: main-service + config: + import: "configserver:" + cloud: + config: + discovery: + enabled: true + serviceId: config-server + fail-fast: true + retry: + useRandomPolicy: true + max-interval: 10000 + max-attempts: 100 + +eureka: + client: + registerWithEureka: true + serviceUrl: + defaultZone: http://localhost:8761/eureka/ + instance: + instance-id: ${spring.application.name}${random.int} + preferIpAddress: false + hostname: localhost \ No newline at end of file diff --git a/core/main-service/src/main/resources/schema.sql b/core/main-service/src/main/resources/schema.sql new file mode 100644 index 0000000..eb81edb --- /dev/null +++ b/core/main-service/src/main/resources/schema.sql @@ -0,0 +1,76 @@ + +CREATE TABLE IF NOT EXISTS users ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + email VARCHAR(254) NOT NULL UNIQUE, + name VARCHAR(250) NOT NULL +); + +CREATE TABLE IF NOT EXISTS categories ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + cat_name VARCHAR(50) NOT NULL UNIQUE +); + + +CREATE TABLE IF NOT EXISTS events ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + initiator BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + categories_id BIGINT NOT NULL REFERENCES categories(id) ON DELETE RESTRICT, + title VARCHAR(120) NOT NULL, + annotation VARCHAR(2000) NOT NULL, + description VARCHAR(7000) NOT NULL, + state VARCHAR(20) NOT NULL, + lat FLOAT, + lon FLOAT, + participant_limit BIGINT NOT NULL, + request_moderation BOOLEAN NOT NULL, + paid BOOLEAN NOT NULL, + event_date TIMESTAMP NOT NULL, + published_on TIMESTAMP, + created_on TIMESTAMP NOT NULL +); + +CREATE TABLE IF NOT EXISTS views ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + event_id BIGINT NOT NULL REFERENCES events(id) ON DELETE CASCADE ON UPDATE RESTRICT, + ip VARCHAR(15) NOT NULL +); + +CREATE TABLE IF NOT EXISTS requests ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + requester_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE ON UPDATE RESTRICT, + event_id BIGINT NOT NULL REFERENCES events(id) ON DELETE CASCADE ON UPDATE RESTRICT, + status VARCHAR(15) NOT NULL, + created_at TIMESTAMP NOT NULL +); + +CREATE TABLE IF NOT EXISTS compilations ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + pinned BOOLEAN NOT NULL, + title VARCHAR(50) NOT NULL +); + +CREATE TABLE IF NOT EXISTS compilations_events ( + compilations_id BIGINT NOT NULL, + events_id BIGINT NOT NULL, + + CONSTRAINT pk_compilations_events PRIMARY KEY (compilations_id, events_id), + CONSTRAINT fk_compilations FOREIGN KEY (compilations_id) REFERENCES compilations (id) ON DELETE CASCADE, + CONSTRAINT fk_events FOREIGN KEY (events_id) REFERENCES events (id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS comments ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + textual_content VARCHAR(1000) NOT NULL, + author_id BIGINT NOT NULL, + event_id BIGINT NOT NULL, + create_time TIMESTAMP NOT NULL, + patch_time TIMESTAMP, + approved BOOLEAN DEFAULT TRUE NOT NULL, + + CONSTRAINT pk_comment PRIMARY KEY (id), + CONSTRAINT fk_user_comment FOREIGN KEY (author_id) REFERENCES users (id) ON DELETE CASCADE, + CONSTRAINT fk_event_comment FOREIGN KEY (event_id) REFERENCES events (id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_textual_content ON comments (textual_content); +CREATE INDEX IF NOT EXISTS idx_event_id_comment_id ON comments (event_id, id); \ No newline at end of file diff --git a/core/main-service/src/test/java/ru/practicum/client/StatClientTest.java b/core/main-service/src/test/java/ru/practicum/client/StatClientTest.java new file mode 100644 index 0000000..d66d7c9 --- /dev/null +++ b/core/main-service/src/test/java/ru/practicum/client/StatClientTest.java @@ -0,0 +1,48 @@ +package ru.practicum.client; + +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.context.SpringBootTest; +import ru.practicum.EventHitDto; +import ru.practicum.EventStatsResponseDto; +import ru.practicum.ewm.client.StatClient; + +import java.time.LocalDateTime; +import java.util.Collection; + +@Disabled("Выполнять только при запущенных Discovery and Config servers") +@AutoConfigureTestDatabase +@SpringBootTest +public class StatClientTest { + + @Autowired + StatClient statClient; + + @Test + void simpleClientTest() { + System.out.println(1); + LocalDateTime now = LocalDateTime.now(); + EventHitDto hit = EventHitDto.builder() + .app("app1") + .uri("/events/2") + .ip("192.168.1.2") + .timestamp(now.minusHours(2)) + .build(); + LocalDateTime start = LocalDateTime.now().minusDays(1); + LocalDateTime end = LocalDateTime.now().plusDays(1); + + statClient.hit(hit); + + Collection eventStatsResponseDtoCollection = statClient.stats(start, end, null, true); + System.out.println(eventStatsResponseDtoCollection.size() + " уникальных:"); + for (EventStatsResponseDto ev : eventStatsResponseDtoCollection) System.out.println(ev); + + Collection eventStatsResponseDtoCollection2 = statClient.stats(start, end, null, false); + System.out.println(eventStatsResponseDtoCollection2.size() + " всего:"); + for (EventStatsResponseDto ev : eventStatsResponseDtoCollection2) System.out.println(ev); + + } + +} diff --git a/core/main-service/src/test/java/ru/practicum/controller/UserControllerTest.java b/core/main-service/src/test/java/ru/practicum/controller/UserControllerTest.java new file mode 100644 index 0000000..0a190f5 --- /dev/null +++ b/core/main-service/src/test/java/ru/practicum/controller/UserControllerTest.java @@ -0,0 +1,426 @@ +package ru.practicum.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Disabled; +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.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import ru.practicum.exception.ConflictException; +import ru.practicum.exception.NotFoundException; +import ru.practicum.user.controller.UserController; +import ru.practicum.user.dto.NewUserRequestDto; +import ru.practicum.user.dto.UserDto; +import ru.practicum.user.service.UserService; + +import java.util.Arrays; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@Disabled("Выполнять только при запущенных Discovery and Config servers") +@WebMvcTest(UserController.class) +class UserControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private UserService userService; + + // CREATE USER TESTS + + @Test + void createUser_WithValidData_ReturnsCreatedUser() throws Exception { + // Given + NewUserRequestDto requestDto = NewUserRequestDto.builder() + .email("test@example.com") + .name("Test User") + .build(); + + UserDto expectedUser = UserDto.builder() + .id(1L) + .email("test@example.com") + .name("Test User") + .build(); + + when(userService.create(any(NewUserRequestDto.class))).thenReturn(expectedUser); + + // When & Then + mockMvc.perform(post("/admin/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requestDto))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(1L)) + .andExpect(jsonPath("$.email").value("test@example.com")) + .andExpect(jsonPath("$.name").value("Test User")); + + verify(userService).create(any(NewUserRequestDto.class)); + } + + @Test + void createUser_WithDuplicateEmail_ReturnsConflict() throws Exception { + // Given + NewUserRequestDto requestDto = NewUserRequestDto.builder() + .email("duplicate@example.com") + .name("Test User") + .build(); + + when(userService.create(any(NewUserRequestDto.class))) + .thenThrow(new ConflictException("User with email duplicate@example.com already exists", + "Integrity constraint has been violated")); + + // When & Then + mockMvc.perform(post("/admin/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requestDto))) + .andExpect(status().isConflict()); + + verify(userService).create(any(NewUserRequestDto.class)); + } + + @Test + void createUser_WithBlankEmail_ReturnsBadRequest() throws Exception { + // Given + NewUserRequestDto requestDto = NewUserRequestDto.builder() + .email("") + .name("Test User") + .build(); + + // When & Then + mockMvc.perform(post("/admin/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requestDto))) + .andExpect(status().isBadRequest()); + + verify(userService, never()).create(any(NewUserRequestDto.class)); + } + + @Test + void createUser_WithInvalidEmail_ReturnsBadRequest() throws Exception { + // Given + NewUserRequestDto requestDto = NewUserRequestDto.builder() + .email("invalid-email") + .name("Test User") + .build(); + + // When & Then + mockMvc.perform(post("/admin/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requestDto))) + .andExpect(status().isBadRequest()); + + verify(userService, never()).create(any(NewUserRequestDto.class)); + } + + @Test + void createUser_WithTooShortEmail_ReturnsBadRequest() throws Exception { + // Given + NewUserRequestDto requestDto = NewUserRequestDto.builder() + .email("a@b.c") // 5 characters, minimum is 6 + .name("Test User") + .build(); + + // When & Then + mockMvc.perform(post("/admin/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requestDto))) + .andExpect(status().isBadRequest()); + + verify(userService, never()).create(any(NewUserRequestDto.class)); + } + + @Test + void createUser_WithTooLongEmail_ReturnsBadRequest() throws Exception { + // Given + String longEmail = "a".repeat(250) + "@example.com"; // More than 254 characters + NewUserRequestDto requestDto = NewUserRequestDto.builder() + .email(longEmail) + .name("Test User") + .build(); + + // When & Then + mockMvc.perform(post("/admin/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requestDto))) + .andExpect(status().isBadRequest()); + + verify(userService, never()).create(any(NewUserRequestDto.class)); + } + + @Test + void createUser_WithBlankName_ReturnsBadRequest() throws Exception { + // Given + NewUserRequestDto requestDto = NewUserRequestDto.builder() + .email("test@example.com") + .name("") + .build(); + + // When & Then + mockMvc.perform(post("/admin/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requestDto))) + .andExpect(status().isBadRequest()); + + verify(userService, never()).create(any(NewUserRequestDto.class)); + } + + @Test + void createUser_WithTooShortName_ReturnsBadRequest() throws Exception { + // Given + NewUserRequestDto requestDto = NewUserRequestDto.builder() + .email("test@example.com") + .name("A") // 1 character, minimum is 2 + .build(); + + // When & Then + mockMvc.perform(post("/admin/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requestDto))) + .andExpect(status().isBadRequest()); + + verify(userService, never()).create(any(NewUserRequestDto.class)); + } + + @Test + void createUser_WithTooLongName_ReturnsBadRequest() throws Exception { + // Given + String longName = "A".repeat(251); // More than 250 characters + NewUserRequestDto requestDto = NewUserRequestDto.builder() + .email("test@example.com") + .name(longName) + .build(); + + // When & Then + mockMvc.perform(post("/admin/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requestDto))) + .andExpect(status().isBadRequest()); + + verify(userService, never()).create(any(NewUserRequestDto.class)); + } + + @Test + void createUser_WithNullEmail_ReturnsBadRequest() throws Exception { + // Given + NewUserRequestDto requestDto = NewUserRequestDto.builder() + .email(null) + .name("Test User") + .build(); + + // When & Then + mockMvc.perform(post("/admin/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requestDto))) + .andExpect(status().isBadRequest()); + + verify(userService, never()).create(any(NewUserRequestDto.class)); + } + + @Test + void createUser_WithNullName_ReturnsBadRequest() throws Exception { + // Given + NewUserRequestDto requestDto = NewUserRequestDto.builder() + .email("test@example.com") + .name(null) + .build(); + + // When & Then + mockMvc.perform(post("/admin/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requestDto))) + .andExpect(status().isBadRequest()); + + verify(userService, never()).create(any(NewUserRequestDto.class)); + } + + // DELETE USER TESTS + + @Test + void deleteUser_WithValidId_ReturnsNoContent() throws Exception { + // Given + Long userId = 1L; + doNothing().when(userService).delete(userId); + + // When & Then + mockMvc.perform(delete("/admin/users/{userId}", userId)) + .andExpect(status().isNoContent()); + + verify(userService).delete(userId); + } + + @Test + void deleteUser_WithNonExistentId_ReturnsNotFound() throws Exception { + // Given + Long userId = 999L; + doThrow(new NotFoundException("User with id=" + userId + " was not found")) + .when(userService).delete(userId); + + // When & Then + mockMvc.perform(delete("/admin/users/{userId}", userId)) + .andExpect(status().isNotFound()); + + verify(userService).delete(userId); + } + + @Test + void deleteUser_WithInvalidId_ReturnsBadRequest() throws Exception { + // Given + Long invalidUserId = -1L; + + // When & Then + mockMvc.perform(delete("/admin/users/{userId}", invalidUserId)) + .andExpect(status().isBadRequest()); + + verify(userService, never()).delete(any(Long.class)); + } + + @Test + void deleteUser_WithZeroId_ReturnsBadRequest() throws Exception { + // Given + Long invalidUserId = 0L; + + // When & Then + mockMvc.perform(delete("/admin/users/{userId}", invalidUserId)) + .andExpect(status().isBadRequest()); + + verify(userService, never()).delete(any(Long.class)); + } + + // GET USERS TESTS + + @Test + void getUsers_WithoutParameters_ReturnsDefaultPagedUsers() throws Exception { + // Given + List expectedUsers = Arrays.asList( + UserDto.builder().id(1L).email("user1@example.com").name("User 1").build(), + UserDto.builder().id(2L).email("user2@example.com").name("User 2").build() + ); + + when(userService.findByIdListWithOffsetAndLimit(null, 0, 10)) + .thenReturn(expectedUsers); + + // When & Then + mockMvc.perform(get("/admin/users")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(2)) + .andExpect(jsonPath("$[0].id").value(1L)) + .andExpect(jsonPath("$[0].email").value("user1@example.com")) + .andExpect(jsonPath("$[0].name").value("User 1")) + .andExpect(jsonPath("$[1].id").value(2L)) + .andExpect(jsonPath("$[1].email").value("user2@example.com")) + .andExpect(jsonPath("$[1].name").value("User 2")); + + verify(userService).findByIdListWithOffsetAndLimit(null, 0, 10); + } + + @Test + void getUsers_WithCustomPagination_ReturnsPagedUsers() throws Exception { + // Given + List expectedUsers = Arrays.asList( + UserDto.builder().id(3L).email("user3@example.com").name("User 3").build() + ); + + when(userService.findByIdListWithOffsetAndLimit(null, 5, 5)) + .thenReturn(expectedUsers); + + // When & Then + mockMvc.perform(get("/admin/users") + .param("from", "5") + .param("size", "5")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(1)) + .andExpect(jsonPath("$[0].id").value(3L)) + .andExpect(jsonPath("$[0].email").value("user3@example.com")) + .andExpect(jsonPath("$[0].name").value("User 3")); + + verify(userService).findByIdListWithOffsetAndLimit(null, 5, 5); + } + + @Test + void getUsers_WithSpecificIds_ReturnsFilteredUsers() throws Exception { + // Given + List userIds = Arrays.asList(1L, 3L); + List expectedUsers = Arrays.asList( + UserDto.builder().id(1L).email("user1@example.com").name("User 1").build(), + UserDto.builder().id(3L).email("user3@example.com").name("User 3").build() + ); + + when(userService.findByIdListWithOffsetAndLimit(userIds, 0, 10)) + .thenReturn(expectedUsers); + + // When & Then + mockMvc.perform(get("/admin/users") + .param("ids", "1,3")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(2)) + .andExpect(jsonPath("$[0].id").value(1L)) + .andExpect(jsonPath("$[1].id").value(3L)); + + verify(userService).findByIdListWithOffsetAndLimit(userIds, 0, 10); + } + + @Test + void getUsers_WithEmptyResult_ReturnsEmptyList() throws Exception { + // Given + when(userService.findByIdListWithOffsetAndLimit(null, 0, 10)) + .thenReturn(Arrays.asList()); + + // When & Then + mockMvc.perform(get("/admin/users")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(0)); + + verify(userService).findByIdListWithOffsetAndLimit(null, 0, 10); + } + + @Test + void getUsers_WithSingleId_ReturnsFilteredUser() throws Exception { + // Given + List userIds = Arrays.asList(1L); + List expectedUsers = Arrays.asList( + UserDto.builder().id(1L).email("user1@example.com").name("User 1").build() + ); + + when(userService.findByIdListWithOffsetAndLimit(userIds, 0, 10)) + .thenReturn(expectedUsers); + + // When & Then + mockMvc.perform(get("/admin/users") + .param("ids", "1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(1)) + .andExpect(jsonPath("$[0].id").value(1L)) + .andExpect(jsonPath("$[0].email").value("user1@example.com")) + .andExpect(jsonPath("$[0].name").value("User 1")); + + verify(userService).findByIdListWithOffsetAndLimit(userIds, 0, 10); + } + + @Test + void getUsers_WithNonExistentIds_ReturnsEmptyList() throws Exception { + // Given + List userIds = Arrays.asList(999L, 1000L); + + when(userService.findByIdListWithOffsetAndLimit(userIds, 0, 10)) + .thenReturn(Arrays.asList()); + + // When & Then + mockMvc.perform(get("/admin/users") + .param("ids", "999,1000")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(0)); + + verify(userService).findByIdListWithOffsetAndLimit(userIds, 0, 10); + } + +} \ No newline at end of file diff --git a/core/main-service/src/test/java/ru/practicum/service/UserServiceIntegrationTest.java b/core/main-service/src/test/java/ru/practicum/service/UserServiceIntegrationTest.java new file mode 100644 index 0000000..c241a5b --- /dev/null +++ b/core/main-service/src/test/java/ru/practicum/service/UserServiceIntegrationTest.java @@ -0,0 +1,365 @@ +package ru.practicum.service; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; +import ru.practicum.exception.ConflictException; +import ru.practicum.exception.NotFoundException; +import ru.practicum.user.dto.NewUserRequestDto; +import ru.practicum.user.dto.UserDto; +import ru.practicum.user.model.User; +import ru.practicum.user.repository.UserRepository; +import ru.practicum.user.service.UserService; + +import java.util.Arrays; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@Disabled("Выполнять только при запущенных Discovery and Config servers") +@SpringBootTest +@AutoConfigureTestDatabase +@Transactional +class UserServiceIntegrationTest { + + @Autowired + private UserService userService; + + @Autowired + private UserRepository userRepository; + + @PersistenceContext + private EntityManager entityManager; + + @BeforeEach + void setUp() { + userRepository.deleteAll(); + entityManager.flush(); + entityManager.clear(); + } + + // CREATE USER TESTS + + @Test + void create_WithValidData_ShouldSaveUserAndReturnDto() { + // Given + NewUserRequestDto requestDto = NewUserRequestDto.builder() + .email("test@example.com") + .name("Test User") + .build(); + + // When + UserDto result = userService.create(requestDto); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getId()).isNotNull(); + assertThat(result.getEmail()).isEqualTo("test@example.com"); + assertThat(result.getName()).isEqualTo("Test User"); + + // Verify user was saved in database + User savedUser = userRepository.findById(result.getId()).orElse(null); + assertThat(savedUser).isNotNull(); + assertThat(savedUser.getEmail()).isEqualTo("test@example.com"); + assertThat(savedUser.getName()).isEqualTo("Test User"); + } + + @Test + void create_WithDuplicateEmail_ShouldThrowConflictException() { + // Given + User existingUser = User.builder() + .email("duplicate@example.com") + .name("Existing User") + .build(); + userRepository.save(existingUser); + + NewUserRequestDto requestDto = NewUserRequestDto.builder() + .email("duplicate@example.com") + .name("New User") + .build(); + + // When & Then + assertThatThrownBy(() -> userService.create(requestDto)) + .isInstanceOf(ConflictException.class) + .hasMessageContaining("duplicate@example.com"); + + // Verify only one user exists + List users = userRepository.findAll(); + assertThat(users).hasSize(1); + assertThat(users.get(0).getName()).isEqualTo("Existing User"); + } + + @Test + void create_WithCaseInsensitiveEmail_ShouldWorkCorrectly() { + // Given + NewUserRequestDto requestDto = NewUserRequestDto.builder() + .email("TeSt@ExAmPLE.COM") + .name("Test User") + .build(); + + // When + UserDto result = userService.create(requestDto); + + // Then + assertThat(result.getEmail()).isEqualTo("TeSt@ExAmPLE.COM"); + + // Verify saved in database + User savedUser = userRepository.findById(result.getId()).orElse(null); + assertThat(savedUser.getEmail()).isEqualTo("TeSt@ExAmPLE.COM"); + } + + @Test + void create_WithMinimalValidData_ShouldCreateUser() { + // Given + NewUserRequestDto requestDto = NewUserRequestDto.builder() + .email("ab@c.de") // 6 characters - minimum valid + .name("AB") // 2 characters - minimum valid + .build(); + + // When + UserDto result = userService.create(requestDto); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getId()).isNotNull(); + assertThat(result.getEmail()).isEqualTo("ab@c.de"); + assertThat(result.getName()).isEqualTo("AB"); + } + + @Test + void create_WithMaximalValidData_ShouldCreateUser() { + // Given + String maxEmail = "a".repeat(245) + "@test.com"; // 254 characters - maximum valid + String maxName = "A".repeat(250); // 250 characters - maximum valid + + NewUserRequestDto requestDto = NewUserRequestDto.builder() + .email(maxEmail) + .name(maxName) + .build(); + + // When + UserDto result = userService.create(requestDto); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getId()).isNotNull(); + assertThat(result.getEmail()).isEqualTo(maxEmail); + assertThat(result.getName()).isEqualTo(maxName); + } + + // DELETE USER TESTS + + @Test + void delete_WithExistingUserId_ShouldDeleteUser() { + // Given + User user = User.builder() + .email("todelete@example.com") + .name("User To Delete") + .build(); + User savedUser = userRepository.save(user); + Long userId = savedUser.getId(); + + // When + userService.delete(userId); + + // Then + assertThat(userRepository.findById(userId)).isEmpty(); + assertThat(userRepository.count()).isEqualTo(0); + } + + @Test + void delete_WithNonExistentUserId_ShouldThrowNotFoundException() { + // Given + Long nonExistentId = 999L; + + // When & Then + assertThatThrownBy(() -> userService.delete(nonExistentId)) + .isInstanceOf(NotFoundException.class); + + // Verify database is unchanged + assertThat(userRepository.count()).isEqualTo(0); + } + + @Test + void delete_WithMultipleUsers_ShouldDeleteOnlySpecificUser() { + // Given + User user1 = userRepository.save(User.builder() + .email("user1@example.com") + .name("User 1") + .build()); + User user2 = userRepository.save(User.builder() + .email("user2@example.com") + .name("User 2") + .build()); + User user3 = userRepository.save(User.builder() + .email("user3@example.com") + .name("User 3") + .build()); + + // When + userService.delete(user2.getId()); + + // Then + assertThat(userRepository.findById(user1.getId())).isPresent(); + assertThat(userRepository.findById(user2.getId())).isEmpty(); + assertThat(userRepository.findById(user3.getId())).isPresent(); + assertThat(userRepository.count()).isEqualTo(2); + } + + // FIND BY ID LIST WITH OFFSET AND LIMIT TESTS + + @Test + void findByIdListWithOffsetAndLimit_WithNullIdList_ShouldReturnPagedResults() { + // Given + createTestUsers(15); + + // When + List result = userService.findByIdListWithOffsetAndLimit(null, 0, 10); + + // Then + assertThat(result).hasSize(10); + assertThat(result).extracting(UserDto::getEmail) + .contains("user0@example.com", "user1@example.com", "user9@example.com"); + } + + @Test + void findByIdListWithOffsetAndLimit_WithEmptyIdList_ShouldReturnPagedResults() { + // Given + createTestUsers(5); + + // When + List result = userService.findByIdListWithOffsetAndLimit(List.of(), 0, 10); + + // Then + assertThat(result).hasSize(5); + } + + @Test + void findByIdListWithOffsetAndLimit_WithOffsetAndLimit_ShouldReturnCorrectPage() { + // Given + createTestUsers(15); + + // When + List result = userService.findByIdListWithOffsetAndLimit(null, 1, 5); + + // Then + assertThat(result).hasSize(5); + assertThat(result).extracting(UserDto::getEmail) + .contains("user0@example.com", "user1@example.com", "user4@example.com"); + } + + @Test + void findByIdListWithOffsetAndLimit_WithSpecificIds_ShouldReturnFilteredResults() { + // Given + List users = createTestUsers(10); + List specificIds = Arrays.asList( + users.get(0).getId(), + users.get(2).getId(), + users.get(4).getId() + ); + + // When + List result = userService.findByIdListWithOffsetAndLimit(specificIds, 0, 10); + + // Then + assertThat(result).hasSize(3); + assertThat(result).extracting(UserDto::getId) + .containsExactlyInAnyOrderElementsOf(specificIds); + } + + @Test + void findByIdListWithOffsetAndLimit_WithNonExistentIds_ShouldReturnEmptyList() { + // Given + createTestUsers(5); + List nonExistentIds = Arrays.asList(999L, 1000L, 1001L); + + // When + List result = userService.findByIdListWithOffsetAndLimit(nonExistentIds, 0, 10); + + // Then + assertThat(result).isEmpty(); + } + + @Test + void findByIdListWithOffsetAndLimit_WithMixedExistentAndNonExistentIds_ShouldReturnOnlyExistentUsers() { + // Given + List users = createTestUsers(3); + List mixedIds = Arrays.asList( + users.get(0).getId(), + 999L, // non-existent + users.get(1).getId(), + 1000L // non-existent + ); + + // When + List result = userService.findByIdListWithOffsetAndLimit(mixedIds, 0, 10); + + // Then + assertThat(result).hasSize(2); + assertThat(result).extracting(UserDto::getId) + .containsExactlyInAnyOrder(users.get(0).getId(), users.get(1).getId()); + } + + @Test + void findByIdListWithOffsetAndLimit_WithEmptyDatabase_ShouldReturnEmptyList() { + // Given - empty database + + // When + List result = userService.findByIdListWithOffsetAndLimit(null, 0, 10); + + // Then + assertThat(result).isEmpty(); + } + + @Test + void findByIdListWithOffsetAndLimit_WithLargeOffset_ShouldReturnEmptyList() { + // Given + createTestUsers(5); + + // When + List result = userService.findByIdListWithOffsetAndLimit(null, 10, 10); + + // Then + assertThat(result).isEmpty(); + } + + @Test + void findByIdListWithOffsetAndLimit_WithSingleUser_ShouldReturnSingleResult() { + // Given + User user = userRepository.save(User.builder() + .email("single@example.com") + .name("Single User") + .build()); + + // When + List result = userService.findByIdListWithOffsetAndLimit(null, 0, 10); + + // Then + assertThat(result).hasSize(1); + assertThat(result.get(0).getId()).isEqualTo(user.getId()); + assertThat(result.get(0).getEmail()).isEqualTo("single@example.com"); + assertThat(result.get(0).getName()).isEqualTo("Single User"); + } + + // Helper methods + + private List createTestUsers(int count) { + List users = new java.util.ArrayList<>(); + for (int i = 0; i < count; i++) { + User user = User.builder() + .email("user" + i + "@example.com") + .name("User " + i) + .build(); + users.add(userRepository.save(user)); + } + return users; + } + +} \ No newline at end of file diff --git a/core/pom.xml b/core/pom.xml new file mode 100644 index 0000000..0ece0d5 --- /dev/null +++ b/core/pom.xml @@ -0,0 +1,19 @@ + + + 4.0.0 + + ru.practicum + explore-with-me + 0.0.1-SNAPSHOT + + + core + pom + + + main-service + + + \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index be96142..2437e74 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,14 +1,60 @@ services: + + main-server: + build: server + image: main-server + container_name: main-server + restart: on-failure + ports: + - "8080:8080" + depends_on: + - db-main + environment: + - SPRING_DATASOURCE_URL=jdbc:postgresql://db-main:5432/main_db + - EXPLORE_WITH_ME.STAT_SERVER.URL=http://stats-server:9090 + stats-server: + build: stat/stat-server + image: stat-server + container_name: stat-server + restart: on-failure ports: - "9090:9090" + depends_on: + - db-stat + environment: + - SPRING_DATASOURCE_URL=jdbc:postgresql://db-stat:5432/stat_db - stats-db: - image: postgres:16.1 - - ewm-service: + db-main: + image: postgres:alpine + container_name: db-main ports: - - "8080:8080" + - "5433:5432" + volumes: + - ./main-postgres:/var/lib/postgresql/data/ + environment: + - POSTGRES_DB=main_db + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=12345 + healthcheck: + test: pg_isready -q -d $$POSTGRES_DB -U $$POSTGRES_USER + timeout: 5s + interval: 5s + retries: 10 - ewm-db: - image: postgres:16.1 + db-stat: + image: postgres:alpine + container_name: db-stat + ports: + - "5432:5432" + volumes: + - ./stat-postgres:/var/lib/postgresql/data/ + environment: + - POSTGRES_DB=stat_db + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=12345 + healthcheck: + test: pg_isready -q -d $$POSTGRES_DB -U $$POSTGRES_USER + timeout: 5s + interval: 5s + retries: 10 diff --git a/ewm-feature-comments-spec.json b/ewm-feature-comments-spec.json new file mode 100644 index 0000000..2354dd5 --- /dev/null +++ b/ewm-feature-comments-spec.json @@ -0,0 +1,1004 @@ +{ + "openapi": "3.0.1", + "info": { + "description": "Спецификация описывает набор endpoints для работы с комментариями к событиям.\n\nEndpoins разделены на 3 части: \n\n1. Пользователь может оставить комментарий, редактировать его и удалить.\n\n2. Администратор (модератор) может reject (скрыть) комментарий, либо approve (вернуть) скрытый комментарий. По умолчанию все комментарии открыты. Также админ может искать в комментах по тексту, чтобы найти экстремизм. Может запрашивать все комментарии конкретного пользователя для отчета в ФСБ. А может полностью удалять комменты скрывая следы преступления.\n\n3. Неограниченный круг лиц может прочитать конкретный коммент к событию. Может прочитать все комменты к событию. Также можно запросить коммент по ID без привязки к событию.", + "title": "Описание API фичи \"Комментарии\"", + "version": "1.0" + }, + "servers": [ + { + "description": "Generated server url", + "url": "http://localhost:8080" + } + ], + "tags": [ + { + "description": "API комментатора", + "name": "Private: Комментарии" + }, + { + "description": "API администратора", + "name": "Admin: Комментарии" + }, + { + "description": "Публичный API", + "name": "Public: Комментарии" + } + ], + "paths": { + "/users/{userId}/events/{eventId}/comments": { + "post": { + "description": "Пользователь userId добавляет новый комментарий к событию eventId", + "operationId": "addComment", + "parameters": [ + { + "description": "id комментатора", + "in": "path", + "name": "userId", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "description": "id комментируемого события", + "in": "path", + "name": "eventId", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommentCreateDto" + } + } + }, + "description": "данные добавляемого комментария", + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommentDto" + } + } + }, + "description": "Комментарий добавлен" + }, + "400": { + "content": { + "application/json": { + "example": { + "status": "BAD_REQUEST", + "reason": "Incorrectly made request.", + "message": "Field: text. Error: must not be blank. Value: null", + "timestamp": "2022-09-07 09:10:50" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Запрос составлен некорректно" + }, + "404": { + "content": { + "application/json": { + "example": { + "status": "NOT_FOUND", + "reason": "The required object was not found.", + "message": "Event with id=27 was not found", + "timestamp": "2022-09-07 09:10:50" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Событие не найдено" + }, + "409": { + "content": { + "application/json": { + "example": { + "status": "CONFLICT", + "reason": "Forbidden action", + "message": "You can comment PUBLISHED events only!", + "timestamp": "2022-09-07 09:10:50" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Нарушение ограничений бизнес-логики" + } + }, + "summary": "Добавление нового комментария", + "tags": [ + "Private: Комментарии" + ] + } + }, + "/users/{userId}/comments/{comId}": { + "delete": { + "description": "Пользователь userId удаляет свой комментарий comId", + "operationId": "deleteComment", + "parameters": [ + { + "description": "id комментатора", + "in": "path", + "name": "userId", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "description": "id комментария", + "in": "path", + "name": "comId", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "204": { + "description": "Комментарий удален" + }, + "404": { + "content": { + "application/json": { + "example": { + "status": "NOT_FOUND", + "reason": "The required object was not found.", + "message": "Comment with id=27 was not found", + "timestamp": "2022-09-07 09:10:50" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Комментарий не найден" + }, + "409": { + "content": { + "application/json": { + "example": { + "status": "CONFLICT", + "reason": "Unauthorized access by user", + "message": "You can delete your own comment only!", + "timestamp": "2023-01-21 16:56:19" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Нарушение ограничений" + } + }, + "summary": "Удаление комментария", + "tags": [ + "Private: Комментарии" + ] + }, + "patch": { + "description": "Пользователь userId редактирует свой комментарий comId", + "operationId": "updateComment", + "parameters": [ + { + "description": "id комментатора", + "in": "path", + "name": "userId", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "description": "id комментария", + "in": "path", + "name": "comId", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommentCreateDto" + } + } + }, + "description": "Данные комментария для изменения", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommentDto" + } + } + }, + "description": "Данные комментария изменены" + }, + "404": { + "content": { + "application/json": { + "example": { + "status": "NOT_FOUND", + "reason": "The required object was not found.", + "message": "Comment with id=27 was not found", + "timestamp": "2022-09-07 09:10:50" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Комментарий не найден" + }, + "409": { + "content": { + "application/json": { + "example": { + "status": "CONFLICT", + "reason": "Unauthorized access by user", + "message": "You can edit your own comments only!", + "timestamp": "2022-09-07 09:10:50" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Нарушение ограничений" + } + }, + "summary": "Редактирование комментария", + "tags": [ + "Private: Комментарии" + ] + } + }, + "/admin/comments/search": { + "get": { + "description": "Возвращает все комментарии с заданным текстом. Постранично.\nЕсли не найдено ничего, возвращает пустой список", + "operationId": "searchComments", + "parameters": [ + { + "description": "текст для поиска", + "in": "query", + "name": "text", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "количество комментариев, которые нужно пропустить", + "in": "query", + "name": "from", + "required": false, + "schema": { + "minimum": 0, + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "description": "количество комментариев в наборе", + "in": "query", + "name": "size", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 10 + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CommentDto" + } + } + } + }, + "description": "Комментарии найдены" + }, + "400": { + "content": { + "application/json": { + "example": { + "status": "BAD_REQUEST", + "reason": "Incorrectly made request.", + "message": "Failed to convert value of type java.lang.String to required type int; nested exception is java.lang.NumberFormatException: For input string: ad", + "timestamp": "2022-09-07 09:10:50" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Запрос составлен некорректно" + } + }, + "summary": "Поиск комментариев по тексту", + "tags": [ + "Admin: Комментарии" + ] + } + }, + "/admin/users/{userId}/comments": { + "get": { + "description": "Возвращает все комментарии заданного пользователя. Постранично.\nЕсли не найдено ничего, возвращает пустой список", + "operationId": "getUserComments", + "parameters": [ + { + "description": "id комментатора", + "in": "path", + "name": "userId", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "description": "количество комментариев, которые нужно пропустить", + "in": "query", + "name": "from", + "required": false, + "schema": { + "minimum": 0, + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "description": "количество комментариев в наборе", + "in": "query", + "name": "size", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 10 + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CommentDto" + } + } + } + }, + "description": "Комментарии найдены" + }, + "400": { + "content": { + "application/json": { + "example": { + "status": "BAD_REQUEST", + "reason": "Incorrectly made request.", + "message": "Failed to convert value of type java.lang.String to required type int; nested exception is java.lang.NumberFormatException: For input string: ad", + "timestamp": "2022-09-07 09:10:50" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Запрос составлен некорректно" + } + }, + "summary": "Поиск комментариев заданного пользователя", + "tags": [ + "Admin: Комментарии" + ] + } + }, + "/admin/comments/{comId}": { + "delete": { + "description": "Администратор удаляет комментарий comId", + "operationId": "deleteCommentByAdmin", + "parameters": [ + { + "description": "id комментария", + "in": "path", + "name": "comId", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "204": { + "description": "Комментарий удален" + }, + "404": { + "content": { + "application/json": { + "example": { + "status": "NOT_FOUND", + "reason": "The required object was not found.", + "message": "Comment with id=27 was not found", + "timestamp": "2022-09-07 09:10:50" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Комментарий не найден" + } + }, + "summary": "Удаление комментария администратором", + "tags": [ + "Admin: Комментарии" + ] + } + }, + "/admin/comments/{comId}/approve": { + "patch": { + "description": "Администратор делает комментарий видимым", + "operationId": "approveComment", + "parameters": [ + { + "description": "id комментария", + "in": "path", + "name": "comId", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommentDto" + } + } + }, + "description": "Комментарий одобрен" + }, + "404": { + "content": { + "application/json": { + "example": { + "status": "NOT_FOUND", + "reason": "The required object was not found.", + "message": "Comment with id=2727 was not found", + "timestamp": "2022-09-07 09:10:50" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Комментарий не найден" + } + }, + "summary": "Одобрение комментария", + "tags": [ + "Admin: Комментарии" + ] + } + }, + "/admin/comments/{comId}/reject": { + "patch": { + "description": "Администратор делает комментарий невидимым", + "operationId": "rejectComment", + "parameters": [ + { + "description": "id комментария", + "in": "path", + "name": "comId", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommentDto" + } + } + }, + "description": "Комментарий отклонен" + }, + "404": { + "content": { + "application/json": { + "example": { + "status": "NOT_FOUND", + "reason": "The required object was not found.", + "message": "Comment with id=2727 was not found", + "timestamp": "2022-09-07 09:10:50" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Комментарий не найден" + } + }, + "summary": "Отклонение комментария", + "tags": [ + "Admin: Комментарии" + ] + } + }, + "/comments/{comId}": { + "get": { + "description": "Возвращает конкретный комментарий по ID", + "operationId": "getComment", + "parameters": [ + { + "description": "id комментария", + "in": "path", + "name": "comId", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommentDto" + } + } + }, + "description": "Комментарий найден" + }, + "404": { + "content": { + "application/json": { + "example": { + "status": "NOT_FOUND", + "reason": "The required object was not found.", + "message": "Comment with id=2727 was not found", + "timestamp": "2022-09-07 09:10:50" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Комментарий не найден" + } + }, + "summary": "Комментарий по ID", + "tags": [ + "Public: Комментарии" + ] + } + }, + "/events/{eventId}/comments/{commentId}": { + "get": { + "description": "Возвращает комментарий commentId к событию eventId", + "operationId": "getCommentRelatedToEvent", + "parameters": [ + { + "description": "id события", + "in": "path", + "name": "eventId", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "description": "id комментария", + "in": "path", + "name": "commentId", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommentDto" + } + } + }, + "description": "Комментарий найден" + }, + "404": { + "content": { + "application/json": { + "example": { + "status": "NOT_FOUND", + "reason": "The required object was not found.", + "message": "Comment with id=2727 was not found", + "timestamp": "2022-09-07 09:10:50" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Комментарий не найден" + } + }, + "summary": "Комментарий к конкретному событию", + "tags": [ + "Public: Комментарии" + ] + } + }, + "/events/{eventId}/comments": { + "get": { + "description": "Возвращает все комментарии к заданному событию. Постранично.\nЕсли не найдено ничего, возвращает пустой список", + "operationId": "getEventComments", + "parameters": [ + { + "description": "id события", + "in": "path", + "name": "eventId", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "description": "количество комментариев, которые нужно пропустить", + "in": "query", + "name": "from", + "required": false, + "schema": { + "minimum": 0, + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "description": "количество комментариев в наборе", + "in": "query", + "name": "size", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 10 + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CommentShortDto" + } + } + } + }, + "description": "Комментарии найдены" + }, + "404": { + "content": { + "application/json": { + "example": { + "status": "NOT_FOUND", + "reason": "The required object was not found.", + "message": "Event with id=2727 was not found", + "timestamp": "2022-09-07 09:10:50" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Событие не найдено" + } + }, + "summary": "Поиск комментариев к конкретному событию", + "tags": [ + "Public: Комментарии" + ] + } + } + }, + "components": { + "schemas": { + "ApiError": { + "type": "object", + "properties": { + "errors": { + "type": "array", + "description": "Список стектрейсов или описания ошибок", + "example": [], + "items": { + "type": "string", + "description": "Список стектрейсов или описания ошибок", + "example": "[]" + } + }, + "message": { + "type": "string", + "description": "Сообщение об ошибке", + "example": "Only pending or canceled events can be changed" + }, + "reason": { + "type": "string", + "description": "Общее описание причины ошибки", + "example": "For the requested operation the conditions are not met." + }, + "status": { + "type": "string", + "description": "Код статуса HTTP-ответа", + "example": "FORBIDDEN", + "enum": [ + "100 CONTINUE", + "101 SWITCHING_PROTOCOLS", + "102 PROCESSING", + "103 CHECKPOINT", + "200 OK", + "201 CREATED", + "202 ACCEPTED", + "203 NON_AUTHORITATIVE_INFORMATION", + "204 NO_CONTENT", + "205 RESET_CONTENT", + "206 PARTIAL_CONTENT", + "207 MULTI_STATUS", + "208 ALREADY_REPORTED", + "226 IM_USED", + "300 MULTIPLE_CHOICES", + "301 MOVED_PERMANENTLY", + "302 FOUND", + "302 MOVED_TEMPORARILY", + "303 SEE_OTHER", + "304 NOT_MODIFIED", + "305 USE_PROXY", + "307 TEMPORARY_REDIRECT", + "308 PERMANENT_REDIRECT", + "400 BAD_REQUEST", + "401 UNAUTHORIZED", + "402 PAYMENT_REQUIRED", + "403 FORBIDDEN", + "404 NOT_FOUND", + "405 METHOD_NOT_ALLOWED", + "406 NOT_ACCEPTABLE", + "407 PROXY_AUTHENTICATION_REQUIRED", + "408 REQUEST_TIMEOUT", + "409 CONFLICT", + "410 GONE", + "411 LENGTH_REQUIRED", + "412 PRECONDITION_FAILED", + "413 PAYLOAD_TOO_LARGE", + "413 REQUEST_ENTITY_TOO_LARGE", + "414 URI_TOO_LONG", + "414 REQUEST_URI_TOO_LONG", + "415 UNSUPPORTED_MEDIA_TYPE", + "416 REQUESTED_RANGE_NOT_SATISFIABLE", + "417 EXPECTATION_FAILED", + "418 I_AM_A_TEAPOT", + "419 INSUFFICIENT_SPACE_ON_RESOURCE", + "420 METHOD_FAILURE", + "421 DESTINATION_LOCKED", + "422 UNPROCESSABLE_ENTITY", + "423 LOCKED", + "424 FAILED_DEPENDENCY", + "425 TOO_EARLY", + "426 UPGRADE_REQUIRED", + "428 PRECONDITION_REQUIRED", + "429 TOO_MANY_REQUESTS", + "431 REQUEST_HEADER_FIELDS_TOO_LARGE", + "451 UNAVAILABLE_FOR_LEGAL_REASONS", + "500 INTERNAL_SERVER_ERROR", + "501 NOT_IMPLEMENTED", + "502 BAD_GATEWAY", + "503 SERVICE_UNAVAILABLE", + "504 GATEWAY_TIMEOUT", + "505 HTTP_VERSION_NOT_SUPPORTED", + "506 VARIANT_ALSO_NEGOTIATES", + "507 INSUFFICIENT_STORAGE", + "508 LOOP_DETECTED", + "509 BANDWIDTH_LIMIT_EXCEEDED", + "510 NOT_EXTENDED", + "511 NETWORK_AUTHENTICATION_REQUIRED" + ] + }, + "timestamp": { + "type": "string", + "description": "Дата и время когда произошла ошибка (в формате \"yyyy-MM-dd HH:mm:ss\")", + "example": "2022-06-09 06:27:23" + } + }, + "description": "Сведения об ошибке" + }, + "UserDto": { + "required": [ + "email", + "name" + ], + "type": "object", + "properties": { + "email": { + "type": "string", + "description": "Почтовый адрес", + "example": "petrov.i@practicummail.ru" + }, + "id": { + "type": "integer", + "description": "Идентификатор", + "format": "int64", + "readOnly": true, + "example": 1 + }, + "name": { + "type": "string", + "description": "Имя", + "example": "Петров Иван" + } + }, + "description": "Пользователь" + }, + "CommentDto": { + "description": "Комментарий (полная информация)", + "required": [ + "text" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64", + "example": 1 + }, + "text": { + "type": "string", + "description": "Текст комментария", + "example": "Я ждал этого события всю свою жизнь!" + }, + "author": { + "$ref": "#/components/schemas/UserDto" + }, + "event": { + "$ref": "#/components/schemas/EventCommentDto" + }, + "createTime": { + "type": "string", + "description": "Дата и время создания комментария", + "example": "2023-10-11 23:10:05" + }, + "patchTime": { + "type": "string", + "description": "Дата и время последнего редактирования комментария", + "example": "2023-10-15 23:10:05" + }, + "approved": { + "type": "boolean", + "description": "Открыт ли комментарий для просмотра или нет", + "example": true + } + } + }, + "CommentShortDto": { + "description": "Комментарий (сокращенная информация)", + "required": [ + "text" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64", + "example": 1 + }, + "text": { + "type": "string", + "description": "Текст комментария", + "example": "Я ждал этого события всю свою жизнь!" + }, + "author": { + "$ref": "#/components/schemas/UserDto" + }, + "createTime": { + "type": "string", + "description": "Дата и время создания комментария", + "example": "2023-10-11 23:10:05" + } + } + }, + "CommentCreateDto": { + "description": "Комментарий (DTO для создания нового комментария)", + "required": [ + "text" + ], + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "Текст комментария", + "example": "Хуже этого я ничего не видел" + } + } + }, + "EventCommentDto": { + "description": "Событие (сокращенная информация для списка комментариев)", + "required": [ + "title" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64", + "example": 1 + }, + "title": { + "type": "string", + "description": "Текст комментария", + "example": "Пьяные клоуны чинят электрику под песню Я русский" + } + } + } + } + } +} \ No newline at end of file diff --git a/infra/config-server/pom.xml b/infra/config-server/pom.xml new file mode 100644 index 0000000..a35dd76 --- /dev/null +++ b/infra/config-server/pom.xml @@ -0,0 +1,67 @@ + + + 4.0.0 + + + ru.practicum + infra + 0.0.1-SNAPSHOT + + + config-server + + + + + org.springframework.cloud + spring-cloud-config-server + + + + org.springframework.boot + spring-boot-starter-actuator + + + + org.springframework.cloud + spring-cloud-starter-netflix-eureka-client + + + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + + + + + + + + + \ No newline at end of file diff --git a/infra/config-server/src/main/java/ru/practicum/ConfigServerApplication.java b/infra/config-server/src/main/java/ru/practicum/ConfigServerApplication.java new file mode 100644 index 0000000..57dc667 --- /dev/null +++ b/infra/config-server/src/main/java/ru/practicum/ConfigServerApplication.java @@ -0,0 +1,15 @@ +package ru.practicum; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.config.server.EnableConfigServer; + +@EnableConfigServer +@SpringBootApplication +public class ConfigServerApplication { + + public static void main(String[] args) { + SpringApplication.run(ConfigServerApplication.class, args); + } + +} diff --git a/infra/config-server/src/main/resources/application.yaml b/infra/config-server/src/main/resources/application.yaml new file mode 100644 index 0000000..b93c364 --- /dev/null +++ b/infra/config-server/src/main/resources/application.yaml @@ -0,0 +1,43 @@ +info: + app: + name: config-server + description: Explore With Me + version: 1.0-SNAPSHOT + company: + name: Somekind Software + +management: + endpoints.web.exposure.include: health,info,metrics + endpoint.health.show-details: always + metrics.export.prometheus.enabled: true + info.env.enabled: true + +spring: + application: + name: config-server + profiles: + active: native + cloud: + config: + server: + native: + searchLocations: + - classpath:config/{application} + +server: + port: 0 + shutdown: graceful + +logging: + level: + root: INFO + +eureka: + client: + registerWithEureka: true + serviceUrl: + defaultZone: http://localhost:8761/eureka/ + instance: + instance-id: ${spring.application.name}${random.int} + preferIpAddress: false + hostname: localhost \ No newline at end of file diff --git a/infra/config-server/src/main/resources/config/gateway-server/application.yaml b/infra/config-server/src/main/resources/config/gateway-server/application.yaml new file mode 100644 index 0000000..83b5998 --- /dev/null +++ b/infra/config-server/src/main/resources/config/gateway-server/application.yaml @@ -0,0 +1,27 @@ +management: + endpoints.web.exposure.include: health,info,metrics,gateway + endpoint.health.show-details: always + endpoint.gateway.access: read-only + prometheus.metrics.export.enabled: true + info.env.enabled: true + +logging: + level: + root: INFO + org.springframework.cloud.gateway: TRACE + +server: + port: 8080 + shutdown: graceful + +spring: + cloud: + gateway: + server: + webflux: + routes: + + - id: main-service + uri: lb://main-service + predicates: + - Path=/** diff --git a/infra/config-server/src/main/resources/config/main-service/application.yaml b/infra/config-server/src/main/resources/config/main-service/application.yaml new file mode 100644 index 0000000..51e63d7 --- /dev/null +++ b/infra/config-server/src/main/resources/config/main-service/application.yaml @@ -0,0 +1,43 @@ +info: + app: + name: main-service + description: Explore With Me + version: 1.0-SNAPSHOT + company: + name: Somekind Software + +logging: + level: + org.springframework.web: INFO + org.springframework.validation: INFO + +management: + endpoints.web.exposure.include: health,info,metrics + endpoint.health.show-details: always + metrics.export.prometheus.enabled: true + info.env.enabled: true + +explore-with-me: + stat-server.discovery.name: stats-server + datetime.format: yyyy-MM-dd HH:mm:ss + main.datetime.format: yyyy-MM-dd HH:mm:ss + stat.datetime.format: yyyy-MM-dd HH:mm:ss + +server: + port: 0 + shutdown: graceful + +spring: + lifecycle: + timeout-per-shutdown-phase: 30s + jpa: + show-sql: true + properties.hibernate.format_sql: false + hibernate.ddl-auto: none + sql: + init.mode: always + datasource: + driver-class-name: org.postgresql.Driver + url: jdbc:postgresql://localhost:5433/main_db + username: postgres + password: 12345 diff --git a/infra/config-server/src/main/resources/config/stats-server/application.yaml b/infra/config-server/src/main/resources/config/stats-server/application.yaml new file mode 100644 index 0000000..cad3bfb --- /dev/null +++ b/infra/config-server/src/main/resources/config/stats-server/application.yaml @@ -0,0 +1,40 @@ +info: + app: + name: stats-service + description: Explore With Me + version: 1.0-SNAPSHOT + company: + name: Somekind Software + +logging: + level: + org.springframework.web: INFO + org.springframework.validation: INFO + +management: + endpoints.web.exposure.include: health,info,metrics + endpoint.health.show-details: always + metrics.export.prometheus.enabled: true + info.env.enabled: true + +explore-with-me: + datetime.format: yyyy-MM-dd HH:mm:ss + +server: + port: 0 + shutdown: graceful + +spring: + lifecycle: + timeout-per-shutdown-phase: 30s + jpa: + show-sql: true + properties.hibernate.format_sql: false + hibernate.ddl-auto: none + sql: + init.mode: always + datasource: + driver-class-name: org.postgresql.Driver + url: jdbc:postgresql://localhost:5432/stat_db + username: postgres + password: 12345 diff --git a/infra/discovery-server/pom.xml b/infra/discovery-server/pom.xml new file mode 100644 index 0000000..215be64 --- /dev/null +++ b/infra/discovery-server/pom.xml @@ -0,0 +1,63 @@ + + + 4.0.0 + + + ru.practicum + infra + 0.0.1-SNAPSHOT + + + discovery-server + + + + + org.springframework.cloud + spring-cloud-starter-netflix-eureka-server + + + + org.springframework.boot + spring-boot-starter-actuator + + + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + + + + + + + + + + \ No newline at end of file diff --git a/infra/discovery-server/src/main/java/ru/practicum/DiscoveryServerApplication.java b/infra/discovery-server/src/main/java/ru/practicum/DiscoveryServerApplication.java new file mode 100644 index 0000000..6ae03ca --- /dev/null +++ b/infra/discovery-server/src/main/java/ru/practicum/DiscoveryServerApplication.java @@ -0,0 +1,15 @@ +package ru.practicum; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.netflix.eureka.server.EnableEurekaServer; + +@EnableEurekaServer +@SpringBootApplication +public class DiscoveryServerApplication { + + public static void main(String[] args) { + SpringApplication.run(DiscoveryServerApplication.class, args); + } + +} diff --git a/infra/discovery-server/src/main/resources/application.yaml b/infra/discovery-server/src/main/resources/application.yaml new file mode 100644 index 0000000..cb91954 --- /dev/null +++ b/infra/discovery-server/src/main/resources/application.yaml @@ -0,0 +1,30 @@ +info: + app: + name: discovery-server + description: My Area Guide + version: 1.0-SNAPSHOT + company: + name: Somekind Software + +management: + endpoints.web.exposure.include: health,info,metrics + endpoint.health.show-details: always + metrics.export.prometheus.enabled: true + info.env.enabled: true + +spring: + application: + name: discovery-server + +server: + port: 8761 + shutdown: graceful + +eureka: + instance: + hostname: localhost + client: + registerWithEureka: false + fetchRegistry: false + serviceUrl: + defaultZone: http://${eureka.instance.hostname}:${server.port}/eureka/ diff --git a/infra/gateway-server/pom.xml b/infra/gateway-server/pom.xml new file mode 100644 index 0000000..dd05ee7 --- /dev/null +++ b/infra/gateway-server/pom.xml @@ -0,0 +1,87 @@ + + + 4.0.0 + + + ru.practicum + infra + 0.0.1-SNAPSHOT + + + gateway-server + + + + + org.springframework.boot + spring-boot-starter-actuator + + + + org.springframework.cloud + spring-cloud-starter-config + + + + org.springframework.retry + spring-retry + + + + org.springframework.cloud + spring-cloud-starter-netflix-eureka-client + + + + org.springframework.boot + spring-boot-starter-webflux + + + + org.springframework.cloud + spring-cloud-gateway-server-webflux + + + + org.springframework.cloud + spring-cloud-starter-loadbalancer + + + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + + + + + + + + + \ No newline at end of file diff --git a/infra/gateway-server/src/main/java/ru/practicum/GatewayServerApplication.java b/infra/gateway-server/src/main/java/ru/practicum/GatewayServerApplication.java new file mode 100644 index 0000000..23f3376 --- /dev/null +++ b/infra/gateway-server/src/main/java/ru/practicum/GatewayServerApplication.java @@ -0,0 +1,13 @@ +package ru.practicum; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class GatewayServerApplication { + + public static void main(String[] args) { + SpringApplication.run(GatewayServerApplication.class, args); + } + +} diff --git a/infra/gateway-server/src/main/resources/application.yaml b/infra/gateway-server/src/main/resources/application.yaml new file mode 100644 index 0000000..2344aea --- /dev/null +++ b/infra/gateway-server/src/main/resources/application.yaml @@ -0,0 +1,25 @@ +spring: + application: + name: gateway-server + config: + import: "configserver:" + cloud: + config: + discovery: + enabled: true + serviceId: config-server + fail-fast: true + retry: + useRandomPolicy: true + max-interval: 10000 + max-attempts: 100 + +eureka: + client: + registerWithEureka: true + serviceUrl: + defaultZone: http://localhost:8761/eureka/ + instance: + instance-id: ${spring.application.name}${random.int} + preferIpAddress: false + hostname: localhost diff --git a/infra/pom.xml b/infra/pom.xml new file mode 100644 index 0000000..a07aff1 --- /dev/null +++ b/infra/pom.xml @@ -0,0 +1,21 @@ + + + 4.0.0 + + ru.practicum + explore-with-me + 0.0.1-SNAPSHOT + + + infra + pom + + + discovery-server + config-server + gateway-server + + + \ No newline at end of file diff --git a/pom.xml b/pom.xml index 5b3a851..5c23a44 100644 --- a/pom.xml +++ b/pom.xml @@ -1,187 +1,75 @@ + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 org.springframework.boot spring-boot-starter-parent - 3.3.4 + 3.5.7 Explore With Me + + core + infra + stats + - ru.practicum + ru.practicum explore-with-me 0.0.1-SNAPSHOT pom - 21 UTF-8 + 21 + + 0.0.1-SNAPSHOT + 2025.0.0 + + 3.14.0 + + + + + org.springframework.cloud + spring-cloud-dependencies + ${spring-cloud-dependencies.version} + pom + import + + + + ru.practicum + stats-client + ${stats-client.version} + + + + ru.practicum + stats-common + ${stats-client.version} + + + + + + org.apache.maven.plugins - maven-surefire-plugin - - - test - - - - - org.apache.maven.plugins - maven-checkstyle-plugin - 3.1.2 - - checkstyle.xml - true - true - true - - - - - check - - compile - - - - - com.puppycrawl.tools - checkstyle - 10.3 - - - - - com.github.spotbugs - spotbugs-maven-plugin - 4.8.5.0 - - Max - High - - - - - check - - - - - - org.jacoco - jacoco-maven-plugin - 0.8.12 - - file - - - - jacoco-initialize - - prepare-agent - - - - jacoco-check - - check - - - - - BUNDLE - - - INSTRUCTION - COVEREDRATIO - 0.01 - - - LINE - COVEREDRATIO - 0.2 - - - BRANCH - COVEREDRATIO - 0.2 - - - COMPLEXITY - COVEREDRATIO - 0.2 - - - METHOD - COVEREDRATIO - 0.2 - - - CLASS - MISSEDCOUNT - 1 - - - - - - - - jacoco-site - install - - report - - - + maven-compiler-plugin + ${maven-compiler-plugin.version} + - - - check - - - - org.apache.maven.plugins - maven-checkstyle-plugin - - - com.github.spotbugs - spotbugs-maven-plugin - - - - - - - com.github.spotbugs - spotbugs-maven-plugin - - - - - - coverage - - - - org.jacoco - jacoco-maven-plugin - - - - - diff --git a/postman/feature.json b/postman/feature.json new file mode 100644 index 0000000..31f4674 --- /dev/null +++ b/postman/feature.json @@ -0,0 +1,2848 @@ +{ + "info": { + "_postman_id": "88864e61-ce56-4bd9-8ae2-18e12943f097", + "name": "Feature", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "42457555" + }, + "item": [ + { + "name": "Private", + "item": [ + { + "name": "Создание комментария", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + " \r", + "const main = async () => {\r", + " const api = new API(pm);\r", + " const utils = new RandomUtils(); \r", + "\r", + " const user = await api.addUser(utils.getUser());\r", + " pm.collectionVariables.set(\"userId\", user.id);\r", + " const category = await api.addCategory(utils.getCategory());\r", + " const event = await api.addEvent(user.id, utils.getEvent(category.id));\r", + " pm.collectionVariables.set(\"eventId\", event.id);\r", + " await api.publishEvent(event.id); \r", + " const commentbody = utils.getComment();\r", + " pm.request.body.update({\r", + " mode: 'raw',\r", + " raw: JSON.stringify(commentbody),\r", + " options: { raw: { language: 'json' } }\r", + " });\r", + "}; \r", + "\r", + " const interval = setInterval(() => {}, 1000);\r", + "\r", + "setTimeout(async () => \r", + " {\r", + " try {\r", + " await main();\r", + " } catch (e) {\r", + " console.error(e);\r", + " } finally {\r", + " clearInterval(interval);\r", + " }\r", + " }, \r", + " 500 \r", + ");" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "\r", + "pm.test(\"Ответ должен содержать код статуса 201 и данные в формате json\", function() {\r", + " pm.response.to.have.status(201);\r", + " pm.response.to.be.withBody;\r", + " pm.response.to.be.json;\r", + "});\r", + "\r", + "const source = JSON.parse(pm.request.body.raw);\r", + "const target = pm.response.json();\r", + "\r", + "\r", + "pm.test(\"Комментарий должен содержать обязательные поля\", function() {\r", + " pm.expect(target).to.have.property('id');\r", + " pm.expect(target).to.have.property('text');\r", + " pm.expect(target).to.have.property('author');\r", + " pm.expect(target).to.have.property('event');\r", + " pm.expect(target).to.have.property('createTime');\r", + " pm.expect(target).to.have.property('approved');\r", + "});\r", + "\r", + "pm.test(\"Текст комментария должен соответствовать отправленному\", function() {\r", + " pm.expect(target.text).to.equal(source.text);\r", + "});\r", + "\r", + "pm.test(\"Автор комментария должен содержать обязательные поля\", function() {\r", + " pm.expect(target.author).to.have.property('id');\r", + " pm.expect(target.author).to.have.property('name');\r", + " pm.expect(target.author).to.have.property('email');\r", + "});\r", + "\r", + "pm.test(\"Событие комментария должно содержать обязательные поля\", function() {\r", + " pm.expect(target.event).to.have.property('id');\r", + " pm.expect(target.event).to.have.property('title');\r", + "});\r", + "\r", + "pm.test(\"Дата создания должна быть валидной\", function() {\r", + " pm.expect(new Date(target.createTime)).to.be.a('Date');\r", + " pm.expect(new Date(target.createTime).getTime()).to.be.above(0);\r", + "});\r", + "\r", + " \r", + "pm.test(\"Автор комментария должен быть создателем\", function() {\r", + " const userId = pm.collectionVariables.get(\"userId\");\r", + " pm.expect(target.author.id).to.equal(userId);\r", + "});\r", + "\r", + "pm.test(\"Событие комментария должно соответствовать запрошенному\", function() {\r", + " const eventId = pm.collectionVariables.get(\"eventId\");\r", + " pm.expect(target.event.id).to.equal(eventId);\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + }, + { + "key": "Accept", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{{request_body}}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/users/:userId/events/:eventId/comments", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "users", + ":userId", + "events", + ":eventId", + "comments" + ], + "variable": [ + { + "key": "userId", + "value": "{{userId}}" + }, + { + "key": "eventId", + "value": "{{eventId}}" + } + ] + } + }, + "response": [] + }, + { + "name": "Создание комментария на не опубликованном событии", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + " \r", + "const main = async () => {\r", + " const api = new API(pm);\r", + " const utils = new RandomUtils(); \r", + " const user = await api.addUser(utils.getUser());\r", + " pm.collectionVariables.set(\"userId\", user.id);\r", + " const category = await api.addCategory(utils.getCategory());\r", + " const event = await api.addEvent(user.id, utils.getEvent(category.id));\r", + " pm.collectionVariables.set(\"eventId\", event.id);\r", + " const commentbody = utils.getComment();\r", + " pm.request.body.update({\r", + " mode: 'raw',\r", + " raw: JSON.stringify(commentbody),\r", + " options: { raw: { language: 'json' } }\r", + " });\r", + "}; \r", + "\r", + " const interval = setInterval(() => {}, 1000);\r", + "\r", + "setTimeout(async () => \r", + " {\r", + " try {\r", + " await main();\r", + " } catch (e) {\r", + " console.error(e);\r", + " } finally {\r", + " clearInterval(interval);\r", + " }\r", + " }, \r", + " 500 \r", + ");" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Код статуса 409\", function() {\r", + " pm.response.to.have.status(409);\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + }, + { + "key": "Accept", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{{request_body}}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/users/:userId/events/:eventId/comments", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "users", + ":userId", + "events", + ":eventId", + "comments" + ], + "variable": [ + { + "key": "userId", + "value": "{{userId}}" + }, + { + "key": "eventId", + "value": "{{eventId}}" + } + ] + } + }, + "response": [] + }, + { + "name": "Удаление комментария", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + " \r", + "const main = async () => {\r", + " const api = new API(pm);\r", + " const utils = new RandomUtils(); \r", + "\r", + " const user = await api.addUser(utils.getUser());\r", + " pm.collectionVariables.set(\"userId\", user.id);\r", + " const category = await api.addCategory(utils.getCategory());\r", + " const event = await api.addEvent(user.id, utils.getEvent(category.id));\r", + " await api.publishEvent(event.id); \r", + " const comment = await api.addComment(user.id, event.id, utils.getComment());\r", + " pm.collectionVariables.set(\"comId\", comment.id);\r", + "}; \r", + "\r", + " const interval = setInterval(() => {}, 1000);\r", + "\r", + "setTimeout(async () => \r", + " {\r", + " try {\r", + " await main();\r", + " } catch (e) {\r", + " console.error(e);\r", + " } finally {\r", + " clearInterval(interval);\r", + " }\r", + " }, \r", + " 500 \r", + ");" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Ответ должен содержать код статуса 204\", function () {\r", + " pm.response.to.have.status(204);\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "DELETE", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + }, + { + "key": "Accept", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/users/:userId/comments/:comId", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "users", + ":userId", + "comments", + ":comId" + ], + "variable": [ + { + "key": "userId", + "value": "{{userId}}" + }, + { + "key": "comId", + "value": "{{comId}}" + } + ] + } + }, + "response": [] + }, + { + "name": "Удаление комментария с неправильным id", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + " \r", + "const main = async () => {\r", + " const api = new API(pm);\r", + " const utils = new RandomUtils(); \r", + "\r", + " const user = await api.addUser(utils.getUser());\r", + " pm.collectionVariables.set(\"userId\", user.id);\r", + " pm.collectionVariables.set(\"comId\", 100000);\r", + "}; \r", + "\r", + "\r", + "\r", + " const interval = setInterval(() => {}, 1000);\r", + "\r", + "setTimeout(async () => \r", + " {\r", + " try {\r", + " await main();\r", + " } catch (e) {\r", + " console.error(e);\r", + " } finally {\r", + " clearInterval(interval);\r", + " }\r", + " }, \r", + " 500 \r", + ");" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Ответ должен содержать код статуса 404\", function () {\r", + " pm.response.to.have.status(404);\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{baseUrl}}/users/:userId/comments/:comId", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "users", + ":userId", + "comments", + ":comId" + ], + "variable": [ + { + "key": "userId", + "value": "{{userId}}" + }, + { + "key": "comId", + "value": "{{comId}}" + } + ] + } + }, + "response": [] + }, + { + "name": "Изменение комментария", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "const main = async () => {\r", + " const api = new API(pm);\r", + " const utils = new RandomUtils(); \r", + "\r", + " const user = await api.addUser(utils.getUser());\r", + " pm.collectionVariables.set(\"userId\", user.id);\r", + " const category = await api.addCategory(utils.getCategory());\r", + " const event = await api.addEvent(user.id, utils.getEvent(category.id));\r", + " pm.collectionVariables.set(\"eventId\", event.id);\r", + " await api.publishEvent(event.id); \r", + " const comment = await api.addComment(user.id, event.id, utils.getComment());\r", + " pm.collectionVariables.set(\"comId\", comment.id);\r", + " const comment2=utils.getComment();\r", + " pm.request.body = JSON.stringify(comment2);\r", + "}; \r", + "\r", + "\r", + " const interval = setInterval(() => {}, 1000);\r", + "\r", + "setTimeout(async () => \r", + " {\r", + " try {\r", + " await main();\r", + " } catch (e) {\r", + " console.error(e);\r", + " } finally {\r", + " clearInterval(interval);\r", + " }\r", + " }, \r", + " 500 \r", + ");" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Ответ должен содержать код статуса 200 и данные в формате json\", function() {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.be.withBody;\r", + " pm.response.to.be.json;\r", + "});\r", + "\r", + "const source = JSON.parse(pm.request.body);\r", + "const target = pm.response.json();\r", + "\r", + "pm.test(\"Комментарий должен содержать обязательные поля\", function() {\r", + " pm.expect(target).to.have.property('id');\r", + " pm.expect(target).to.have.property('text');\r", + " pm.expect(target).to.have.property('author');\r", + " pm.expect(target).to.have.property('event');\r", + " pm.expect(target).to.have.property('createTime');\r", + " pm.expect(target).to.have.property('approved');\r", + "});\r", + "\r", + "pm.test(\"Текст комментария должен соответствовать отправленному\", function() {\r", + " pm.expect(target.text).to.equal(source.text);\r", + "});\r", + "\r", + "pm.test(\"Автор комментария должен содержать обязательные поля\", function() {\r", + " pm.expect(target.author).to.have.property('id');\r", + " pm.expect(target.author).to.have.property('name');\r", + " pm.expect(target.author).to.have.property('email');\r", + "});\r", + "\r", + "pm.test(\"Событие комментария должно содержать обязательные поля\", function() {\r", + " pm.expect(target.event).to.have.property('id');\r", + " pm.expect(target.event).to.have.property('title');\r", + "});\r", + "\r", + "pm.test(\"Дата создания должна быть валидной\", function() {\r", + " pm.expect(new Date(target.createTime)).to.be.a('Date');\r", + " pm.expect(new Date(target.createTime).getTime()).to.be.above(0);\r", + "});\r", + "\r", + "pm.test(\"Автор комментария должен быть создателем\", function() {\r", + " const userId = pm.collectionVariables.get(\"userId\");\r", + " pm.expect(target.author.id).to.equal(userId);\r", + "});\r", + "\r", + "pm.test(\"Событие комментария должно соответствовать запрошенному\", function() {\r", + " const eventId = pm.collectionVariables.get(\"eventId\");\r", + " pm.expect(target.event.id).to.equal(eventId);\r", + "});\r", + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + }, + { + "key": "Accept", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/users/:userId/comments/:comId", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "users", + ":userId", + "comments", + ":comId" + ], + "variable": [ + { + "key": "userId", + "value": "{{userId}}" + }, + { + "key": "comId", + "value": "{{comId}}" + } + ] + } + }, + "response": [] + }, + { + "name": "Создание комментария с пустым текстом", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + " \r", + "const main = async () => {\r", + " const api = new API(pm);\r", + " const utils = new RandomUtils(); \r", + "\r", + " const user = await api.addUser(utils.getUser());\r", + " pm.collectionVariables.set(\"userId\", user.id);\r", + " const category = await api.addCategory(utils.getCategory());\r", + " const event = await api.addEvent(user.id, utils.getEvent(category.id));\r", + " pm.collectionVariables.set(\"eventId\", event.id);\r", + " await api.publishEvent(event.id); \r", + " const comment = utils.getComment();\r", + " comment.text = null; \r", + " pm.request.body.update({\r", + " mode: 'raw',\r", + " raw: JSON.stringify(comment),\r", + " options: { raw: { language: 'json' } }\r", + " });\r", + "}; \r", + "\r", + " const interval = setInterval(() => {}, 1000);\r", + "\r", + "setTimeout(async () => \r", + " {\r", + " try {\r", + " await main();\r", + " } catch (e) {\r", + " console.error(e);\r", + " } finally {\r", + " clearInterval(interval);\r", + " }\r", + " }, \r", + " 500 \r", + ");" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Ответ должен содержать код статуса 400\", function() {\r", + " pm.response.to.have.status(400);\r", + "});\r", + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{{request_body}}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/users/:userId/events/:eventId/comments", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "users", + ":userId", + "events", + ":eventId", + "comments" + ], + "variable": [ + { + "key": "userId", + "value": "{{userId}}" + }, + { + "key": "eventId", + "value": "{{eventId}}" + } + ] + } + }, + "response": [] + }, + { + "name": "Создание комментария несуществующим пользователем", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + " \r", + "const main = async () => {\r", + " const api = new API(pm);\r", + " const utils = new RandomUtils(); \r", + "\r", + " const user = await api.addUser(utils.getUser());\r", + " pm.collectionVariables.set(\"userId\", user.id);\r", + " const category = await api.addCategory(utils.getCategory());\r", + " const event = await api.addEvent(user.id, utils.getEvent(category.id));\r", + " pm.collectionVariables.set(\"eventId\", event.id);\r", + " await api.publishEvent(event.id); \r", + " const comment = utils.getComment();\r", + " comment.text = null; \r", + " pm.request.body.update({\r", + " mode: 'raw',\r", + " raw: JSON.stringify(comment),\r", + " options: { raw: { language: 'json' } }\r", + " });\r", + "}; \r", + "\r", + " const interval = setInterval(() => {}, 1000);\r", + "\r", + "setTimeout(async () => \r", + " {\r", + " try {\r", + " await main();\r", + " } catch (e) {\r", + " console.error(e);\r", + " } finally {\r", + " clearInterval(interval);\r", + " }\r", + " }, \r", + " 500 \r", + ");" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Ответ должен содержать код статуса 400\", function() {\r", + " pm.response.to.have.status(400);\r", + "});\r", + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{{request_body}}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/users/:userId/events/:eventId/comments", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "users", + ":userId", + "events", + ":eventId", + "comments" + ], + "variable": [ + { + "key": "userId", + "value": "{{userId}}" + }, + { + "key": "eventId", + "value": "{{eventId}}" + } + ] + } + }, + "response": [] + }, + { + "name": "Изменение комментария не создателем", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "const main = async () => {\r", + " const api = new API(pm);\r", + " const utils = new RandomUtils(); \r", + "\r", + " const user = await api.addUser(utils.getUser());\r", + " pm.collectionVariables.set(\"userId\", 1000);\r", + " const category = await api.addCategory(utils.getCategory());\r", + " const event = await api.addEvent(user.id, utils.getEvent(category.id));\r", + " pm.collectionVariables.set(\"eventId\", event.id);\r", + " await api.publishEvent(event.id); \r", + " const comment = await api.addComment(user.id, event.id, utils.getComment());\r", + " pm.collectionVariables.set(\"comId\", comment.id);\r", + " const comment2=utils.getComment();\r", + " pm.request.body = JSON.stringify(comment2);\r", + "}; \r", + "\r", + "\r", + " const interval = setInterval(() => {}, 1000);\r", + "\r", + "setTimeout(async () => \r", + " {\r", + " try {\r", + " await main();\r", + " } catch (e) {\r", + " console.error(e);\r", + " } finally {\r", + " clearInterval(interval);\r", + " }\r", + " }, \r", + " 500 \r", + ");" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Ответ должен содержать код статуса 409\", function() {\r", + " pm.response.to.have.status(409);\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + }, + { + "key": "Accept", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/users/:userId/comments/:comId", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "users", + ":userId", + "comments", + ":comId" + ], + "variable": [ + { + "key": "userId", + "value": "{{userId}}" + }, + { + "key": "comId", + "value": "{{comId}}" + } + ] + } + }, + "response": [] + }, + { + "name": "Изменение комментария с пустым текстом", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "const main = async () => {\r", + " const api = new API(pm);\r", + " const utils = new RandomUtils(); \r", + "\r", + " const user = await api.addUser(utils.getUser());\r", + " pm.collectionVariables.set(\"userId\", 1000);\r", + " const category = await api.addCategory(utils.getCategory());\r", + " const event = await api.addEvent(user.id, utils.getEvent(category.id));\r", + " pm.collectionVariables.set(\"eventId\", event.id);\r", + " await api.publishEvent(event.id); \r", + " const comment = await api.addComment(user.id, event.id, utils.getComment());\r", + " pm.collectionVariables.set(\"comId\", comment.id);\r", + " const comment2=utils.getComment();\r", + " comment2.text=null;\r", + " pm.request.body = JSON.stringify(comment2);\r", + "}; \r", + "\r", + "\r", + " const interval = setInterval(() => {}, 1000);\r", + "\r", + "setTimeout(async () => \r", + " {\r", + " try {\r", + " await main();\r", + " } catch (e) {\r", + " console.error(e);\r", + " } finally {\r", + " clearInterval(interval);\r", + " }\r", + " }, \r", + " 500 \r", + ");" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Ответ должен содержать код статуса 400\", function() {\r", + " pm.response.to.have.status(400);\r", + "});\r", + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + }, + { + "key": "Accept", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/users/:userId/comments/:comId", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "users", + ":userId", + "comments", + ":comId" + ], + "variable": [ + { + "key": "userId", + "value": "{{userId}}" + }, + { + "key": "comId", + "value": "{{comId}}" + } + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Public", + "item": [ + { + "name": "Получения комментария по id", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "const main = async () => {\r", + " const api = new API(pm);\r", + " const utils = new RandomUtils(); \r", + "\r", + " const user = await api.addUser(utils.getUser());\r", + " pm.collectionVariables.set(\"userId\", user.id); \r", + " const category = await api.addCategory(utils.getCategory());\r", + " const event = await api.addEvent(user.id, utils.getEvent(category.id));\r", + " await api.publishEvent(event.id); \r", + " const comment = await api.addComment(user.id, event.id, utils.getComment());\r", + " pm.collectionVariables.set(\"comId\", comment.id);\r", + " pm.collectionVariables.set(\"comment\", comment); \r", + "}; \r", + "\r", + "\r", + "\r", + " const interval = setInterval(() => {}, 1000);\r", + "\r", + "setTimeout(async () => \r", + " {\r", + " try {\r", + " await main();\r", + " } catch (e) {\r", + " console.error(e);\r", + " } finally {\r", + " clearInterval(interval);\r", + " }\r", + " }, \r", + " 500 \r", + ");" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Ответ должен содержать код статуса 200 и данные в формате json\", function() {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.be.withBody;\r", + " pm.response.to.be.json;\r", + "});\r", + "\r", + "const savedComment = pm.collectionVariables.get(\"comment\");\r", + "const uId = pm.collectionVariables.get(\"userId\"); \r", + "const responseComment = pm.response.json();\r", + "\r", + "pm.test(\"Ответ должен соответствовать сохраненному комментарию\", function() {\r", + " pm.expect(responseComment.text).to.equal(savedComment.text);\r", + " pm.expect(responseComment.author.id).to.equal(uId); \r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + }, + { + "key": "Accept", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/comments/:comId", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "comments", + ":comId" + ], + "variable": [ + { + "key": "comId", + "value": "{{comId}}" + } + ] + } + }, + "response": [] + }, + { + "name": "Получение списка комментариев по eventId", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "const main = async () => {\r", + " const api = new API(pm);\r", + " const utils = new RandomUtils(); \r", + "\r", + " const user = await api.addUser(utils.getUser());\r", + " pm.collectionVariables.set(\"userId\", user.id); \r", + " const category = await api.addCategory(utils.getCategory());\r", + " const event = await api.addEvent(user.id, utils.getEvent(category.id));\r", + " pm.collectionVariables.set(\"eventId\",event.id);\r", + " await api.publishEvent(event.id); \r", + " const comment1 = await api.addComment(user.id, event.id, utils.getComment());\r", + " const comment2 = await api.addComment(user.id, event.id, utils.getComment()); \r", + " pm.collectionVariables.set(\"comment1\",comment1); \r", + " pm.collectionVariables.set(\"comment2\",comment2);\r", + "}; \r", + "\r", + "\r", + "\r", + " const interval = setInterval(() => {}, 1000);\r", + "\r", + "setTimeout(async () => \r", + " {\r", + " try {\r", + " await main();\r", + " } catch (e) {\r", + " console.error(e);\r", + " } finally {\r", + " clearInterval(interval);\r", + " }\r", + " }, \r", + " 500 \r", + ");" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Ответ должен содержать код статуса 200 и данные в формате json\", function() {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.be.withBody;\r", + " pm.response.to.be.json;\r", + "});\r", + "\r", + "const savedComment1 = pm.collectionVariables.get(\"comment1\");\r", + "const savedComment2 = pm.collectionVariables.get(\"comment2\");\r", + "const uId = pm.collectionVariables.get(\"userId\");\r", + "const responseComment1 = pm.response.json()[0];\r", + "const responseComment2 = pm.response.json()[1]; \r", + "\r", + "pm.test(\"Ответ должен соответствовать сохраненному комментарию\", function() {\r", + " pm.expect(responseComment1.text).to.equal(savedComment1.text);\r", + " pm.expect(responseComment2.text).to.equal(savedComment2.text);\r", + " pm.expect(responseComment1.author.id).to.equal(uId);\r", + " pm.expect(responseComment2.author.id).to.equal(uId);\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + }, + { + "key": "Accept", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/events/:eventId/comments", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "events", + ":eventId", + "comments" + ], + "variable": [ + { + "key": "eventId", + "value": "{{eventId}}" + } + ] + } + }, + "response": [] + }, + { + "name": "Получение комментария к конкретному событию", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "const main = async () => {\r", + " const api = new API(pm);\r", + " const utils = new RandomUtils(); \r", + "\r", + " const user = await api.addUser(utils.getUser());\r", + " pm.collectionVariables.set(\"userId\", user.id); \r", + " const category = await api.addCategory(utils.getCategory());\r", + " const event = await api.addEvent(user.id, utils.getEvent(category.id));\r", + " pm.collectionVariables.set(\"eventId\", event.id); \r", + " await api.publishEvent(event.id); \r", + " const comment = await api.addComment(user.id, event.id, utils.getComment());\r", + " pm.collectionVariables.set(\"comId\", comment.id);\r", + " pm.collectionVariables.set(\"comment\", comment); \r", + "}; \r", + "\r", + "\r", + "\r", + " const interval = setInterval(() => {}, 1000);\r", + "\r", + "setTimeout(async () => \r", + " {\r", + " try {\r", + " await main();\r", + " } catch (e) {\r", + " console.error(e);\r", + " } finally {\r", + " clearInterval(interval);\r", + " }\r", + " }, \r", + " 500 \r", + ");" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Ответ должен содержать код статуса 200 и данные в формате json\", function() {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.be.withBody;\r", + " pm.response.to.be.json;\r", + "});\r", + "\r", + "\r", + "const savedComment = pm.collectionVariables.get(\"comment\");\r", + "const uId = pm.collectionVariables.get(\"userId\"); \r", + "const responseComment = pm.response.json();\r", + "\r", + "pm.test(\"Ответ должен соответствовать сохраненному комментарию\", function() {\r", + " pm.expect(responseComment.text).to.equal(savedComment.text);\r", + " pm.expect(responseComment.author.id).to.equal(uId); \r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/events/:eventId/comments/:commentId", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "events", + ":eventId", + "comments", + ":commentId" + ], + "variable": [ + { + "key": "eventId", + "value": "{{eventId}}" + }, + { + "key": "commentId", + "value": "{{comId}}" + } + ] + } + }, + "response": [] + }, + { + "name": "Получение комментария с несуществующим id", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "const main = async () => {\r", + " const api = new API(pm);\r", + " const utils = new RandomUtils(); \r", + "\r", + " const user = await api.addUser(utils.getUser());\r", + " const category = await api.addCategory(utils.getCategory());\r", + " const event = await api.addEvent(user.id, utils.getEvent(category.id));\r", + " await api.publishEvent(event.id); \r", + " await api.addComment(user.id, event.id, utils.getComment());\r", + " pm.collectionVariables.set(\"comId\", 10000);\r", + "}; \r", + "\r", + "\r", + "\r", + " const interval = setInterval(() => {}, 1000);\r", + "\r", + "setTimeout(async () => \r", + " {\r", + " try {\r", + " await main();\r", + " } catch (e) {\r", + " console.error(e);\r", + " } finally {\r", + " clearInterval(interval);\r", + " }\r", + " }, \r", + " 500 \r", + ");" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Ответ должен содержать код статуса 404\", function() {\r", + " pm.response.to.have.status(404);\r", + "});\r", + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/comments/:comId", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "comments", + ":comId" + ], + "variable": [ + { + "key": "comId", + "value": "{{comId}}" + } + ] + } + }, + "response": [] + }, + { + "name": "Получение закрытого комментария", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "const main = async () => {\r", + " const api = new API(pm);\r", + " const utils = new RandomUtils(); \r", + "\r", + " const user = await api.addUser(utils.getUser());\r", + " const category = await api.addCategory(utils.getCategory());\r", + " const event = await api.addEvent(user.id, utils.getEvent(category.id));\r", + " await api.publishEvent(event.id); \r", + " const comment = await api.addComment(user.id, event.id, utils.getComment());\r", + " await api.rejectComment(comment.id); \r", + " pm.collectionVariables.set(\"comId\", comment.id);\r", + "}; \r", + "\r", + "\r", + "\r", + " const interval = setInterval(() => {}, 1000);\r", + "\r", + "setTimeout(async () => \r", + " {\r", + " try {\r", + " await main();\r", + " } catch (e) {\r", + " console.error(e);\r", + " } finally {\r", + " clearInterval(interval);\r", + " }\r", + " }, \r", + " 500 \r", + ");" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Ответ должен содержать код статуса 403\", function() {\r", + " pm.response.to.have.status(403);\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/comments/:comId", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "comments", + ":comId" + ], + "variable": [ + { + "key": "comId", + "value": "{{comId}}" + } + ] + } + }, + "response": [] + }, + { + "name": "Получение существующего комментария к неправильно указанному событию", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "const main = async () => {\r", + " const api = new API(pm);\r", + " const utils = new RandomUtils(); \r", + "\r", + " const user = await api.addUser(utils.getUser());\r", + " pm.collectionVariables.set(\"userId\", user.id); \r", + " const category = await api.addCategory(utils.getCategory());\r", + " const event = await api.addEvent(user.id, utils.getEvent(category.id));\r", + " pm.collectionVariables.set(\"eventId\", 10000); \r", + " await api.publishEvent(event.id); \r", + " const comment = await api.addComment(user.id, event.id, utils.getComment());\r", + " pm.collectionVariables.set(\"comId\", comment.id);\r", + " pm.collectionVariables.set(\"comment\", comment); \r", + "}; \r", + "\r", + "\r", + "\r", + " const interval = setInterval(() => {}, 1000);\r", + "\r", + "setTimeout(async () => \r", + " {\r", + " try {\r", + " await main();\r", + " } catch (e) {\r", + " console.error(e);\r", + " } finally {\r", + " clearInterval(interval);\r", + " }\r", + " }, \r", + " 500 \r", + ");" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Ответ должен содержать код статуса 404\", function() {\r", + " pm.response.to.have.status(404);\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/events/:eventId/comments/:commentId", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "events", + ":eventId", + "comments", + ":commentId" + ], + "variable": [ + { + "key": "eventId", + "value": "{{eventId}}" + }, + { + "key": "commentId", + "value": "{{comId}}" + } + ] + } + }, + "response": [] + }, + { + "name": "Получение списка комментариев к несуществующему событию", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "const main = async () => {\r", + " const api = new API(pm);\r", + " const utils = new RandomUtils(); \r", + "\r", + " const user = await api.addUser(utils.getUser());\r", + " pm.collectionVariables.set(\"userId\", user.id); \r", + " const category = await api.addCategory(utils.getCategory());\r", + " const event = await api.addEvent(user.id, utils.getEvent(category.id));\r", + " pm.collectionVariables.set(\"eventId\", 10000);\r", + " await api.publishEvent(event.id); \r", + " const comment1 = await api.addComment(user.id, event.id, utils.getComment());\r", + " const comment2 = await api.addComment(user.id, event.id, utils.getComment()); \r", + " pm.collectionVariables.set(\"comment1\",comment1); \r", + " pm.collectionVariables.set(\"comment2\",comment2);\r", + "}; \r", + "\r", + "\r", + "\r", + " const interval = setInterval(() => {}, 1000);\r", + "\r", + "setTimeout(async () => \r", + " {\r", + " try {\r", + " await main();\r", + " } catch (e) {\r", + " console.error(e);\r", + " } finally {\r", + " clearInterval(interval);\r", + " }\r", + " }, \r", + " 500 \r", + ");" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Ответ должен содержать код статуса 404\", function() {\r", + " pm.response.to.have.status(404);\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/events/:eventId/comments", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "events", + ":eventId", + "comments" + ], + "variable": [ + { + "key": "eventId", + "value": "{{eventId}}" + } + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Admin", + "item": [ + { + "name": "Поиск комментариев по тексту", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "const main = async () => {\r", + " const api = new API(pm);\r", + " const utils = new RandomUtils(); \r", + "\r", + " const user = await api.addUser(utils.getUser());\r", + " pm.collectionVariables.set(\"userId\", user.id); \r", + " const category = await api.addCategory(utils.getCategory());\r", + " const event = await api.addEvent(user.id, utils.getEvent(category.id));\r", + " await api.publishEvent(event.id); \r", + " const comment = await api.addComment(user.id, event.id, utils.getComment());\r", + " pm.collectionVariables.set(\"text\", comment.text); \r", + " pm.collectionVariables.set(\"comment\", comment); \r", + "};\r", + "\r", + "\r", + "\r", + "const interval = setInterval(() => {}, 1000);\r", + "\r", + "setTimeout(async () => \r", + " {\r", + " try {\r", + " await main();\r", + " } catch (e) {\r", + " console.error(e);\r", + " } finally {\r", + " clearInterval(interval);\r", + " }\r", + " }, \r", + " 500 \r", + ");" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Ответ должен содержать код статуса 200 и данные в формате json\", function() {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.be.withBody;\r", + " pm.response.to.be.json;\r", + "});\r", + "\r", + "\r", + "const savedComment = pm.collectionVariables.get(\"comment\");\r", + "const uId = pm.collectionVariables.get(\"userId\"); \r", + "const responseComment = pm.response.json()[0];\r", + "\r", + "pm.test(\"Ответ должен соответствовать сохраненному комментарию\", function() {\r", + " pm.expect(responseComment.text).to.equal(savedComment.text);\r", + " pm.expect(responseComment.author.id).to.equal(uId); \r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json", + "type": "text" + }, + { + "key": "", + "value": "", + "type": "text", + "disabled": true + } + ], + "url": { + "raw": "{{baseUrl}}/admin/comments/search?text={{text}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "admin", + "comments", + "search" + ], + "query": [ + { + "key": "text", + "value": "{{text}}" + } + ] + } + }, + "response": [] + }, + { + "name": "Поиск комментариев заданного пользователя", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "const main = async () => {\r", + " const api = new API(pm);\r", + " const utils = new RandomUtils(); \r", + "\r", + " const user = await api.addUser(utils.getUser());\r", + " pm.collectionVariables.set(\"userId\", user.id); \r", + " const category = await api.addCategory(utils.getCategory());\r", + " const event = await api.addEvent(user.id, utils.getEvent(category.id));\r", + " await api.publishEvent(event.id); \r", + " const comment1 = await api.addComment(user.id, event.id, utils.getComment());\r", + " const comment2 = await api.addComment(user.id, event.id, utils.getComment());\r", + " const comment3 = await api.addComment(user.id, event.id, utils.getComment());\r", + " pm.collectionVariables.set(\"comment1\", comment1); \r", + " pm.collectionVariables.set(\"comment2\", comment2); \r", + " pm.collectionVariables.set(\"comment3\", comment3); \r", + "};\r", + "\r", + "\r", + "\r", + "const interval = setInterval(() => {}, 1000);\r", + "\r", + "setTimeout(async () => \r", + " {\r", + " try {\r", + " await main();\r", + " } catch (e) {\r", + " console.error(e);\r", + " } finally {\r", + " clearInterval(interval);\r", + " }\r", + " }, \r", + " 500 \r", + ");" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Ответ должен содержать код статуса 200 и данные в формате json\", function() {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.be.withBody;\r", + " pm.response.to.be.json;\r", + "});\r", + "\r", + "\r", + "const savedComment1 = pm.collectionVariables.get(\"comment1\");\r", + "const savedComment2 = pm.collectionVariables.get(\"comment2\");\r", + "const savedComment3 = pm.collectionVariables.get(\"comment3\");\r", + "const responseComment1 = pm.response.json()[0];\r", + "const responseComment2 = pm.response.json()[1];\r", + "const responseComment3 = pm.response.json()[2];\r", + "\r", + "pm.test(\"Ответ должен соответствовать сохраненным комментариям пользователя\", function() {\r", + " pm.expect(responseComment1.text).to.equal(savedComment1.text);\r", + " pm.expect(responseComment2.text).to.equal(savedComment2.text);\r", + " pm.expect(responseComment3.text).to.equal(savedComment3.text);\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/admin/users/:userId/comments", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "admin", + "users", + ":userId", + "comments" + ], + "variable": [ + { + "key": "userId", + "value": "{{userId}}" + } + ] + } + }, + "response": [] + }, + { + "name": "Удаление комментария администратором", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "const main = async () => {\r", + " const api = new API(pm);\r", + " const utils = new RandomUtils(); \r", + "\r", + " const user = await api.addUser(utils.getUser());\r", + " const category = await api.addCategory(utils.getCategory());\r", + " const event = await api.addEvent(user.id, utils.getEvent(category.id));\r", + " await api.publishEvent(event.id); \r", + " const comment = await api.addComment(user.id, event.id, utils.getComment());\r", + " pm.collectionVariables.set(\"comId\", comment.id); \r", + "};\r", + "\r", + "\r", + "const interval = setInterval(() => {}, 1000);\r", + "\r", + "setTimeout(async () => \r", + " {\r", + " try {\r", + " await main();\r", + " } catch (e) {\r", + " console.error(e);\r", + " } finally {\r", + " clearInterval(interval);\r", + " }\r", + " }, \r", + " 500 \r", + ");" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Ответ должен содержать код статуса 204\", function () {\r", + " pm.response.to.have.status(204);\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{baseUrl}}/admin/comments/:comId", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "admin", + "comments", + ":comId" + ], + "variable": [ + { + "key": "comId", + "value": "{{comId}}" + } + ] + } + }, + "response": [] + }, + { + "name": "Одобрение комментария", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Ответ должен содержать код статуса 200\", function () {\r", + " pm.response.to.have.status(200);\r", + "});\r", + "\r", + "const responseComment = pm.response.json(); \r", + "const savedComment = pm.collectionVariables.get(\"comment\");\r", + "\r", + "pm.test(\"Ответ должен соответствовать сохраненным комментариям пользователя\", function() {\r", + " pm.expect(responseComment.text).to.equal(savedComment.text);\r", + " pm.expect(responseComment.approved).to.equal(true); \r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "const main = async () => {\r", + " const api = new API(pm);\r", + " const utils = new RandomUtils(); \r", + "\r", + " const user = await api.addUser(utils.getUser());\r", + " const category = await api.addCategory(utils.getCategory());\r", + " const event = await api.addEvent(user.id, utils.getEvent(category.id));\r", + " await api.publishEvent(event.id); \r", + " const comment = await api.addComment(user.id, event.id, utils.getComment());\r", + " await api.rejectComment(comment.id);\r", + " pm.collectionVariables.set(\"comId\", comment.id); \r", + " pm.collectionVariables.set(\"comment\", comment); \r", + "};\r", + "\r", + "\r", + "\r", + "const interval = setInterval(() => {}, 1000);\r", + "\r", + "setTimeout(async () => \r", + " {\r", + " try {\r", + " await main();\r", + " } catch (e) {\r", + " console.error(e);\r", + " } finally {\r", + " clearInterval(interval);\r", + " }\r", + " }, \r", + " 500 \r", + ");" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "PATCH", + "header": [], + "url": { + "raw": "{{baseUrl}}/admin/comments/:comId/approve", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "admin", + "comments", + ":comId", + "approve" + ], + "variable": [ + { + "key": "comId", + "value": "{{comId}}" + } + ] + } + }, + "response": [] + }, + { + "name": "Отклонение комментария", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "const main = async () => {\r", + " const api = new API(pm);\r", + " const utils = new RandomUtils(); \r", + "\r", + " const user = await api.addUser(utils.getUser());\r", + " const category = await api.addCategory(utils.getCategory());\r", + " const event = await api.addEvent(user.id, utils.getEvent(category.id));\r", + " await api.publishEvent(event.id); \r", + " const comment = await api.addComment(user.id, event.id, utils.getComment());\r", + " pm.collectionVariables.set(\"comId\", comment.id); \r", + " pm.collectionVariables.set(\"comment\", comment); \r", + "};\r", + "\r", + "\r", + "\r", + "const interval = setInterval(() => {}, 1000);\r", + "\r", + "setTimeout(async () => \r", + " {\r", + " try {\r", + " await main();\r", + " } catch (e) {\r", + " console.error(e);\r", + " } finally {\r", + " clearInterval(interval);\r", + " }\r", + " }, \r", + " 500 \r", + ");" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Ответ должен содержать код статуса 200\", function () {\r", + " pm.response.to.have.status(200);\r", + "});\r", + "\r", + "const responseComment = pm.response.json(); \r", + "const savedComment = pm.collectionVariables.get(\"comment\");\r", + "\r", + "pm.test(\"Ответ должен соответствовать сохраненным комментариям пользователя\", function() {\r", + " pm.expect(responseComment.text).to.equal(savedComment.text);\r", + " pm.expect(responseComment.approved).to.equal(false); \r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "PATCH", + "header": [], + "url": { + "raw": "{{baseUrl}}/admin/comments/:comId/reject", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "admin", + "comments", + ":comId", + "reject" + ], + "variable": [ + { + "key": "comId", + "value": "{{comId}}" + } + ] + } + }, + "response": [] + }, + { + "name": "Удаление несуществующего комментария", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "const main = async () => {\r", + " const api = new API(pm);\r", + " const utils = new RandomUtils(); \r", + "\r", + " const user = await api.addUser(utils.getUser());\r", + " const category = await api.addCategory(utils.getCategory());\r", + " const event = await api.addEvent(user.id, utils.getEvent(category.id));\r", + " await api.publishEvent(event.id); \r", + " const comment = await api.addComment(user.id, event.id, utils.getComment());\r", + " pm.collectionVariables.set(\"comId\",10000); \r", + "};\r", + "\r", + "\r", + "const interval = setInterval(() => {}, 1000);\r", + "\r", + "setTimeout(async () => \r", + " {\r", + " try {\r", + " await main();\r", + " } catch (e) {\r", + " console.error(e);\r", + " } finally {\r", + " clearInterval(interval);\r", + " }\r", + " }, \r", + " 500 \r", + ");" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Ответ должен содержать код статуса 404\", function() {\r", + " pm.response.to.have.status(404);\r", + "});\r", + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{baseUrl}}/admin/comments/:comId", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "admin", + "comments", + ":comId" + ], + "variable": [ + { + "key": "comId", + "value": "{{comId}}" + } + ] + } + }, + "response": [] + }, + { + "name": "Поиск несуществующего текста", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "const main = async () => {\r", + " const api = new API(pm);\r", + " const utils = new RandomUtils(); \r", + "\r", + " const user = await api.addUser(utils.getUser());\r", + " pm.collectionVariables.set(\"userId\", user.id); \r", + " const category = await api.addCategory(utils.getCategory());\r", + " const event = await api.addEvent(user.id, utils.getEvent(category.id));\r", + " await api.publishEvent(event.id); \r", + " const comment = await api.addComment(user.id, event.id, utils.getComment());\r", + " pm.collectionVariables.set(\"text\", \"ZZZZZZZZZZZ\"); \r", + " pm.collectionVariables.set(\"comment\", comment); \r", + "};\r", + "\r", + "\r", + "\r", + "const interval = setInterval(() => {}, 1000);\r", + "\r", + "setTimeout(async () => \r", + " {\r", + " try {\r", + " await main();\r", + " } catch (e) {\r", + " console.error(e);\r", + " } finally {\r", + " clearInterval(interval);\r", + " }\r", + " }, \r", + " 500 \r", + ");" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Ответ должен содержать код статуса 200 и данные в формате json\", function() {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.be.withBody;\r", + " pm.response.to.be.json;\r", + "});\r", + "\r", + "const response = pm.response.json();\r", + "\r", + "\r", + "pm.test(\"Размер массива должен быть равен 0\", function() {\r", + " pm.expect(response).to.be.an('array').that.is.empty;\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/admin/comments/search?text={{text}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "admin", + "comments", + "search" + ], + "query": [ + { + "key": "text", + "value": "{{text}}" + } + ] + } + }, + "response": [] + }, + { + "name": "Поиск по тексту с проверкой нечувствительности к регистру", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "const main = async () => {\r", + " const api = new API(pm);\r", + " const utils = new RandomUtils(); \r", + "\r", + " const user = await api.addUser(utils.getUser());\r", + " pm.collectionVariables.set(\"userId\", user.id); \r", + " const category = await api.addCategory(utils.getCategory());\r", + " const event = await api.addEvent(user.id, utils.getEvent(category.id));\r", + " await api.publishEvent(event.id); \r", + " const comment = await api.addComment(user.id, event.id, utils.getComment());\r", + " const upText = comment.text.toUpperCase();\r", + " pm.collectionVariables.set(\"text\", upText); \r", + " pm.collectionVariables.set(\"comment\", comment); \r", + "};\r", + "\r", + "\r", + "\r", + "const interval = setInterval(() => {}, 1000);\r", + "\r", + "setTimeout(async () => \r", + " {\r", + " try {\r", + " await main();\r", + " } catch (e) {\r", + " console.error(e);\r", + " } finally {\r", + " clearInterval(interval);\r", + " }\r", + " }, \r", + " 500 \r", + ");" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Ответ должен содержать код статуса 200 и данные в формате json\", function() {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.be.withBody;\r", + " pm.response.to.be.json;\r", + "});\r", + "\r", + "\r", + "const savedComment = pm.collectionVariables.get(\"comment\");\r", + "const uId = pm.collectionVariables.get(\"userId\"); \r", + "const responseComment = pm.response.json()[0];\r", + "\r", + "pm.test(\"Ответ должен соответствовать сохраненному комментарию\", function() {\r", + " pm.expect(responseComment.text).to.equal(savedComment.text);\r", + " pm.expect(responseComment.author.id).to.equal(uId); \r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/admin/comments/search?text={{text}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "admin", + "comments", + "search" + ], + "query": [ + { + "key": "text", + "value": "{{text}}" + } + ] + } + }, + "response": [] + }, + { + "name": "Поиск комментариев несуществующего пользователя", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "const main = async () => {\r", + " const api = new API(pm);\r", + " const utils = new RandomUtils(); \r", + "\r", + " const user = await api.addUser(utils.getUser());\r", + " pm.collectionVariables.set(\"userId\", 10000); \r", + " const category = await api.addCategory(utils.getCategory());\r", + " const event = await api.addEvent(user.id, utils.getEvent(category.id));\r", + " await api.publishEvent(event.id); \r", + " const comment1 = await api.addComment(user.id, event.id, utils.getComment());\r", + " const comment2 = await api.addComment(user.id, event.id, utils.getComment());\r", + " const comment3 = await api.addComment(user.id, event.id, utils.getComment());\r", + " pm.collectionVariables.set(\"comment1\", comment1); \r", + " pm.collectionVariables.set(\"comment2\", comment2); \r", + " pm.collectionVariables.set(\"comment3\", comment3); \r", + "};\r", + "\r", + "\r", + "\r", + "const interval = setInterval(() => {}, 1000);\r", + "\r", + "setTimeout(async () => \r", + " {\r", + " try {\r", + " await main();\r", + " } catch (e) {\r", + " console.error(e);\r", + " } finally {\r", + " clearInterval(interval);\r", + " }\r", + " }, \r", + " 500 \r", + ");" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Ответ должен содержать код статуса 404\", function() {\r", + " pm.response.to.have.status(404);\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/admin/users/:userId/comments", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "admin", + "users", + ":userId", + "comments" + ], + "variable": [ + { + "key": "userId", + "value": "{{userId}}" + } + ] + } + }, + "response": [] + }, + { + "name": "Одобрение несуществующего комментария", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "const main = async () => {\r", + " const api = new API(pm);\r", + " const utils = new RandomUtils(); \r", + "\r", + " const user = await api.addUser(utils.getUser());\r", + " const category = await api.addCategory(utils.getCategory());\r", + " const event = await api.addEvent(user.id, utils.getEvent(category.id));\r", + " await api.publishEvent(event.id); \r", + " const comment = await api.addComment(user.id, event.id, utils.getComment());\r", + " await api.rejectComment(comment.id);\r", + " pm.collectionVariables.set(\"comId\", 10000); \r", + "};\r", + "\r", + "\r", + "\r", + "const interval = setInterval(() => {}, 1000);\r", + "\r", + "setTimeout(async () => \r", + " {\r", + " try {\r", + " await main();\r", + " } catch (e) {\r", + " console.error(e);\r", + " } finally {\r", + " clearInterval(interval);\r", + " }\r", + " }, \r", + " 500 \r", + ");" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Ответ должен содержать код статуса 404\", function() {\r", + " pm.response.to.have.status(404);\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "PATCH", + "header": [], + "url": { + "raw": "{{baseUrl}}/admin/comments/:comId/approve", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "admin", + "comments", + ":comId", + "approve" + ], + "variable": [ + { + "key": "comId", + "value": "{{comId}}" + } + ] + } + }, + "response": [] + }, + { + "name": "Отклонение несуществующего комментария", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "const main = async () => {\r", + " const api = new API(pm);\r", + " const utils = new RandomUtils(); \r", + "\r", + " const user = await api.addUser(utils.getUser());\r", + " const category = await api.addCategory(utils.getCategory());\r", + " const event = await api.addEvent(user.id, utils.getEvent(category.id));\r", + " await api.publishEvent(event.id); \r", + " const comment = await api.addComment(user.id, event.id, utils.getComment());\r", + " pm.collectionVariables.set(\"comId\", 10000); \r", + "};\r", + "\r", + "\r", + "\r", + "const interval = setInterval(() => {}, 1000);\r", + "\r", + "setTimeout(async () => \r", + " {\r", + " try {\r", + " await main();\r", + " } catch (e) {\r", + " console.error(e);\r", + " } finally {\r", + " clearInterval(interval);\r", + " }\r", + " }, \r", + " 500 \r", + ");" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Ответ должен содержать код статуса 404\", function() {\r", + " pm.response.to.have.status(404);\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "PATCH", + "header": [], + "url": { + "raw": "{{baseUrl}}/admin/comments/:comId/reject", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "admin", + "comments", + ":comId", + "reject" + ], + "variable": [ + { + "key": "comId", + "value": "{{comId}}" + } + ] + } + }, + "response": [] + } + ] + } + ], + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "packages": {}, + "exec": [ + "API = class {\r", + " constructor(postman, verbose = false, baseUrl = \"http://localhost:8080\") {\r", + " this.baseUrl = baseUrl;\r", + " this.pm = postman;\r", + " this._verbose = verbose;\r", + " }\r", + "\r", + " async addUser(user, verbose=null) {\r", + " return this.post(\"/admin/users\", user, \"Ошибка при добавлении нового пользователя: \", verbose);\r", + " }\r", + "\r", + " async rejectComment (comId, verbose = null) { \r", + " return this.patch(\"/admin/comments/\"+comId+\"/reject\", \"Ошибка при изменении статуса комментария: \", verbose); \r", + " }\r", + "\r", + " async addComment(userId, eventId, comment, verbose=null) {\r", + " return this.post(\"/users/\" + userId + \"/events/\" + eventId + \"/comments\", comment, \r", + " \"Ошибка при добавлении нового комментария: \", verbose);\r", + " }\r", + "\r", + " async updateComment(userId, commentId, comment, verbose=null) {\r", + " return this.patch(\"/users/\" + userId + \"/comments/\" + commentId, comment, \r", + " \"Ошибка при обновлении комментария: \", verbose);\r", + " }\r", + "\r", + " async deleteComment(userId, commentId, verbose=null) {\r", + " return new Promise((resolve, reject) => {\r", + " verbose = verbose == null ? this._verbose : verbose;\r", + "\r", + " const request = {\r", + " url: this.baseUrl + \"/users/\" + userId + \"/comments/\" + commentId,\r", + " method: \"DELETE\",\r", + " header: { \"Content-Type\": \"application/json\" },\r", + " };\r", + "\r", + " if(verbose) {\r", + " console.log(\"Отправляю запрос: \", request);\r", + " }\r", + "\r", + " try {\r", + " this.pm.sendRequest(request, (error, response) => {\r", + " if(error || (response.code >= 400 && response.code <= 599)) {\r", + " let err = error ? error : JSON.stringify(response.json());\r", + " console.error(\"При выполнении запроса к серверу возникла ошика.\\n\", err,\r", + " \"\\nДля отладки проблемы повторите такой же запрос к вашей программе \" + \r", + " \"на локальном компьютере. Данные запроса:\\n\", JSON.stringify(request));\r", + "\r", + " reject(new Error(\"Ошибка при удалении комментария: \" + err));\r", + " }\r", + "\r", + " if(verbose) {\r", + " console.log(\"Результат обработки запроса: код состояния - \", response.code);\r", + " }\r", + "\r", + " resolve(response.code === 204 ? \"Comment deleted successfully\" : response.json());\r", + " });\r", + " } catch(err) {\r", + " if(verbose) {\r", + " console.error(\"Ошибка при удалении комментария: \", err);\r", + " }\r", + " return Promise.reject(err);\r", + " }\r", + " });\r", + " }\r", + "\r", + " async addCategory(category, verbose=null) {\r", + " return this.post(\"/admin/categories\", category, \"Ошибка при добавлении новой категории: \", verbose);\r", + " }\r", + "\r", + " async addEvent(userId, event, verbose=null) {\r", + " return this.post(\"/users/\" + userId + \"/events\", event, \"Ошибка при добавлении нового события: \", verbose);\r", + " }\r", + "\r", + " async addCompilation(compilation, verbose=null) {\r", + " return this.post(\"/admin/compilations\", compilation, \"Ошибка при добавлении новой подборки: \", verbose);\r", + " }\r", + "\r", + " async publishParticipationRequest(eventId, userId, verbose=null) {\r", + " return this.post('/users/' + userId + '/requests?eventId=' + eventId, null, \"Ошибка при добавлении нового запроса на участие в событии\", verbose);\r", + " }\r", + "\r", + " async publishEvent(eventId, verbose=null) {\r", + " return this.patch('/admin/events/' + eventId, {stateAction: \"PUBLISH_EVENT\"}, \"Ошибка при публикации события\", verbose);\r", + " }\r", + " \r", + " async rejectEvent(eventId, verbose=null) {\r", + " return this.patch('/admin/events/' + eventId, {stateAction: \"REJECT_EVENT\"}, \"Ошибка при отмене события\", verbose);\r", + " }\r", + "\r", + " async acceptParticipationRequest(eventId, userId, reqId, verbose=null) {\r", + " return this.patch('/users/' + userId + '/events/' + eventId + '/requests/', {requestIds:[reqId], status: \"CONFIRMED\"}, \"Ошибка при принятии заявки на участие в событии\", verbose);\r", + " }\r", + "\r", + " async findCategory(catId, verbose=null) {\r", + " return this.get('/categories/' + catId, null, \"Ошибка при поиске категории по id\", verbose);\r", + " }\r", + "\r", + " async findCompilation(compId, verbose=null) {\r", + " return this.get('/compilations/' + compId, null, \"Ошибка при поиске подборки по id\", verbose);\r", + " }\r", + "\r", + " async findEvent(eventId, verbose=null) {\r", + " return this.get('/events/' + eventId, null, \"Ошибка при поиске события по id\", verbose);\r", + " }\r", + "\r", + " async findUser(userId, verbose=null) {\r", + " return this.get('/admin/users?ids=' + userId, null, \"Ошибка при поиске пользователя по id\", verbose);\r", + " }\r", + "\r", + " async post(path, body, errorText = \"Ошибка при выполнении post-запроса: \", verbose=null) {\r", + " return this.sendRequest(\"POST\", path, body, errorText, verbose);\r", + " }\r", + "\r", + " async patch(path, body = null, errorText = \"Ошибка при выполнении patch-запроса: \", verbose=null) {\r", + " return this.sendRequest(\"PATCH\", path, body, errorText, verbose);\r", + " }\r", + "\r", + " async get(path, body = null, errorText = \"Ошибка при выполнении get-запроса: \", verbose=null) {\r", + " return this.sendRequest(\"GET\", path, body, errorText, verbose);\r", + " }\r", + "\r", + " async sendRequest(method, path, body=null, errorText = \"Ошибка при выполнении запроса: \", verbose=null) {\r", + " return new Promise((resolve, reject) => {\r", + " verbose = verbose == null ? this._verbose : verbose;\r", + "\r", + " const request = {\r", + " url: this.baseUrl + path,\r", + " method: method,\r", + " body: body == null ? \"\" : JSON.stringify(body),\r", + " header: { \"Content-Type\": \"application/json\" },\r", + " };\r", + "\r", + " if(verbose) {\r", + " console.log(\"Отправляю запрос: \", request);\r", + " }\r", + "\r", + " try {\r", + " this.pm.sendRequest(request, (error, response) => {\r", + " if(error || (response.code >= 400 && response.code <= 599)) {\r", + " let err = error ? error : JSON.stringify(response.json());\r", + " console.error(\"При выполнении запроса к серверу возникла ошика.\\n\", err,\r", + " \"\\nДля отладки проблемы повторите такой же запрос к вашей программе \" + \r", + " \"на локальном компьютере. Данные запроса:\\n\", JSON.stringify(request));\r", + "\r", + " reject(new Error(errorText + err));\r", + " }\r", + "\r", + " if(verbose) {\r", + " console.log(\"Результат обработки запроса: код состояния - \", response.code, \", тело: \", response.json());\r", + " }\r", + "\r", + " resolve(response.json());\r", + " });\r", + " } catch(err) {\r", + " if(verbose) {\r", + " console.error(errorText, err);\r", + " }\r", + " return Promise.reject(err);\r", + " }\r", + " });\r", + " }\r", + "};\r", + "\r", + "RandomUtils = class {\r", + " constructor() {}\r", + "\r", + "\r", + " getComment() {\r", + " return {\r", + " text: pm.variables.replaceIn('{{$randomLoremSentence}}') + \" \" + \r", + " pm.variables.replaceIn('{{$randomLoremParagraph}}').slice(0, 200)\r", + " };\r", + " }\r", + "\r", + " getUser() {\r", + " return {\r", + " name: pm.variables.replaceIn('{{$randomFullName}}'),\r", + " email: pm.variables.replaceIn('{{$randomEmail}}')\r", + " };\r", + " }\r", + "\r", + " getCategory() {\r", + " return {\r", + " name: pm.variables.replaceIn('{{$randomWord}}') + Math.floor(Math.random() * 100).toString()\r", + " };\r", + " }\r", + "\r", + " getEvent(categoryId) {\r", + " return {\r", + " annotation: pm.variables.replaceIn('{{$randomLoremParagraph}}'),\r", + " category: categoryId,\r", + " description: pm.variables.replaceIn('{{$randomLoremParagraphs}}'),\r", + " eventDate: this.getFutureDateTime(),\r", + " location: {\r", + " lat: parseFloat(pm.variables.replaceIn('{{$randomLatitude}}')),\r", + " lon: parseFloat(pm.variables.replaceIn('{{$randomLongitude}}')),\r", + " },\r", + " paid: pm.variables.replaceIn('{{$randomBoolean}}'),\r", + " participantLimit: pm.variables.replaceIn('{{$randomInt}}'),\r", + " requestModeration: pm.variables.replaceIn('{{$randomBoolean}}'),\r", + " title: pm.variables.replaceIn('{{$randomLoremSentence}}'),\r", + " }\r", + " }\r", + "\r", + " getCompilation(...eventIds) {\r", + " return {\r", + " title: pm.variables.replaceIn('{{$randomLoremSentence}}').slice(0, 50),\r", + " pinned: pm.variables.replaceIn('{{$randomBoolean}}'),\r", + " events: eventIds\r", + " };\r", + " }\r", + "\r", + "\r", + " getFutureDateTime(hourShift = 5, minuteShift=0, yearShift=0) {\r", + " let moment = require('moment');\r", + "\r", + " let m = moment();\r", + " m.add(hourShift, 'hour');\r", + " m.add(minuteShift, 'minute');\r", + " m.add(yearShift, 'year');\r", + "\r", + " return m.format('YYYY-MM-DD HH:mm:ss');\r", + " }\r", + "\r", + " getWord(length = 1) {\r", + " let result = '';\r", + " const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';\r", + " const charactersLength = characters.length;\r", + " let counter = 0;\r", + " while (counter < length) {\r", + " result += characters.charAt(Math.floor(Math.random() * charactersLength));\r", + " counter += 1;\r", + " }\r", + " return result;\r", + " }\r", + "}" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "packages": {}, + "exec": [ + "" + ] + } + } + ], + "variable": [ + { + "key": "baseUrl", + "value": "http://localhost:8080", + "type": "string" + }, + { + "key": "userId", + "value": "1" + }, + { + "key": "eventId", + "value": "1" + }, + { + "key": "comId", + "value": "1" + }, + { + "key": "comment", + "value": "" + }, + { + "key": "uId1", + "value": "" + }, + { + "key": "uId2", + "value": "" + }, + { + "key": "comment1", + "value": "" + }, + { + "key": "comment2", + "value": "" + }, + { + "key": "text", + "value": "" + }, + { + "key": "comment3", + "value": "" + }, + { + "key": "request_body", + "value": "", + "type": "string" + } + ] +} \ No newline at end of file diff --git a/stats/pom.xml b/stats/pom.xml new file mode 100644 index 0000000..6687c3f --- /dev/null +++ b/stats/pom.xml @@ -0,0 +1,21 @@ + + + 4.0.0 + + ru.practicum + explore-with-me + 0.0.1-SNAPSHOT + + + stats + pom + + + stats-common + stats-server + stats-client + + + \ No newline at end of file diff --git a/stats/stats-client/pom.xml b/stats/stats-client/pom.xml new file mode 100644 index 0000000..b278e0d --- /dev/null +++ b/stats/stats-client/pom.xml @@ -0,0 +1,56 @@ + + + 4.0.0 + + + ru.practicum + stats + 0.0.1-SNAPSHOT + + + stats-client + + + + + ru.practicum + stats-common + + + + org.projectlombok + lombok + provided + + + + org.springframework.cloud + spring-cloud-commons + + + + org.springframework.retry + spring-retry + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + + + + + + + \ No newline at end of file diff --git a/stats/stats-client/src/main/java/ru/practicum/ewm/client/RestStatClient.java b/stats/stats-client/src/main/java/ru/practicum/ewm/client/RestStatClient.java new file mode 100644 index 0000000..7634cd7 --- /dev/null +++ b/stats/stats-client/src/main/java/ru/practicum/ewm/client/RestStatClient.java @@ -0,0 +1,142 @@ +package ru.practicum.ewm.client; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.cloud.client.ServiceInstance; +import org.springframework.cloud.client.discovery.DiscoveryClient; +import org.springframework.context.event.EventListener; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.MediaType; +import org.springframework.retry.backoff.FixedBackOffPolicy; +import org.springframework.retry.policy.MaxAttemptsRetryPolicy; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; +import org.springframework.web.util.UriComponentsBuilder; +import ru.practicum.EventHitDto; +import ru.practicum.EventStatsResponseDto; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Random; + + +@Slf4j +@Component +public class RestStatClient implements StatClient { + + private final DiscoveryClient discoveryClient; + private final Random random = new Random(); + private final DateTimeFormatter formatter; + private final String name; + + private String statUrl; + private RestClient restClient; + private boolean doRenewingServerUrl = false; + + public RestStatClient( + DiscoveryClient discoveryClient, + @Value("${explore-with-me.stat-server.discovery.name:}") String name, + @Value("${explore-with-me.stat-server.url:http://localhost:9090}") String url, + @Value("${explore-with-me.stat.datetime.format}") String format + ) { + this.discoveryClient = discoveryClient; + this.name = name; + this.formatter = DateTimeFormatter.ofPattern(format); + this.statUrl = url; + this.restClient = RestClient.builder().baseUrl(statUrl).build(); + } + + @EventListener(ApplicationReadyEvent.class) + public void init() { + if (name == null || name.isBlank()) return; + + FixedBackOffPolicy fixedBackOffPolicy = new FixedBackOffPolicy(); + fixedBackOffPolicy.setBackOffPeriod(5000L); + MaxAttemptsRetryPolicy retryPolicy = new MaxAttemptsRetryPolicy(); + retryPolicy.setMaxAttempts(10); + RetryTemplate retryTemplate = new RetryTemplate(); + retryTemplate.setBackOffPolicy(fixedBackOffPolicy); + retryTemplate.setRetryPolicy(retryPolicy); + + try { + ServiceInstance instance = retryTemplate.execute(retryContext -> { + List instances = discoveryClient.getInstances(name); + if (instances.isEmpty()) throw new RuntimeException("try again"); + return instances.get(random.nextInt(instances.size())); + }); + statUrl = instance.getUri().toString(); + restClient = RestClient.builder().baseUrl(statUrl).build(); + log.info("Retrieved init stat server url: {}", statUrl); + } catch (Exception e) { + log.warn("Discovery server error: {}", e.getMessage()); + } + + doRenewingServerUrl = true; + } + + @Scheduled(fixedDelay = 60000) + public void renewServerUrl() { + if (!doRenewingServerUrl) return; + try { + List urls = discoveryClient.getInstances(name).stream() + .map(i -> i.getUri().toString()) + .toList(); + + if (urls.isEmpty() || urls.contains(statUrl)) return; + + statUrl = urls.get(random.nextInt(urls.size())); + restClient = RestClient.builder().baseUrl(statUrl).build(); + log.info("Retrieved new stat server url: {}", statUrl); + } catch (Exception e) { + log.warn("Discovery server error: {}", e.getMessage()); + } + } + + @Override + public void hit(EventHitDto eventHitDto) { + try { + restClient + .post() + .uri("/hit") + .body(eventHitDto) + .contentType(MediaType.APPLICATION_JSON) + .retrieve() + .toBodilessEntity(); + } catch (RestClientException e) { + log.warn("Failed call /hit on stat server: {}", e.getMessage()); + } + } + + @Override + public Collection stats(LocalDateTime start, LocalDateTime end, List uris, Boolean unique) { + try { + UriComponentsBuilder uriBuilder = UriComponentsBuilder.fromUriString("/stats") + .queryParam("start", start.format(formatter)) + .queryParam("end", end.format(formatter)); + if (uris != null && !uris.isEmpty()) + uriBuilder.queryParam("uris", String.join(",", uris)); + if (unique != null) + uriBuilder.queryParam("unique", unique); + String uri = uriBuilder.build().toUriString(); + return restClient + .get() + .uri(uri) + .retrieve() + .body( + new ParameterizedTypeReference>() { + } + ); + } catch (RestClientException e) { + log.error("Failed call /stats on stat server: {}", e.getMessage()); + return Collections.emptyList(); + } + } + +} diff --git a/stats/stats-client/src/main/java/ru/practicum/ewm/client/StatClient.java b/stats/stats-client/src/main/java/ru/practicum/ewm/client/StatClient.java new file mode 100644 index 0000000..31d0071 --- /dev/null +++ b/stats/stats-client/src/main/java/ru/practicum/ewm/client/StatClient.java @@ -0,0 +1,21 @@ +package ru.practicum.ewm.client; + +import ru.practicum.EventHitDto; +import ru.practicum.EventStatsResponseDto; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; + +public interface StatClient { + + void hit(EventHitDto eventHitDto); + + Collection stats( + LocalDateTime start, + LocalDateTime end, + List uris, + Boolean unique + ); + +} diff --git a/stats/stats-common/pom.xml b/stats/stats-common/pom.xml new file mode 100644 index 0000000..032aa6c --- /dev/null +++ b/stats/stats-common/pom.xml @@ -0,0 +1,65 @@ + + + 4.0.0 + + + ru.practicum + stats + 0.0.1-SNAPSHOT + + + stats-common + + + + + org.springframework.boot + spring-boot-starter-web + + + + org.projectlombok + lombok + + + + org.springframework.boot + spring-boot-starter-validation + + + + org.springframework.boot + spring-boot-starter-actuator + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + + + + + + + + + + \ No newline at end of file diff --git a/stats/stats-common/src/main/java/ru/practicum/EventHitDto.java b/stats/stats-common/src/main/java/ru/practicum/EventHitDto.java new file mode 100644 index 0000000..8e0bfa9 --- /dev/null +++ b/stats/stats-common/src/main/java/ru/practicum/EventHitDto.java @@ -0,0 +1,31 @@ +package ru.practicum; + +import com.fasterxml.jackson.annotation.JsonFormat; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class EventHitDto { + + @NotNull(message = "Field 'app' should not be null") + private String app; + + @NotNull(message = "Field 'uri' should not be null") + private String uri; + + @NotNull(message = "Field 'ip' should not be null") + private String ip; + + @NotNull(message = "Field 'timestamp' should not be null") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime timestamp; + +} diff --git a/stats/stats-common/src/main/java/ru/practicum/EventStatsResponseDto.java b/stats/stats-common/src/main/java/ru/practicum/EventStatsResponseDto.java new file mode 100644 index 0000000..1423680 --- /dev/null +++ b/stats/stats-common/src/main/java/ru/practicum/EventStatsResponseDto.java @@ -0,0 +1,20 @@ +package ru.practicum; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class EventStatsResponseDto { + + private String app; + + private String uri; + + private Long hits; + +} diff --git a/stats/stats-common/src/main/java/ru/practicum/exception/ErrorResponse.java b/stats/stats-common/src/main/java/ru/practicum/exception/ErrorResponse.java new file mode 100644 index 0000000..1d2d744 --- /dev/null +++ b/stats/stats-common/src/main/java/ru/practicum/exception/ErrorResponse.java @@ -0,0 +1,23 @@ +package ru.practicum.exception; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.http.HttpStatus; + +import java.time.Instant; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ErrorResponse { + + private Instant timestamp; + private HttpStatus status; + private String error; + private String message; + private String path; + +} diff --git a/stats/stats-common/src/main/java/ru/practicum/serialize/LocalDateTimeDeserializer.java b/stats/stats-common/src/main/java/ru/practicum/serialize/LocalDateTimeDeserializer.java new file mode 100644 index 0000000..ca5c292 --- /dev/null +++ b/stats/stats-common/src/main/java/ru/practicum/serialize/LocalDateTimeDeserializer.java @@ -0,0 +1,35 @@ +package ru.practicum.serialize; + +import com.fasterxml.jackson.core.JacksonException; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +@Component +public class LocalDateTimeDeserializer extends StdDeserializer { + + private DateTimeFormatter formatter; + + public LocalDateTimeDeserializer() { + super(LocalDateTime.class); + } + + @Autowired + public void setFormatter(@Value("${explore-with-me.datetime.format}") String dateTimeFormat) { + this.formatter = DateTimeFormatter.ofPattern(dateTimeFormat); + } + + @Override + public LocalDateTime deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException, JacksonException { + String date = jsonParser.getText(); + return LocalDateTime.parse(date, formatter); + } + +} diff --git a/stats/stats-common/src/main/java/ru/practicum/serialize/LocalDateTimeSerializer.java b/stats/stats-common/src/main/java/ru/practicum/serialize/LocalDateTimeSerializer.java new file mode 100644 index 0000000..3b0abd0 --- /dev/null +++ b/stats/stats-common/src/main/java/ru/practicum/serialize/LocalDateTimeSerializer.java @@ -0,0 +1,33 @@ +package ru.practicum.serialize; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.ser.std.StdSerializer; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +@Component +public class LocalDateTimeSerializer extends StdSerializer { + + private DateTimeFormatter formatter; + + public LocalDateTimeSerializer() { + super(LocalDateTime.class); + } + + @Autowired + public void setFormatter(@Value("${explore-with-me.datetime.format}") String dateTimeFormat) { + this.formatter = DateTimeFormatter.ofPattern(dateTimeFormat); + } + + @Override + public void serialize(LocalDateTime localDateTime, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException { + jsonGenerator.writeString(localDateTime.format(formatter)); + } + +} diff --git a/stats/stats-common/src/main/java/ru/practicum/validation/StringToBooleanConverter.java b/stats/stats-common/src/main/java/ru/practicum/validation/StringToBooleanConverter.java new file mode 100644 index 0000000..5591fb9 --- /dev/null +++ b/stats/stats-common/src/main/java/ru/practicum/validation/StringToBooleanConverter.java @@ -0,0 +1,18 @@ +package ru.practicum.validation; + +import org.springframework.core.convert.converter.Converter; +import org.springframework.stereotype.Component; + + +@Component +public class StringToBooleanConverter implements Converter { + + @Override + public Boolean convert(String source) { + if ("true".equalsIgnoreCase(source)) return true; + if ("false".equalsIgnoreCase(source)) return false; + + throw new IllegalArgumentException("Failed to convert string " + source + " to Boolean"); + } + +} diff --git a/stats/stats-common/src/main/java/ru/practicum/validation/StringToLocalDateTimeConverter.java b/stats/stats-common/src/main/java/ru/practicum/validation/StringToLocalDateTimeConverter.java new file mode 100644 index 0000000..f3ff61a --- /dev/null +++ b/stats/stats-common/src/main/java/ru/practicum/validation/StringToLocalDateTimeConverter.java @@ -0,0 +1,32 @@ +package ru.practicum.validation; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.convert.converter.Converter; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; + +@Component +public class StringToLocalDateTimeConverter implements Converter { + + private DateTimeFormatter formatter; + + @Value("${explore-with-me.datetime.format}") + public void setFormatter(String dateTimeFormat) { + this.formatter = DateTimeFormatter.ofPattern(dateTimeFormat); + } + + @Override + public LocalDateTime convert(String source) { + if (source == null || source.isEmpty()) return null; + + try { + return LocalDateTime.parse(source, formatter); + } catch (DateTimeParseException e) { + throw new IllegalArgumentException("Failed to convert string " + source + " to LocalDateTime"); + } + } + +} diff --git a/stats/stats-common/src/main/java/ru/practicum/validation/WebConfig.java b/stats/stats-common/src/main/java/ru/practicum/validation/WebConfig.java new file mode 100644 index 0000000..ac3495f --- /dev/null +++ b/stats/stats-common/src/main/java/ru/practicum/validation/WebConfig.java @@ -0,0 +1,20 @@ +package ru.practicum.validation; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.format.FormatterRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +@RequiredArgsConstructor +public class WebConfig implements WebMvcConfigurer { + + private final StringToLocalDateTimeConverter stringToLocalDateTimeConverter; + private final StringToBooleanConverter stringToBooleanConverter; + + @Override + public void addFormatters(FormatterRegistry registry) { + registry.addConverter(stringToLocalDateTimeConverter); + registry.addConverter(stringToBooleanConverter); + } +} diff --git a/stats/stats-server/Dockerfile b/stats/stats-server/Dockerfile new file mode 100644 index 0000000..16eef7a --- /dev/null +++ b/stats/stats-server/Dockerfile @@ -0,0 +1,4 @@ +FROM amazoncorretto:21-alpine +LABEL authors="Слава" +COPY target/*.jar app.jar +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/stats/stats-server/pom.xml b/stats/stats-server/pom.xml new file mode 100644 index 0000000..fae216f --- /dev/null +++ b/stats/stats-server/pom.xml @@ -0,0 +1,133 @@ + + + 4.0.0 + + + ru.practicum + stats + 0.0.1-SNAPSHOT + + + stats-server + + + + + ru.practicum + stats-common + + + + org.springframework.boot + spring-boot-starter-web + + + + org.postgresql + postgresql + + + + com.h2database + h2 + test + + + + org.projectlombok + lombok + + + + org.springframework.boot + spring-boot-starter-actuator + + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.springframework.boot + spring-boot-starter-validation + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + org.mapstruct + mapstruct + 1.6.1 + + + + org.mapstruct + mapstruct-processor + 1.6.1 + provided + + + + + + org.springframework.cloud + spring-cloud-starter-netflix-eureka-client + + + + org.springframework.cloud + spring-cloud-starter-config + + + + org.springframework.retry + spring-retry + + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + + + org.mapstruct + mapstruct-processor + 1.6.1 + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + + + \ No newline at end of file diff --git a/stats/stats-server/src/main/java/ru/practicum/GlobalExceptionHandler.java b/stats/stats-server/src/main/java/ru/practicum/GlobalExceptionHandler.java new file mode 100644 index 0000000..872872c --- /dev/null +++ b/stats/stats-server/src/main/java/ru/practicum/GlobalExceptionHandler.java @@ -0,0 +1,97 @@ +package ru.practicum; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import ru.practicum.exception.ErrorResponse; + +import java.time.Instant; + +@Slf4j +@ControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler( + ConstraintViolationException.class // Custom annotation exceptions + ) + public ResponseEntity handleConstraintViolation(ConstraintViolationException e, HttpServletRequest request) { + String errorMessage = e.getConstraintViolations().stream() + .map(ConstraintViolation::getMessage) + .findFirst() + .orElse("Invalid input"); + + log.debug("VALIDATION FAILED: {}", e.getMessage()); + + ErrorResponse errorResponse = ErrorResponse.builder() + .timestamp(Instant.now()) + .status(HttpStatus.BAD_REQUEST) + .error("Validation Failed") + .message(errorMessage) + .path(request.getRequestURI()) + .build(); + return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST); + } + + + @ExceptionHandler( + MethodArgumentNotValidException.class // @Valid annotation exceptions + ) + public ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException e, HttpServletRequest request) { + String errorMessage = e.getBindingResult().getAllErrors().getFirst().getDefaultMessage(); + Object target = e.getBindingResult().getTarget(); + log.debug("VALIDATION FAILED: {} for {}", errorMessage, target); + ErrorResponse errorResponse = ErrorResponse.builder() + .timestamp(Instant.now()) + .status(HttpStatus.BAD_REQUEST) + .error("Validation Failed") + .message(errorMessage) + .path(request.getRequestURI()) + .build(); + return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler({ + IllegalArgumentException.class, // wrong arguments like -1 + MethodArgumentTypeMismatchException.class, // argument type mismatch + HttpMessageNotReadableException.class, // wrong json in request body + MissingServletRequestParameterException.class // missing RequestParam + }) + public ResponseEntity handleIllegalArgument(Throwable e, HttpServletRequest request) { + log.debug("ILLEGAL ARGUMENT: {}", e.getMessage()); + ErrorResponse errorResponse = ErrorResponse.builder() + .timestamp(Instant.now()) + .status(HttpStatus.BAD_REQUEST) + .error("Illegal Argument") + .message(e.getMessage()) + .path(request.getRequestURI()) + .build(); + return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST); + } + + + @ExceptionHandler( + RuntimeException.class // Internal Server Error + ) + public ResponseEntity handleRuntimeException(RuntimeException e, HttpServletRequest request) { + log.debug("INTERNAL SERVER ERROR: {}", e.getMessage()); + ErrorResponse errorResponse = ErrorResponse.builder() + .timestamp(Instant.now()) + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .error("Internal Server Error") + .message(e.getMessage()) + .path(request.getRequestURI()) + .build(); + return new ResponseEntity<>(errorResponse, HttpStatus.INTERNAL_SERVER_ERROR); + } + + +} diff --git a/stats/stats-server/src/main/java/ru/practicum/StatServer.java b/stats/stats-server/src/main/java/ru/practicum/StatServer.java new file mode 100644 index 0000000..9bcc2cd --- /dev/null +++ b/stats/stats-server/src/main/java/ru/practicum/StatServer.java @@ -0,0 +1,13 @@ +package ru.practicum; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class StatServer { + + public static void main(String[] args) { + SpringApplication.run(StatServer.class, args); + } + +} diff --git a/stats/stats-server/src/main/java/ru/practicum/controller/StatsController.java b/stats/stats-server/src/main/java/ru/practicum/controller/StatsController.java new file mode 100644 index 0000000..65b39b9 --- /dev/null +++ b/stats/stats-server/src/main/java/ru/practicum/controller/StatsController.java @@ -0,0 +1,42 @@ +package ru.practicum.controller; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import ru.practicum.EventHitDto; +import ru.practicum.EventStatsResponseDto; +import ru.practicum.service.StatsService; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; + +@RestController +@RequestMapping +@RequiredArgsConstructor +@Validated +public class StatsController { + + private final StatsService statsService; + + @PostMapping("/hit") + @ResponseStatus(code = HttpStatus.CREATED) + public void hit( + @RequestBody @Valid EventHitDto eventHitDto + ) { + statsService.hit(eventHitDto); + } + + @GetMapping("/stats") + public Collection stats( + @RequestParam(required = true) LocalDateTime start, + @RequestParam(required = true) LocalDateTime end, + @RequestParam(required = false) List uris, + @RequestParam(defaultValue = "false") Boolean unique + ) { + return statsService.getStats(start, end, uris, unique); + } + +} diff --git a/stats/stats-server/src/main/java/ru/practicum/model/Stat.java b/stats/stats-server/src/main/java/ru/practicum/model/Stat.java new file mode 100644 index 0000000..0fab7ea --- /dev/null +++ b/stats/stats-server/src/main/java/ru/practicum/model/Stat.java @@ -0,0 +1,34 @@ +package ru.practicum.model; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Data +@Builder +@EqualsAndHashCode +@Entity +@AllArgsConstructor +@NoArgsConstructor +@Table(name = "stat") +public class Stat { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long statId; + + @Column(name = "app", nullable = false, length = 50) + private String app; + + @Column(name = "ip", nullable = false, length = 15) + private String ip; + + @Column(name = "time_stamp", nullable = false) + private LocalDateTime timestamp; + + @Column(name = "uri", nullable = false, length = 50) + private String uri; + +} \ No newline at end of file diff --git a/stats/stats-server/src/main/java/ru/practicum/model/mapper/StatMapper.java b/stats/stats-server/src/main/java/ru/practicum/model/mapper/StatMapper.java new file mode 100644 index 0000000..2e6eac7 --- /dev/null +++ b/stats/stats-server/src/main/java/ru/practicum/model/mapper/StatMapper.java @@ -0,0 +1,17 @@ +package ru.practicum.model.mapper; + +import org.mapstruct.Mapper; +import org.mapstruct.factory.Mappers; +import ru.practicum.EventHitDto; +import ru.practicum.model.Stat; + +@Mapper(componentModel = "spring") +public interface StatMapper { + + StatMapper INSTANCE = Mappers.getMapper(StatMapper.class); + + Stat toStat(EventHitDto statDto); + + EventHitDto toEventHitDto(Stat stat); + +} diff --git a/stats/stats-server/src/main/java/ru/practicum/repository/StatServiceRepository.java b/stats/stats-server/src/main/java/ru/practicum/repository/StatServiceRepository.java new file mode 100644 index 0000000..17dd8ed --- /dev/null +++ b/stats/stats-server/src/main/java/ru/practicum/repository/StatServiceRepository.java @@ -0,0 +1,40 @@ +package ru.practicum.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import ru.practicum.EventStatsResponseDto; +import ru.practicum.model.Stat; + +import java.time.LocalDateTime; +import java.util.List; + +public interface StatServiceRepository extends JpaRepository { + + @Query("select new ru.practicum.EventStatsResponseDto(stat.app, stat.uri, count(distinct stat.ip)) " + + "from Stat as stat " + + "where stat.timestamp between ?1 and ?2 " + + "group by stat.app, stat.uri " + + "order by count(distinct stat.ip) desc") + List findAllByTimestampBetweenStartAndEndWithUniqueIp(LocalDateTime start, LocalDateTime end); + + @Query("select new ru.practicum.EventStatsResponseDto(stat.app, stat.uri, count(stat.ip)) " + + "from Stat as stat " + + "where stat.timestamp between ?1 and ?2 " + + "group by stat.app, stat.uri " + + "order by count(stat.ip) desc ") + List findAllByTimestampBetweenStartAndEndWhereIpNotUnique(LocalDateTime start, LocalDateTime end); + + @Query("select new ru.practicum.EventStatsResponseDto(stat.app, stat.uri, count(distinct stat.ip)) " + + "from Stat as stat " + + "where stat.timestamp between ?1 and ?2 and stat.uri in ?3 " + + "group by stat.app, stat.uri " + + "order by count(distinct stat.ip) desc ") + List findAllByTimestampBetweenStartAndEndWithUrisUniqueIp(LocalDateTime start, LocalDateTime end, List uris); + + @Query("select new ru.practicum.EventStatsResponseDto(stat.app, stat.uri, count(stat.ip)) " + + "from Stat as stat " + + "where stat.timestamp between ?1 and ?2 and stat.uri in ?3 " + + "group by stat.app, stat.uri " + + "order by count(stat.ip) desc ") + List findAllByTimestampBetweenStartAndEndWithUrisIpNotUnique(LocalDateTime start, LocalDateTime end, List uris); +} \ No newline at end of file diff --git a/stats/stats-server/src/main/java/ru/practicum/service/StatsService.java b/stats/stats-server/src/main/java/ru/practicum/service/StatsService.java new file mode 100644 index 0000000..0a7245c --- /dev/null +++ b/stats/stats-server/src/main/java/ru/practicum/service/StatsService.java @@ -0,0 +1,21 @@ +package ru.practicum.service; + +import ru.practicum.EventHitDto; +import ru.practicum.EventStatsResponseDto; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; + +public interface StatsService { + + void hit(EventHitDto eventHitDto); + + Collection getStats( + LocalDateTime start, + LocalDateTime end, + List uris, + boolean isUnique + ); + +} diff --git a/stats/stats-server/src/main/java/ru/practicum/service/StatsServiceImpl.java b/stats/stats-server/src/main/java/ru/practicum/service/StatsServiceImpl.java new file mode 100644 index 0000000..f6a9467 --- /dev/null +++ b/stats/stats-server/src/main/java/ru/practicum/service/StatsServiceImpl.java @@ -0,0 +1,57 @@ +package ru.practicum.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import ru.practicum.EventHitDto; +import ru.practicum.EventStatsResponseDto; +import ru.practicum.model.Stat; +import ru.practicum.model.mapper.StatMapper; +import ru.practicum.repository.StatServiceRepository; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Slf4j +public class StatsServiceImpl implements StatsService { + + private final StatServiceRepository statServiceRepository; + + @Transactional + public void hit(EventHitDto eventHitDto) { + log.info("Hit - invoked"); + Stat stat = statServiceRepository.save(StatMapper.INSTANCE.toStat(eventHitDto)); + log.info("Hit - stat saved successfully - {}", stat); + } + + @Override + @Transactional(readOnly = true) + public Collection getStats(LocalDateTime start, LocalDateTime end, List uris, boolean isUnique) { + log.info("getStats - invoked"); + if (start.isAfter(end)) { + log.error("Error occurred: The start date cannot be later than the end date"); + throw new IllegalArgumentException("The start date cannot be later than the end date"); + } + if (uris == null || uris.isEmpty()) { + if (isUnique) { + log.info("getStats - success - unique = true, uris empty"); + return statServiceRepository.findAllByTimestampBetweenStartAndEndWithUniqueIp(start, end); + } else { + log.info("getStats - success - unique = false, uris empty"); + return statServiceRepository.findAllByTimestampBetweenStartAndEndWhereIpNotUnique(start, end); + } + } else { + if (isUnique) { + log.info("getStats - success - unique = true, uris not empty"); + return statServiceRepository.findAllByTimestampBetweenStartAndEndWithUrisUniqueIp(start, end, uris); + } else { + log.info("getStats - success - unique = false, uris not empty"); + return statServiceRepository.findAllByTimestampBetweenStartAndEndWithUrisIpNotUnique(start, end, uris); + } + } + } +} \ No newline at end of file diff --git a/stats/stats-server/src/main/resources/application.yaml b/stats/stats-server/src/main/resources/application.yaml new file mode 100644 index 0000000..392170a --- /dev/null +++ b/stats/stats-server/src/main/resources/application.yaml @@ -0,0 +1,25 @@ +spring: + application: + name: stats-service + config: + import: "configserver:" + cloud: + config: + discovery: + enabled: true + serviceId: config-server + fail-fast: true + retry: + useRandomPolicy: true + max-interval: 10000 + max-attempts: 100 + +eureka: + client: + registerWithEureka: true + serviceUrl: + defaultZone: http://localhost:8761/eureka/ + instance: + instance-id: ${spring.application.name}${random.int} + preferIpAddress: false + hostname: localhost diff --git a/stats/stats-server/src/main/resources/schema.sql b/stats/stats-server/src/main/resources/schema.sql new file mode 100644 index 0000000..c6b71bc --- /dev/null +++ b/stats/stats-server/src/main/resources/schema.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS stat ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + app VARCHAR(50) NOT NULL, + uri VARCHAR(50) NOT NULL, + ip VARCHAR(15) NOT NULL, + time_stamp TIMESTAMP NOT NULL, + CONSTRAINT pk_stat PRIMARY KEY (id) + ); \ No newline at end of file diff --git a/stats/stats-server/src/test/java/ru/practicum/jsontest/EventHitDtoJsonTest.java b/stats/stats-server/src/test/java/ru/practicum/jsontest/EventHitDtoJsonTest.java new file mode 100644 index 0000000..fde29c4 --- /dev/null +++ b/stats/stats-server/src/test/java/ru/practicum/jsontest/EventHitDtoJsonTest.java @@ -0,0 +1,182 @@ +package ru.practicum.jsontest; + +import jakarta.annotation.PostConstruct; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.json.JsonTest; +import org.springframework.boot.test.json.JacksonTester; +import org.springframework.core.env.Environment; +import ru.practicum.EventHitDto; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +import static org.assertj.core.api.Assertions.assertThat; + +@Disabled("Выполнять только при запущенных Discovery and Config servers") +@JsonTest +class EventHitDtoJsonTest { + + @Autowired + private JacksonTester json; + + @Autowired + private Environment environment; + + private DateTimeFormatter formatter; + + @PostConstruct + void setup() { + String dateTimeFormat = environment.getProperty("explore-with-me.datetime.format"); + this.formatter = DateTimeFormatter.ofPattern(dateTimeFormat); + } + + @Test + void shouldSerializeEventHitDto() throws Exception { + // Given + LocalDateTime timestamp = LocalDateTime.of(2024, 7, 15, 14, 30, 45); + EventHitDto eventHit = EventHitDto.builder() + .app("main-service") + .uri("/events/1") + .ip("192.168.1.1") + .timestamp(timestamp) + .build(); + + // When & Then + assertThat(json.write(eventHit)).isStrictlyEqualToJson("{" + """ + "app": "main-service", + "uri": "/events/1", + "ip": "192.168.1.1", + "timestamp": "FORMATTED" + } + """.replace("FORMATTED", timestamp.format(formatter))); + } + + @Test + void shouldDeserializeEventHitDto() throws Exception { + // Given + LocalDateTime timestamp = LocalDateTime.of(2024, 7, 15, 14, 30, 45); + String jsonContent = "{" + """ + "app": "main-service", + "uri": "/events/1", + "ip": "192.168.1.1", + "timestamp": "FORMATTED" + } + """.replace("FORMATTED", timestamp.format(formatter)); + + // When + EventHitDto eventHit = json.parse(jsonContent).getObject(); + + // Then + Assertions.assertThat(eventHit.getApp()).isEqualTo("main-service"); + Assertions.assertThat(eventHit.getUri()).isEqualTo("/events/1"); + Assertions.assertThat(eventHit.getIp()).isEqualTo("192.168.1.1"); + Assertions.assertThat(eventHit.getTimestamp()).isEqualTo(timestamp); + } + + @Test + void shouldSerializeWithDifferentDateTimeFormat() throws Exception { + // Given + LocalDateTime timestamp = LocalDateTime.of(2024, 12, 25, 23, 59, 59); + EventHitDto eventHit = EventHitDto.builder() + .app("analytics-service") + .uri("/api/stats") + .ip("10.0.0.1") + .timestamp(timestamp) + .build(); + + // When & Then + assertThat(json.write(eventHit)).hasJsonPath("$.timestamp") + .extractingJsonPathStringValue("$.timestamp") + .isEqualTo(timestamp.format(formatter)); + } + + @Test + void shouldDeserializeWithSpecialCharacters() throws Exception { + // Given + LocalDateTime timestamp = LocalDateTime.of(2024, 1, 1, 0, 0, 0); + String jsonContent = "{" + """ + "app": "test-app", + "uri": "/events/search?query=test&eventSort=date", + "ip": "127.0.0.1", + "timestamp": "FORMATTED" + } + """.replace("FORMATTED", timestamp.format(formatter)); + + // When + EventHitDto eventHit = json.parse(jsonContent).getObject(); + + // Then + Assertions.assertThat(eventHit.getApp()).isEqualTo("test-app"); + Assertions.assertThat(eventHit.getUri()).isEqualTo("/events/search?query=test&eventSort=date"); + Assertions.assertThat(eventHit.getIp()).isEqualTo("127.0.0.1"); + Assertions.assertThat(eventHit.getTimestamp()).isEqualTo(timestamp); + } + + @Test + void shouldDeserializeTimestampCorrectly() throws Exception { + // Given + LocalDateTime timestamp = LocalDateTime.of(2024, 3, 15, 16, 45, 22); + String jsonContent = "{" + """ + "app": "web-app", + "uri": "/", + "ip": "203.0.113.1", + "timestamp": "FORMATTED" + } + """.replace("FORMATTED", timestamp.format(formatter)); + + // When + EventHitDto eventHit = json.parse(jsonContent).getObject(); + + // Then + Assertions.assertThat(eventHit.getTimestamp()) + .isEqualTo(timestamp); + } + + @Test + void shouldSerializeAllFieldsCorrectly() throws Exception { + // Given + LocalDateTime timestamp = LocalDateTime.of(2024, 8, 20, 12, 0, 0); + EventHitDto eventHit = EventHitDto.builder() + .app("integration-test") + .uri("/api/v1/events/123") + .ip("172.16.0.1") + .timestamp(timestamp) + .build(); + + // When & Then + assertThat(json.write(eventHit)) + .hasJsonPath("$.app").extractingJsonPathStringValue("$.app").isEqualTo("integration-test"); + assertThat(json.write(eventHit)) + .hasJsonPath("$.uri").extractingJsonPathStringValue("$.uri").isEqualTo("/api/v1/events/123"); + assertThat(json.write(eventHit)) + .hasJsonPath("$.ip").extractingJsonPathStringValue("$.ip").isEqualTo("172.16.0.1"); + assertThat(json.write(eventHit)) + .hasJsonPath("$.timestamp").extractingJsonPathStringValue("$.timestamp").isEqualTo(timestamp.format(formatter)); + } + + @Test + void shouldRoundTripSerializationAndDeserialization() throws Exception { + // Given + EventHitDto original = EventHitDto.builder() + .app("round-trip-test") + .uri("/test/roundtrip") + .ip("192.168.1.100") + .timestamp(LocalDateTime.of(2024, 5, 10, 15, 30, 45)) + .build(); + + // When + String jsonString = json.write(original).getJson(); + EventHitDto deserialized = json.parse(jsonString).getObject(); + + // Then + Assertions.assertThat(deserialized).isEqualTo(original); + Assertions.assertThat(deserialized.getApp()).isEqualTo(original.getApp()); + Assertions.assertThat(deserialized.getUri()).isEqualTo(original.getUri()); + Assertions.assertThat(deserialized.getIp()).isEqualTo(original.getIp()); + Assertions.assertThat(deserialized.getTimestamp()).isEqualTo(original.getTimestamp()); + } + +} \ No newline at end of file diff --git a/stats/stats-server/src/test/java/ru/practicum/mockmvc/StatsControllerTest.java b/stats/stats-server/src/test/java/ru/practicum/mockmvc/StatsControllerTest.java new file mode 100644 index 0000000..8931d4f --- /dev/null +++ b/stats/stats-server/src/test/java/ru/practicum/mockmvc/StatsControllerTest.java @@ -0,0 +1,585 @@ +package ru.practicum.mockmvc; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PostConstruct; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import ru.practicum.EventHitDto; +import ru.practicum.EventStatsResponseDto; +import ru.practicum.controller.StatsController; +import ru.practicum.service.StatsService; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +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; + +@Disabled("Выполнять только при запущенных Discovery and Config servers") +@WebMvcTest(StatsController.class) +class StatsControllerTest { + + private final String invalidDateTimeFormat = "2023-01-01T00:00:00"; + private final String validApp = "ewm-main-service"; + private final String validUri = "/events/1"; + private final String validIp = "192.168.1.1"; + private String validStartFormat; + private String validEndFormat; + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private StatsService statsService; + + private DateTimeFormatter formatter; + + @Value("${explore-with-me.datetime.format}") + public void setFormatter(String dateTimeFormat) { + this.formatter = DateTimeFormatter.ofPattern(dateTimeFormat); + } + + @PostConstruct + void setup() { + validStartFormat = LocalDateTime.of(2023, 1, 1, 0, 0, 0).format(formatter); + validEndFormat = LocalDateTime.of(2023, 1, 2, 0, 0, 0).format(formatter); + } + + + // ==================== POST /hit Tests ==================== + + @Test + void hit_ValidEventHitDto_ShouldReturnCreated() throws Exception { + // Given + EventHitDto validEventHit = EventHitDto.builder() + .app(validApp) + .uri(validUri) + .ip(validIp) + .timestamp(LocalDateTime.of(2023, 1, 1, 0, 0, 0)) + .build(); + + doNothing().when(statsService).hit(any(EventHitDto.class)); + + // When & Then + mockMvc.perform(post("/hit") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validEventHit))) + .andExpect(status().isCreated()); + + verify(statsService, times(1)).hit(any(EventHitDto.class)); + } + + @Test + void hit_NullApp_ShouldReturnBadRequest() throws Exception { + // Given + EventHitDto eventHitWithNullApp = EventHitDto.builder() + .app(null) + .uri(validUri) + .ip(validIp) + .timestamp(LocalDateTime.of(2023, 1, 1, 0, 0, 0)) + .build(); + + // When & Then + mockMvc.perform(post("/hit") + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(eventHitWithNullApp))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("Validation Failed")) + .andExpect(jsonPath("$.message").value("Field 'app' should not be null")); + + verify(statsService, never()).hit(any(EventHitDto.class)); + } + + @Test + void hit_NullUri_ShouldReturnBadRequest() throws Exception { + // Given + EventHitDto eventHitWithNullUri = EventHitDto.builder() + .app(validApp) + .uri(null) + .ip(validIp) + .timestamp(LocalDateTime.of(2023, 1, 1, 0, 0, 0)) + .build(); + + // When & Then + mockMvc.perform(post("/hit") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(eventHitWithNullUri))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("Validation Failed")) + .andExpect(jsonPath("$.message").value("Field 'uri' should not be null")); + + verify(statsService, never()).hit(any(EventHitDto.class)); + } + + @Test + void hit_NullIp_ShouldReturnBadRequest() throws Exception { + // Given + EventHitDto eventHitWithNullIp = EventHitDto.builder() + .app(validApp) + .uri(validUri) + .ip(null) + .timestamp(LocalDateTime.of(2023, 1, 1, 0, 0, 0)) + .build(); + + // When & Then + mockMvc.perform(post("/hit") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(eventHitWithNullIp))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("Validation Failed")) + .andExpect(jsonPath("$.message").value("Field 'ip' should not be null")); + + verify(statsService, never()).hit(any(EventHitDto.class)); + } + + @Test + void hit_NullTimestamp_ShouldReturnBadRequest() throws Exception { + // Given + EventHitDto eventHitWithNullTimestamp = EventHitDto.builder() + .app(validApp) + .uri(validUri) + .ip(validIp) + .timestamp(null) + .build(); + + // When & Then + mockMvc.perform(post("/hit") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(eventHitWithNullTimestamp))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("Validation Failed")) + .andExpect(jsonPath("$.message").value("Field 'timestamp' should not be null")); + + verify(statsService, never()).hit(any(EventHitDto.class)); + } + + @Test + void hit_EmptyRequestBody_ShouldReturnBadRequest() throws Exception { + // When & Then + mockMvc.perform(post("/hit") + .contentType(MediaType.APPLICATION_JSON) + .content("")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("Illegal Argument")); + + verify(statsService, never()).hit(any(EventHitDto.class)); + } + + @Test + void hit_InvalidJsonFormat_ShouldReturnBadRequest() throws Exception { + // Given + String invalidJson = "{ invalid json }"; + + // When & Then + mockMvc.perform(post("/hit") + .contentType(MediaType.APPLICATION_JSON) + .content(invalidJson)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("Illegal Argument")); + + verify(statsService, never()).hit(any(EventHitDto.class)); + } + + // ==================== GET /stats Tests ==================== + + @Test + void stats_ValidParametersWithoutUris_ShouldReturnStats() throws Exception { + // Given + List expectedStats = Arrays.asList( + EventStatsResponseDto.builder() + .app(validApp) + .uri(validUri) + .hits(10L) + .build(), + EventStatsResponseDto.builder() + .app(validApp) + .uri("/events/2") + .hits(5L) + .build() + ); + + when(statsService.getStats(any(LocalDateTime.class), any(LocalDateTime.class), + any(), anyBoolean())) + .thenReturn(expectedStats); + + // When & Then + mockMvc.perform(get("/stats") + .param("start", validStartFormat) + .param("end", validEndFormat)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$.length()").value(2)) + .andExpect(jsonPath("$[0].app").value(validApp)) + .andExpect(jsonPath("$[0].uri").value(validUri)) + .andExpect(jsonPath("$[0].hits").value(10)) + .andExpect(jsonPath("$[1].app").value(validApp)) + .andExpect(jsonPath("$[1].uri").value("/events/2")) + .andExpect(jsonPath("$[1].hits").value(5)); + + verify(statsService, times(1)).getStats(any(LocalDateTime.class), + any(LocalDateTime.class), + isNull(), + eq(false)); + } + + @Test + void stats_ValidParametersWithUris_ShouldReturnStats() throws Exception { + // Given + List expectedStats = Collections.singletonList( + EventStatsResponseDto.builder() + .app(validApp) + .uri(validUri) + .hits(15L) + .build() + ); + + when(statsService.getStats(any(LocalDateTime.class), any(LocalDateTime.class), + any(List.class), anyBoolean())) + .thenReturn(expectedStats); + + // When & Then + mockMvc.perform(get("/stats") + .param("start", validStartFormat) + .param("end", validEndFormat) + .param("uris", "/events/1", "/events/2")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$.length()").value(1)) + .andExpect(jsonPath("$[0].app").value(validApp)) + .andExpect(jsonPath("$[0].uri").value(validUri)) + .andExpect(jsonPath("$[0].hits").value(15)); + + verify(statsService, times(1)).getStats(any(LocalDateTime.class), + any(LocalDateTime.class), + any(List.class), + eq(false)); + } + + @Test + void stats_UniqueParameterTrue_ShouldReturnStats() throws Exception { + // Given + List expectedStats = Collections.singletonList( + EventStatsResponseDto.builder() + .app(validApp) + .uri(validUri) + .hits(8L) + .build() + ); + + when(statsService.getStats(any(LocalDateTime.class), any(LocalDateTime.class), + any(), anyBoolean())) + .thenReturn(expectedStats); + + // When & Then + mockMvc.perform(get("/stats") + .param("start", validStartFormat) + .param("end", validEndFormat) + .param("unique", "true")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$.length()").value(1)) + .andExpect(jsonPath("$[0].hits").value(8)); + + verify(statsService, times(1)).getStats(any(LocalDateTime.class), + any(LocalDateTime.class), + isNull(), + eq(true)); + } + + @Test + void stats_UniqueParameterFalse_ShouldReturnStats() throws Exception { + // Given + List expectedStats = Collections.singletonList( + EventStatsResponseDto.builder() + .app(validApp) + .uri(validUri) + .hits(20L) + .build() + ); + + when(statsService.getStats(any(LocalDateTime.class), any(LocalDateTime.class), + any(), anyBoolean())) + .thenReturn(expectedStats); + + // When & Then + mockMvc.perform(get("/stats") + .param("start", validStartFormat) + .param("end", validEndFormat) + .param("unique", "false")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$.length()").value(1)) + .andExpect(jsonPath("$[0].hits").value(20)); + + verify(statsService, times(1)).getStats(any(LocalDateTime.class), + any(LocalDateTime.class), + isNull(), + eq(false)); + } + + @Test + void stats_UniqueParameterCaseInsensitive_ShouldReturnStats() throws Exception { + // Given + when(statsService.getStats(any(LocalDateTime.class), any(LocalDateTime.class), + any(), anyBoolean())) + .thenReturn(Collections.emptyList()); + + // When & Then - Test different cases + mockMvc.perform(get("/stats") + .param("start", validStartFormat) + .param("end", validEndFormat) + .param("unique", "TRUE")) + .andExpect(status().isOk()); + + mockMvc.perform(get("/stats") + .param("start", validStartFormat) + .param("end", validEndFormat) + .param("unique", "False")) + .andExpect(status().isOk()); + + verify(statsService, times(2)).getStats(any(LocalDateTime.class), + any(LocalDateTime.class), + any(), + anyBoolean()); + } + + @Test + void stats_MissingStartParameter_ShouldReturnBadRequest() throws Exception { + // When & Then + mockMvc.perform(get("/stats") + .param("end", validEndFormat)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("Illegal Argument")); + + verify(statsService, never()).getStats(any(LocalDateTime.class), + any(LocalDateTime.class), + any(), + anyBoolean()); + } + + @Test + void stats_MissingEndParameter_ShouldReturnBadRequest() throws Exception { + // When & Then + mockMvc.perform(get("/stats") + .param("start", validStartFormat)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("Illegal Argument")); + + verify(statsService, never()).getStats(any(LocalDateTime.class), + any(LocalDateTime.class), + any(), + anyBoolean()); + } + + @Test + void stats_InvalidStartDateFormat_ShouldReturnBadRequest() throws Exception { + // When & Then + mockMvc.perform(get("/stats") + .param("start", invalidDateTimeFormat) + .param("end", validEndFormat)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("Illegal Argument")); + + verify(statsService, never()).getStats(any(LocalDateTime.class), + any(LocalDateTime.class), + any(), + anyBoolean()); + } + + @Test + void stats_InvalidEndDateFormat_ShouldReturnBadRequest() throws Exception { + // When & Then + mockMvc.perform(get("/stats") + .param("start", validStartFormat) + .param("end", invalidDateTimeFormat)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("Illegal Argument")); + + verify(statsService, never()).getStats(any(LocalDateTime.class), + any(LocalDateTime.class), + any(), + anyBoolean()); + } + + @Test + void stats_InvalidUniqueParameter_ShouldReturnBadRequest() throws Exception { + // When & Then + mockMvc.perform(get("/stats") + .param("start", validStartFormat) + .param("end", validEndFormat) + .param("unique", "invalid")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("Illegal Argument")); + + verify(statsService, never()).getStats(any(LocalDateTime.class), + any(LocalDateTime.class), + any(), + anyBoolean()); + } + + @Test + void stats_EmptyUrisList_ShouldReturnStats() throws Exception { + // Given + when(statsService.getStats(any(LocalDateTime.class), any(LocalDateTime.class), + any(), anyBoolean())) + .thenReturn(Collections.emptyList()); + + // When & Then + mockMvc.perform(get("/stats") + .param("start", validStartFormat) + .param("end", validEndFormat) + .param("uris", "")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$.length()").value(0)); + + verify(statsService, times(1)).getStats(any(LocalDateTime.class), + any(LocalDateTime.class), + any(List.class), + eq(false)); + } + + @Test + void stats_MultipleUris_ShouldReturnStats() throws Exception { + // Given + List expectedStats = Arrays.asList( + EventStatsResponseDto.builder() + .app(validApp) + .uri("/events/1") + .hits(10L) + .build(), + EventStatsResponseDto.builder() + .app(validApp) + .uri("/events/2") + .hits(5L) + .build() + ); + + when(statsService.getStats(any(LocalDateTime.class), any(LocalDateTime.class), + any(List.class), anyBoolean())) + .thenReturn(expectedStats); + + // When & Then + mockMvc.perform(get("/stats") + .param("start", validStartFormat) + .param("end", validEndFormat) + .param("uris", "/events/1", "/events/2", "/events/3")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$.length()").value(2)); + + verify(statsService, times(1)).getStats(any(LocalDateTime.class), + any(LocalDateTime.class), + any(List.class), + eq(false)); + } + + @Test + void stats_ServiceReturnsEmptyList_ShouldReturnEmptyArray() throws Exception { + // Given + when(statsService.getStats(any(LocalDateTime.class), any(LocalDateTime.class), + any(), anyBoolean())) + .thenReturn(Collections.emptyList()); + + // When & Then + mockMvc.perform(get("/stats") + .param("start", validStartFormat) + .param("end", validEndFormat)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$.length()").value(0)); + + verify(statsService, times(1)).getStats(any(LocalDateTime.class), + any(LocalDateTime.class), + isNull(), + eq(false)); + } + + @Test + void stats_ServiceThrowsException_ShouldReturnInternalServerError() throws Exception { + // Given + when(statsService.getStats(any(LocalDateTime.class), any(LocalDateTime.class), + any(), anyBoolean())) + .thenThrow(new RuntimeException("Very bad error")); + + // When & Then + mockMvc.perform(get("/stats") + .param("start", validStartFormat) + .param("end", validEndFormat)) + .andExpect(status().isInternalServerError()); + + verify(statsService, times(1)).getStats(any(LocalDateTime.class), + any(LocalDateTime.class), + any(), + anyBoolean()); + } + + // ==================== Edge Cases and Integration Tests ==================== + + @Test + void stats_BoundaryDateValues_ShouldReturnStats() throws Exception { + // Given + when(statsService.getStats(any(LocalDateTime.class), any(LocalDateTime.class), + any(), anyBoolean())) + .thenReturn(Collections.emptyList()); + + // When & Then - Test with same start and end dates + mockMvc.perform(get("/stats") + .param("start", validStartFormat) + .param("end", validStartFormat)) + .andExpect(status().isOk()); + + verify(statsService, times(1)).getStats(any(LocalDateTime.class), + any(LocalDateTime.class), + any(), + anyBoolean()); + } + + @Test + void stats_AllParametersProvided_ShouldReturnStats() throws Exception { + // Given + List expectedStats = Collections.singletonList( + EventStatsResponseDto.builder() + .app(validApp) + .uri(validUri) + .hits(3L) + .build() + ); + + when(statsService.getStats(any(LocalDateTime.class), any(LocalDateTime.class), + any(List.class), anyBoolean())) + .thenReturn(expectedStats); + + // When & Then + mockMvc.perform(get("/stats") + .param("start", validStartFormat) + .param("end", validEndFormat) + .param("uris", "/events/1") + .param("unique", "true")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$.length()").value(1)) + .andExpect(jsonPath("$[0].hits").value(3)); + + verify(statsService, times(1)).getStats(any(LocalDateTime.class), + any(LocalDateTime.class), + any(List.class), + eq(true)); + } +} \ No newline at end of file diff --git a/stats/stats-server/src/test/java/ru/practicum/service/StatsServiceImplTest.java b/stats/stats-server/src/test/java/ru/practicum/service/StatsServiceImplTest.java new file mode 100644 index 0000000..51c683b --- /dev/null +++ b/stats/stats-server/src/test/java/ru/practicum/service/StatsServiceImplTest.java @@ -0,0 +1,358 @@ +package ru.practicum.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; +import ru.practicum.EventHitDto; +import ru.practicum.EventStatsResponseDto; +import ru.practicum.model.Stat; +import ru.practicum.repository.StatServiceRepository; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@Disabled("Выполнять только при запущенных Discovery and Config servers") +@SpringBootTest +@Transactional +@AutoConfigureTestDatabase +class StatsServiceImplTest { + + @Autowired + private StatsService statsService; + + @Autowired + private StatServiceRepository statServiceRepository; + + private EventHitDto eventHitDto1; + private EventHitDto eventHitDto2; + private EventHitDto eventHitDto3; + private EventHitDto eventHitDto4; + + @BeforeEach + void setUp() { + LocalDateTime now = LocalDateTime.now(); + + eventHitDto1 = EventHitDto.builder() + .app("app1") + .uri("/events/1") + .ip("192.168.1.1") + .timestamp(now.minusHours(1)) + .build(); + + eventHitDto2 = EventHitDto.builder() + .app("app1") + .uri("/events/2") + .ip("192.168.1.2") + .timestamp(now.minusHours(2)) + .build(); + + eventHitDto3 = EventHitDto.builder() + .app("app2") + .uri("/events/1") + .ip("192.168.1.1") + .timestamp(now.minusHours(3)) + .build(); + + eventHitDto4 = EventHitDto.builder() + .app("app1") + .uri("/events/1") + .ip("192.168.1.1") + .timestamp(now.minusHours(4)) + .build(); + } + + @Test + void hit_ShouldSaveEventSuccessfully() { + // When + statsService.hit(eventHitDto1); + + // Then + List savedStats = statServiceRepository.findAll(); + assertEquals(1, savedStats.size()); + + Stat savedStat = savedStats.get(0); + assertEquals(eventHitDto1.getApp(), savedStat.getApp()); + assertEquals(eventHitDto1.getUri(), savedStat.getUri()); + assertEquals(eventHitDto1.getIp(), savedStat.getIp()); + assertEquals(eventHitDto1.getTimestamp(), savedStat.getTimestamp()); + } + + @Test + void hit_ShouldSaveMultipleEventsSuccessfully() { + // When + statsService.hit(eventHitDto1); + statsService.hit(eventHitDto2); + statsService.hit(eventHitDto3); + + // Then + List savedStats = statServiceRepository.findAll(); + assertEquals(3, savedStats.size()); + } + + @Test + void getStats_ShouldThrowExceptionWhenStartIsAfterEnd() { + // Given + LocalDateTime start = LocalDateTime.now(); + LocalDateTime end = start.minusHours(1); + + // When & Then + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> statsService.getStats(start, end, null, false) + ); + } + + @Test + void getStats_ShouldReturnStatsWithUniqueIpWhenUrisAreEmptyAndUniqueIsTrue() { + // Given + statsService.hit(eventHitDto1); + statsService.hit(eventHitDto2); + statsService.hit(eventHitDto3); + statsService.hit(eventHitDto4); + LocalDateTime start = LocalDateTime.now().minusDays(1); + LocalDateTime end = LocalDateTime.now().plusDays(1); + + // When + Collection result = statsService.getStats(start, end, null, true); + + // Then + assertEquals(3, result.size()); + + EventStatsResponseDto[] statsArray = result.toArray(new EventStatsResponseDto[0]); + + // Проверяем, что результат отсортирован по убыванию hits + assertTrue(statsArray[0].getHits() >= statsArray[1].getHits()); + assertTrue(statsArray[1].getHits() >= statsArray[2].getHits()); + } + + @Test + void getStats_ShouldReturnStatsWithoutUniqueIpWhenUrisAreEmptyAndUniqueIsFalse() { + // Given + statsService.hit(eventHitDto1); + statsService.hit(eventHitDto2); + statsService.hit(eventHitDto3); + statsService.hit(eventHitDto4); + LocalDateTime start = LocalDateTime.now().minusDays(1); + LocalDateTime end = LocalDateTime.now().plusDays(1); + + // When + Collection result = statsService.getStats(start, end, null, false); + + // Then + assertEquals(3, result.size()); + + // Проверяем, что учитываются все хиты, включая повторы IP + long totalHits = result.stream().mapToLong(EventStatsResponseDto::getHits).sum(); + assertEquals(4, totalHits); + } + + @Test + void getStats_ShouldReturnStatsWithUniqueIpForSpecificUris() { + // Given + statsService.hit(eventHitDto1); + statsService.hit(eventHitDto2); + statsService.hit(eventHitDto3); + statsService.hit(eventHitDto4); + LocalDateTime start = LocalDateTime.now().minusDays(1); + LocalDateTime end = LocalDateTime.now().plusDays(1); + List uris = List.of("/events/1"); + + // When + Collection result = statsService.getStats(start, end, uris, true); + + // Then + assertEquals(2, result.size()); + + // Проверяем, что возвращаются только статистики для указанных URI + result.forEach(stat -> assertEquals("/events/1", stat.getUri())); + } + + @Test + void getStats_ShouldReturnStatsWithoutUniqueIpForSpecificUris() { + // Given + statsService.hit(eventHitDto1); + statsService.hit(eventHitDto2); + statsService.hit(eventHitDto3); + statsService.hit(eventHitDto4); + LocalDateTime start = LocalDateTime.now().minusDays(1); + LocalDateTime end = LocalDateTime.now().plusDays(1); + List uris = List.of("/events/1", "/events/2"); + + // When + Collection result = statsService.getStats(start, end, uris, false); + + // Then + assertEquals(3, result.size()); + + // Проверяем, что возвращаются только статистики для указанных URI + result.forEach(stat -> + assertTrue(stat.getUri().equals("/events/1") || stat.getUri().equals("/events/2")) + ); + } + + @Test + void getStats_ShouldReturnEmptyListWhenNoDataInTimeRange() { + // Given + statsService.hit(eventHitDto1); + statsService.hit(eventHitDto2); + statsService.hit(eventHitDto3); + statsService.hit(eventHitDto4); + LocalDateTime start = LocalDateTime.now().plusDays(1); + LocalDateTime end = LocalDateTime.now().plusDays(2); + + // When + Collection result = statsService.getStats(start, end, null, false); + + // Then + assertTrue(result.isEmpty()); + } + + @Test + void getStats_ShouldReturnEmptyListWhenNoMatchingUris() { + // Given + statsService.hit(eventHitDto1); + statsService.hit(eventHitDto2); + statsService.hit(eventHitDto3); + statsService.hit(eventHitDto4); + LocalDateTime start = LocalDateTime.now().minusDays(1); + LocalDateTime end = LocalDateTime.now().plusDays(1); + List uris = List.of("/events/999"); + + // When + Collection result = statsService.getStats(start, end, uris, false); + + // Then + assertTrue(result.isEmpty()); + } + + @Test + void getStats_ShouldHandleEmptyUrisList() { + // Given + statsService.hit(eventHitDto1); + statsService.hit(eventHitDto2); + statsService.hit(eventHitDto3); + statsService.hit(eventHitDto4); + LocalDateTime start = LocalDateTime.now().minusDays(1); + LocalDateTime end = LocalDateTime.now().plusDays(1); + List emptyUris = List.of(); + + // When + Collection result = statsService.getStats(start, end, emptyUris, false); + + // Then + assertEquals(3, result.size()); + } + + @Test + void getStats_ShouldCorrectlyCountUniqueIps() { + // Given + // Создаем несколько хитов с одинаковыми IP для одного URI + EventHitDto hit1 = EventHitDto.builder() + .app("app1") + .uri("/events/1") + .ip("192.168.1.1") + .timestamp(LocalDateTime.now().minusHours(1)) + .build(); + + EventHitDto hit2 = EventHitDto.builder() + .app("app1") + .uri("/events/1") + .ip("192.168.1.1") + .timestamp(LocalDateTime.now().minusHours(2)) + .build(); + + EventHitDto hit3 = EventHitDto.builder() + .app("app1") + .uri("/events/1") + .ip("192.168.1.2") + .timestamp(LocalDateTime.now().minusHours(3)) + .build(); + + statsService.hit(hit1); + statsService.hit(hit2); + statsService.hit(hit3); + + LocalDateTime start = LocalDateTime.now().minusDays(1); + LocalDateTime end = LocalDateTime.now().plusDays(1); + + // When - уникальные IP + Collection uniqueResult = statsService.getStats(start, end, null, true); + + // Then + assertEquals(1, uniqueResult.size()); + EventStatsResponseDto uniqueStat = uniqueResult.iterator().next(); + assertEquals(2L, uniqueStat.getHits()); // 2 уникальных IP + + // When - все IP (не уникальные) + Collection allResult = statsService.getStats(start, end, null, false); + + // Then + assertEquals(1, allResult.size()); + EventStatsResponseDto allStat = allResult.iterator().next(); + assertEquals(3L, allStat.getHits()); // 3 общих хита + } + + @Test + void getStats_ShouldReturnResultsSortedByHitsDesc() { + // Given + // Создаем данные с разным количеством хитов + EventHitDto hit1 = EventHitDto.builder() + .app("app1") + .uri("/events/1") + .ip("192.168.1.1") + .timestamp(LocalDateTime.now().minusHours(1)) + .build(); + + EventHitDto hit2 = EventHitDto.builder() + .app("app1") + .uri("/events/2") + .ip("192.168.1.1") + .timestamp(LocalDateTime.now().minusHours(2)) + .build(); + + EventHitDto hit3 = EventHitDto.builder() + .app("app1") + .uri("/events/2") + .ip("192.168.1.2") + .timestamp(LocalDateTime.now().minusHours(3)) + .build(); + + EventHitDto hit4 = EventHitDto.builder() + .app("app1") + .uri("/events/2") + .ip("192.168.1.3") + .timestamp(LocalDateTime.now().minusHours(4)) + .build(); + + statsService.hit(hit1); + statsService.hit(hit2); + statsService.hit(hit3); + statsService.hit(hit4); + + LocalDateTime start = LocalDateTime.now().minusDays(1); + LocalDateTime end = LocalDateTime.now().plusDays(1); + + // When + Collection result = statsService.getStats(start, end, null, true); + + // Then + EventStatsResponseDto[] statsArray = result.toArray(new EventStatsResponseDto[0]); + assertEquals(2, statsArray.length); + + // /events/2 должен быть первым (3 уникальных IP) + assertEquals("/events/2", statsArray[0].getUri()); + assertEquals(3L, statsArray[0].getHits()); + + // /events/1 должен быть вторым (1 уникальный IP) + assertEquals("/events/1", statsArray[1].getUri()); + assertEquals(1L, statsArray[1].getHits()); + } + +} \ No newline at end of file From 4919e7d45a50a9ab813ef5248a48678424d7f8a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D0=BB=D0=B0=D0=B2=D0=B0?= Date: Sun, 9 Nov 2025 16:41:12 +0400 Subject: [PATCH 2/4] remove ResponseEntity from CategoryAdminController --- .../controller/CategoryAdminController.java | 27 +++++++------------ .../practicum/event/mapper/EventMapper.java | 1 - 2 files changed, 9 insertions(+), 19 deletions(-) diff --git a/core/main-service/src/main/java/ru/practicum/category/controller/CategoryAdminController.java b/core/main-service/src/main/java/ru/practicum/category/controller/CategoryAdminController.java index a59d530..8d5b8d7 100644 --- a/core/main-service/src/main/java/ru/practicum/category/controller/CategoryAdminController.java +++ b/core/main-service/src/main/java/ru/practicum/category/controller/CategoryAdminController.java @@ -4,7 +4,6 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; import org.springframework.validation.BindingResult; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; @@ -22,7 +21,8 @@ public class CategoryAdminController { private final CategoryAdminService categoryAdminService; @PostMapping - public ResponseEntity addCategory( + @ResponseStatus(HttpStatus.CREATED) + public CategoryDto addCategory( @RequestBody @Validated(CreateOrUpdateValidator.Create.class) CategoryDto requestCategory, BindingResult bindingResult @@ -30,33 +30,24 @@ public ResponseEntity addCategory( log.info("Calling the POST request to /admin/categories endpoint"); if (bindingResult.hasErrors()) { log.error("Validation error with category name"); - return ResponseEntity.badRequest().body((requestCategory)); + throw new IllegalArgumentException("Validation failed"); } - return ResponseEntity - .status(HttpStatus.CREATED) - .body(categoryAdminService.createCategory(requestCategory)); + return categoryAdminService.createCategory(requestCategory); } @DeleteMapping("/{catId}") - public ResponseEntity deleteCategories( - @PathVariable @Positive Long catId - ) { + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deleteCategories(@PathVariable @Positive Long catId) { log.info("Calling the DELETE request to /admin/categories/{catId} endpoint"); categoryAdminService.deleteCategory(catId); - return ResponseEntity - .status(HttpStatus.NO_CONTENT) - .body("Category deleted: " + catId); } @PatchMapping("/{catId}") - public ResponseEntity updateCategory( + public CategoryDto updateCategory( @PathVariable Long catId, @RequestBody @Validated(CreateOrUpdateValidator.Update.class) CategoryDto categoryDto ) { log.info("Calling the PATCH request to /admin/categories/{catId} endpoint"); - return ResponseEntity - .status(HttpStatus.OK) - .body(categoryAdminService.updateCategory(catId, categoryDto)); + return categoryAdminService.updateCategory(catId, categoryDto); } - -} \ No newline at end of file +} diff --git a/core/main-service/src/main/java/ru/practicum/event/mapper/EventMapper.java b/core/main-service/src/main/java/ru/practicum/event/mapper/EventMapper.java index 170356d..c57915b 100644 --- a/core/main-service/src/main/java/ru/practicum/event/mapper/EventMapper.java +++ b/core/main-service/src/main/java/ru/practicum/event/mapper/EventMapper.java @@ -11,7 +11,6 @@ public class EventMapper { - public static Event toEvent( NewEventDto newEventDto, User initiator, From 7c0094639aadfb5818b90c46c2e6b18932b04593 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D0=BB=D0=B0=D0=B2=D0=B0?= Date: Sun, 9 Nov 2025 18:12:19 +0400 Subject: [PATCH 3/4] optimize @Transactional annotations --- .../category/service/CategoryPublicServiceImpl.java | 1 - .../practicum/comment/service/CommentAdminServiceImpl.java | 5 ++++- .../practicum/comment/service/CommentPrivateServiceImpl.java | 5 ++++- .../practicum/comment/service/CommentPublicServiceImpl.java | 1 - .../compilation/service/CompilationPublicServiceImpl.java | 1 - .../ru/practicum/event/service/EventAdminServiceImpl.java | 1 - .../ru/practicum/event/service/EventPrivateServiceImpl.java | 1 - .../ru/practicum/event/service/EventPublicServiceImpl.java | 1 - .../java/ru/practicum/request/service/RequestService.java | 1 - .../src/main/java/ru/practicum/user/service/UserService.java | 1 - 10 files changed, 8 insertions(+), 10 deletions(-) diff --git a/core/main-service/src/main/java/ru/practicum/category/service/CategoryPublicServiceImpl.java b/core/main-service/src/main/java/ru/practicum/category/service/CategoryPublicServiceImpl.java index 44374ab..b5d6fe6 100644 --- a/core/main-service/src/main/java/ru/practicum/category/service/CategoryPublicServiceImpl.java +++ b/core/main-service/src/main/java/ru/practicum/category/service/CategoryPublicServiceImpl.java @@ -17,7 +17,6 @@ @Service @RequiredArgsConstructor -@Transactional(readOnly = true) @Slf4j public class CategoryPublicServiceImpl implements CategoryPublicService { diff --git a/core/main-service/src/main/java/ru/practicum/comment/service/CommentAdminServiceImpl.java b/core/main-service/src/main/java/ru/practicum/comment/service/CommentAdminServiceImpl.java index af3018c..2fc2665 100644 --- a/core/main-service/src/main/java/ru/practicum/comment/service/CommentAdminServiceImpl.java +++ b/core/main-service/src/main/java/ru/practicum/comment/service/CommentAdminServiceImpl.java @@ -19,13 +19,14 @@ @Service @RequiredArgsConstructor @Slf4j -@Transactional + public class CommentAdminServiceImpl implements CommentAdminService { private final CommentRepository repository; private final UserRepository userRepository; @Override + @Transactional public void delete(Long comId) { log.info("admin delete - invoked"); if (!repository.existsById(comId)) { @@ -61,6 +62,7 @@ public List findAllByUserId(Long userId, int from, int size) { } @Override + @Transactional public CommentDto approveComment(Long comId) { log.info("approveComment - invoked"); Comment comment = repository.findById(comId) @@ -72,6 +74,7 @@ public CommentDto approveComment(Long comId) { } @Override + @Transactional public CommentDto rejectComment(Long comId) { log.info("rejectComment - invoked"); Comment comment = repository.findById(comId).orElseThrow(() -> new NotFoundException("Comment not found")); diff --git a/core/main-service/src/main/java/ru/practicum/comment/service/CommentPrivateServiceImpl.java b/core/main-service/src/main/java/ru/practicum/comment/service/CommentPrivateServiceImpl.java index c0dce74..8196187 100644 --- a/core/main-service/src/main/java/ru/practicum/comment/service/CommentPrivateServiceImpl.java +++ b/core/main-service/src/main/java/ru/practicum/comment/service/CommentPrivateServiceImpl.java @@ -22,7 +22,7 @@ @Service @RequiredArgsConstructor @Slf4j -@Transactional + public class CommentPrivateServiceImpl implements CommentPrivateService { private final CommentRepository repository; @@ -30,6 +30,7 @@ public class CommentPrivateServiceImpl implements CommentPrivateService { private final EventRepository eventRepository; @Override + @Transactional public CommentDto createComment(Long userId, Long eventId, CommentCreateDto commentDto) { log.info("createComment - invoked"); Comment comment = CommentMapper.toComment(commentDto); @@ -56,6 +57,7 @@ public CommentDto createComment(Long userId, Long eventId, CommentCreateDto comm } @Override + @Transactional public void deleteComment(Long userId, Long comId) { log.info("deleteComment - invoked"); Comment comment = repository.findById(comId) @@ -72,6 +74,7 @@ public void deleteComment(Long userId, Long comId) { } @Override + @Transactional public CommentDto patchComment(Long userId, Long comId, CommentCreateDto commentCreateDto) { log.info("patchComment - invoked"); Comment comment = repository.findById(comId) diff --git a/core/main-service/src/main/java/ru/practicum/comment/service/CommentPublicServiceImpl.java b/core/main-service/src/main/java/ru/practicum/comment/service/CommentPublicServiceImpl.java index 3464075..e9fa243 100644 --- a/core/main-service/src/main/java/ru/practicum/comment/service/CommentPublicServiceImpl.java +++ b/core/main-service/src/main/java/ru/practicum/comment/service/CommentPublicServiceImpl.java @@ -21,7 +21,6 @@ import static ru.practicum.util.Util.createPageRequestAsc; @Service -@Transactional(readOnly = true) @RequiredArgsConstructor @Slf4j public class CommentPublicServiceImpl implements CommentPublicService { diff --git a/core/main-service/src/main/java/ru/practicum/compilation/service/CompilationPublicServiceImpl.java b/core/main-service/src/main/java/ru/practicum/compilation/service/CompilationPublicServiceImpl.java index 58df11c..77171d5 100644 --- a/core/main-service/src/main/java/ru/practicum/compilation/service/CompilationPublicServiceImpl.java +++ b/core/main-service/src/main/java/ru/practicum/compilation/service/CompilationPublicServiceImpl.java @@ -16,7 +16,6 @@ import java.util.List; @Service -@Transactional(readOnly = true) @RequiredArgsConstructor @Slf4j public class CompilationPublicServiceImpl implements CompilationPublicService { diff --git a/core/main-service/src/main/java/ru/practicum/event/service/EventAdminServiceImpl.java b/core/main-service/src/main/java/ru/practicum/event/service/EventAdminServiceImpl.java index 1ee7e93..551b6ed 100644 --- a/core/main-service/src/main/java/ru/practicum/event/service/EventAdminServiceImpl.java +++ b/core/main-service/src/main/java/ru/practicum/event/service/EventAdminServiceImpl.java @@ -27,7 +27,6 @@ @Service @RequiredArgsConstructor -@Transactional(readOnly = true) public class EventAdminServiceImpl implements EventAdminService { private final EventRepository eventRepository; diff --git a/core/main-service/src/main/java/ru/practicum/event/service/EventPrivateServiceImpl.java b/core/main-service/src/main/java/ru/practicum/event/service/EventPrivateServiceImpl.java index 32f5a7d..ad80c86 100644 --- a/core/main-service/src/main/java/ru/practicum/event/service/EventPrivateServiceImpl.java +++ b/core/main-service/src/main/java/ru/practicum/event/service/EventPrivateServiceImpl.java @@ -29,7 +29,6 @@ @Service @RequiredArgsConstructor -@Transactional(readOnly = true) public class EventPrivateServiceImpl implements EventPrivateService { private final UserRepository userRepository; diff --git a/core/main-service/src/main/java/ru/practicum/event/service/EventPublicServiceImpl.java b/core/main-service/src/main/java/ru/practicum/event/service/EventPublicServiceImpl.java index fce8535..40ffb3f 100644 --- a/core/main-service/src/main/java/ru/practicum/event/service/EventPublicServiceImpl.java +++ b/core/main-service/src/main/java/ru/practicum/event/service/EventPublicServiceImpl.java @@ -28,7 +28,6 @@ @Service @RequiredArgsConstructor -@Transactional(readOnly = true) public class EventPublicServiceImpl implements EventPublicService { private final StatClient statClient; diff --git a/core/main-service/src/main/java/ru/practicum/request/service/RequestService.java b/core/main-service/src/main/java/ru/practicum/request/service/RequestService.java index 7278c48..94884f2 100644 --- a/core/main-service/src/main/java/ru/practicum/request/service/RequestService.java +++ b/core/main-service/src/main/java/ru/practicum/request/service/RequestService.java @@ -26,7 +26,6 @@ @Service @RequiredArgsConstructor -@Transactional(readOnly = true) public class RequestService { private final RequestRepository requestRepository; diff --git a/core/main-service/src/main/java/ru/practicum/user/service/UserService.java b/core/main-service/src/main/java/ru/practicum/user/service/UserService.java index d3644be..b108d49 100644 --- a/core/main-service/src/main/java/ru/practicum/user/service/UserService.java +++ b/core/main-service/src/main/java/ru/practicum/user/service/UserService.java @@ -17,7 +17,6 @@ @Service @RequiredArgsConstructor -@Transactional(readOnly = true) public class UserService { private final UserRepository userRepository; From fca9990095547390a8569e4906261fd21aa24e04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D0=BB=D0=B0=D0=B2=D0=B0?= Date: Sun, 9 Nov 2025 18:19:16 +0400 Subject: [PATCH 4/4] improve log messages --- .../service/CommentAdminServiceImpl.java | 27 +++++----- .../service/CommentPrivateServiceImpl.java | 54 ++++++++++++------- .../service/CommentPublicServiceImpl.java | 45 +++++++++++----- .../service/CompilationAdminServiceImpl.java | 50 ++++++++++++----- .../service/CompilationPublicServiceImpl.java | 33 ++++++++---- 5 files changed, 141 insertions(+), 68 deletions(-) diff --git a/core/main-service/src/main/java/ru/practicum/comment/service/CommentAdminServiceImpl.java b/core/main-service/src/main/java/ru/practicum/comment/service/CommentAdminServiceImpl.java index 2fc2665..38e9dd4 100644 --- a/core/main-service/src/main/java/ru/practicum/comment/service/CommentAdminServiceImpl.java +++ b/core/main-service/src/main/java/ru/practicum/comment/service/CommentAdminServiceImpl.java @@ -13,13 +13,11 @@ import ru.practicum.comment.repository.CommentRepository; import ru.practicum.exception.NotFoundException; import ru.practicum.user.repository.UserRepository; - import java.util.List; @Service @RequiredArgsConstructor @Slf4j - public class CommentAdminServiceImpl implements CommentAdminService { private final CommentRepository repository; @@ -28,9 +26,9 @@ public class CommentAdminServiceImpl implements CommentAdminService { @Override @Transactional public void delete(Long comId) { - log.info("admin delete - invoked"); + log.info("admin delete - invoked for comment ID: {}", comId); if (!repository.existsById(comId)) { - log.error("User with id = {} not exist", comId); + log.error("Comment with id = {} not found", comId); throw new NotFoundException("Comment not found"); } log.info("Result: comment with id = {} deleted", comId); @@ -39,48 +37,49 @@ public void delete(Long comId) { @Override public List search(String text, int from, int size) { - log.info("admin search - invoked"); + log.info("admin search - invoked with text='{}', from={}, size={}", text, from, size); Pageable pageable = PageRequest.of(from / size, size); Page page = repository.findAllByText(text, pageable); List list = page.getContent(); - log.info("Result: list of comments size = {} ", list.size()); + log.info("Result: found {} comments for search query '{}'", list.size(), text); return CommentMapper.toListCommentDto(list); } @Override public List findAllByUserId(Long userId, int from, int size) { - log.info("admin findAllByUserId - invoked"); + log.info("admin findAllByUserId - invoked for user ID: {}, from={}, size={}", userId, from, size); if (!userRepository.existsById(userId)) { - log.error("User with id = {} not exist", userId); + log.error("User with id = {} not found", userId); throw new NotFoundException("User not found"); } Pageable pageable = PageRequest.of(from / size, size); Page page = repository.findAllByAuthorId(userId, pageable); List list = page.getContent(); - log.info("Result: list of comments size = {} ", list.size()); + log.info("Result: user ID {} has {} comments", userId, list.size()); return CommentMapper.toListCommentDto(list); } @Override @Transactional public CommentDto approveComment(Long comId) { - log.info("approveComment - invoked"); + log.info("approveComment - invoked for comment ID: {}", comId); Comment comment = repository.findById(comId) .orElseThrow(() -> new NotFoundException("Comment not found")); comment.setApproved(true); repository.save(comment); - log.info("Result: comment with id = {} approved", comId); + log.info("Result: comment with id = {} approved successfully", comId); return CommentMapper.toCommentDto(comment); } @Override @Transactional public CommentDto rejectComment(Long comId) { - log.info("rejectComment - invoked"); - Comment comment = repository.findById(comId).orElseThrow(() -> new NotFoundException("Comment not found")); + log.info("rejectComment - invoked for comment ID: {}", comId); + Comment comment = repository.findById(comId) + .orElseThrow(() -> new NotFoundException("Comment not found")); comment.setApproved(false); repository.save(comment); - log.info("Result: comment with id = {} rejected", comId); + log.info("Result: comment with id = {} rejected successfully", comId); return CommentMapper.toCommentDto(comment); } } \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/practicum/comment/service/CommentPrivateServiceImpl.java b/core/main-service/src/main/java/ru/practicum/comment/service/CommentPrivateServiceImpl.java index 8196187..c5e47c7 100644 --- a/core/main-service/src/main/java/ru/practicum/comment/service/CommentPrivateServiceImpl.java +++ b/core/main-service/src/main/java/ru/practicum/comment/service/CommentPrivateServiceImpl.java @@ -22,7 +22,6 @@ @Service @RequiredArgsConstructor @Slf4j - public class CommentPrivateServiceImpl implements CommentPrivateService { private final CommentRepository repository; @@ -32,63 +31,80 @@ public class CommentPrivateServiceImpl implements CommentPrivateService { @Override @Transactional public CommentDto createComment(Long userId, Long eventId, CommentCreateDto commentDto) { - log.info("createComment - invoked"); + log.info("createComment - invoked for user ID: {}, event ID: {}", userId, eventId); + Comment comment = CommentMapper.toComment(commentDto); + User author = userRepository.findById(userId) .orElseThrow(() -> { - log.error("User with id = {} - not registered", userId); + log.error("User with id = {} not registered", userId); return new NotFoundException("Please register first then you can comment"); }); + Event event = eventRepository.findById(eventId) .orElseThrow(() -> { - log.error("Event with id = {} - not exist", eventId); + log.error("Event with id = {} does not exist", eventId); return new NotFoundException("Event not found"); }); + if (!event.getState().equals(State.PUBLISHED)) { - log.error("Event state = {} - should be PUBLISHED", event.getState()); - throw new ConflictException("Event not published you cant comment it"); + log.error("Event ID {} has state = {}, expected PUBLISHED", eventId, event.getState()); + throw new ConflictException("Event not published, you can't comment it"); } + comment.setAuthor(author); comment.setEvent(event); - comment.setApproved(true); // по умолчанию комменты видны, но админ может удалить/вернуть + comment.setApproved(true); comment.setCreateTime(LocalDateTime.now().withNano(0)); - log.info("Result: new comment created"); + + log.info("Result: new comment created for user ID: {}, event ID: {}, comment ID: {}", + userId, eventId, comment.getId()); + return CommentMapper.toCommentDto(repository.save(comment)); } @Override @Transactional public void deleteComment(Long userId, Long comId) { - log.info("deleteComment - invoked"); + log.info("deleteComment - invoked by user ID: {}, for comment ID: {}", userId, comId); + Comment comment = repository.findById(comId) .orElseThrow(() -> { - log.error("Comment with id = {} - not exist", comId); + log.error("Comment with id = {} does not exist", comId); return new NotFoundException("Comment not found"); }); + if (!comment.getAuthor().getId().equals(userId)) { - log.error("Unauthorized access by user"); - throw new ConflictException("you didn't write this comment and can't delete it"); + log.error("Unauthorized access: user ID {} tried to delete comment ID {}, but author is ID {}", + userId, comId, comment.getAuthor().getId()); + throw new ConflictException("You didn't write this comment and can't delete it"); } - log.info("Result: comment with id = {} - deleted", comId); + + log.info("Result: comment with id = {} deleted by user ID {}", comId, userId); repository.deleteById(comId); } @Override @Transactional public CommentDto patchComment(Long userId, Long comId, CommentCreateDto commentCreateDto) { - log.info("patchComment - invoked"); + log.info("patchComment - invoked by user ID: {}, for comment ID: {}", userId, comId); + Comment comment = repository.findById(comId) .orElseThrow(() -> { - log.error("Comment with id = {} - not exist", comId); + log.error("Comment with id = {} does not exist", comId); return new NotFoundException("Comment not found"); }); + if (!comment.getAuthor().getId().equals(userId)) { - log.error("Unauthorized access by user"); - throw new ConflictException("you didn't write this comment and can't patch it"); + log.error("Unauthorized access: user ID {} tried to patch comment ID {}, but author is ID {}", + userId, comId, comment.getAuthor().getId()); + throw new ConflictException("You didn't write this comment and can't patch it"); } + comment.setText(commentCreateDto.getText()); comment.setPatchTime(LocalDateTime.now().withNano(0)); - log.info("Result: comment with id = {} - updated", comId); + + log.info("Result: comment with id = {} updated by user ID {}", comId, userId); return CommentMapper.toCommentDto(comment); } -} \ No newline at end of file +} diff --git a/core/main-service/src/main/java/ru/practicum/comment/service/CommentPublicServiceImpl.java b/core/main-service/src/main/java/ru/practicum/comment/service/CommentPublicServiceImpl.java index e9fa243..94a8949 100644 --- a/core/main-service/src/main/java/ru/practicum/comment/service/CommentPublicServiceImpl.java +++ b/core/main-service/src/main/java/ru/practicum/comment/service/CommentPublicServiceImpl.java @@ -30,54 +30,73 @@ public class CommentPublicServiceImpl implements CommentPublicService { @Override public CommentDto getComment(Long comId) { - log.info("getComment - invoked"); + log.info("getComment - invoked for comment ID: {}", comId); + Comment comment = repository.findById(comId) .orElseThrow(() -> { - log.error("Comment with id = {} - not exist", comId); + log.error("Comment with ID {} not found", comId); return new NotFoundException("Comment not found"); }); + if (!comment.isApproved()) { - log.warn("Comment with id = {} is not approved", comId); + log.warn("Comment with ID {} is not approved (current state: {})", + comId, comment.isApproved()); throw new ForbiddenException("Comment is not approved"); } - log.info("Result: comment with id= {}", comId); + + log.info("Result: successfully retrieved approved comment with ID {}", comId); return CommentMapper.toCommentDto(comment); } @Override public List getCommentsByEvent(Long eventId, int from, int size) { - log.info("getCommentsByEvent - invoked"); + log.info("getCommentsByEvent - invoked for event ID: {}, from: {}, size: {}", + eventId, from, size); + if (!eventRepository.existsById(eventId)) { - log.error("Event with id = {} - not exist", eventId); + log.error("Event with ID {} does not exist", eventId); throw new NotFoundException("Event not found"); } + Pageable pageable = createPageRequestAsc("createTime", from, size); Page commentsPage = repository.findAllByEventId(eventId, pageable); List comments = commentsPage.getContent(); + List approvedComments = comments.stream() .filter(Comment::isApproved) .collect(Collectors.toList()); - log.info("Result : list of approved comments size = {}", approvedComments.size()); + + log.info("Result: retrieved {} approved comments for event ID {} (requested {} items, offset {})", + approvedComments.size(), eventId, size, from); + return CommentMapper.toListCommentShortDto(approvedComments); } @Override public CommentDto getCommentByEventAndCommentId(Long eventId, Long commentId) { - log.info("getCommentByEventAndCommentId - invoked"); + log.info("getCommentByEventAndCommentId - invoked for event ID: {}, comment ID: {}", + eventId, commentId); + Comment comment = repository.findById(commentId) .orElseThrow(() -> { - log.error("Comment with id = {} does not exist", commentId); + log.error("Comment with ID {} not found", commentId); return new NotFoundException("Comment not found"); }); + if (!comment.getEvent().getId().equals(eventId)) { - log.error("Comment with id = {} does not belong to event with id = {}", commentId, eventId); + log.error("Comment ID {} does not belong to event ID {} (belongs to event ID {})", + commentId, eventId, comment.getEvent().getId()); throw new NotFoundException("Comment not found for the specified event"); } + if (!comment.isApproved()) { - log.warn("Comment with id = {} is not approved", commentId); + log.warn("Comment ID {} is not approved (cannot be accessed)", commentId); throw new ForbiddenException("Comment is not approved"); } - log.info("Result: comment with eventId= {} and commentId= {}", eventId, commentId); + + log.info("Result: successfully retrieved comment ID {} for event ID {}", + commentId, eventId); + return CommentMapper.toCommentDto(comment); } -} \ No newline at end of file +} diff --git a/core/main-service/src/main/java/ru/practicum/compilation/service/CompilationAdminServiceImpl.java b/core/main-service/src/main/java/ru/practicum/compilation/service/CompilationAdminServiceImpl.java index c8d89ad..69d6ed6 100644 --- a/core/main-service/src/main/java/ru/practicum/compilation/service/CompilationAdminServiceImpl.java +++ b/core/main-service/src/main/java/ru/practicum/compilation/service/CompilationAdminServiceImpl.java @@ -28,33 +28,53 @@ public class CompilationAdminServiceImpl implements CompilationAdminService { @Override public CompilationDto createCompilation(NewCompilationDto request) { - log.info("createCompilation - invoked"); - Set events; - events = (request.getEvents() != null && !request.getEvents().isEmpty()) ? - new HashSet<>(eventRepository.findAllById(request.getEvents())) : new HashSet<>(); + log.info("createCompilation - invoked. Title: '{}', pinned: {}, eventCount: {}", + request.getTitle(), request.getPinned(), + (request.getEvents() != null ? request.getEvents().size() : 0)); + + Set events = (request.getEvents() != null && !request.getEvents().isEmpty()) + ? new HashSet<>(eventRepository.findAllById(request.getEvents())) + : new HashSet<>(); + Compilation compilation = Compilation.builder() .pinned(request.getPinned() != null && request.getPinned()) .title(request.getTitle()) .events(events) .build(); - return CompilationMapper.toCompilationDto(compilationRepository.save(compilation)); + + Compilation savedCompilation = compilationRepository.save(compilation); + log.info("Result: compilation created with ID: {}, title: '{}'", + savedCompilation.getId(), savedCompilation.getTitle()); + return CompilationMapper.toCompilationDto(savedCompilation); } @Override public void deleteCompilation(Long compId) { - log.info("deleteCompilation(- invoked"); + log.info("deleteCompilation - invoked for compilation ID: {}", compId); + if (!compilationRepository.existsById(compId)) { - throw new NotFoundException("Compilation Not Found"); + log.error("Compilation with ID {} not found", compId); + throw new NotFoundException("Compilation not found"); } - log.info("Result: compilation with id {} deleted ", compId); + compilationRepository.deleteById(compId); + log.info("Result: compilation with ID {} deleted successfully", compId); } @Override public CompilationDto updateCompilation(Long compId, UpdateCompilationDto updateCompilationDto) { - log.info("updateCompilation - invoked"); + log.info("updateCompilation - invoked for ID: {}. Changes - title: {}, pinned: {}, eventCount: {}", + compId, + updateCompilationDto.getTitle(), + updateCompilationDto.getPinned(), + (updateCompilationDto.getEvents() != null ? updateCompilationDto.getEvents().size() : "unchanged")); + Compilation compilation = compilationRepository.findById(compId) - .orElseThrow(() -> new NotFoundException("Compilation with id " + compId + " not found")); + .orElseThrow(() -> { + log.error("Compilation with ID {} not found", compId); + return new NotFoundException("Compilation not found"); + }); + if (updateCompilationDto.getTitle() != null) { compilation.setTitle(updateCompilationDto.getTitle()); } @@ -65,8 +85,14 @@ public CompilationDto updateCompilation(Long compId, UpdateCompilationDto update HashSet events = new HashSet<>(eventRepository.findAllById(updateCompilationDto.getEvents())); compilation.setEvents(events); } + Compilation updatedCompilation = compilationRepository.save(compilation); - log.info("Result: compilation with id {} updated ", compId); + log.info("Result: compilation ID {} updated successfully. New title: '{}', pinned: {}, eventCount: {}", + updatedCompilation.getId(), + updatedCompilation.getTitle(), + updatedCompilation.getPinned(), + updatedCompilation.getEvents().size()); + return CompilationMapper.toCompilationDto(updatedCompilation); } -} \ No newline at end of file +} diff --git a/core/main-service/src/main/java/ru/practicum/compilation/service/CompilationPublicServiceImpl.java b/core/main-service/src/main/java/ru/practicum/compilation/service/CompilationPublicServiceImpl.java index 77171d5..f4ef0e0 100644 --- a/core/main-service/src/main/java/ru/practicum/compilation/service/CompilationPublicServiceImpl.java +++ b/core/main-service/src/main/java/ru/practicum/compilation/service/CompilationPublicServiceImpl.java @@ -6,7 +6,6 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; import ru.practicum.compilation.dto.CompilationDto; import ru.practicum.compilation.mapper.CompilationMapper; import ru.practicum.compilation.model.Compilation; @@ -24,20 +23,34 @@ public class CompilationPublicServiceImpl implements CompilationPublicService { @Override public CompilationDto readCompilationById(Long compId) { - log.info("readCompilationById - invoked"); - Compilation compilation = compilationRepository.findById(compId).orElseThrow(() -> - new NotFoundException("Compilation not found")); - log.info("Result: {}", compilation); + log.info("readCompilationById - invoked for compilation ID: {}", compId); + + Compilation compilation = compilationRepository.findById(compId) + .orElseThrow(() -> { + log.error("Compilation with ID {} not found", compId); + return new NotFoundException("Compilation not found"); + }); + + log.info("Result: compilation ID {} retrieved successfully (title: '{}')", + compId, compilation.getTitle()); + return CompilationMapper.toCompilationDto(compilation); } @Override public List readAllCompilations(Boolean pinned, int from, int size) { + log.info("readAllCompilations - invoked. Pinned: {}, from: {}, size: {}", + pinned, from, size); + Pageable pageable = PageRequest.of(from, size, Sort.Direction.ASC, "id"); - List compilations; - compilations = (pinned == null) ? compilationRepository.findAll(pageable).getContent() : - compilationRepository.findAllByPinned(pinned, pageable); - log.info("Result: {}", compilations); + List compilations = (pinned == null) + ? compilationRepository.findAll(pageable).getContent() + : compilationRepository.findAllByPinned(pinned, pageable); + + int resultSize = compilations.size(); + log.info("Result: retrieved {} compilations (pinned={}, from={}, size={})", + resultSize, pinned, from, size); + return CompilationMapper.toCompilationDtoList(compilations); } -} \ No newline at end of file +}