diff --git a/.run/full-local.run.xml b/.run/full-local.run.xml
new file mode 100644
index 0000000..29f7ca1
--- /dev/null
+++ b/.run/full-local.run.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.run/main-db.run.xml b/.run/main-db.run.xml
new file mode 100644
index 0000000..0f75026
--- /dev/null
+++ b/.run/main-db.run.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.run/main-local.run.xml b/.run/main-local.run.xml
new file mode 100644
index 0000000..33b0c90
--- /dev/null
+++ b/.run/main-local.run.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.run/stats-db.run.xml b/.run/stats-db.run.xml
new file mode 100644
index 0000000..af4d305
--- /dev/null
+++ b/.run/stats-db.run.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.run/stats-local.run.xml b/.run/stats-local.run.xml
new file mode 100644
index 0000000..ed94ad4
--- /dev/null
+++ b/.run/stats-local.run.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..a5ef670
--- /dev/null
+++ b/README.md
@@ -0,0 +1,254 @@
+# Explore With Me (Исследуй со мной)
+
+Приложение-афиша, позволяющее пользователям делиться информацией об интересных событиях и находить компанию для участия в них. Изначально проект был разработан в команде в рамках обучения в Яндекс Практикуме, а в настоящее время дорабатывается и расширяется как индивидуальный дипломный проект.
+
+## Оглавление
+
+- [Технологии](#технологии)
+- [Структура проекта](#структура-проекта)
+- [API Спецификации](#api-спецификации)
+- [Начало работы](#начало-работы)
+ - [Предварительные требования](#предварительные-требования)
+ - [Сборка проекта](#сборка-проекта)
+ - [Запуск с использованием Docker Compose](#запуск-с-использованием-docker-compose)
+ - [Локальный запуск для разработки (IntelliJ IDEA)](#локальный-запуск-для-разработки-intellij-idea)
+ - [Локальный запуск Stats Service](#локальный-запуск-stats-service-stats-server)
+ - [Локальный запуск Main Service](#локальный-запуск-main-service-main-service)
+- [Примеры использования API](#примеры-использования-api)
+ - [Публичные эндпоинты Событий, Категорий, Подборок](#публичные-эндпоинты-событий-категорий-подборок)
+ - [Публичные эндпоинты Комментариев](#публичные-эндпоинты-комментариев)
+- [Тестирование](#тестирование)
+ - [Юнит и Интеграционные тесты](#юнит-и-интеграционные-тесты)
+ - [Postman-тесты для Дополнительной Функциональности](#postman-тесты-для-дополнительной-функциональности)
+- [Реализованная Дополнительная Функциональность: Комментарии](#реализованная-дополнительная-функциональность-комментарии)
+- [История проекта и Автор](#история-проекта-и-автор)
+
+## Технологии
+
+- Java 21
+- Spring Boot 3.4.5
+- Spring Data JPA, QueryDSL
+- Spring MVC, Spring AOP (для интеграции со StatsClient)
+- PostgreSQL 16.1
+- Maven
+- Docker / Docker Compose
+- Lombok
+- MapStruct (для маппинга DTO)
+- JUnit 5, Mockito
+- Testcontainers
+- Checkstyle, Spotbugs, Jacoco (для контроля качества кода)
+
+## Структура проекта
+
+Проект является многомодульным Maven-проектом и состоит из следующих основных частей:
+
+- `explore-with-me` (корневой POM)
+ - `ewm-common`: Общий модуль, содержащий классы, используемые как основным сервисом, так и сервисом статистики (например, `ApiError.java`, общие константы).
+ - `main-service`: Основной сервис приложения. Отвечает за бизнес-логику, управление пользователями, событиями, категориями, подборками и запросами на участие. Взаимодействует с `stats-client` для сбора статистики.
+ - `Dockerfile`
+ - `schema.sql` (для инициализации схемы БД `ewm_main_db`)
+ - `stats-service` (родительский POM для модулей статистики)
+ - `stats-dto`: Data Transfer Objects (DTO) для сервиса статистики.
+ * `stats-client`: HTTP-клиент для взаимодействия с API сервиса статистики (используется `main-service`).
+ * `stats-server`: Сервис статистики (сбор и предоставление данных о запросах к эндпоинтам).
+ * `Dockerfile`
+ * `schema.sql` (для инициализации схемы БД `ewm_stats_db`)
+
+## API Спецификации
+
+Актуальные спецификации API, включая эндпоинты для реализованной дополнительной функциональности "Комментарии", можно найти в репозитории:
+
+- **Основной сервис:** [`ewm-main-service-spec.json`](https://github.com/impatient0/java-plus-graduation/blob/main/ewm-main-service-spec.json)
+ * *Примечание: Оригинальная спецификация от Яндекс Практикума [здесь](https://raw.githubusercontent.com/yandex-praktikum/java-explore-with-me/main/ewm-main-service-spec.json) не включает эндпоинты для комментариев. Описание реализованных эндпоинтов для комментариев см. в разделе [Реализованная Дополнительная Функциональность: Комментарии](#реализованная-дополнительная-функциональность-комментарии).*
+- **Сервис статистики:** [`ewm-stats-service.json`](https://github.com/impatient0/java-plus-graduation/blob/main/ewm-stats-service-spec.json)
+
+*Рекомендуется просматривать через Swagger Editor или аналогичный инструмент.*
+
+## Начало работы
+
+### Предварительные требования
+
+Для работы с проектом вам понадобятся:
+
+- JDK 21
+- Apache Maven 3.6+
+- Docker и Docker Compose
+- IntelliJ IDEA (рекомендуется)
+
+### Сборка проекта
+
+Для сборки всех модулей проекта (включая генерацию Q-типов QueryDSL и реализаций MapStruct) выполните:
+```bash
+mvn clean install
+```
+Эта команда также запустит статические анализаторы кода и юнит-тесты.
+
+### Запуск с использованием Docker Compose
+
+Это основной способ запуска всего приложения для проверки взаимодействия сервисов.
+
+1. **Соберите проект:** `mvn clean install`
+2. **Запустите сервисы:**
+ В корневой директории проекта выполните:
+ ```bash
+ docker-compose up --build -d
+ ```
+ - Сервис статистики (`stats-server`): `http://localhost:9090`
+ - Основной сервис (`main-service`): `http://localhost:8080`
+
+3. **Просмотр логов:**
+ ```bash
+ docker-compose logs -f main-service
+ docker-compose logs -f stats-server
+ ```
+4. **Остановка сервисов:**
+ ```bash
+ docker-compose down
+ ```
+ Для удаления volumes (данных БД):
+ ```bash
+ docker-compose down -v
+ ```
+ *Примечание: При первом запуске `docker-compose up` скрипты `schema.sql` из каждого сервиса будут выполнены для создания таблиц в соответствующих базах данных.*
+
+### Локальный запуск для разработки (IntelliJ IDEA)
+
+#### Локальный запуск Stats Service (`stats-server`)
+
+Предусмотрен профиль запуска `stat-local` в IntelliJ IDEA.
+
+1. **База данных для `stats-server`:** Настройте локальный PostgreSQL согласно `stats-service/stats-server/src/main/resources/application-local.yml` (порт, имя БД, пользователь, пароль).
+ ```yaml
+ # stats-service/stats-server/src/main/resources/application-local.yml
+ spring:
+ datasource:
+ url: jdbc:postgresql://localhost:6543/ewm_stats_db # Пример
+ username: stats_user
+ password: stats_password
+ jpa:
+ hibernate:
+ ddl-auto: validate # Используется schema.sql из classpath (src/main/resources)
+ sql:
+ init:
+ mode: always # Для выполнения schema.sql при локальном запуске
+ ```
+2. **Запуск `StatsServerApplication`:** Используйте Run Configuration "stat-local" (VM options: `-Dspring.profiles.active=local`).
+
+#### Локальный запуск Main Service (`main-service`)
+
+Предусмотрен профиль запуска `main-local` в IntelliJ IDEA.
+
+1. **База данных для `main-service`:** Настройте локальный PostgreSQL согласно `main-service/src/main/resources/application-local.yml`.
+ ```yaml
+ # main-service/src/main/resources/application-local.yml
+ stats-server:
+ url: http://localhost:9090 # Если stats-server тоже запущен локально
+
+ spring:
+ datasource:
+ url: jdbc:postgresql://localhost:5432/ewm_main_db # Пример
+ username: ewm_user
+ password: ewm_password
+ jpa:
+ hibernate:
+ ddl-auto: validate # Используется schema.sql из classpath
+ sql:
+ init:
+ mode: always # Для выполнения schema.sql при локальном запуске
+ ```
+2. **Запуск `MainServiceApplication`:** Используйте Run Configuration "main-local" (VM options: `-Dspring.profiles.active=local`). Убедитесь, что `stats-server` уже запущен (локально или в Docker), так как `main-service` от него зависит.
+
+## Примеры использования API
+
+### Публичные эндпоинты Событий, Категорий, Подборок
+
+- **Получение списка событий с фильтрацией:**
+ `GET http://localhost:8080/events?text=концерт&categories=1,2&paid=true&rangeStart=2025-06-01 00:00:00&rangeEnd=2025-06-30 23:59:59&onlyAvailable=true&sort=VIEWS&from=0&size=10`
+ *(Предполагается, что даты и время URL-кодированы)*
+
+- **Получение подробной информации о событии:**
+ `GET http://localhost:8080/events/{eventId}`
+
+- **Получение списка категорий:**
+ `GET http://localhost:8080/categories?from=0&size=10`
+
+- **Получение категории по ID:**
+ `GET http://localhost:8080/categories/{catId}`
+
+- **Получение списка подборок:**
+ `GET http://localhost:8080/compilations?pinned=true&from=0&size=10`
+
+- **Получение подборки по ID:**
+ `GET http://localhost:8080/compilations/{compId}`
+
+### Публичные эндпоинты Комментариев
+
+- **Получение списка комментариев к событию:**
+ `GET http://localhost:8080/events/{eventId}/comments?from=0&size=10&sort=createdOn,DESC`
+
+## Тестирование
+
+### Юнит и Интеграционные тесты
+
+Для запуска всех тестов в проекте выполните:
+
+```bash
+mvn test
+```
+Проект использует JUnit 5, Mockito и Testcontainers для различных уровней тестирования (юнит-тесты, интеграционные тесты с БД). Отчеты о покрытии кода (Jacoco) генерируются в `target/site/jacoco/`.
+
+### Postman-тесты для Дополнительной Функциональности
+
+Для проверки работоспособности эндпоинтов реализованной дополнительной функциональности "Комментарии" подготовлена Postman-коллекция.
+
+- **Расположение:** `postman/feature.json` в корне репозитория.
+- **Проверка:** Тесты в коллекции проверяют основные сценарии использования API комментариев, включая коды ответов, базовый формат JSON и значения полей.
+
+## Реализованная Дополнительная Функциональность: Комментарии
+
+В рамках командного этапа проекта была выбрана и реализована дополнительная функциональность: **"Комментарии к событиям"**.
+
+### Обзор функционала:
+
+Реализована возможность для пользователей оставлять, редактировать и удалять свои комментарии к опубликованным событиям, а также для администраторов модерировать (удалять, восстанавливать) любые комментарии.
+
+**Ключевые возможности:**
+
+* **Пользователи (Private API):**
+ * Создание комментария к событию (`POST /users/{userId}/comments?eventId={eventId}`).
+ * Комментарии можно оставлять только к опубликованным событиям, у которых включена опция комментирования.
+ * Редактирование своего комментария (`PATCH /users/{userId}/comments/{commentId}`).
+ * Возможно только в течение 6 часов после создания.
+ * Устанавливается флаг `isEdited`.
+ * "Мягкое" удаление своего комментария (`DELETE /users/{userId}/comments/{commentId}`).
+ * Комментарий помечается как удаленный (`isDeleted = true`), но не удаляется физически.
+ * Получение списка своих комментариев (`GET /users/{userId}/comments`).
+
+* **Администраторы (Admin API):**
+ * "Мягкое" удаление любого комментария (`DELETE /admin/comments/{commentId}`).
+ * Восстановление "мягко" удаленного комментария (`PATCH /admin/comments/{commentId}/restore`).
+ * Получение списка всех комментариев с фильтрацией (`GET /admin/comments`) по автору, событию, статусу удаления. В ответе (`CommentAdminDto`) передается флаг `isDeleted`.
+
+* **Все пользователи (Public API):**
+ * Получение списка комментариев для конкретного события (`GET /events/{eventId}/comments`).
+ * Возвращаются только не удаленные комментарии.
+ * Если комментарии к событию отключены (`Event.commentsEnabled = false`), возвращается пустой список.
+ * Поддерживается пагинация и сортировка (по умолчанию по дате создания, сначала новые).
+
+* **Интеграция с Событиями (`Event`):**
+ * В сущность `Event` добавлено поле `commentsEnabled` (boolean, default `true`), позволяющее инициатору или администратору включать/отключать возможность комментирования для события. Это поле управляется через эндпоинты создания/обновления событий.
+ * Настроено каскадное удаление комментариев при удалении связанного события или автора.
+
+**Детальное описание новых эндпоинтов и DTO для комментариев представлено в обновленной спецификации API `ewm-main-service-spec.json`** (см. раздел [API Спецификации](#api-спецификации)).
+
+## История проекта и Автор
+
+Изначально данный проект был разработан в команде из четырех человек в рамках курса "Java-разработчик" от Яндекс Практикума. Командная работа включала в себя реализацию основного сервиса, сервиса статистики и дополнительной функциональности "Комментарии".
+
+Над командной частью проекта работали:
+- Иван Петровский (Team Lead) - [@impatient0](https://github.com/impatient0)
+- Андрей Гагарский - [@Gagarskiy-Andrey](https://github.com/Gagarskiy-Andrey)
+- Валерия Бутько - [@progingir](https://github.com/progingir)
+- Сергей Филипповских - [@SergikF](https://github.com/SergikF)
+
+В настоящее время проект развивается как **индивидуальный дипломный проект** Иваном Петровским. Дальнейшие доработки, рефакторинг и расширение функционала ведутся в рамках данного репозитория.
diff --git a/compose.yaml b/compose.yaml
new file mode 100644
index 0000000..a5406f4
--- /dev/null
+++ b/compose.yaml
@@ -0,0 +1,73 @@
+services:
+ stats-server:
+ build: stats-service/stats-server
+ container_name: ewm-stats-server-compose
+ depends_on:
+ stats-db:
+ condition: service_healthy
+ ports:
+ - "9090:9090"
+ environment:
+ - SPRING_DATASOURCE_URL=jdbc:postgresql://stats-db:5432/ewm_stats_db
+ - SPRING_DATASOURCE_USERNAME=stats_user
+ - SPRING_DATASOURCE_PASSWORD=stats_password
+ - JAVA_OPTS=-Duser.timezone=UTC
+
+ stats-db:
+ image: postgres:16.1
+ container_name: ewm-stats-db-compose
+ ports:
+ - "6543:5432"
+ environment:
+ POSTGRES_USER: stats_user
+ POSTGRES_PASSWORD: stats_password
+ POSTGRES_DB: ewm_stats_db
+ volumes:
+ - stats_db_data:/var/lib/postgresql/data
+ - ./stats-service/stats-server/src/main/resources/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql
+ healthcheck:
+ test: [ "CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB} -p 5432" ]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ start_period: 10s
+
+ ewm-service:
+ build: main-service
+ container_name: ewm-main-service-compose
+ depends_on:
+ ewm-db:
+ condition: service_healthy
+ stats-server:
+ condition: service_started
+ ports:
+ - "8080:8080"
+ environment:
+ - SPRING_DATASOURCE_URL=jdbc:postgresql://ewm-db:5432/ewm_main_db
+ - SPRING_DATASOURCE_USERNAME=ewm_user
+ - SPRING_DATASOURCE_PASSWORD=ewm_password
+ - JAVA_OPTS=-Duser.timezone=UTC
+
+ ewm-db:
+ image: postgres:16.1
+ container_name: ewm-main-db-compose
+ ports:
+ - "5432:5432"
+ environment:
+ POSTGRES_USER: ewm_user
+ POSTGRES_PASSWORD: ewm_password
+ POSTGRES_DB: ewm_main_db
+ volumes:
+ - main_db_data:/var/lib/postgresql/data
+ - ./main-service/src/main/resources/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql
+ healthcheck:
+ test: [ "CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB} -p 5432" ]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ start_period: 10s
+
+
+volumes:
+ stats_db_data: {}
+ main_db_data: {}
\ No newline at end of file
diff --git a/ewm-common/pom.xml b/ewm-common/pom.xml
new file mode 100644
index 0000000..c835673
--- /dev/null
+++ b/ewm-common/pom.xml
@@ -0,0 +1,31 @@
+
+
+ 4.0.0
+
+ ru.practicum
+ explore-with-me
+ 0.0.1-SNAPSHOT
+ ../pom.xml
+
+
+ ewm-common
+ jar
+
+
+
+ org.projectlombok
+ lombok
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+ com.fasterxml.jackson.core
+ jackson-annotations
+
+
+
+
\ No newline at end of file
diff --git a/ewm-common/src/main/java/ru/practicum/explorewithme/common/constants/DateTimeConstants.java b/ewm-common/src/main/java/ru/practicum/explorewithme/common/constants/DateTimeConstants.java
new file mode 100644
index 0000000..db15663
--- /dev/null
+++ b/ewm-common/src/main/java/ru/practicum/explorewithme/common/constants/DateTimeConstants.java
@@ -0,0 +1,23 @@
+package ru.practicum.explorewithme.common.constants;
+
+import java.time.format.DateTimeFormatter;
+
+public final class DateTimeConstants {
+
+ private DateTimeConstants() {
+ throw new UnsupportedOperationException("This is a utility class and cannot be instantiated");
+ }
+
+ /**
+ * Стандартный шаблон формата даты и времени, используемый во всем приложении.
+ * Формат: "yyyy-MM-dd HH:mm:ss"
+ */
+ public static final String DATE_TIME_FORMAT_PATTERN = "yyyy-MM-dd HH:mm:ss";
+
+ /**
+ * Предварительно созданный экземпляр DateTimeFormatter для стандартного формата даты и времени.
+ * Может быть использован для парсинга и форматирования объектов LocalDateTime.
+ */
+ public static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern(DATE_TIME_FORMAT_PATTERN);
+
+}
\ No newline at end of file
diff --git a/ewm-common/src/main/java/ru/practicum/explorewithme/common/error/ApiError.java b/ewm-common/src/main/java/ru/practicum/explorewithme/common/error/ApiError.java
new file mode 100644
index 0000000..98f1692
--- /dev/null
+++ b/ewm-common/src/main/java/ru/practicum/explorewithme/common/error/ApiError.java
@@ -0,0 +1,26 @@
+package ru.practicum.explorewithme.common.error;
+
+import static ru.practicum.explorewithme.common.constants.DateTimeConstants.DATE_TIME_FORMAT_PATTERN;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.Builder;
+import lombok.Getter;
+import org.springframework.http.HttpStatus;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Getter
+@Builder
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class ApiError {
+ private HttpStatus status;
+ private String reason;
+ private String message;
+
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = DATE_TIME_FORMAT_PATTERN)
+ private LocalDateTime timestamp;
+
+ private List errors;
+}
\ No newline at end of file
diff --git a/ewm-main-service-spec.json b/ewm-main-service-spec.json
index f28d141..1bc100f 100644
--- a/ewm-main-service-spec.json
+++ b/ewm-main-service-spec.json
@@ -47,6 +47,18 @@
{
"description": "API для работы с подборками событий",
"name": "Admin: Подборки событий"
+ },
+ {
+ "description": "Закрытый API для работы с комментариями пользователей",
+ "name": "Private: Комментарии"
+ },
+ {
+ "description": "API для администрирования комментариев",
+ "name": "Admin: Комментарии"
+ },
+ {
+ "description": "Публичный API для работы с комментариями",
+ "name": "Public: Комментарии"
}
],
"paths": {
@@ -462,7 +474,8 @@
"name": "rangeStart",
"required": false,
"schema": {
- "type": "string"
+ "type": "string",
+ "format": "date-time"
}
},
{
@@ -471,7 +484,8 @@
"name": "rangeEnd",
"required": false,
"schema": {
- "type": "string"
+ "type": "string",
+ "format": "date-time"
}
},
{
@@ -529,7 +543,7 @@
"description": "Запрос составлен некорректно"
}
},
- "summary": "Поиск событий",
+ "summary": "Поиск событий (Admin)",
"tags": [
"Admin: События"
]
@@ -538,7 +552,7 @@
"/admin/events/{eventId}": {
"patch": {
"description": "Редактирование данных любого события администратором. Валидация данных не требуется.\nОбратите внимание:\n - дата начала изменяемого события должна быть не ранее чем за час от даты публикации. (Ожидается код ошибки 409)\n- событие можно публиковать, только если оно в состоянии ожидания публикации (Ожидается код ошибки 409)\n- событие можно отклонить, только если оно еще не опубликовано (Ожидается код ошибки 409)",
- "operationId": "updateEvent_1",
+ "operationId": "updateEventByAdmin",
"parameters": [
{
"description": "id события",
@@ -755,7 +769,7 @@
},
"/admin/users/{userId}": {
"delete": {
- "operationId": "delete",
+ "operationId": "deleteUserByAdmin",
"parameters": [
{
"description": "id пользователя",
@@ -798,7 +812,7 @@
"/categories": {
"get": {
"description": "В случае, если по заданным фильтрам не найдено ни одной категории, возвращает пустой список",
- "operationId": "getCategories",
+ "operationId": "getCategoriesPublic",
"parameters": [
{
"description": "количество категорий, которые нужно пропустить для формирования текущего набора",
@@ -855,7 +869,7 @@
"description": "Запрос составлен некорректно"
}
},
- "summary": "Получение категорий",
+ "summary": "Получение категорий (Public)",
"tags": [
"Public: Категории"
]
@@ -864,7 +878,7 @@
"/categories/{catId}": {
"get": {
"description": "В случае, если категории с заданным id не найдено, возвращает статус код 404",
- "operationId": "getCategory",
+ "operationId": "getCategoryPublic",
"parameters": [
{
"description": "id категории",
@@ -921,7 +935,7 @@
"description": "Категория не найдена или недоступна"
}
},
- "summary": "Получение информации о категории по её идентификатору",
+ "summary": "Получение информации о категории по её идентификатору (Public)",
"tags": [
"Public: Категории"
]
@@ -930,7 +944,7 @@
"/compilations": {
"get": {
"description": "В случае, если по заданным фильтрам не найдено ни одной подборки, возвращает пустой список",
- "operationId": "getCompilations",
+ "operationId": "getCompilationsPublic",
"parameters": [
{
"description": "искать только закрепленные/не закрепленные подборки",
@@ -996,7 +1010,7 @@
"description": "Запрос составлен некорректно"
}
},
- "summary": "Получение подборок событий",
+ "summary": "Получение подборок событий (Public)",
"tags": [
"Public: Подборки событий"
]
@@ -1005,7 +1019,7 @@
"/compilations/{compId}": {
"get": {
"description": "В случае, если подборки с заданным id не найдено, возвращает статус код 404",
- "operationId": "getCompilation",
+ "operationId": "getCompilationPublic",
"parameters": [
{
"description": "id подборки",
@@ -1062,7 +1076,7 @@
"description": "Подборка не найдена или недоступна"
}
},
- "summary": "Получение подборки событий по его id",
+ "summary": "Получение подборки событий по его id (Public)",
"tags": [
"Public: Подборки событий"
]
@@ -1071,7 +1085,7 @@
"/events": {
"get": {
"description": "Обратите внимание: \n- это публичный эндпоинт, соответственно в выдаче должны быть только опубликованные события\n- текстовый поиск (по аннотации и подробному описанию) должен быть без учета регистра букв\n- если в запросе не указан диапазон дат [rangeStart-rangeEnd], то нужно выгружать события, которые произойдут позже текущей даты и времени\n- информация о каждом событии должна включать в себя количество просмотров и количество уже одобренных заявок на участие\n- информацию о том, что по этому эндпоинту был осуществлен и обработан запрос, нужно сохранить в сервисе статистики\n\nВ случае, если по заданным фильтрам не найдено ни одного события, возвращает пустой список",
- "operationId": "getEvents_1",
+ "operationId": "getEventsPublic",
"parameters": [
{
"description": "текст для поиска в содержимом аннотации и подробном описании события",
@@ -1112,7 +1126,8 @@
"name": "rangeStart",
"required": false,
"schema": {
- "type": "string"
+ "type": "string",
+ "format": "date-time"
}
},
{
@@ -1121,7 +1136,8 @@
"name": "rangeEnd",
"required": false,
"schema": {
- "type": "string"
+ "type": "string",
+ "format": "date-time"
}
},
{
@@ -1202,7 +1218,7 @@
"description": "Запрос составлен некорректно"
}
},
- "summary": "Получение событий с возможностью фильтрации",
+ "summary": "Получение событий с возможностью фильтрации (Public)",
"tags": [
"Public: События"
]
@@ -1211,7 +1227,7 @@
"/events/{id}": {
"get": {
"description": "Обратите внимание:\n- событие должно быть опубликовано\n- информация о событии должна включать в себя количество просмотров и количество подтвержденных запросов\n- информацию о том, что по этому эндпоинту был осуществлен и обработан запрос, нужно сохранить в сервисе статистики\n\nВ случае, если события с заданным id не найдено, возвращает статус код 404",
- "operationId": "getEvent_1",
+ "operationId": "getEventPublic",
"parameters": [
{
"description": "id события",
@@ -1268,7 +1284,7 @@
"description": "Событие не найдено или недоступно"
}
},
- "summary": "Получение подробной информации об опубликованном событии по его идентификатору",
+ "summary": "Получение подробной информации об опубликованном событии по его идентификатору (Public)",
"tags": [
"Public: События"
]
@@ -1277,7 +1293,7 @@
"/users/{userId}/events": {
"get": {
"description": "В случае, если по заданным фильтрам не найдено ни одного события, возвращает пустой список",
- "operationId": "getEvents",
+ "operationId": "getEventsAddedByUser",
"parameters": [
{
"description": "id текущего пользователя",
@@ -1351,7 +1367,7 @@
},
"post": {
"description": "Обратите внимание: дата и время на которые намечено событие не может быть раньше, чем через два часа от текущего момента",
- "operationId": "addEvent",
+ "operationId": "addEventByUser",
"parameters": [
{
"description": "id текущего пользователя",
@@ -1428,7 +1444,7 @@
"/users/{userId}/events/{eventId}": {
"get": {
"description": "В случае, если события с заданным id не найдено, возвращает статус код 404",
- "operationId": "getEvent",
+ "operationId": "getEventAddedByUser",
"parameters": [
{
"description": "id текущего пользователя",
@@ -1502,7 +1518,7 @@
},
"patch": {
"description": "Обратите внимание:\n- изменить можно только отмененные события или события в состоянии ожидания модерации (Ожидается код ошибки 409)\n- дата и время на которые намечено событие не может быть раньше, чем через два часа от текущего момента (Ожидается код ошибки 409)\n",
- "operationId": "updateEvent",
+ "operationId": "updateEventByUser",
"parameters": [
{
"description": "id текущего пользователя",
@@ -1605,7 +1621,7 @@
"/users/{userId}/events/{eventId}/requests": {
"get": {
"description": "В случае, если по заданным фильтрам не найдено ни одной заявки, возвращает пустой список",
- "operationId": "getEventParticipants",
+ "operationId": "getEventParticipantsByUser",
"parameters": [
{
"description": "id текущего пользователя",
@@ -1666,7 +1682,7 @@
},
"patch": {
"description": "Обратите внимание:\n- если для события лимит заявок равен 0 или отключена пре-модерация заявок, то подтверждение заявок не требуется\n- нельзя подтвердить заявку, если уже достигнут лимит по заявкам на данное событие (Ожидается код ошибки 409)\n- статус можно изменить только у заявок, находящихся в состоянии ожидания (Ожидается код ошибки 409)\n- если при подтверждении данной заявки, лимит заявок для события исчерпан, то все неподтверждённые заявки необходимо отклонить",
- "operationId": "changeRequestStatus",
+ "operationId": "changeRequestStatusByUser",
"parameters": [
{
"description": "id текущего пользователя",
@@ -1983,6 +1999,532 @@
"Private: Запросы на участие"
]
}
+ },
+ "/users/{userId}/comments": {
+ "post": {
+ "tags": [
+ "Private: Комментарии"
+ ],
+ "summary": "Создание нового комментария к событию",
+ "description": "Создает новый комментарий к событию от имени авторизованного пользователя.",
+ "operationId": "addComment",
+ "parameters": [
+ {
+ "name": "userId",
+ "in": "path",
+ "description": "ID пользователя, создающего комментарий",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "format": "int64"
+ }
+ },
+ {
+ "name": "eventId",
+ "in": "query",
+ "description": "ID события, к которому добавляется комментарий",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "format": "int64"
+ }
+ }
+ ],
+ "requestBody": {
+ "description": "Данные нового комментария",
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/NewCommentDto"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "Комментарий успешно создан",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CommentDto"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Некорректный запрос (например, невалидный текст комментария)",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiError"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Пользователь или событие не найдены",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiError"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "Конфликт (например, событие не опубликовано или комментарии к событию отключены)",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "get": {
+ "tags": [
+ "Private: Комментарии"
+ ],
+ "summary": "Получение списка своих комментариев",
+ "description": "Получает список всех комментариев, оставленных текущим пользователем, которые не помечены как удаленные.",
+ "operationId": "getUserComments",
+ "parameters": [
+ {
+ "name": "userId",
+ "in": "path",
+ "description": "ID пользователя, чьи комментарии запрашиваются",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "format": "int64"
+ }
+ },
+ {
+ "name": "from",
+ "in": "query",
+ "description": "Количество комментариев, которые нужно пропустить для формирования текущего набора",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "format": "int32",
+ "minimum": 0,
+ "default": 0
+ }
+ },
+ {
+ "name": "size",
+ "in": "query",
+ "description": "Количество комментариев в наборе",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "format": "int32",
+ "minimum": 1,
+ "default": 10
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Список комментариев пользователя",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/CommentDto"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/users/{userId}/comments/{commentId}": {
+ "patch": {
+ "tags": [
+ "Private: Комментарии"
+ ],
+ "summary": "Обновление своего комментария",
+ "description": "Обновляет текст своего комментария, если он не удален и не прошло 6 часов с момента создания.",
+ "operationId": "updateUserComment",
+ "parameters": [
+ {
+ "name": "userId",
+ "in": "path",
+ "description": "ID пользователя, обновляющего комментарий",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "format": "int64"
+ }
+ },
+ {
+ "name": "commentId",
+ "in": "path",
+ "description": "ID обновляемого комментария",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "format": "int64"
+ }
+ }
+ ],
+ "requestBody": {
+ "description": "Новый текст комментария",
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UpdateCommentDto"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Комментарий успешно обновлен",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CommentDto"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Некорректный запрос (например, невалидный текст комментария)",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiError"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Комментарий не найден",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiError"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "Конфликт (например, комментарий помечен как удаленный или истекло время для редактирования)",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "tags": [
+ "Private: Комментарии"
+ ],
+ "summary": "\"Мягкое\" удаление своего комментария",
+ "description": "Помечает свой комментарий как удаленный. Фактически комментарий не удаляется из базы данных.",
+ "operationId": "deleteUserComment",
+ "parameters": [
+ {
+ "name": "userId",
+ "in": "path",
+ "description": "ID пользователя, удаляющего комментарий",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "format": "int64"
+ }
+ },
+ {
+ "name": "commentId",
+ "in": "path",
+ "description": "ID удаляемого комментария",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "format": "int64"
+ }
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "Комментарий успешно помечен как удаленный"
+ },
+ "404": {
+ "description": "Комментарий не найден",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/admin/comments": {
+ "get": {
+ "tags": [
+ "Admin: Комментарии"
+ ],
+ "summary": "Получение списка всех комментариев с фильтрами (Admin)",
+ "description": "Администратор может получить список всех комментариев с возможностью фильтрации по автору, событию и статусу удаления.",
+ "operationId": "getAllCommentsAdmin",
+ "parameters": [
+ {
+ "name": "userId",
+ "in": "query",
+ "description": "ID автора комментария для фильтрации",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "format": "int64"
+ }
+ },
+ {
+ "name": "eventId",
+ "in": "query",
+ "description": "ID события для фильтрации",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "format": "int64"
+ }
+ },
+ {
+ "name": "isDeleted",
+ "in": "query",
+ "description": "Фильтр по статусу удаления (true - показывать удаленные, false - не удаленные, отсутствует - все)",
+ "required": false,
+ "schema": {
+ "type": "boolean"
+ }
+ },
+ {
+ "name": "from",
+ "in": "query",
+ "description": "Количество комментариев, которые нужно пропустить для формирования текущего набора",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "format": "int32",
+ "minimum": 0,
+ "default": 0
+ }
+ },
+ {
+ "name": "size",
+ "in": "query",
+ "description": "Количество комментариев в наборе",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "format": "int32",
+ "minimum": 1,
+ "default": 10
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Список комментариев",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/CommentDto"
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Некорректные параметры запроса",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/admin/comments/{commentId}": {
+ "delete": {
+ "tags": [
+ "Admin: Комментарии"
+ ],
+ "summary": "\"Мягкое\" удаление любого комментария (Admin)",
+ "description": "Администратор помечает любой комментарий как удаленный.",
+ "operationId": "deleteCommentByAdmin",
+ "parameters": [
+ {
+ "name": "commentId",
+ "in": "path",
+ "description": "ID удаляемого комментария",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "format": "int64"
+ }
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "Комментарий успешно помечен как удаленный"
+ },
+ "404": {
+ "description": "Комментарий не найден",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/admin/comments/{commentId}/restore": {
+ "patch": {
+ "tags": [
+ "Admin: Комментарии"
+ ],
+ "summary": "Восстановление \"мягко\" удаленного комментария (Admin)",
+ "description": "Администратор восстанавливает комментарий, ранее помеченный как удаленный.",
+ "operationId": "restoreCommentByAdmin",
+ "parameters": [
+ {
+ "name": "commentId",
+ "in": "path",
+ "description": "ID восстанавливаемого комментария",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "format": "int64"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Комментарий успешно восстановлен",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CommentDto"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Комментарий не найден",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/events/{eventId}/comments": {
+ "get": {
+ "tags": [
+ "Public: Комментарии"
+ ],
+ "summary": "Получение списка комментариев для события (Public)",
+ "description": "Получает список всех не удаленных комментариев для указанного события. Если комментарии для события отключены, возвращает пустой список.",
+ "operationId": "getEventCommentsPublic",
+ "parameters": [
+ {
+ "name": "eventId",
+ "in": "path",
+ "description": "ID события",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "format": "int64"
+ }
+ },
+ {
+ "name": "from",
+ "in": "query",
+ "description": "Количество комментариев, которые нужно пропустить для формирования текущего набора",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "format": "int32",
+ "minimum": 0,
+ "default": 0
+ }
+ },
+ {
+ "name": "size",
+ "in": "query",
+ "description": "Количество комментариев в наборе",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "format": "int32",
+ "minimum": 1,
+ "default": 10
+ }
+ },
+ {
+ "name": "sort",
+ "in": "query",
+ "description": "Вариант сортировки комментариев (например, 'createdOn,DESC' или 'createdOn,ASC'). По умолчанию 'createdOn,DESC'.",
+ "required": false,
+ "schema": {
+ "type": "string",
+ "default": "createdOn,DESC"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Список комментариев к событию",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/CommentDto"
+ }
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Событие не найдено или не опубликовано",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiError"
+ }
+ }
+ }
+ }
+ }
+ }
}
},
"components": {
@@ -2215,6 +2757,7 @@
},
"createdOn": {
"type": "string",
+ "format": "date-time",
"description": "Дата и время создания события (в формате \"yyyy-MM-dd HH:mm:ss\")",
"example": "2022-09-06 11:00:23"
},
@@ -2225,6 +2768,7 @@
},
"eventDate": {
"type": "string",
+ "format": "date-time",
"description": "Дата и время на которые намечено событие (в формате \"yyyy-MM-dd HH:mm:ss\")",
"example": "2024-12-31 15:10:05"
},
@@ -2254,6 +2798,7 @@
},
"publishedOn": {
"type": "string",
+ "format": "date-time",
"description": "Дата и время публикации события (в формате \"yyyy-MM-dd HH:mm:ss\")",
"example": "2022-09-06 15:10:05"
},
@@ -2283,6 +2828,11 @@
"description": "Количество просмотрев события",
"format": "int64",
"example": 999
+ },
+ "commentsEnabled": {
+ "type": "boolean",
+ "description": "Разрешены ли комментарии для данного события.",
+ "default": true
}
}
},
@@ -2360,6 +2910,7 @@
},
"eventDate": {
"type": "string",
+ "format": "date-time",
"description": "Дата и время на которые намечено событие (в формате \"yyyy-MM-dd HH:mm:ss\")",
"example": "2024-12-31 15:10:05"
},
@@ -2531,6 +3082,7 @@
},
"eventDate": {
"type": "string",
+ "format": "date-time",
"description": "Дата и время на которые намечено событие. Дата и время указываются в формате \"yyyy-MM-dd HH:mm:ss\"",
"example": "2024-12-31 15:10:05"
},
@@ -2562,6 +3114,11 @@
"type": "string",
"description": "Заголовок события",
"example": "Сплав на байдарках"
+ },
+ "commentsEnabled": {
+ "type": "boolean",
+ "description": "Разрешены ли комментарии для события. По умолчанию true.",
+ "default": true
}
},
"description": "Новое событие"
@@ -2595,6 +3152,7 @@
"properties": {
"created": {
"type": "string",
+ "format": "date-time",
"description": "Дата и время создания заявки",
"example": "2022-09-06T21:10:05.432"
},
@@ -2677,6 +3235,7 @@
},
"eventDate": {
"type": "string",
+ "format": "date-time",
"description": "Новые дата и время на которые намечено событие. Дата и время указываются в формате \"yyyy-MM-dd HH:mm:ss\"",
"example": "2023-10-11 23:10:05"
},
@@ -2713,6 +3272,10 @@
"type": "string",
"description": "Новый заголовок",
"example": "Сап прогулки по рекам и каналам"
+ },
+ "commentsEnabled": {
+ "type": "boolean",
+ "description": "Новое значение флага, разрешены ли комментарии для события."
}
},
"description": "Данные для изменения информации о событии. Если поле в запросе не указано (равно null) - значит изменение этих данных не треубется."
@@ -2742,6 +3305,7 @@
},
"eventDate": {
"type": "string",
+ "format": "date-time",
"description": "Новые дата и время на которые намечено событие. Дата и время указываются в формате \"yyyy-MM-dd HH:mm:ss\"",
"example": "2023-10-11 23:10:05"
},
@@ -2779,6 +3343,10 @@
"type": "string",
"description": "Новый заголовок",
"example": "Сап прогулки по рекам и каналам"
+ },
+ "commentsEnabled": {
+ "type": "boolean",
+ "description": "Новое значение флага, разрешены ли комментарии для события."
}
},
"description": "Данные для изменения информации о событии. Если поле в запросе не указано (равно null) - значит изменение этих данных не треубется."
@@ -2830,7 +3398,81 @@
}
},
"description": "Пользователь (краткая информация)"
+ },
+ "NewCommentDto": {
+ "type": "object",
+ "required": [
+ "text"
+ ],
+ "properties": {
+ "text": {
+ "type": "string",
+ "description": "Текст комментария",
+ "minLength": 1,
+ "maxLength": 2000
+ }
+ },
+ "description": "Данные для создания нового комментария"
+ },
+ "UpdateCommentDto": {
+ "type": "object",
+ "required": [
+ "text"
+ ],
+ "properties": {
+ "text": {
+ "type": "string",
+ "description": "Новый текст комментария",
+ "minLength": 1,
+ "maxLength": 2000
+ }
+ },
+ "description": "Данные для обновления комментария"
+ },
+ "CommentDto": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "integer",
+ "format": "int64",
+ "description": "Идентификатор комментария",
+ "readOnly": true
+ },
+ "text": {
+ "type": "string",
+ "description": "Текст комментария. Может содержать плейсхолдер, если комментарий удален."
+ },
+ "author": {
+ "$ref": "#/components/schemas/UserShortDto"
+ },
+ "eventId": {
+ "type": "integer",
+ "format": "int64",
+ "description": "Идентификатор события, к которому относится комментарий"
+ },
+ "createdOn": {
+ "type": "string",
+ "format": "date-time",
+ "description": "Дата и время создания комментария (в формате \"yyyy-MM-dd HH:mm:ss\")"
+ },
+ "updatedOn": {
+ "type": "string",
+ "format": "date-time",
+ "description": "Дата и время последнего обновления комментария (в формате \"yyyy-MM-dd HH:mm:ss\")",
+ "nullable": true
+ },
+ "isEdited": {
+ "type": "boolean",
+ "description": "Флаг, указывающий, был ли комментарий отредактирован"
+ },
+ "isDeleted": {
+ "type": "boolean",
+ "description": "Флаг, указывающий, удален ли комментарий (актуально для админских запросов)",
+ "default": false
+ }
+ },
+ "description": "Представление комментария"
}
}
}
-}
+}
\ No newline at end of file
diff --git a/main-service/Dockerfile b/main-service/Dockerfile
new file mode 100644
index 0000000..0ff1817
--- /dev/null
+++ b/main-service/Dockerfile
@@ -0,0 +1,5 @@
+FROM eclipse-temurin:21-jre-jammy
+VOLUME /tmp
+ARG JAR_FILE=target/*.jar
+COPY ${JAR_FILE} app.jar
+ENTRYPOINT ["sh", "-c", "java ${JAVA_OPTS} -jar /app.jar"]
\ No newline at end of file
diff --git a/main-service/pom.xml b/main-service/pom.xml
new file mode 100644
index 0000000..1ac60b0
--- /dev/null
+++ b/main-service/pom.xml
@@ -0,0 +1,108 @@
+
+
+ 4.0.0
+
+ ru.practicum
+ explore-with-me
+ 0.0.1-SNAPSHOT
+
+
+ main-service
+ jar
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+
+
+ ru.practicum
+ stats-client
+ ${project.version}
+
+
+ ru.practicum
+ stats-dto
+ ${project.version}
+
+
+ ru.practicum
+ ewm-common
+ ${project.version}
+
+
+ org.springframework.boot
+ spring-boot-starter-data-jpa
+
+
+ org.postgresql
+ postgresql
+
+
+ org.projectlombok
+ lombok
+ provided
+
+
+ com.querydsl
+ querydsl-apt
+
+
+ com.querydsl
+ querydsl-jpa
+ jakarta
+
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
+ org.springframework.boot
+ spring-boot-starter-aop
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+
+
+ org.mapstruct
+ mapstruct
+
+
+ org.testcontainers
+ testcontainers
+
+
+ org.testcontainers
+ junit-jupiter
+
+
+ org.testcontainers
+ postgresql
+
+
+ com.h2database
+ h2
+ test
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+
\ No newline at end of file
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/MainServiceApplication.java b/main-service/src/main/java/ru/practicum/explorewithme/main/MainServiceApplication.java
new file mode 100644
index 0000000..7856da2
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/MainServiceApplication.java
@@ -0,0 +1,15 @@
+package ru.practicum.explorewithme.main;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.context.annotation.Import;
+import ru.practicum.explorewithme.stats.client.config.StatsClientModuleConfiguration;
+
+@SpringBootApplication
+@Import(StatsClientModuleConfiguration.class)
+public class MainServiceApplication {
+ public static void main(String[] args) {
+ SpringApplication.run(MainServiceApplication.class, args);
+ }
+
+}
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/aspect/LogStatsHit.java b/main-service/src/main/java/ru/practicum/explorewithme/main/aspect/LogStatsHit.java
new file mode 100644
index 0000000..1e6e821
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/aspect/LogStatsHit.java
@@ -0,0 +1,11 @@
+package ru.practicum.explorewithme.main.aspect;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface LogStatsHit {
+}
\ No newline at end of file
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/aspect/StatsHitAspect.java b/main-service/src/main/java/ru/practicum/explorewithme/main/aspect/StatsHitAspect.java
new file mode 100644
index 0000000..875100b
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/aspect/StatsHitAspect.java
@@ -0,0 +1,75 @@
+package ru.practicum.explorewithme.main.aspect;
+
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.aspectj.lang.JoinPoint;
+import org.aspectj.lang.annotation.AfterReturning;
+import org.aspectj.lang.annotation.Aspect;
+import org.aspectj.lang.annotation.Pointcut;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
+import ru.practicum.explorewithme.stats.client.StatsClient;
+import ru.practicum.explorewithme.stats.dto.EndpointHitDto;
+
+import java.time.LocalDateTime;
+
+@Aspect
+@Component
+@RequiredArgsConstructor
+@Slf4j
+public class StatsHitAspect {
+
+ private final StatsClient statsClient;
+
+ @Value("${spring.application.name:ewm-main-service}")
+ private String appName;
+
+ @Pointcut("@annotation(LogStatsHit)")
+ public void methodsToLogHit() {
+ }
+
+ @AfterReturning(pointcut = "methodsToLogHit()")
+ public void logHit(JoinPoint joinPoint) {
+ ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
+ if (attributes == null) {
+ log.warn("Cannot log hit: HttpServletRequest is not available in the current context for method: {}",
+ joinPoint.getSignature().toShortString());
+ return;
+ }
+ HttpServletRequest request = attributes.getRequest();
+
+ String uri = request.getRequestURI();
+
+ String ip;
+ String xRealIp = request.getHeader("X-Real-IP");
+ if (StringUtils.hasText(xRealIp)) { // StringUtils.hasText проверяет на null, "", " "
+ ip = xRealIp;
+ log.debug("StatsHitAspect: Using IP from X-Real-IP header: {}", ip);
+ } else {
+ ip = request.getRemoteAddr();
+ log.debug("StatsHitAspect: X-Real-IP header not found or empty, using remoteAddr: {}", ip);
+ }
+
+ LocalDateTime timestamp = LocalDateTime.now();
+
+ log.debug("StatsHitAspect: Logging hit for app='{}', uri='{}', ip='{}'", appName, uri, ip);
+
+ EndpointHitDto hitDto = EndpointHitDto.builder()
+ .app(appName)
+ .uri(uri)
+ .ip(ip)
+ .timestamp(timestamp)
+ .build();
+
+ try {
+ statsClient.saveHit(hitDto);
+ log.debug("StatsHitAspect: Hit successfully sent to stats service for URI: {}", uri);
+ } catch (Exception e) {
+ log.error("StatsHitAspect: Failed to send hit to stats service for URI: {}. Error: {}", uri, e.getMessage());
+ }
+ }
+}
\ No newline at end of file
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/config/JpaAuditingConfig.java b/main-service/src/main/java/ru/practicum/explorewithme/main/config/JpaAuditingConfig.java
new file mode 100644
index 0000000..f6dbade
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/config/JpaAuditingConfig.java
@@ -0,0 +1,10 @@
+package ru.practicum.explorewithme.main.config;
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
+
+@Configuration
+@EnableJpaAuditing
+@SuppressWarnings("unused")
+public class JpaAuditingConfig {
+}
\ No newline at end of file
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/controller/admin/AdminCategoryController.java b/main-service/src/main/java/ru/practicum/explorewithme/main/controller/admin/AdminCategoryController.java
new file mode 100644
index 0000000..ab334b1
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/controller/admin/AdminCategoryController.java
@@ -0,0 +1,51 @@
+package ru.practicum.explorewithme.main.controller.admin;
+
+import ru.practicum.explorewithme.main.dto.NewCategoryDto;
+import ru.practicum.explorewithme.main.dto.CategoryDto;
+import ru.practicum.explorewithme.main.service.CategoryService;
+import org.springframework.http.HttpStatus;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+import jakarta.validation.Valid;
+
+import lombok.extern.slf4j.Slf4j;
+import lombok.RequiredArgsConstructor;
+
+@RestController
+@RequestMapping("/admin/categories")
+@RequiredArgsConstructor
+@Validated
+@Slf4j
+public class AdminCategoryController {
+
+ private final CategoryService categoryService;
+
+ @PostMapping
+ @ResponseStatus(HttpStatus.CREATED)
+ public CategoryDto createCategory(@Valid @RequestBody NewCategoryDto newCategoryDto) {
+ log.info("Admin: Received request to add category: {}", newCategoryDto);
+ CategoryDto result = categoryService.createCategory(newCategoryDto);
+ log.info("Admin: Adding category: {}", result);
+ return result;
+ }
+
+ @PatchMapping("/{categoryId}")
+ @ResponseStatus(HttpStatus.OK)
+ public CategoryDto updateCategory(@PathVariable Long categoryId,
+ @Valid @RequestBody NewCategoryDto categoryDto) {
+ log.info("Admin: Received request to update category with Id: {}, new data: {}", categoryId, categoryDto);
+ CategoryDto result = categoryService.updateCategory(categoryId, categoryDto);
+ log.info("Admin: Updated category: {}", result);
+ return result;
+ }
+
+ @DeleteMapping("/{categoryId}")
+ @ResponseStatus(HttpStatus.NO_CONTENT)
+ public void deleteCategory(@PathVariable Long categoryId) {
+ log.info("Admin: Received request to delete category with Id: {}", categoryId);
+ categoryService.deleteCategory(categoryId);
+ log.info("Admin: Delete category with Id: {}", categoryId);
+ }
+
+}
\ No newline at end of file
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/controller/admin/AdminCommentController.java b/main-service/src/main/java/ru/practicum/explorewithme/main/controller/admin/AdminCommentController.java
new file mode 100644
index 0000000..047427c
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/controller/admin/AdminCommentController.java
@@ -0,0 +1,81 @@
+package ru.practicum.explorewithme.main.controller.admin;
+
+import jakarta.validation.constraints.Positive;
+import jakarta.validation.constraints.PositiveOrZero;
+import java.util.List;
+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.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PatchMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RestController;
+import ru.practicum.explorewithme.main.dto.CommentAdminDto;
+import ru.practicum.explorewithme.main.service.CommentService;
+import ru.practicum.explorewithme.main.service.params.AdminCommentSearchParams;
+
+@RestController
+@RequestMapping("/admin/comments")
+@RequiredArgsConstructor
+@Validated
+@Slf4j
+public class AdminCommentController {
+
+ private final CommentService commentService;
+
+ @DeleteMapping("/{commentId}")
+ @ResponseStatus(HttpStatus.NO_CONTENT)
+ public void deleteComment(@PathVariable @Positive Long commentId) {
+ log.info("Admin: Received request to delete comment with Id: {}", commentId);
+ commentService.deleteCommentByAdmin(commentId);
+ log.info("Admin: Comment with Id: {} marked as deleted", commentId);
+ }
+
+ @PatchMapping("/{commentId}/restore")
+ @ResponseStatus(HttpStatus.OK)
+ public CommentAdminDto restoreComment(@PathVariable @Positive Long commentId) {
+ log.info("Admin: Received request to restore comment with Id: {}", commentId);
+ CommentAdminDto restoredComment = commentService.restoreCommentByAdmin(commentId);
+ log.info("Admin: Comment with Id: {} restored", commentId);
+ return restoredComment;
+ }
+
+ /**
+ * Получение списка всех комментариев с возможностью фильтрации администратором.
+ *
+ * @param userId ID автора комментария для фильтрации (опционально)
+ * @param eventId ID события для фильтрации (опционально)
+ * @param isDeleted Фильтр по статусу удаления (true - удаленные, false - не удаленные, null - все) (опционально)
+ * @param from количество элементов, которые нужно пропустить для формирования текущего набора
+ * @param size количество элементов в наборе
+ * @return Список CommentDto
+ */
+ @GetMapping
+ @ResponseStatus(HttpStatus.OK)
+ public List getAllCommentsAdmin(
+ @RequestParam(name = "userId", required = false) @Positive Long userId,
+ @RequestParam(name = "eventId", required = false) @Positive Long eventId,
+ @RequestParam(name = "isDeleted", required = false) Boolean isDeleted,
+ @RequestParam(name = "from", defaultValue = "0") @PositiveOrZero int from,
+ @RequestParam(name = "size", defaultValue = "10") @Positive int size) {
+
+ log.info("Admin: Received request to get all comments with filters: userId={}, eventId={}, isDeleted={}, from={}, size={}",
+ userId, eventId, isDeleted, from, size);
+
+ AdminCommentSearchParams searchParams = AdminCommentSearchParams.builder()
+ .userId(userId)
+ .eventId(eventId)
+ .isDeleted(isDeleted)
+ .build();
+
+ List comments = commentService.getAllCommentsAdmin(searchParams, from, size);
+
+ log.info("Admin: Found {} comments matching criteria.", comments.size());
+ return comments;
+ }
+}
\ No newline at end of file
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/controller/admin/AdminCompilationController.java b/main-service/src/main/java/ru/practicum/explorewithme/main/controller/admin/AdminCompilationController.java
new file mode 100644
index 0000000..53ae279
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/controller/admin/AdminCompilationController.java
@@ -0,0 +1,51 @@
+package ru.practicum.explorewithme.main.controller.admin;
+
+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.explorewithme.main.dto.CompilationDto;
+import ru.practicum.explorewithme.main.dto.NewCompilationDto;
+import ru.practicum.explorewithme.main.dto.UpdateCompilationRequestDto;
+import ru.practicum.explorewithme.main.service.CompilationService;
+
+@RestController
+@RequestMapping("/admin/compilations")
+@RequiredArgsConstructor
+@Validated
+@Slf4j
+public class AdminCompilationController {
+
+ private final CompilationService compilationService;
+
+ @PostMapping
+ @ResponseStatus(HttpStatus.CREATED)
+ public CompilationDto createCompilation(@Valid @RequestBody NewCompilationDto newCompilationDto) {
+ log.info("Admin: Received request to create compilation: {}", newCompilationDto);
+ CompilationDto result = compilationService.saveCompilation(newCompilationDto);
+ log.info("Admin: Created compilation: {}", result);
+ return result;
+ }
+
+ @PatchMapping("/{compId}")
+ @ResponseStatus(HttpStatus.OK)
+ public CompilationDto updateCompilation(
+ @PathVariable @Positive Long compId,
+ @Valid @RequestBody UpdateCompilationRequestDto updateCompilationRequestDto) {
+ log.info("Admin: Received request to update compilation id={} with data: {}", compId, updateCompilationRequestDto);
+ CompilationDto result = compilationService.updateCompilation(compId, updateCompilationRequestDto);
+ log.info("Admin: Updated compilation: {}", result);
+ return result;
+ }
+
+ @DeleteMapping("/{compId}")
+ @ResponseStatus(HttpStatus.NO_CONTENT)
+ public void deleteCompilation(@PathVariable @Positive Long compId) {
+ log.info("Admin: Received request to delete compilation with id={}", compId);
+ compilationService.deleteCompilation(compId);
+ log.info("Admin: Deleted compilation with id={}", compId);
+ }
+}
\ No newline at end of file
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/controller/admin/AdminEventController.java b/main-service/src/main/java/ru/practicum/explorewithme/main/controller/admin/AdminEventController.java
new file mode 100644
index 0000000..86f79f5
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/controller/admin/AdminEventController.java
@@ -0,0 +1,105 @@
+package ru.practicum.explorewithme.main.controller.admin;
+
+import static ru.practicum.explorewithme.common.constants.DateTimeConstants.DATE_TIME_FORMAT_PATTERN;
+
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.format.annotation.DateTimeFormat;
+import org.springframework.http.HttpStatus;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+import ru.practicum.explorewithme.main.dto.EventFullDto;
+import ru.practicum.explorewithme.main.dto.UpdateEventAdminRequestDto;
+import ru.practicum.explorewithme.main.model.EventState;
+import ru.practicum.explorewithme.main.service.EventService;
+
+import jakarta.validation.constraints.Positive;
+import jakarta.validation.constraints.PositiveOrZero;
+import java.time.LocalDateTime;
+import java.util.List;
+import ru.practicum.explorewithme.main.service.params.AdminEventSearchParams;
+
+@RestController
+@RequestMapping("/admin/events")
+@RequiredArgsConstructor
+@Validated
+@Slf4j
+public class AdminEventController {
+
+ private final EventService eventService;
+ private static final String DATETIME_FORMAT = DATE_TIME_FORMAT_PATTERN;
+
+ /**
+ * Поиск событий администратором.
+ * Эндпоинт возвращает полную информацию обо всех событиях подходящих под переданные условия.
+ * В случае, если по заданным фильтрам не найдено ни одного события, возвращает пустой список.
+ *
+ * @param users список id пользователей, чьи события нужно найти
+ * @param states список состояний в которых находятся искомые события
+ * @param categories список id категорий в которых будет вестись поиск
+ * @param rangeStart дата и время не раньше которых должно произойти событие
+ * @param rangeEnd дата и время не позже которых должно произойти событие
+ * @param from количество событий, которые нужно пропустить для формирования текущего набора
+ * @param size количество событий в наборе
+ * @return Список EventFullDto
+ */
+ @GetMapping
+ @ResponseStatus(HttpStatus.OK)
+ public List searchEventsAdmin(
+ @RequestParam(name = "users", required = false) List users,
+ @RequestParam(name = "states", required = false) List states,
+ @RequestParam(name = "categories", required = false) List categories,
+ @RequestParam(name = "rangeStart", required = false)
+ @DateTimeFormat(pattern = DATETIME_FORMAT) LocalDateTime rangeStart,
+ @RequestParam(name = "rangeEnd", required = false)
+ @DateTimeFormat(pattern = DATETIME_FORMAT) LocalDateTime rangeEnd,
+ @RequestParam(name = "from", defaultValue = "0") @PositiveOrZero int from,
+ @RequestParam(name = "size", defaultValue = "10") @Positive int size) {
+
+ log.info("Admin: Received request to search events with params: users={}, states={}, categories={}, " +
+ "rangeStart={}, rangeEnd={}, from={}, size={}",
+ users, states, categories, rangeStart, rangeEnd, from, size);
+
+ AdminEventSearchParams params = AdminEventSearchParams.builder().users(users).states(states)
+ .categories(categories).rangeStart(rangeStart).rangeEnd(rangeEnd).build();
+
+ List foundEvents = eventService.getEventsAdmin(
+ params,
+ from,
+ size
+ );
+ log.info("Admin: Found {} events for the given criteria.", foundEvents.size());
+ return foundEvents;
+ }
+
+ /**
+ * Редактирование данных события и его статуса (отклонение/публикация) администратором.
+ * Валидация данных не требуется (согласно старому ТЗ, но DTO содержит аннотации валидации).
+ * Обратите внимание:
+ *
+ * - дата начала изменяемого события должна быть не ранее чем за час от даты публикации. (Ожидается код ошибки 409)
+ * - событие можно публиковать, только если оно в состоянии ожидания публикации (Ожидается код ошибки 409)
+ * - событие можно отклонить, только если оно еще не опубликовано (Ожидается код ошибки 409)
+ *
+ *
+ * @param eventId ID события
+ * @param updateEventAdminRequestDto Данные для изменения информации о событии
+ * @return Обновленное EventFullDto
+ */
+ @PatchMapping("/{eventId}")
+ @ResponseStatus(HttpStatus.OK)
+ public EventFullDto moderateEventByAdmin(
+ @PathVariable Long eventId,
+ @Valid @RequestBody UpdateEventAdminRequestDto updateEventAdminRequestDto) {
+
+ log.info("Admin: Received request to moderate event id={} with data: {}",
+ eventId, updateEventAdminRequestDto);
+
+ EventFullDto moderatedEvent = eventService.moderateEventByAdmin(eventId, updateEventAdminRequestDto);
+
+ log.info("Admin: Event id={} moderated successfully. New state: {}",
+ eventId, moderatedEvent.getState());
+ return moderatedEvent;
+ }
+}
\ No newline at end of file
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/controller/admin/AdminUserController.java b/main-service/src/main/java/ru/practicum/explorewithme/main/controller/admin/AdminUserController.java
new file mode 100644
index 0000000..cb29ce0
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/controller/admin/AdminUserController.java
@@ -0,0 +1,62 @@
+package ru.practicum.explorewithme.main.controller.admin;
+
+import ru.practicum.explorewithme.main.dto.NewUserRequestDto;
+import ru.practicum.explorewithme.main.dto.UserDto;
+import ru.practicum.explorewithme.main.service.UserService;
+import org.springframework.http.HttpStatus;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.Positive;
+import jakarta.validation.constraints.PositiveOrZero;
+import java.util.List;
+
+import lombok.extern.slf4j.Slf4j;
+import lombok.RequiredArgsConstructor;
+import ru.practicum.explorewithme.main.service.params.GetListUsersParameters;
+
+@RestController
+@RequestMapping("/admin/users")
+@RequiredArgsConstructor
+@Validated
+@Slf4j
+public class AdminUserController {
+
+ private final UserService userService;
+
+ @PostMapping
+ @ResponseStatus(HttpStatus.CREATED)
+ public UserDto createUser(@Valid @RequestBody NewUserRequestDto newUserDto) {
+ log.info("Admin: Received request to add user: {}", newUserDto);
+ UserDto result = userService.createUser(newUserDto);
+ log.info("Admin: Adding user: {}", result);
+ return result;
+ }
+
+ @DeleteMapping("/{userId}")
+ @ResponseStatus(HttpStatus.NO_CONTENT)
+ public void deleteUser(@PathVariable Long userId) {
+ log.info("Admin: Received request to delete user with Id: {}", userId);
+ userService.deleteUser(userId);
+ log.info("Admin: Delete user with Id: {}", userId);
+ }
+
+ @GetMapping
+ @ResponseStatus(HttpStatus.OK)
+ public List getUsers(
+ @RequestParam(required = false) List ids,
+ @RequestParam(defaultValue = "0") @PositiveOrZero int from,
+ @RequestParam(defaultValue = "10") @Positive int size) {
+ log.info("Admin: Received request to get list users with parameters: ids {}, from {}, size {}", ids, from, size);
+ GetListUsersParameters parameters = GetListUsersParameters.builder()
+ .ids(ids)
+ .from(from)
+ .size(size)
+ .build();
+ List result = userService.getUsers(parameters);
+ log.info("Admin: Received list users: {}", result);
+ return result;
+ }
+
+}
\ No newline at end of file
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/controller/priv/PrivateCommentController.java b/main-service/src/main/java/ru/practicum/explorewithme/main/controller/priv/PrivateCommentController.java
new file mode 100644
index 0000000..c1d4196
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/controller/priv/PrivateCommentController.java
@@ -0,0 +1,69 @@
+package ru.practicum.explorewithme.main.controller.priv;
+
+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.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+import ru.practicum.explorewithme.main.dto.CommentDto;
+import ru.practicum.explorewithme.main.dto.NewCommentDto;
+import ru.practicum.explorewithme.main.dto.UpdateCommentDto;
+import ru.practicum.explorewithme.main.service.CommentService;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/users/{userId}/comments")
+@RequiredArgsConstructor
+@Validated
+@Slf4j
+public class PrivateCommentController {
+
+ private final CommentService commentService;
+
+ @PostMapping
+ public ResponseEntity createComment(
+ @PathVariable @Positive Long userId,
+ @RequestParam @Positive Long eventId,
+ @Valid @RequestBody NewCommentDto newCommentDto) {
+ log.info("Создание нового комментария {} зарегистрированным пользователем c id {} к событию с id {}",
+ newCommentDto, userId, eventId);
+ return ResponseEntity.status(HttpStatus.CREATED)
+ .body(commentService.addComment(userId, eventId, newCommentDto));
+ }
+
+ @PatchMapping("/{commentId}")
+ public ResponseEntity updateComment(
+ @PathVariable @Positive Long userId,
+ @PathVariable @Positive Long commentId,
+ @Valid @RequestBody UpdateCommentDto updateCommentDto) {
+ log.info("Обновление комментария c id {} пользователем c id {}, новый комментарий {}",
+ commentId, userId, updateCommentDto);
+ return ResponseEntity.status(HttpStatus.OK)
+ .body(commentService.updateUserComment(userId, commentId, updateCommentDto));
+ }
+
+ @GetMapping
+ public ResponseEntity> getUserComments(
+ @PathVariable @Positive Long userId,
+ @RequestParam(defaultValue = "0") @PositiveOrZero int from,
+ @RequestParam(defaultValue = "10") @Positive int size) {
+ List result = commentService.getUserComments(userId, from, size);
+ log.info("Получение списка комментариев {} пользователя c id {}", result, userId);
+ return ResponseEntity.status(HttpStatus.OK).body(result);
+ }
+
+ @DeleteMapping("/{commentId}")
+ @ResponseStatus(HttpStatus.NO_CONTENT)
+ public void deleteComment(
+ @PathVariable @Positive Long userId,
+ @PathVariable @Positive Long commentId) {
+ log.info("User id={}: Received request to delete comment with Id: {}", userId, commentId);
+ commentService.deleteUserComment(userId, commentId);
+ log.info("User id={}: Comment with Id: {} marked as deleted", userId, commentId);
+ }
+}
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/controller/priv/PrivateEventController.java b/main-service/src/main/java/ru/practicum/explorewithme/main/controller/priv/PrivateEventController.java
new file mode 100644
index 0000000..0d247ae
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/controller/priv/PrivateEventController.java
@@ -0,0 +1,156 @@
+package ru.practicum.explorewithme.main.controller.priv;
+
+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.explorewithme.main.dto.EventFullDto;
+import ru.practicum.explorewithme.main.dto.EventRequestStatusUpdateRequestDto;
+import ru.practicum.explorewithme.main.dto.EventRequestStatusUpdateResultDto;
+import ru.practicum.explorewithme.main.dto.EventShortDto;
+import ru.practicum.explorewithme.main.dto.NewEventDto;
+import ru.practicum.explorewithme.main.dto.UpdateEventUserRequestDto;
+import ru.practicum.explorewithme.main.dto.ParticipationRequestDto;
+import ru.practicum.explorewithme.main.service.EventService;
+import ru.practicum.explorewithme.main.service.RequestService;
+import ru.practicum.explorewithme.main.service.params.EventRequestStatusUpdateRequestParams;
+
+import jakarta.validation.constraints.Positive;
+import jakarta.validation.constraints.PositiveOrZero;
+import ru.practicum.explorewithme.main.service.params.EventRequestStatusUpdateRequestParams;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/users/{userId}/events")
+@RequiredArgsConstructor
+@Validated
+@Slf4j
+public class PrivateEventController {
+
+ private final EventService eventService;
+ private final RequestService requestService;
+
+ /**
+ * Получение событий, добавленных текущим пользователем.
+ * В случае, если по заданным фильтрам не найдено ни одного события, возвращает пустой список.
+ *
+ * @param userId ID текущего пользователя
+ * @param from количество элементов, которые нужно пропустить для формирования текущего набора
+ * @param size количество элементов в наборе
+ * @return Список EventShortDto
+ */
+ @GetMapping
+ @ResponseStatus(HttpStatus.OK)
+ public List getEventsAddedByCurrentUser(
+ @PathVariable Long userId,
+ @RequestParam(name = "from", defaultValue = "0") @PositiveOrZero int from,
+ @RequestParam(name = "size", defaultValue = "10") @Positive int size) {
+
+ log.info("User id={}: Received request to get own events, from={}, size={}", userId, from, size);
+ List events = eventService.getEventsByOwner(userId, from, size);
+ log.info("User id={}: Found {} events. From={}, size={}", userId, events.size(), from, size);
+ return events;
+ }
+
+ /**
+ * Получение полной информации о событии, добавленном текущим пользователем.
+ * В случае, если события с заданным id не найдено, возвращает статус код 404.
+ *
+ * @param userId ID текущего пользователя
+ * @param eventId ID события
+ * @return EventFullDto с полной информацией о событии
+ */
+ @GetMapping("/{eventId}")
+ @ResponseStatus(HttpStatus.OK)
+ public EventFullDto getFullEventInfoByOwner(
+ @PathVariable Long userId,
+ @PathVariable Long eventId) {
+
+ log.info("User id={}: Received request to get full info for event id={}", userId, eventId);
+ EventFullDto eventFullDto = eventService.getEventPrivate(userId, eventId);
+ log.info("User id={}: Found full info for event id={}: {}", userId, eventId, eventFullDto.getId());
+ return eventFullDto;
+ }
+
+ /**
+ * Добавление нового события текущим пользователем.
+ * Новое событие будет добавлено со статусом PENDING и требует модерации.
+ * Дата и время на которые намечено событие не может быть раньше, чем через два часа от текущего момента (Ожидается код ошибки 409)
+ *
+ * @param userId ID текущего пользователя
+ * @param newEventDto Объект NewEventDto, содержащий данные для создания нового события
+ * @return ResponseEntity с EventFullDto созданного события и статусом HTTP 201 CREATED
+ */
+ @PostMapping
+ public ResponseEntity addEventPrivate(@PathVariable Long userId, @Valid @RequestBody NewEventDto newEventDto) {
+ log.info("Создание нового события {} зарегистрированным пользователем c id {}", newEventDto, userId);
+ return ResponseEntity.status(HttpStatus.CREATED).body(eventService.addEventPrivate(userId, newEventDto));
+ }
+
+ /**
+ * Изменение события, добавленного текущим пользователем.
+ * Обратите внимание:
+ *
+ * - изменить можно только отмененные события или события в состоянии ожидания модерации (Ожидается код ошибки 409)
+ * - дата и время на которые намечено событие не может быть раньше, чем через два часа от текущего момента (Ожидается код ошибки 409)
+ *
+ *
+ * @param userId ID текущего пользователя
+ * @param eventId ID редактируемого события
+ * @param updateEventUserRequestDto Новые данные события
+ * @return Обновленное EventFullDto
+ */
+ @PatchMapping("/{eventId}")
+ @ResponseStatus(HttpStatus.OK)
+ public EventFullDto updateEventByOwner(
+ @PathVariable Long userId,
+ @PathVariable Long eventId,
+ @Valid @RequestBody UpdateEventUserRequestDto updateEventUserRequestDto) {
+
+ log.info("User id={}: Received request to update event id={} with data: {}",
+ userId, eventId, updateEventUserRequestDto);
+
+ EventFullDto updatedEvent = eventService.updateEventByOwner(userId, eventId, updateEventUserRequestDto);
+
+ log.info("User id={}: Event id={} updated successfully. New title: {}",
+ userId, eventId, updatedEvent.getTitle());
+ return updatedEvent;
+ }
+
+
+ @GetMapping("/{eventId}/requests")
+ @ResponseStatus(HttpStatus.OK)
+ public List getEventRequests(
+ @PathVariable @Positive Long userId,
+ @PathVariable @Positive Long eventId) {
+ log.info("Private: Received request to get list requests for event {} when initiator {}", eventId, userId);
+ List result = requestService.getEventRequests(userId, eventId);
+ log.info("Private: Received list requests for event {} when initiator {} : {}", eventId, userId, result);
+ return result;
+ }
+
+
+ @PatchMapping("/{eventId}/requests")
+ @ResponseStatus(HttpStatus.OK)
+ public EventRequestStatusUpdateResultDto updateRequestsStatus(
+ @PathVariable @Positive Long userId,
+ @PathVariable @Positive Long eventId,
+ @Valid @RequestBody EventRequestStatusUpdateRequestDto requestStatusUpdate) {
+ log.info("Private: Received request to change status requests {} for event {} when initiator {}",
+ requestStatusUpdate.getRequestIds(), eventId, userId);
+ EventRequestStatusUpdateRequestParams requestParams = EventRequestStatusUpdateRequestParams.builder()
+ .userId(userId)
+ .eventId(eventId)
+ .requestIds(requestStatusUpdate.getRequestIds())
+ .status(requestStatusUpdate.getStatus())
+ .build();
+ EventRequestStatusUpdateResultDto result = requestService.updateRequestsStatus(requestParams);
+ log.info("Private: Received list requests for event {} when initiator {} : {}", eventId, userId, result);
+ return result;
+ }
+
+}
\ No newline at end of file
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/controller/priv/PrivateRequestController.java b/main-service/src/main/java/ru/practicum/explorewithme/main/controller/priv/PrivateRequestController.java
new file mode 100644
index 0000000..acf6494
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/controller/priv/PrivateRequestController.java
@@ -0,0 +1,55 @@
+package ru.practicum.explorewithme.main.controller.priv;
+
+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.explorewithme.main.dto.ParticipationRequestDto;
+import ru.practicum.explorewithme.main.service.RequestService;
+
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/users/{userId}/requests")
+@RequiredArgsConstructor
+@Validated
+@Slf4j
+public class PrivateRequestController {
+
+ private final RequestService requestService;
+
+ @PostMapping
+ @ResponseStatus(HttpStatus.CREATED)
+ public ParticipationRequestDto createRequest(
+ @PathVariable @Positive Long userId,
+ @RequestParam @Positive Long eventId) {
+ log.info("Private: Received request to add user {} in event: {}", userId, eventId);
+ ParticipationRequestDto result = requestService.createRequest(userId, eventId);
+ log.info("Private: Adding user: {}", result);
+ return result;
+ }
+
+ @PatchMapping("/{requestId}/cancel")
+ @ResponseStatus(HttpStatus.OK)
+ public ParticipationRequestDto cancelRequest(
+ @PathVariable @Positive Long userId,
+ @PathVariable @Positive Long requestId) {
+ log.info("Private: Received request user {} to cancel request with Id: {}", userId, requestId);
+ ParticipationRequestDto result = requestService.cancelRequest(userId, requestId);
+ log.info("Private: Cancel request: {}", result);
+ return result;
+ }
+
+ @GetMapping
+ @ResponseStatus(HttpStatus.OK)
+ public List getRequests(@PathVariable @Positive Long userId) {
+ log.info("Private: Received request to get list participation requests for user {}", userId);
+ List result = requestService.getRequests(userId);
+ log.info("Private: Received list participation requests for user {}: {}", userId, result);
+ return result;
+ }
+
+}
\ No newline at end of file
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/controller/pub/PublicCategoryController.java b/main-service/src/main/java/ru/practicum/explorewithme/main/controller/pub/PublicCategoryController.java
new file mode 100644
index 0000000..d9e379a
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/controller/pub/PublicCategoryController.java
@@ -0,0 +1,45 @@
+package ru.practicum.explorewithme.main.controller.pub;
+
+import jakarta.validation.constraints.Positive;
+import jakarta.validation.constraints.PositiveOrZero;
+import ru.practicum.explorewithme.main.dto.CategoryDto;
+import ru.practicum.explorewithme.main.service.CategoryService;
+import org.springframework.http.HttpStatus;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+import lombok.extern.slf4j.Slf4j;
+import lombok.RequiredArgsConstructor;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/categories")
+@RequiredArgsConstructor
+@Validated
+@Slf4j
+public class PublicCategoryController {
+
+ private final CategoryService categoryService;
+
+ @GetMapping
+ @ResponseStatus(HttpStatus.OK)
+ public List getAllCategories(
+ @RequestParam(defaultValue = "0") @PositiveOrZero int from,
+ @RequestParam(defaultValue = "10") @Positive int size) {
+ log.info("Admin: Received request to get all categories with parameters: from {}, size {}", from, size);
+ List result = categoryService.getAllCategories(from, size);
+ log.info("Admin: Received list of categories: {}", result);
+ return result;
+ }
+
+ @GetMapping("/{categoryId}")
+ @ResponseStatus(HttpStatus.OK)
+ public CategoryDto getCategoryById(@PathVariable Long categoryId) {
+ log.info("Admin: Received request to get category with Id: {}", categoryId);
+ CategoryDto result = categoryService.getCategoryById(categoryId);
+ log.info("Admin: Received category: {}", result);
+ return result;
+ }
+
+}
\ No newline at end of file
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/controller/pub/PublicCommentController.java b/main-service/src/main/java/ru/practicum/explorewithme/main/controller/pub/PublicCommentController.java
new file mode 100644
index 0000000..341a369
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/controller/pub/PublicCommentController.java
@@ -0,0 +1,55 @@
+package ru.practicum.explorewithme.main.controller.pub;
+
+import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.Positive;
+import jakarta.validation.constraints.PositiveOrZero;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.domain.Sort;
+import org.springframework.http.HttpStatus;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+import ru.practicum.explorewithme.main.dto.CommentDto;
+import ru.practicum.explorewithme.main.service.CommentService;
+import ru.practicum.explorewithme.main.service.params.PublicCommentParameters;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/events/{eventId}/comments")
+@RequiredArgsConstructor
+@Validated
+@Slf4j
+public class PublicCommentController {
+
+ private final CommentService commentService;
+
+ @GetMapping
+ @ResponseStatus(HttpStatus.OK)
+ public List getCommentsForEventId(
+ @PathVariable @Positive Long eventId,
+ @RequestParam(name = "from", defaultValue = "0") @PositiveOrZero int from,
+ @RequestParam(name = "size", defaultValue = "10") @Positive int size,
+ @Pattern(regexp = "^(createdOn),(ASC|DESC)$",
+ message = "Параметр sort должен иметь формат createdOn,ASC|DESC")
+ @RequestParam(defaultValue = "createdOn,DESC") String sort) {
+ log.info("Public: Received request to get list comments for eventId:" +
+ " {}, parameters: from: {}, size: {}, sort: {}", eventId, from, size, sort);
+ Sort sortingRule;
+ if (sort != null && sort.equalsIgnoreCase("createdOn,ASC")) {
+ sortingRule = Sort.by(Sort.Direction.ASC, "createdOn");
+ } else {
+ sortingRule = Sort.by(Sort.Direction.DESC, "createdOn");
+ }
+ PublicCommentParameters parameters = PublicCommentParameters.builder()
+ .from(from)
+ .size(size)
+ .sort(sortingRule)
+ .build();
+ List result = commentService.getCommentsForEvent(eventId, parameters);
+ log.info("Public: Got list comments for eventId: {}, parameters: from: {}, size: {}, sort: {}",
+ eventId, from, size, sort);
+ return result;
+ }
+
+}
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/controller/pub/PublicCompilationController.java b/main-service/src/main/java/ru/practicum/explorewithme/main/controller/pub/PublicCompilationController.java
new file mode 100644
index 0000000..348fdd4
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/controller/pub/PublicCompilationController.java
@@ -0,0 +1,45 @@
+package ru.practicum.explorewithme.main.controller.pub;
+
+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.explorewithme.main.dto.CompilationDto;
+import ru.practicum.explorewithme.main.service.CompilationService;
+
+import jakarta.validation.constraints.Positive;
+import jakarta.validation.constraints.PositiveOrZero;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/compilations")
+@RequiredArgsConstructor
+@Validated
+@Slf4j
+public class PublicCompilationController {
+
+ private final CompilationService compilationService;
+
+ @GetMapping
+ @ResponseStatus(HttpStatus.OK)
+ public List getCompilations(
+ @RequestParam(name = "pinned", required = false) Boolean pinned,
+ @RequestParam(name = "from", defaultValue = "0") @PositiveOrZero int from,
+ @RequestParam(name = "size", defaultValue = "10") @Positive int size) {
+ log.info("Received request to get compilations with pinned={}, from={}, size={}", pinned, from, size);
+ List result = compilationService.getCompilations(pinned, from, size);
+ log.info("Found {} compilations", result.size());
+ return result;
+ }
+
+ @GetMapping("/{compId}")
+ @ResponseStatus(HttpStatus.OK)
+ public CompilationDto getCompilationById(@PathVariable @Positive Long compId) {
+ log.info("Received request to get compilation with id={}", compId);
+ CompilationDto result = compilationService.getCompilationById(compId);
+ log.info("Found compilation: {}", result);
+ return result;
+ }
+}
\ No newline at end of file
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/controller/pub/PublicEventController.java b/main-service/src/main/java/ru/practicum/explorewithme/main/controller/pub/PublicEventController.java
new file mode 100644
index 0000000..95df592
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/controller/pub/PublicEventController.java
@@ -0,0 +1,78 @@
+package ru.practicum.explorewithme.main.controller.pub;
+
+import jakarta.validation.constraints.Positive;
+import jakarta.validation.constraints.PositiveOrZero;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.format.annotation.DateTimeFormat;
+import org.springframework.http.HttpStatus;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+import ru.practicum.explorewithme.main.aspect.LogStatsHit;
+import ru.practicum.explorewithme.main.dto.EventFullDto;
+import ru.practicum.explorewithme.main.dto.EventShortDto;
+import ru.practicum.explorewithme.main.service.EventService;
+import ru.practicum.explorewithme.main.service.params.PublicEventSearchParams;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+import static ru.practicum.explorewithme.common.constants.DateTimeConstants.DATE_TIME_FORMAT_PATTERN;
+
+@RestController
+@RequestMapping("/events")
+@RequiredArgsConstructor
+@Validated
+@Slf4j
+public class PublicEventController {
+
+ private final EventService eventService;
+
+ @GetMapping
+ @ResponseStatus(HttpStatus.OK)
+ @LogStatsHit
+ public List getEvents(
+ @RequestParam(name = "text", required = false) String text,
+ @RequestParam(name = "categories", required = false) List categories,
+ @RequestParam(name = "paid", required = false) Boolean paid,
+ @RequestParam(name = "rangeStart", required = false)
+ @DateTimeFormat(pattern = DATE_TIME_FORMAT_PATTERN) LocalDateTime rangeStart,
+ @RequestParam(name = "rangeEnd", required = false)
+ @DateTimeFormat(pattern = DATE_TIME_FORMAT_PATTERN) LocalDateTime rangeEnd,
+ @RequestParam(name = "onlyAvailable", defaultValue = "false") boolean onlyAvailable,
+ @RequestParam(name = "sort", defaultValue = "EVENT_DATE") String sort,
+ @RequestParam(name = "from", defaultValue = "0") @PositiveOrZero int from,
+ @RequestParam(name = "size", defaultValue = "10") @Positive int size,
+ @RequestHeader(name = "X-Real-IP", required = false) String ipAddress) {
+
+ log.info("Public: Received request to get events with params: text={}, categories={}, paid={}, " +
+ "rangeStart={}, rangeEnd={}, onlyAvailable={}, sort={}, from={}, size={}",
+ text, categories, paid, rangeStart, rangeEnd, onlyAvailable, sort, from, size);
+
+ PublicEventSearchParams params = PublicEventSearchParams.builder()
+ .text(text)
+ .categories(categories)
+ .paid(paid)
+ .rangeStart(rangeStart)
+ .rangeEnd(rangeEnd)
+ .onlyAvailable(onlyAvailable)
+ .sort(sort)
+ .build();
+
+ List events = eventService.getEventsPublic(params, from, size);
+ log.info("Public: Found {} events", events.size());
+ return events;
+ }
+
+ @GetMapping("/{eventId}")
+ @ResponseStatus(HttpStatus.OK)
+ @LogStatsHit
+ public EventFullDto getEventById(
+ @PathVariable @Positive Long eventId,
+ @RequestHeader(name = "X-Real-IP", required = false) String ipAddress) {
+ log.info("Public: Received request to get event with id={}", eventId);
+ EventFullDto event = eventService.getEventByIdPublic(eventId);
+ log.info("Public: Found event: {}", event);
+ return event;
+ }
+}
\ No newline at end of file
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/dto/CategoryDto.java b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/CategoryDto.java
new file mode 100644
index 0000000..f9170fc
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/CategoryDto.java
@@ -0,0 +1,17 @@
+package ru.practicum.explorewithme.main.dto;
+
+import lombok.*;
+import lombok.experimental.FieldDefaults;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@FieldDefaults(level = AccessLevel.PRIVATE)
+public class CategoryDto {
+
+ Long id;
+
+ String name;
+
+}
\ No newline at end of file
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/dto/CommentAdminDto.java b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/CommentAdminDto.java
new file mode 100644
index 0000000..f653143
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/CommentAdminDto.java
@@ -0,0 +1,38 @@
+package ru.practicum.explorewithme.main.dto;
+
+import static ru.practicum.explorewithme.common.constants.DateTimeConstants.DATE_TIME_FORMAT_PATTERN;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import java.time.LocalDateTime;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.experimental.FieldDefaults;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@FieldDefaults(level = AccessLevel.PRIVATE)
+public class CommentAdminDto {
+
+ Long id;
+
+ String text;
+
+ UserShortDto author;
+
+ Long eventId;
+
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = DATE_TIME_FORMAT_PATTERN)
+ LocalDateTime createdOn;
+
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = DATE_TIME_FORMAT_PATTERN)
+ LocalDateTime updatedOn;
+
+ Boolean isEdited;
+
+ Boolean isDeleted;
+}
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/dto/CommentDto.java b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/CommentDto.java
new file mode 100644
index 0000000..b5f3d81
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/CommentDto.java
@@ -0,0 +1,37 @@
+package ru.practicum.explorewithme.main.dto;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.experimental.FieldDefaults;
+
+import java.time.LocalDateTime;
+
+import static ru.practicum.explorewithme.common.constants.DateTimeConstants.DATE_TIME_FORMAT_PATTERN;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@FieldDefaults(level = AccessLevel.PRIVATE)
+public class CommentDto {
+
+ Long id;
+
+ String text;
+
+ UserShortDto author;
+
+ Long eventId;
+
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = DATE_TIME_FORMAT_PATTERN)
+ LocalDateTime createdOn;
+
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = DATE_TIME_FORMAT_PATTERN)
+ LocalDateTime updatedOn;
+
+ Boolean isEdited;
+}
\ No newline at end of file
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/dto/CompilationDto.java b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/CompilationDto.java
new file mode 100644
index 0000000..ba194f1
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/CompilationDto.java
@@ -0,0 +1,17 @@
+package ru.practicum.explorewithme.main.dto;
+
+import lombok.*;
+import lombok.experimental.FieldDefaults;
+
+import java.util.Set;
+
+@Data
+@AllArgsConstructor
+@NoArgsConstructor
+@FieldDefaults(level = AccessLevel.PRIVATE)
+public class CompilationDto {
+ Long id;
+ Boolean pinned;
+ String title;
+ Set events;
+}
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/dto/EventFullDto.java b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/EventFullDto.java
new file mode 100644
index 0000000..a10fc4a
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/EventFullDto.java
@@ -0,0 +1,42 @@
+package ru.practicum.explorewithme.main.dto;
+
+import static ru.practicum.explorewithme.common.constants.DateTimeConstants.DATE_TIME_FORMAT_PATTERN;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import java.time.LocalDateTime;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.experimental.FieldDefaults;
+import ru.practicum.explorewithme.main.model.EventState;
+import ru.practicum.explorewithme.main.model.Location;
+
+@Data
+@AllArgsConstructor
+@NoArgsConstructor
+@Builder
+@FieldDefaults(level = AccessLevel.PRIVATE)
+public class EventFullDto {
+ Long id;
+ String annotation;
+ CategoryDto category;
+ Long confirmedRequests;
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = DATE_TIME_FORMAT_PATTERN)
+ LocalDateTime createdOn;
+ String description;
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = DATE_TIME_FORMAT_PATTERN)
+ LocalDateTime eventDate;
+ UserShortDto initiator;
+ Location location;
+ boolean paid;
+ int participantLimit;
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = DATE_TIME_FORMAT_PATTERN)
+ LocalDateTime publishedOn;
+ boolean requestModeration;
+ EventState state;
+ String title;
+ Long views;
+ boolean commentsEnabled;
+}
\ No newline at end of file
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/dto/EventRequestStatusUpdateRequestDto.java b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/EventRequestStatusUpdateRequestDto.java
new file mode 100644
index 0000000..543a73a
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/EventRequestStatusUpdateRequestDto.java
@@ -0,0 +1,24 @@
+package ru.practicum.explorewithme.main.dto;
+
+import jakarta.validation.constraints.NotEmpty;
+import jakarta.validation.constraints.NotNull;
+import lombok.*;
+import lombok.experimental.FieldDefaults;
+import ru.practicum.explorewithme.main.model.RequestStatus;
+
+import java.util.List;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@FieldDefaults(level = AccessLevel.PRIVATE)
+public class EventRequestStatusUpdateRequestDto {
+
+ @NotEmpty
+ List requestIds;
+
+ @NotNull
+ RequestStatus status;
+
+}
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/dto/EventRequestStatusUpdateResultDto.java b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/EventRequestStatusUpdateResultDto.java
new file mode 100644
index 0000000..ad150ad
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/EventRequestStatusUpdateResultDto.java
@@ -0,0 +1,22 @@
+package ru.practicum.explorewithme.main.dto;
+
+import lombok.*;
+import lombok.experimental.FieldDefaults;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@FieldDefaults(level = AccessLevel.PRIVATE)
+public class EventRequestStatusUpdateResultDto {
+
+ @Builder.Default
+ List confirmedRequests = new ArrayList<>();
+
+ @Builder.Default
+ List rejectedRequests = new ArrayList<>();
+
+}
\ No newline at end of file
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/dto/EventShortDto.java b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/EventShortDto.java
new file mode 100644
index 0000000..b89f7c8
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/EventShortDto.java
@@ -0,0 +1,27 @@
+package ru.practicum.explorewithme.main.dto;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.*;
+import lombok.experimental.FieldDefaults;
+
+import java.time.LocalDateTime;
+
+import static ru.practicum.explorewithme.common.constants.DateTimeConstants.DATE_TIME_FORMAT_PATTERN;
+
+@Data
+@AllArgsConstructor
+@NoArgsConstructor
+@Builder
+@FieldDefaults(level = AccessLevel.PRIVATE)
+public class EventShortDto {
+ Long id;
+ String annotation;
+ CategoryDto category;
+ Long confirmedRequests;
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = DATE_TIME_FORMAT_PATTERN)
+ LocalDateTime eventDate;
+ UserShortDto initiator;
+ Boolean paid;
+ String title;
+ Long views;
+}
\ No newline at end of file
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/dto/NewCategoryDto.java b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/NewCategoryDto.java
new file mode 100644
index 0000000..f7e159a
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/NewCategoryDto.java
@@ -0,0 +1,19 @@
+package ru.practicum.explorewithme.main.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+import lombok.*;
+import lombok.experimental.FieldDefaults;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@FieldDefaults(level = AccessLevel.PRIVATE)
+public class NewCategoryDto {
+
+ @NotBlank(message = "Название категории не может быть пустым")
+ @Size(min = 1, max = 50, message = "Название категории должно быть от 1 до 50 символов")
+ String name;
+
+}
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/dto/NewCommentDto.java b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/NewCommentDto.java
new file mode 100644
index 0000000..02f216e
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/NewCommentDto.java
@@ -0,0 +1,22 @@
+package ru.practicum.explorewithme.main.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.experimental.FieldDefaults;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@FieldDefaults(level = AccessLevel.PRIVATE)
+public class NewCommentDto {
+
+ @NotBlank(message = "Comment text cannot be blank.")
+ @Size(min = 1, max = 2000, message = "Comment text must be between 1 and 2000 characters.")
+ String text;
+}
\ No newline at end of file
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/dto/NewCompilationDto.java b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/NewCompilationDto.java
new file mode 100644
index 0000000..5be6917
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/NewCompilationDto.java
@@ -0,0 +1,22 @@
+package ru.practicum.explorewithme.main.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+import lombok.*;
+import lombok.experimental.FieldDefaults;
+
+import java.util.List;
+
+@Builder
+@Data
+@AllArgsConstructor
+@NoArgsConstructor
+@FieldDefaults(level = AccessLevel.PRIVATE)
+public class NewCompilationDto {
+ @Builder.Default
+ Boolean pinned = false;
+ @NotBlank(message = "Название подборки не может быть пустым")
+ @Size(max = 50, message = "Название подборки должно быть до 50 символов")
+ String title;
+ List events;
+}
\ No newline at end of file
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/dto/NewEventDto.java b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/NewEventDto.java
new file mode 100644
index 0000000..08f70d2
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/NewEventDto.java
@@ -0,0 +1,61 @@
+package ru.practicum.explorewithme.main.dto;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import jakarta.validation.constraints.Future;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.PositiveOrZero;
+import jakarta.validation.constraints.Size;
+import lombok.*;
+import lombok.experimental.FieldDefaults;
+import ru.practicum.explorewithme.main.model.Location;
+
+import java.time.LocalDateTime;
+
+import static ru.practicum.explorewithme.common.constants.DateTimeConstants.DATE_TIME_FORMAT_PATTERN;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@JsonIgnoreProperties(ignoreUnknown = true)
+@FieldDefaults(level = AccessLevel.PRIVATE)
+public class NewEventDto {
+
+ @NotBlank(message = "Поле annotation не может быть пустым")
+ @Size(min = 20, max = 2000, message = "Поле annotation должно быть от 20 до 2000 символов")
+ String annotation;
+
+ @NotNull(message = "Поле category не может быть пустым")
+ Long category;
+
+ @NotBlank(message = "Поле description не может быть пустым")
+ @Size(min = 20, max = 7000, message = "Поле description должно быть от 20 до 7000 символов")
+ String description;
+
+ @NotNull(message = "Поле eventDate не может быть пустым")
+ @Future(message = "Поле eventDate должно быть в будущем")
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = DATE_TIME_FORMAT_PATTERN)
+ LocalDateTime eventDate;
+
+ @NotNull(message = "Поле location не может быть пустым")
+ Location location;
+
+ @Builder.Default
+ Boolean paid = false;
+
+ @Builder.Default
+ @PositiveOrZero(message = "Participant limit must be positive or zero")
+ Long participantLimit = 0L;
+
+ @Builder.Default
+ Boolean requestModeration = true;
+
+ @NotBlank(message = "Поле title не может быть пустым")
+ @Size(min = 3, max = 120, message = "Поле title должно быть от 3 до 120 символов")
+ String title;
+
+ @Builder.Default
+ Boolean commentsEnabled = true;
+}
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/dto/NewUserRequestDto.java b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/NewUserRequestDto.java
new file mode 100644
index 0000000..48615f2
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/NewUserRequestDto.java
@@ -0,0 +1,24 @@
+package ru.practicum.explorewithme.main.dto;
+
+import jakarta.validation.constraints.Size;
+import lombok.*;
+
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.NotBlank;
+import lombok.experimental.FieldDefaults;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@FieldDefaults(level = AccessLevel.PRIVATE)
+public class NewUserRequestDto {
+ @NotBlank(message = "Имя не может быть пустым")
+ @Size(min = 2, max = 250, message = "Имя должно быть от 2 до 250 символов")
+ String name;
+
+ @NotBlank(message = "Email не может быть пустым")
+ @Size(min = 6, max = 254, message = "Email должен быть от 6 до 254 символов")
+ @Email(message = "Некорректный формат email")
+ String email;
+}
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/dto/ParticipationRequestDto.java b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/ParticipationRequestDto.java
new file mode 100644
index 0000000..70258a9
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/ParticipationRequestDto.java
@@ -0,0 +1,33 @@
+package ru.practicum.explorewithme.main.dto;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.*;
+import lombok.experimental.FieldDefaults;
+import ru.practicum.explorewithme.main.model.RequestStatus;
+
+import java.time.LocalDateTime;
+
+import static ru.practicum.explorewithme.common.constants.DateTimeConstants.DATE_TIME_FORMAT_PATTERN;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@FieldDefaults(level = AccessLevel.PRIVATE)
+public class ParticipationRequestDto {
+
+ Long id;
+
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = DATE_TIME_FORMAT_PATTERN)
+ LocalDateTime created;
+
+ @JsonProperty("requester")
+ Long requesterId;
+
+ @JsonProperty("event")
+ Long eventId;
+
+ RequestStatus status;
+
+}
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/dto/UpdateCommentDto.java b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/UpdateCommentDto.java
new file mode 100644
index 0000000..28606b8
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/UpdateCommentDto.java
@@ -0,0 +1,22 @@
+package ru.practicum.explorewithme.main.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.experimental.FieldDefaults;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@FieldDefaults(level = AccessLevel.PRIVATE)
+public class UpdateCommentDto {
+
+ @NotBlank(message = "Comment text cannot be blank.")
+ @Size(min = 1, max = 2000, message = "Comment text must be between 1 and 2000 characters.")
+ String text;
+}
\ No newline at end of file
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/dto/UpdateCompilationRequestDto.java b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/UpdateCompilationRequestDto.java
new file mode 100644
index 0000000..fa84b68
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/UpdateCompilationRequestDto.java
@@ -0,0 +1,18 @@
+package ru.practicum.explorewithme.main.dto;
+
+import jakarta.validation.constraints.Size;
+import lombok.*;
+import lombok.experimental.FieldDefaults;
+
+import java.util.List;
+
+@Data
+@AllArgsConstructor
+@NoArgsConstructor
+@FieldDefaults(level = AccessLevel.PRIVATE)
+public class UpdateCompilationRequestDto {
+ Boolean pinned;
+ @Size(min = 1, max = 50, message = "Название подборки должно быть от 1 до 50 символов")
+ String title;
+ List events;
+}
\ No newline at end of file
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/dto/UpdateEventAdminRequestDto.java b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/UpdateEventAdminRequestDto.java
new file mode 100644
index 0000000..9ea4f6b
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/UpdateEventAdminRequestDto.java
@@ -0,0 +1,52 @@
+package ru.practicum.explorewithme.main.dto;
+
+import static ru.practicum.explorewithme.common.constants.DateTimeConstants.DATE_TIME_FORMAT_PATTERN;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import jakarta.validation.constraints.Future;
+import jakarta.validation.constraints.PositiveOrZero;
+import jakarta.validation.constraints.Size;
+import lombok.*;
+import lombok.experimental.FieldDefaults;
+import ru.practicum.explorewithme.main.model.Location;
+
+import java.time.LocalDateTime;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@FieldDefaults(level = AccessLevel.PRIVATE)
+public class UpdateEventAdminRequestDto {
+
+ @Size(min = 20, max = 2000, message = "Annotation length must be between 20 and 2000 characters")
+ String annotation;
+
+ Long category;
+
+ @Size(min = 20, max = 7000, message = "Description length must be between 20 and 7000 characters")
+ String description;
+
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = DATE_TIME_FORMAT_PATTERN)
+ @Future(message = "Event date must be in the future")
+ LocalDateTime eventDate;
+
+ Location location;
+
+ Boolean paid;
+
+ @PositiveOrZero(message = "Participant limit must be positive or zero")
+ Integer participantLimit;
+
+ Boolean requestModeration;
+
+ StateActionAdmin stateAction;
+
+ @Size(min = 3, max = 120, message = "Title length must be between 3 and 120 characters")
+ String title;
+
+ public enum StateActionAdmin {
+ PUBLISH_EVENT,
+ REJECT_EVENT
+ }
+}
\ No newline at end of file
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/dto/UpdateEventUserRequestDto.java b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/UpdateEventUserRequestDto.java
new file mode 100644
index 0000000..e339f0a
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/UpdateEventUserRequestDto.java
@@ -0,0 +1,52 @@
+package ru.practicum.explorewithme.main.dto;
+
+import static ru.practicum.explorewithme.common.constants.DateTimeConstants.DATE_TIME_FORMAT_PATTERN;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import jakarta.validation.constraints.Future;
+import jakarta.validation.constraints.PositiveOrZero;
+import jakarta.validation.constraints.Size;
+import lombok.*;
+import lombok.experimental.FieldDefaults;
+import ru.practicum.explorewithme.main.model.Location;
+
+import java.time.LocalDateTime;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@FieldDefaults(level = AccessLevel.PRIVATE)
+public class UpdateEventUserRequestDto {
+
+ @Size(min = 20, max = 2000, message = "Annotation length must be between 20 and 2000 characters")
+ String annotation;
+
+ Long category;
+
+ @Size(min = 20, max = 7000, message = "Description length must be between 20 and 7000 characters")
+ String description;
+
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = DATE_TIME_FORMAT_PATTERN)
+ @Future(message = "Event date must be in the future")
+ LocalDateTime eventDate;
+
+ Location location;
+
+ Boolean paid;
+
+ @PositiveOrZero(message = "Participant limit must be positive or zero")
+ Integer participantLimit;
+
+ Boolean requestModeration;
+
+ StateActionUser stateAction;
+
+ @Size(min = 3, max = 120, message = "Title length must be between 3 and 120 characters")
+ String title;
+
+ public enum StateActionUser {
+ SEND_TO_REVIEW,
+ CANCEL_REVIEW
+ }
+}
\ No newline at end of file
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/dto/UserDto.java b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/UserDto.java
new file mode 100644
index 0000000..30d7156
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/UserDto.java
@@ -0,0 +1,15 @@
+package ru.practicum.explorewithme.main.dto;
+
+import lombok.*;
+import lombok.experimental.FieldDefaults;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@FieldDefaults(level = AccessLevel.PRIVATE)
+public class UserDto {
+ Long id;
+ String name;
+ String email;
+}
\ No newline at end of file
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/dto/UserShortDto.java b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/UserShortDto.java
new file mode 100644
index 0000000..6c025ef
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/UserShortDto.java
@@ -0,0 +1,18 @@
+package ru.practicum.explorewithme.main.dto;
+
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.experimental.FieldDefaults;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@FieldDefaults(level = AccessLevel.PRIVATE)
+public class UserShortDto {
+ Long id;
+ String name;
+}
\ No newline at end of file
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/error/BusinessRuleViolationException.java b/main-service/src/main/java/ru/practicum/explorewithme/main/error/BusinessRuleViolationException.java
new file mode 100644
index 0000000..a5532f3
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/error/BusinessRuleViolationException.java
@@ -0,0 +1,8 @@
+package ru.practicum.explorewithme.main.error;
+
+public class BusinessRuleViolationException extends RuntimeException {
+
+ public BusinessRuleViolationException(String message) {
+ super(message);
+ }
+}
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/error/EntityAlreadyExistsException.java b/main-service/src/main/java/ru/practicum/explorewithme/main/error/EntityAlreadyExistsException.java
new file mode 100644
index 0000000..4754f24
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/error/EntityAlreadyExistsException.java
@@ -0,0 +1,12 @@
+package ru.practicum.explorewithme.main.error;
+
+public class EntityAlreadyExistsException extends RuntimeException {
+
+ public EntityAlreadyExistsException(String message) {
+ super(message);
+ }
+
+ public EntityAlreadyExistsException(String entityName, String fieldName, String value) {
+ super(String.format("%s with %s = '%s' already exists", entityName, fieldName, value));
+ }
+}
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/error/EntityDeletedException.java b/main-service/src/main/java/ru/practicum/explorewithme/main/error/EntityDeletedException.java
new file mode 100644
index 0000000..6e5e422
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/error/EntityDeletedException.java
@@ -0,0 +1,12 @@
+package ru.practicum.explorewithme.main.error;
+
+public class EntityDeletedException extends RuntimeException {
+
+ public EntityDeletedException(String message) {
+ super(message);
+ }
+
+ public EntityDeletedException(String entityName, String fieldName, Object value) {
+ super(String.format("Entity restriction of removal %s with %s = '%s' - not empty", entityName, fieldName, value));
+ }
+}
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/error/EntityNotFoundException.java b/main-service/src/main/java/ru/practicum/explorewithme/main/error/EntityNotFoundException.java
new file mode 100644
index 0000000..5a6bc58
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/error/EntityNotFoundException.java
@@ -0,0 +1,12 @@
+package ru.practicum.explorewithme.main.error;
+
+public class EntityNotFoundException extends RuntimeException {
+
+ public EntityNotFoundException(String message) {
+ super(message);
+ }
+
+ public EntityNotFoundException(String entityName, String fieldName, Object value) {
+ super(String.format("%s with %s = '%s' not found", entityName, fieldName, value));
+ }
+}
\ No newline at end of file
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/error/GlobalExceptionHandler.java b/main-service/src/main/java/ru/practicum/explorewithme/main/error/GlobalExceptionHandler.java
new file mode 100644
index 0000000..6d262ab
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/error/GlobalExceptionHandler.java
@@ -0,0 +1,211 @@
+package ru.practicum.explorewithme.main.error;
+
+import jakarta.validation.ConstraintViolation;
+import jakarta.validation.ConstraintViolationException;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.stream.Collectors;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.dao.DataIntegrityViolationException;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.converter.HttpMessageNotReadableException;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.MissingServletRequestParameterException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
+import ru.practicum.explorewithme.common.error.ApiError;
+
+@RestControllerAdvice
+@Slf4j
+@SuppressWarnings("unused")
+public class GlobalExceptionHandler {
+
+ @ExceptionHandler(MethodArgumentNotValidException.class)
+ @ResponseStatus(HttpStatus.BAD_REQUEST)
+ public ApiError handleMethodArgumentNotValidException(final MethodArgumentNotValidException e) {
+ List errors = e.getBindingResult()
+ .getFieldErrors()
+ .stream()
+ .map(error -> error.getField() + ": " + error.getDefaultMessage())
+ .collect(Collectors.toList());
+ String errorMessage = "Validation error(s): " + String.join("; ", errors);
+ log.warn(errorMessage, e);
+ return ApiError.builder()
+ .errors(errors)
+ .status(HttpStatus.BAD_REQUEST)
+ .reason("Incorrectly made request due to validation errors.")
+ .message(errorMessage)
+ .timestamp(LocalDateTime.now())
+ .build();
+ }
+
+ @ExceptionHandler(MissingServletRequestParameterException.class)
+ @ResponseStatus(HttpStatus.BAD_REQUEST)
+ public ApiError handleMissingServletRequestParameter(final MissingServletRequestParameterException e) {
+ String errorMessage = "Required request parameter is not present: " + e.getParameterName();
+ log.warn(errorMessage, e);
+ return ApiError.builder()
+ .status(HttpStatus.BAD_REQUEST)
+ .reason("Incorrectly made request.")
+ .message(errorMessage)
+ .timestamp(LocalDateTime.now())
+ .build();
+ }
+
+ @ExceptionHandler(IllegalArgumentException.class)
+ @ResponseStatus(HttpStatus.BAD_REQUEST)
+ public ApiError handleIllegalArgumentException(final IllegalArgumentException e) {
+ log.warn("Illegal argument: {}", e.getMessage(), e);
+ return ApiError.builder()
+ .status(HttpStatus.BAD_REQUEST)
+ .reason("Incorrectly made request due to an invalid argument.")
+ .message(e.getMessage())
+ .timestamp(LocalDateTime.now())
+ .build();
+ }
+
+ @ExceptionHandler(DataIntegrityViolationException.class)
+ @ResponseStatus(HttpStatus.CONFLICT)
+ public ApiError handleDataIntegrityViolationException(final DataIntegrityViolationException e) {
+ log.warn("Database integrity violation: {}", e.getMessage(), e);
+ return ApiError.builder()
+ .status(HttpStatus.CONFLICT)
+ .reason("Integrity constraint has been violated.")
+ .message("A database integrity constraint was violated: " + e.getMostSpecificCause().getMessage())
+ .timestamp(LocalDateTime.now())
+ .build();
+ }
+
+ @ExceptionHandler(HttpMessageNotReadableException.class)
+ @ResponseStatus(HttpStatus.BAD_REQUEST)
+ public ApiError handleHttpMessageNotReadableException(final HttpMessageNotReadableException e) {
+ log.warn("Malformed request body: {}", e.getMessage());
+ return ApiError.builder()
+ .status(HttpStatus.BAD_REQUEST)
+ .reason("Malformed JSON request.")
+ .message("The request body is malformed or unreadable: " + e.getMessage())
+ .timestamp(LocalDateTime.now())
+ .build();
+ }
+
+ @ExceptionHandler(ConstraintViolationException.class)
+ @ResponseStatus(HttpStatus.BAD_REQUEST)
+ public ApiError handleConstraintViolationException(final ConstraintViolationException e) {
+ List errors = e.getConstraintViolations()
+ .stream()
+ .map(violation -> String.format("Parameter '%s': value '%s' %s",
+ extractParameterName(violation),
+ violation.getInvalidValue(),
+ violation.getMessage()))
+ .collect(Collectors.toList());
+
+ String errorMessage = "Validation constraint(s) violated: " + String.join("; ", errors);
+ log.warn(errorMessage, e);
+
+ return ApiError.builder()
+ .errors(errors)
+ .status(HttpStatus.BAD_REQUEST)
+ .reason("One or more validation constraints were violated.")
+ .message(errorMessage)
+ .timestamp(LocalDateTime.now())
+ .build();
+ }
+
+ @ExceptionHandler(MethodArgumentTypeMismatchException.class)
+ @ResponseStatus(HttpStatus.BAD_REQUEST)
+ public ApiError handleMethodArgumentTypeMismatchException(final MethodArgumentTypeMismatchException e) {
+ String parameterName = e.getName();
+ Object invalidValue = e.getValue();
+ Class> requiredType = e.getRequiredType(); // Ожидаемый тип
+
+ String message;
+ if (requiredType != null) {
+ message = String.format("Parameter '%s' should be of type '%s' but was '%s'.",
+ parameterName, requiredType.getSimpleName(), invalidValue);
+ } else {
+ message = String.format("Parameter '%s' has an invalid value '%s'.",
+ parameterName, invalidValue);
+ }
+
+ log.warn("Type mismatch for parameter '{}': required type '{}', value '{}'. Full exception: {}",
+ parameterName, requiredType != null ? requiredType.getName() : "unknown", invalidValue, e.getMessage());
+
+ return ApiError.builder()
+ .status(HttpStatus.BAD_REQUEST)
+ .reason("Incorrectly made request due to a type mismatch for a request parameter.")
+ .message(message)
+ .timestamp(LocalDateTime.now())
+ .build();
+ }
+
+ @ExceptionHandler(EntityAlreadyExistsException.class)
+ @ResponseStatus(HttpStatus.CONFLICT)
+ public ApiError handleEntityAlreadyExistsException(EntityAlreadyExistsException e) {
+ log.warn("Entity already exist: {}", e.getMessage());
+ return ApiError.builder()
+ .status(HttpStatus.CONFLICT)
+ .reason("Requested object already exists")
+ .message(e.getMessage())
+ .timestamp(LocalDateTime.now())
+ .build();
+ }
+
+ @ExceptionHandler(EntityNotFoundException.class)
+ @ResponseStatus(HttpStatus.NOT_FOUND)
+ public ApiError handleEntityNotFoundException(EntityNotFoundException e) {
+ log.warn("Entity not found: {}", e.getMessage());
+ return ApiError.builder()
+ .status(HttpStatus.NOT_FOUND)
+ .reason("Requested object not found")
+ .message(e.getMessage())
+ .timestamp(LocalDateTime.now())
+ .build();
+ }
+
+ @ExceptionHandler(BusinessRuleViolationException.class)
+ @ResponseStatus(HttpStatus.CONFLICT)
+ public ApiError handleBusinessRuleViolationException(BusinessRuleViolationException e) {
+ log.warn("Business rule violation: {}", e.getMessage());
+ return ApiError.builder()
+ .status(HttpStatus.CONFLICT)
+ .reason("Conditions not met for requested operation")
+ .message(e.getMessage())
+ .timestamp(LocalDateTime.now())
+ .build();
+ }
+
+ @ExceptionHandler(EntityDeletedException.class)
+ @ResponseStatus(HttpStatus.CONFLICT)
+ public ApiError handleEntityDeletedException(EntityDeletedException e) {
+ log.warn("Entity restriction of removal - not empty");
+ return ApiError.builder()
+ .status(HttpStatus.NOT_FOUND)
+ .reason("Restriction of removal")
+ .message(e.getMessage())
+ .timestamp(LocalDateTime.now())
+ .build();
+ }
+
+ @ExceptionHandler(Throwable.class)
+ @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
+ public ApiError handleThrowable(final Throwable e) {
+ log.error("An unexpected error occurred: {}", e.getMessage(), e);
+ return ApiError.builder()
+ .status(HttpStatus.INTERNAL_SERVER_ERROR)
+ .reason("An unexpected error occurred on the server.")
+ .message("An internal server error has occurred: " + e.getMessage())
+ .timestamp(LocalDateTime.now())
+ .build();
+ }
+
+ private String extractParameterName(ConstraintViolation> violation) {
+ String propertyPath = violation.getPropertyPath().toString();
+ if (propertyPath.contains(".")) {
+ return propertyPath.substring(propertyPath.lastIndexOf('.') + 1);
+ }
+ return propertyPath;
+ }
+}
+
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/mapper/CategoryMapper.java b/main-service/src/main/java/ru/practicum/explorewithme/main/mapper/CategoryMapper.java
new file mode 100644
index 0000000..9fc7cf3
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/mapper/CategoryMapper.java
@@ -0,0 +1,24 @@
+package ru.practicum.explorewithme.main.mapper;
+
+import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
+import ru.practicum.explorewithme.main.dto.CategoryDto;
+import ru.practicum.explorewithme.main.dto.NewCategoryDto;
+import ru.practicum.explorewithme.main.model.Category;
+
+@Mapper(componentModel = "spring")
+public interface CategoryMapper {
+
+ CategoryDto toDto(Category category);
+
+ default Category fromId(Long id) {
+ if (id == null) return null;
+ Category category = new Category();
+ category.setId(id); // если нужен только id
+ return category;
+ }
+
+ @Mapping(target = "id", ignore = true)
+ Category toCategory(NewCategoryDto newCategoryDto);
+
+}
\ No newline at end of file
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/mapper/CommentMapper.java b/main-service/src/main/java/ru/practicum/explorewithme/main/mapper/CommentMapper.java
new file mode 100644
index 0000000..ddc6930
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/mapper/CommentMapper.java
@@ -0,0 +1,59 @@
+package ru.practicum.explorewithme.main.mapper;
+
+import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
+import org.mapstruct.Mappings;
+import ru.practicum.explorewithme.main.dto.CommentAdminDto; // <<< Новый импорт
+import ru.practicum.explorewithme.main.dto.CommentDto;
+import ru.practicum.explorewithme.main.dto.NewCommentDto;
+import ru.practicum.explorewithme.main.model.Comment;
+
+import java.util.List;
+
+@Mapper(componentModel = "spring", uses = {UserMapper.class})
+public interface CommentMapper {
+
+ /**
+ * Маппинг из NewCommentDto в сущность Comment.
+ * Поля author и event должны быть установлены в сервисе отдельно.
+ * Поля id, createdOn, updatedOn, isEdited, isDeleted будут установлены автоматически/в логике.
+ */
+ @Mappings({
+ @Mapping(target = "id", ignore = true),
+ @Mapping(target = "createdOn", ignore = true),
+ @Mapping(target = "updatedOn", ignore = true),
+ @Mapping(target = "author", ignore = true),
+ @Mapping(target = "event", ignore = true),
+ @Mapping(target = "isEdited", ignore = true),
+ @Mapping(target = "isDeleted", ignore = true)
+ })
+ Comment toComment(NewCommentDto newCommentDto);
+
+
+ /**
+ * Маппинг из сущности Comment в CommentDto (для публичного/пользовательского API).
+ * Поле eventId извлекается из comment.getEvent().getId().
+ * Поле isDeleted не включается.
+ */
+ @Mappings({
+ @Mapping(source = "event.id", target = "eventId"),
+ @Mapping(source = "edited", target = "isEdited")
+ })
+ CommentDto toDto(Comment comment);
+
+ List toDtoList(List comments);
+
+ /**
+ * Маппинг из сущности Comment в CommentAdminDto (для административного API).
+ * Включает поле isDeleted.
+ */
+ @Mappings({
+ @Mapping(source = "event.id", target = "eventId"),
+ @Mapping(source = "edited", target = "isEdited"),
+ @Mapping(source = "deleted", target = "isDeleted")
+ })
+ CommentAdminDto toAdminDto(Comment comment);
+
+ List toAdminDtoList(List comments);
+
+}
\ No newline at end of file
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/mapper/CompilationMapper.java b/main-service/src/main/java/ru/practicum/explorewithme/main/mapper/CompilationMapper.java
new file mode 100644
index 0000000..fdb38eb
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/mapper/CompilationMapper.java
@@ -0,0 +1,24 @@
+package ru.practicum.explorewithme.main.mapper;
+
+import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
+import org.mapstruct.MappingTarget;
+import ru.practicum.explorewithme.main.dto.CompilationDto;
+import ru.practicum.explorewithme.main.dto.NewCompilationDto;
+import ru.practicum.explorewithme.main.dto.UpdateCompilationRequestDto;
+import ru.practicum.explorewithme.main.model.Compilation;
+
+@Mapper(componentModel = "spring", uses = {EventMapper.class})
+public interface CompilationMapper {
+
+ @Mapping(target = "events", source = "events")
+ CompilationDto toDto(Compilation compilation);
+
+ @Mapping(target = "id", ignore = true)
+ @Mapping(target = "events", ignore = true)
+ Compilation toCompilation(NewCompilationDto newCompilationDto);
+
+ @Mapping(target = "id", ignore = true)
+ @Mapping(target = "events", ignore = true)
+ void updateCompilationFromDto(UpdateCompilationRequestDto dto, @MappingTarget Compilation compilation);
+}
\ No newline at end of file
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/mapper/EventMapper.java b/main-service/src/main/java/ru/practicum/explorewithme/main/mapper/EventMapper.java
new file mode 100644
index 0000000..72abfc1
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/mapper/EventMapper.java
@@ -0,0 +1,39 @@
+package ru.practicum.explorewithme.main.mapper;
+
+import java.util.List;
+import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
+import org.mapstruct.Mappings;
+import ru.practicum.explorewithme.main.dto.EventFullDto;
+import ru.practicum.explorewithme.main.dto.EventShortDto;
+import ru.practicum.explorewithme.main.dto.NewEventDto;
+import ru.practicum.explorewithme.main.model.Event;
+
+@Mapper(componentModel = "spring", uses = {CategoryMapper.class, UserMapper.class})
+public interface EventMapper {
+
+ @Mappings({
+ @Mapping(source = "confirmedRequestsCount", target = "confirmedRequests"),
+ @Mapping(target = "views", ignore = true)
+ })
+ EventFullDto toEventFullDto(Event event);
+
+ @Mapping(target = "id", ignore = true)
+ @Mapping(target = "publishedOn", ignore = true)
+ @Mapping(target = "compilations", ignore = true)
+ @Mapping(target = "initiator", ignore = true)
+ @Mapping(target = "createdOn", ignore = true)
+ @Mapping(target = "confirmedRequestsCount", ignore = true)
+ @Mapping(target = "state", expression = "java(ru.practicum.explorewithme.main.model.EventState.PENDING)")
+ Event toEvent(NewEventDto newEventDto);
+
+ List toEventFullDtoList(List events);
+
+ @Mappings({
+ @Mapping(source = "confirmedRequestsCount", target = "confirmedRequests"),
+ @Mapping(target = "views", ignore = true)
+ })
+ EventShortDto toEventShortDto(Event event);
+
+ List toEventShortDtoList(List events);
+}
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/mapper/RequestMapper.java b/main-service/src/main/java/ru/practicum/explorewithme/main/mapper/RequestMapper.java
new file mode 100644
index 0000000..52d0050
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/mapper/RequestMapper.java
@@ -0,0 +1,15 @@
+package ru.practicum.explorewithme.main.mapper;
+
+import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
+import ru.practicum.explorewithme.main.dto.ParticipationRequestDto;
+import ru.practicum.explorewithme.main.model.ParticipationRequest;
+
+@Mapper(componentModel = "spring")
+public interface RequestMapper {
+
+ @Mapping(source = "requester.id", target = "requesterId")
+ @Mapping(source = "event.id", target = "eventId")
+ ParticipationRequestDto toRequestDto(ParticipationRequest participationRequest);
+
+}
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/mapper/UserMapper.java b/main-service/src/main/java/ru/practicum/explorewithme/main/mapper/UserMapper.java
new file mode 100644
index 0000000..73cb19a
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/mapper/UserMapper.java
@@ -0,0 +1,22 @@
+package ru.practicum.explorewithme.main.mapper;
+
+import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
+import ru.practicum.explorewithme.main.dto.NewUserRequestDto;
+import ru.practicum.explorewithme.main.dto.UserDto;
+import ru.practicum.explorewithme.main.dto.UserShortDto;
+import ru.practicum.explorewithme.main.model.User;
+
+@Mapper(componentModel = "spring")
+public interface UserMapper {
+
+ UserShortDto toShortDto(User user);
+
+ UserDto toUserDto(User user);
+
+ @Mapping(target = "id", ignore = true)
+ User toUser(NewUserRequestDto newUserDto);
+
+ User toUser(UserDto userDto);
+
+}
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/model/Category.java b/main-service/src/main/java/ru/practicum/explorewithme/main/model/Category.java
new file mode 100644
index 0000000..c12e2a5
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/model/Category.java
@@ -0,0 +1,30 @@
+package ru.practicum.explorewithme.main.model;
+
+import jakarta.persistence.*;
+import lombok.*;
+
+@Entity
+@Table(name = "categories")
+@Getter
+@Setter
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@ToString
+@EqualsAndHashCode(of = {"id", "name"})
+public class Category {
+
+ /**
+ * Уникальный идентификатор категории.
+ */
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ /**
+ * Уникальное наименование категории.
+ */
+ @Column(name = "name", nullable = false, length = 50, unique = true)
+ private String name;
+
+}
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/model/Comment.java b/main-service/src/main/java/ru/practicum/explorewithme/main/model/Comment.java
new file mode 100644
index 0000000..992e617
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/model/Comment.java
@@ -0,0 +1,81 @@
+package ru.practicum.explorewithme.main.model;
+
+import jakarta.persistence.*;
+import lombok.*;
+import org.hibernate.annotations.OnDelete;
+import org.hibernate.annotations.OnDeleteAction;
+import org.springframework.data.annotation.CreatedDate;
+import org.springframework.data.annotation.LastModifiedDate;
+import org.springframework.data.jpa.domain.support.AuditingEntityListener;
+
+import java.time.LocalDateTime;
+
+@Entity
+@Table(name = "comments")
+@Getter
+@Setter
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@ToString(exclude = {"author", "event"})
+@EqualsAndHashCode(of = {"id"})
+@EntityListeners(AuditingEntityListener.class)
+public class Comment {
+
+ /**
+ * Уникальный идентификатор комментария.
+ */
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ /**
+ * Текст комментария.
+ */
+ @Column(name = "text", nullable = false, length = 2000)
+ private String text;
+
+ /**
+ * Дата и время создания комментария. Устанавливается автоматически.
+ */
+ @CreatedDate
+ @Column(name = "created_on", nullable = false, updatable = false)
+ private LocalDateTime createdOn;
+
+ /**
+ * Дата и время последнего обновления комментария. Устанавливается автоматически при изменении.
+ */
+ @LastModifiedDate
+ @Column(name = "updated_on")
+ private LocalDateTime updatedOn;
+
+ /**
+ * Автор комментария.
+ */
+ @ManyToOne(fetch = FetchType.LAZY, optional = false)
+ @JoinColumn(name = "author_id", nullable = false)
+ @OnDelete(action = OnDeleteAction.CASCADE)
+ private User author;
+
+ /**
+ * Событие, к которому относится комментарий.
+ */
+ @ManyToOne(fetch = FetchType.LAZY, optional = false)
+ @JoinColumn(name = "event_id", nullable = false)
+ @OnDelete(action = OnDeleteAction.CASCADE)
+ private Event event;
+
+ /**
+ * Флаг, указывающий, был ли комментарий отредактирован.
+ */
+ @Column(name = "is_edited", nullable = false)
+ @Builder.Default
+ private boolean isEdited = false;
+
+ /**
+ * Флаг, указывающий, был ли комментарий удален.
+ */
+ @Column(name = "is_deleted", nullable = false)
+ @Builder.Default
+ private boolean isDeleted = false;
+}
\ No newline at end of file
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/model/Compilation.java b/main-service/src/main/java/ru/practicum/explorewithme/main/model/Compilation.java
new file mode 100644
index 0000000..cd963ea
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/model/Compilation.java
@@ -0,0 +1,50 @@
+package ru.practicum.explorewithme.main.model;
+
+import jakarta.persistence.*;
+import lombok.*;
+
+import java.util.HashSet;
+import java.util.Set;
+
+@Entity
+@Table(name = "compilations")
+@Getter
+@Setter
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@ToString(exclude = "events")
+@EqualsAndHashCode(of = {"id", "title"})
+public class Compilation {
+
+ /**
+ * Уникальный идентификатор подборки.
+ */
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ /**
+ * Флаг, закреплена ли подборка на главной странице.
+ */
+ @Column(name = "pinned", nullable = false)
+ private boolean pinned;
+
+ /**
+ * Название подборки.
+ */
+ @Column(name = "title", nullable = false, unique = true, length = 128)
+ private String title;
+
+ /**
+ * События, входящие в подборку.
+ */
+ @ManyToMany(fetch = FetchType.LAZY)
+ @Builder.Default
+ @JoinTable(
+ name = "compilation_events",
+ joinColumns = @JoinColumn(name = "compilation_id"),
+ inverseJoinColumns = @JoinColumn(name = "event_id")
+ )
+ private Set events = new HashSet<>();
+}
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/model/Event.java b/main-service/src/main/java/ru/practicum/explorewithme/main/model/Event.java
new file mode 100644
index 0000000..0000d06
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/model/Event.java
@@ -0,0 +1,133 @@
+package ru.practicum.explorewithme.main.model;
+
+import jakarta.persistence.*;
+import lombok.*;
+
+import java.time.LocalDateTime;
+import java.util.HashSet;
+import java.util.Set;
+import org.hibernate.annotations.Formula;
+import org.springframework.data.annotation.CreatedDate;
+import org.springframework.data.jpa.domain.support.AuditingEntityListener;
+
+@Entity
+@Table(name = "events")
+@Getter
+@Setter
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@ToString(exclude = {"category", "initiator", "compilations"})
+@EqualsAndHashCode(of = {"id", "title", "annotation", "eventDate", "publishedOn"})
+@EntityListeners(AuditingEntityListener.class)
+public class Event {
+
+ /**
+ * Уникальный идентификатор события
+ */
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ /**
+ * Краткая аннотация события
+ */
+ @Column(name = "annotation", nullable = false, length = 2000)
+ private String annotation;
+
+ /**
+ * Полное описание события
+ */
+ @Column(name = "description", nullable = false, length = 7000)
+ private String description;
+
+ /**
+ * Дата и время проведения события
+ */
+ @Column(name = "event_date", nullable = false)
+ private LocalDateTime eventDate;
+
+ /**
+ * Дата и время создания события
+ */
+ @CreatedDate
+ @Column(name = "created_on", nullable = false)
+ private LocalDateTime createdOn;
+
+ /**
+ * Дата и время публикации события
+ */
+ @Column(name = "published_on")
+ private LocalDateTime publishedOn;
+
+ /**
+ * Флаг платного участия
+ */
+ @Column(name = "paid", nullable = false)
+ private boolean paid;
+
+ /**
+ * Лимит участников события (0 - без ограничений)
+ */
+ @Column(name = "participant_limit", nullable = false)
+ private int participantLimit;
+
+ /**
+ * Требуется ли модерация заявок на участие
+ */
+ @Column(name = "request_moderation", nullable = false)
+ private boolean requestModeration;
+
+ /**
+ * Заголовок события
+ */
+ @Column(name = "title", nullable = false, length = 120)
+ private String title;
+
+ /**
+ * Категория события
+ */
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "category_id", nullable = false)
+ private Category category;
+
+ /**
+ * Инициатор события
+ */
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "initiator_id", nullable = false)
+ private User initiator;
+
+ /**
+ * Местоположение события
+ */
+ @Embedded
+ private Location location;
+
+ /**
+ * Текущее состояние события
+ */
+ @Enumerated(EnumType.STRING)
+ @Column(name = "state", nullable = false, length = 20)
+ private EventState state;
+
+ /**
+ * Список подборок, в которых присутствует событие (создано для корректной обратной выборки)
+ */
+ @ManyToMany(mappedBy = "events")
+ @Builder.Default
+ private Set compilations = new HashSet<>();
+
+ /**
+ * Количество подтверждённых заявок
+ */
+ @Formula("(SELECT COUNT(r.id) FROM requests r WHERE r.event_id = id AND r.status = 'CONFIRMED')")
+ private long confirmedRequestsCount;
+
+ /**
+ * Разрешены ли комментарии
+ */
+ @Column(name = "comments_enabled", nullable = false)
+ private boolean commentsEnabled;
+
+}
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/model/EventState.java b/main-service/src/main/java/ru/practicum/explorewithme/main/model/EventState.java
new file mode 100644
index 0000000..fe1a3c6
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/model/EventState.java
@@ -0,0 +1,22 @@
+package ru.practicum.explorewithme.main.model;
+
+/**
+ * Состояния жизненного цикла события
+ */
+public enum EventState {
+ /**
+ * Ожидает модерации
+ */
+ PENDING,
+
+ /**
+ * Опубликовано
+ */
+ PUBLISHED,
+
+ /**
+ * Отменено
+ */
+ CANCELED
+}
+
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/model/Location.java b/main-service/src/main/java/ru/practicum/explorewithme/main/model/Location.java
new file mode 100644
index 0000000..2354000
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/model/Location.java
@@ -0,0 +1,29 @@
+package ru.practicum.explorewithme.main.model;
+
+import jakarta.persistence.*;
+import lombok.*;
+
+@Embeddable
+@Getter
+@Setter
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@ToString
+@EqualsAndHashCode
+public class Location {
+
+ /**
+ * Широта географической точки.
+ */
+ @Column(name = "lat", nullable = false)
+ private Float lat;
+
+ /**
+ * Долгота географической точки.
+ */
+ @Column(name = "lon", nullable = false)
+ private Float lon;
+
+}
+
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/model/ParticipationRequest.java b/main-service/src/main/java/ru/practicum/explorewithme/main/model/ParticipationRequest.java
new file mode 100644
index 0000000..2928989
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/model/ParticipationRequest.java
@@ -0,0 +1,56 @@
+package ru.practicum.explorewithme.main.model;
+
+import jakarta.persistence.*;
+import lombok.*;
+
+import java.time.LocalDateTime;
+import org.springframework.data.annotation.CreatedDate;
+import org.springframework.data.jpa.domain.support.AuditingEntityListener;
+
+@Entity
+@Table(name = "requests", uniqueConstraints = {
+ @UniqueConstraint(name = "unique_requester_event", columnNames = {"requester_id", "event_id"})
+})
+@Getter
+@Setter
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@ToString(exclude = {"requester", "event"})
+@EqualsAndHashCode(of = {"id", "created"})
+@EntityListeners(AuditingEntityListener.class)
+public class ParticipationRequest {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ /**
+ * Дата и время создания запроса
+ */
+ @CreatedDate
+ @Column(name = "created", nullable = false)
+ private LocalDateTime created;
+
+ /**
+ * Пользователь, создавший запрос на участие
+ */
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "requester_id", nullable = false)
+ private User requester;
+
+ /**
+ * Событие, на которое пользователь хочет попасть
+ */
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "event_id", nullable = false)
+ private Event event;
+
+ /**
+ * Статус запроса (PENDING, CONFIRMED, REJECTED, CANCELED)
+ */
+ @Enumerated(EnumType.STRING)
+ @Column(name = "status", nullable = false, length = 20)
+ private RequestStatus status;
+}
+
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/model/RequestStatus.java b/main-service/src/main/java/ru/practicum/explorewithme/main/model/RequestStatus.java
new file mode 100644
index 0000000..e8b05b1
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/model/RequestStatus.java
@@ -0,0 +1,27 @@
+package ru.practicum.explorewithme.main.model;
+
+/**
+ * Статусы запросов на участие в событии
+ */
+public enum RequestStatus {
+ /**
+ * Ожидает подтверждения
+ */
+ PENDING,
+
+ /**
+ * Подтвержден
+ */
+ CONFIRMED,
+
+ /**
+ * Отклонен
+ */
+ REJECTED,
+
+ /**
+ * Отменен
+ */
+ CANCELED
+}
+
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/model/User.java b/main-service/src/main/java/ru/practicum/explorewithme/main/model/User.java
new file mode 100644
index 0000000..3165df3
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/model/User.java
@@ -0,0 +1,36 @@
+package ru.practicum.explorewithme.main.model;
+
+import jakarta.persistence.*;
+import lombok.*;
+
+@Entity
+@Table(name = "users")
+@Getter
+@Setter
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@ToString
+@EqualsAndHashCode(of = {"id", "email"})
+public class User {
+
+ /**
+ * Уникальный идентификатор пользователя.
+ */
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ /**
+ * Имя пользователя.
+ */
+ @Column(name = "name", nullable = false, length = 250)
+ private String name;
+
+ /**
+ * Электронная почта пользователя (уникальный идентификатор).
+ */
+ @Column(name = "email", nullable = false, length = 254, unique = true)
+ private String email;
+
+}
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/repository/CategoryRepository.java b/main-service/src/main/java/ru/practicum/explorewithme/main/repository/CategoryRepository.java
new file mode 100644
index 0000000..79ffbc5
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/repository/CategoryRepository.java
@@ -0,0 +1,16 @@
+package ru.practicum.explorewithme.main.repository;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.stereotype.Repository;
+import ru.practicum.explorewithme.main.model.Category;
+
+@Repository
+public interface CategoryRepository extends JpaRepository {
+
+ @Query("SELECT CASE WHEN COUNT(c) > 0 THEN true ELSE false END" +
+ " FROM Category c WHERE LOWER(TRIM(c.name)) = LOWER(TRIM(:name))")
+ boolean existsByNameIgnoreCaseAndTrim(@Param("name") String name);
+
+}
\ No newline at end of file
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/repository/CommentRepository.java b/main-service/src/main/java/ru/practicum/explorewithme/main/repository/CommentRepository.java
new file mode 100644
index 0000000..8d24c12
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/repository/CommentRepository.java
@@ -0,0 +1,26 @@
+package ru.practicum.explorewithme.main.repository;
+
+
+import com.querydsl.core.types.Predicate;
+import org.jetbrains.annotations.NotNull;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.EntityGraph;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.querydsl.QuerydslPredicateExecutor;
+import ru.practicum.explorewithme.main.model.Comment;
+
+public interface CommentRepository extends JpaRepository,
+ QuerydslPredicateExecutor {
+
+ @EntityGraph(attributePaths = {"author"})
+ Page findByEventIdAndIsDeletedFalse(Long eventId, Pageable pageable);
+
+ @EntityGraph(attributePaths = {"author"})
+ @Override
+ @NotNull Page findAll(@NotNull Predicate predicate, @NotNull Pageable pageable);
+
+ @EntityGraph(attributePaths = {"author"})
+ Page findByAuthorIdAndIsDeletedFalse(Long authorId, Pageable pageable);
+
+}
\ No newline at end of file
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/repository/CompilationRepository.java b/main-service/src/main/java/ru/practicum/explorewithme/main/repository/CompilationRepository.java
new file mode 100644
index 0000000..9052f08
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/repository/CompilationRepository.java
@@ -0,0 +1,25 @@
+package ru.practicum.explorewithme.main.repository;
+
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.EntityGraph;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.stereotype.Repository;
+import ru.practicum.explorewithme.main.model.Compilation;
+
+@Repository
+public interface CompilationRepository extends JpaRepository {
+
+ @EntityGraph(attributePaths = {"events", "events.category", "events.initiator"})
+ Page findByPinned(Boolean pinned, Pageable pageable);
+
+ @Override
+ @EntityGraph(attributePaths = {"events", "events.category", "events.initiator"})
+ Page findAll(Pageable pageable);
+
+ @Query("SELECT CASE WHEN COUNT(c) > 0 THEN true ELSE false END " +
+ "FROM Compilation c WHERE LOWER(TRIM(c.title)) = LOWER(TRIM(:title))")
+ boolean existsByTitleIgnoreCaseAndTrim(@Param("title") String title);
+}
\ No newline at end of file
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/repository/EventRepository.java b/main-service/src/main/java/ru/practicum/explorewithme/main/repository/EventRepository.java
new file mode 100644
index 0000000..f8ad046
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/repository/EventRepository.java
@@ -0,0 +1,22 @@
+package ru.practicum.explorewithme.main.repository;
+
+import java.util.Optional;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.querydsl.QuerydslPredicateExecutor;
+import ru.practicum.explorewithme.main.model.Event;
+import ru.practicum.explorewithme.main.model.EventState;
+
+public interface EventRepository extends JpaRepository, QuerydslPredicateExecutor {
+ Page findByInitiatorId(Long userId, Pageable pageable);
+
+ Optional findByIdAndInitiatorId(Long eventId, Long userId);
+
+ boolean existsByCategoryId(Long categoryId);
+
+ boolean existsByIdAndInitiator_Id(Long id, Long initiatorId);
+
+ Optional findByIdAndState(Long eventId, EventState state);
+
+}
\ No newline at end of file
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/repository/RequestRepository.java b/main-service/src/main/java/ru/practicum/explorewithme/main/repository/RequestRepository.java
new file mode 100644
index 0000000..b9beb29
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/repository/RequestRepository.java
@@ -0,0 +1,52 @@
+package ru.practicum.explorewithme.main.repository;
+
+import java.util.Set;
+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 org.springframework.stereotype.Repository;
+import ru.practicum.explorewithme.main.model.ParticipationRequest;
+import ru.practicum.explorewithme.main.model.RequestStatus;
+
+import java.util.List;
+import java.util.Optional;
+
+@Repository
+public interface RequestRepository extends JpaRepository {
+
+ boolean existsByEvent_IdAndRequester_Id(Long requestEventId, Long userId);
+
+ int countByEvent_IdAndStatusEquals(Long eventId, RequestStatus status);
+
+ List findByRequester_Id(Long userId);
+
+ Optional findByIdAndRequester_Id(Long requestId, Long userId);
+
+ List findAllByIdIn(List requestIdsForUpdate);
+
+ int countByIdInAndEvent_Id(List requestIdsForUpdate, Long eventId);
+
+ @Modifying
+ @Query("UPDATE ParticipationRequest r SET r.status = ru.practicum.explorewithme.main.model.RequestStatus.REJECTED " +
+ "WHERE r.event.id = :eventId AND r.status = :status")
+ void updateStatusToRejected(@Param("eventId") Long eventId, @Param("status") RequestStatus status);
+
+ List findByEvent_IdAndStatus(Long eventId, RequestStatus status);
+
+ List findByEvent_Id(Long eventId);
+
+ long countByEventIdAndStatus(Long eventId, RequestStatus status);
+
+ @Query("SELECT r.event.id as eventId, COUNT(r.id) as requestCount " +
+ "FROM ParticipationRequest r " +
+ "WHERE r.event.id IN :eventIds AND r.status = 'CONFIRMED' " +
+ "GROUP BY r.event.id")
+ List countConfirmedRequestsForEventIds(@Param("eventIds") Set eventIds);
+
+ interface ConfirmedRequestCountProjection {
+ Long getEventId();
+
+ Long getRequestCount();
+ }
+}
\ No newline at end of file
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/repository/UserRepository.java b/main-service/src/main/java/ru/practicum/explorewithme/main/repository/UserRepository.java
new file mode 100644
index 0000000..0019f01
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/repository/UserRepository.java
@@ -0,0 +1,18 @@
+package ru.practicum.explorewithme.main.repository;
+
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+import ru.practicum.explorewithme.main.model.User;
+
+import java.util.List;
+
+@Repository
+public interface UserRepository extends JpaRepository {
+
+ boolean existsByEmail(String email);
+
+ Page findAllByIdIn(List ids, Pageable pageable);
+
+}
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/service/CategoryService.java b/main-service/src/main/java/ru/practicum/explorewithme/main/service/CategoryService.java
new file mode 100644
index 0000000..9644a4a
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/service/CategoryService.java
@@ -0,0 +1,19 @@
+package ru.practicum.explorewithme.main.service;
+
+import ru.practicum.explorewithme.main.dto.CategoryDto;
+import ru.practicum.explorewithme.main.dto.NewCategoryDto;
+
+import java.util.List;
+
+public interface CategoryService {
+
+ CategoryDto createCategory(NewCategoryDto newCategoryDto);
+
+ void deleteCategory(Long categoryId);
+
+ CategoryDto updateCategory(Long categoryId, NewCategoryDto categoryDto);
+
+ CategoryDto getCategoryById(Long categoryId);
+
+ List getAllCategories(int from, int size);
+}
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/service/CategoryServiceImpl.java b/main-service/src/main/java/ru/practicum/explorewithme/main/service/CategoryServiceImpl.java
new file mode 100644
index 0000000..9c9a4e1
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/service/CategoryServiceImpl.java
@@ -0,0 +1,91 @@
+package ru.practicum.explorewithme.main.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.explorewithme.main.dto.CategoryDto;
+import ru.practicum.explorewithme.main.dto.NewCategoryDto;
+import ru.practicum.explorewithme.main.error.EntityAlreadyExistsException;
+import ru.practicum.explorewithme.main.error.EntityDeletedException;
+import ru.practicum.explorewithme.main.mapper.CategoryMapper;
+import ru.practicum.explorewithme.main.model.Category;
+import ru.practicum.explorewithme.main.error.EntityNotFoundException;
+import ru.practicum.explorewithme.main.repository.CategoryRepository;
+import ru.practicum.explorewithme.main.repository.EventRepository;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+@Service
+@RequiredArgsConstructor
+public class CategoryServiceImpl implements CategoryService {
+
+ private final CategoryRepository categoryRepository;
+ private final EventRepository eventRepository;
+ private final CategoryMapper categoryMapper;
+
+ @Override
+ @Transactional
+ public CategoryDto createCategory(NewCategoryDto newCategoryDto) {
+ if (!categoryRepository.existsByNameIgnoreCaseAndTrim(newCategoryDto.getName())) {
+ return categoryMapper.toDto(categoryRepository
+ .save(categoryMapper.toCategory(newCategoryDto)));
+ } else {
+ throw new EntityAlreadyExistsException("Category", "name", newCategoryDto.getName());
+ }
+ }
+
+ @Override
+ @Transactional
+ public CategoryDto updateCategory(Long categoryId, NewCategoryDto newCategoryDto) {
+ Category category = categoryRepository.findById(categoryId)
+ .orElseThrow(() -> new EntityNotFoundException("Category", "Id", categoryId));
+
+ if (categoryRepository.existsByNameIgnoreCaseAndTrim(newCategoryDto.getName()) &&
+ !category.getName().equalsIgnoreCase(newCategoryDto.getName())) {
+ throw new EntityAlreadyExistsException("Category", "name", newCategoryDto.getName());
+ }
+
+ if (newCategoryDto.getName() != null && !newCategoryDto.getName().isBlank()) {
+ category.setName(newCategoryDto.getName());
+ }
+
+ return categoryMapper.toDto(categoryRepository.save(category));
+ }
+
+ @Override
+ @Transactional
+ public void deleteCategory(Long categoryId) {
+
+ if (categoryRepository.findById(categoryId).isEmpty()) {
+ throw new EntityNotFoundException("Category", "Id", categoryId);
+ }
+ if (eventRepository.existsByCategoryId(categoryId)) {
+ throw new EntityDeletedException("Category", "name", categoryId);
+ } else {
+ categoryRepository.deleteById(categoryId);
+ }
+
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public List getAllCategories(int from, int size) {
+ Pageable pageable = PageRequest.of(from / size, size);
+ return categoryRepository.findAll(pageable).stream()
+ .map(categoryMapper::toDto)
+ .sorted((c1, c2) -> c1.getName().compareToIgnoreCase(c2.getName()))
+ .collect(Collectors.toList());
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public CategoryDto getCategoryById(Long categoryId) {
+ Category category = categoryRepository.findById(categoryId)
+ .orElseThrow(() -> new EntityNotFoundException("Category", "Id", categoryId));
+ return categoryMapper.toDto(category);
+ }
+
+}
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/service/CommentService.java b/main-service/src/main/java/ru/practicum/explorewithme/main/service/CommentService.java
new file mode 100644
index 0000000..fccc369
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/service/CommentService.java
@@ -0,0 +1,29 @@
+package ru.practicum.explorewithme.main.service;
+
+import ru.practicum.explorewithme.main.dto.CommentAdminDto;
+import ru.practicum.explorewithme.main.dto.CommentDto;
+import ru.practicum.explorewithme.main.dto.NewCommentDto;
+import ru.practicum.explorewithme.main.dto.UpdateCommentDto;
+import ru.practicum.explorewithme.main.service.params.AdminCommentSearchParams;
+import ru.practicum.explorewithme.main.service.params.PublicCommentParameters;
+
+import java.util.List;
+
+public interface CommentService {
+
+ List getCommentsForEvent(Long eventId, PublicCommentParameters publicCommentParameters);
+
+ List getUserComments(Long userId, int from, int size);
+
+ CommentDto addComment(Long userId, Long eventId, NewCommentDto newCommentDto);
+
+ CommentDto updateUserComment(Long userId, Long commentId, UpdateCommentDto updateCommentDto);
+
+ void deleteCommentByAdmin(Long commentId);
+
+ void deleteUserComment(Long userId, Long commentId);
+
+ CommentAdminDto restoreCommentByAdmin(Long commentId);
+
+ List getAllCommentsAdmin(AdminCommentSearchParams searchParams, int from, int size);
+}
\ No newline at end of file
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/service/CommentServiceImpl.java b/main-service/src/main/java/ru/practicum/explorewithme/main/service/CommentServiceImpl.java
new file mode 100644
index 0000000..94165c5
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/service/CommentServiceImpl.java
@@ -0,0 +1,194 @@
+package ru.practicum.explorewithme.main.service;
+
+import com.querydsl.core.BooleanBuilder;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Optional;
+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.data.domain.Sort;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import ru.practicum.explorewithme.main.dto.CommentAdminDto;
+import ru.practicum.explorewithme.main.dto.CommentDto;
+import ru.practicum.explorewithme.main.dto.NewCommentDto;
+import ru.practicum.explorewithme.main.dto.UpdateCommentDto;
+import ru.practicum.explorewithme.main.error.BusinessRuleViolationException;
+import ru.practicum.explorewithme.main.error.EntityNotFoundException;
+import ru.practicum.explorewithme.main.mapper.CommentMapper;
+import ru.practicum.explorewithme.main.model.Comment;
+import ru.practicum.explorewithme.main.model.Event;
+import ru.practicum.explorewithme.main.model.EventState;
+import ru.practicum.explorewithme.main.model.QComment;
+import ru.practicum.explorewithme.main.model.User;
+import ru.practicum.explorewithme.main.repository.CommentRepository;
+import ru.practicum.explorewithme.main.repository.EventRepository;
+import ru.practicum.explorewithme.main.repository.UserRepository;
+import ru.practicum.explorewithme.main.service.params.AdminCommentSearchParams;
+import ru.practicum.explorewithme.main.service.params.PublicCommentParameters;
+
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class CommentServiceImpl implements CommentService {
+
+ private final UserRepository userRepository;
+ private final CommentRepository commentRepository;
+ private final EventRepository eventRepository;
+ private final CommentMapper commentMapper;
+
+ @Override
+ @Transactional(readOnly = true)
+ public List getCommentsForEvent(Long eventId, PublicCommentParameters parameters) {
+ Event event = eventRepository.findByIdAndState(eventId, EventState.PUBLISHED)
+ .orElseThrow(() -> new EntityNotFoundException("Published event", "Id", eventId));
+
+ if (!event.isCommentsEnabled()) {
+ return List.of();
+ }
+
+ Pageable pageable = PageRequest.of(parameters.getFrom() / parameters.getSize(),
+ parameters.getSize(), parameters.getSort());
+
+ List result = commentRepository.findByEventIdAndIsDeletedFalse(eventId, pageable).getContent();
+
+ return commentMapper.toDtoList(result);
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public List getUserComments(Long userId, int from, int size) {
+ if (!userRepository.existsById(userId)) {
+ throw new EntityNotFoundException("Пользователь с id " + userId + " не найден");
+ }
+
+ Sort sort = Sort.by(Sort.Direction.DESC, "createdOn");
+ Pageable pageable = PageRequest.of(from / size, size, sort);
+
+ List result = commentRepository.findByAuthorIdAndIsDeletedFalse(userId, pageable).getContent();
+
+ return commentMapper.toDtoList(result);
+ }
+
+ @Override
+ @Transactional
+ public CommentDto addComment(Long userId, Long eventId, NewCommentDto newCommentDto) {
+ User author = userRepository.findById(userId)
+ .orElseThrow(() -> new EntityNotFoundException("Пользователь с id " + userId + " не найден"));
+
+ Event event = eventRepository.findById(eventId)
+ .orElseThrow(() -> new EntityNotFoundException("Событие с id " + eventId + " не найдено"));
+
+ if (!event.getState().equals(EventState.PUBLISHED)) {
+ throw new BusinessRuleViolationException("Событие еще не опубликовано");
+ }
+
+ if (!event.isCommentsEnabled()) {
+ throw new BusinessRuleViolationException("Комментарии запрещены");
+ }
+
+ Comment comment = commentMapper.toComment(newCommentDto);
+ comment.setAuthor(author);
+ comment.setEvent(event);
+
+ return commentMapper.toDto(commentRepository.save(comment));
+ }
+
+ @Override
+ @Transactional
+ public CommentDto updateUserComment(Long userId, Long commentId, UpdateCommentDto updateCommentDto) {
+ Optional comment = commentRepository.findById(commentId);
+
+ if (comment.isEmpty()) {
+ throw new EntityNotFoundException("Комментарий с id " + commentId + " не найден");
+ }
+
+ Comment existedComment = comment.get();
+
+ if (!existedComment.getAuthor().getId().equals(userId)) {
+ throw new EntityNotFoundException("Искомый комментарий с id " + commentId + " пользователя с id " + userId + " не найден");
+ }
+
+ if (existedComment.isDeleted()) {
+ throw new BusinessRuleViolationException("Редактирование невозможно. Комментарий удален");
+ }
+
+ if (existedComment.getCreatedOn().isBefore(LocalDateTime.now().minusHours(6))) {
+ throw new BusinessRuleViolationException("Время для редактирования истекло");
+ }
+
+ existedComment.setText(updateCommentDto.getText());
+ existedComment.setEdited(true);
+
+ return commentMapper.toDto(commentRepository.saveAndFlush(existedComment));
+ }
+
+ @Override
+ @Transactional
+ public void deleteCommentByAdmin(Long commentId) {
+ Comment comment = commentRepository.findById(commentId)
+ .orElseThrow(() -> new EntityNotFoundException(String.format("Comment with id=%d not found", commentId)));
+ if (!comment.isDeleted()) {
+ comment.setDeleted(true);
+ commentRepository.save(comment);
+ }
+ }
+
+ @Override
+ @Transactional
+ public void deleteUserComment(Long userId, Long commentId) {
+ Comment comment = commentRepository.findById(commentId)
+ .orElseThrow(() -> new EntityNotFoundException(String.format("Comment with id=%d not found", commentId)));
+ if (!comment.getAuthor().getId().equals(userId)) {
+ throw new EntityNotFoundException(String.format("Comment with id=%d not found for user with id=%d", commentId, userId));
+ }
+ if (!comment.isDeleted()) {
+ comment.setDeleted(true);
+ commentRepository.save(comment);
+ }
+ }
+
+ @Override
+ @Transactional
+ public CommentAdminDto restoreCommentByAdmin(Long commentId) {
+ Comment comment = commentRepository.findById(commentId)
+ .orElseThrow(() -> new EntityNotFoundException(String.format("Comment with id=%d not found", commentId)));
+ if (comment.isDeleted()) {
+ comment.setDeleted(false);
+ commentRepository.save(comment);
+ }
+ return commentMapper.toAdminDto(comment);
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public List getAllCommentsAdmin(AdminCommentSearchParams searchParams, int from, int size) {
+ log.debug("Admin: Searching all comments with params: {}, from={}, size={}", searchParams, from, size);
+
+ QComment qComment = QComment.comment;
+ BooleanBuilder predicate = new BooleanBuilder();
+
+ if (searchParams.getUserId() != null) {
+ predicate.and(qComment.author.id.eq(searchParams.getUserId()));
+ }
+
+ if (searchParams.getEventId() != null) {
+ predicate.and(qComment.event.id.eq(searchParams.getEventId()));
+ }
+
+ if (searchParams.getIsDeleted() != null) {
+ predicate.and(qComment.isDeleted.eq(searchParams.getIsDeleted()));
+ }
+
+ Pageable pageable = PageRequest.of(from / size, size, Sort.by(Sort.Direction.DESC, "createdOn"));
+
+ Page commentPage = commentRepository.findAll(predicate, pageable);
+
+ List result = commentMapper.toAdminDtoList(commentPage.getContent());
+ log.debug("Admin: Found {} comments for the given criteria.", result.size());
+ return result;
+ }
+}
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/service/CompilationService.java b/main-service/src/main/java/ru/practicum/explorewithme/main/service/CompilationService.java
new file mode 100644
index 0000000..5fab69d
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/service/CompilationService.java
@@ -0,0 +1,19 @@
+package ru.practicum.explorewithme.main.service;
+
+import ru.practicum.explorewithme.main.dto.CompilationDto;
+import ru.practicum.explorewithme.main.dto.NewCompilationDto;
+import ru.practicum.explorewithme.main.dto.UpdateCompilationRequestDto;
+
+import java.util.List;
+
+public interface CompilationService {
+ List getCompilations(Boolean pinned, Integer from, Integer size);
+
+ CompilationDto getCompilationById(Long compId);
+
+ CompilationDto saveCompilation(NewCompilationDto request);
+
+ CompilationDto updateCompilation(Long compId, UpdateCompilationRequestDto request);
+
+ void deleteCompilation(Long compId);
+}
\ No newline at end of file
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/service/CompilationServiceImpl.java b/main-service/src/main/java/ru/practicum/explorewithme/main/service/CompilationServiceImpl.java
new file mode 100644
index 0000000..834a9a9
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/service/CompilationServiceImpl.java
@@ -0,0 +1,155 @@
+package ru.practicum.explorewithme.main.service;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import ru.practicum.explorewithme.main.dto.CompilationDto;
+import ru.practicum.explorewithme.main.dto.NewCompilationDto;
+import ru.practicum.explorewithme.main.dto.UpdateCompilationRequestDto;
+import ru.practicum.explorewithme.main.dto.EventShortDto;
+import ru.practicum.explorewithme.main.error.EntityAlreadyExistsException;
+import ru.practicum.explorewithme.main.error.EntityNotFoundException;
+import ru.practicum.explorewithme.main.mapper.CompilationMapper;
+import ru.practicum.explorewithme.main.model.Compilation;
+import ru.practicum.explorewithme.main.model.Event;
+import ru.practicum.explorewithme.main.repository.CompilationRepository;
+import ru.practicum.explorewithme.main.repository.EventRepository;
+
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+@Service
+@RequiredArgsConstructor
+@Slf4j
+@Transactional
+public class CompilationServiceImpl implements CompilationService {
+
+ private final CompilationRepository compilationRepository;
+ private final EventRepository eventRepository;
+ private final CompilationMapper compilationMapper;
+
+ @Transactional(readOnly = true)
+ public List getCompilations(Boolean pinned, Pageable pageable) {
+ log.debug("Fetching compilations with pinned={} and pageable={}", pinned, pageable);
+ List compilations = (pinned != null)
+ ? compilationRepository.findByPinned(pinned, pageable).getContent()
+ : compilationRepository.findAll(pageable).getContent();
+ List result = compilations.stream()
+ .map(compilationMapper::toDto)
+ .map(this::addConfirmedRequestsAndViews)
+ .collect(Collectors.toList());
+ log.debug("Found {} compilations", result.size());
+ return result;
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public List getCompilations(Boolean pinned, Integer from, Integer size) {
+ log.debug("Fetching compilations with pinned={}, from={}, size={}", pinned, from, size);
+ Pageable pageable = PageRequest.of(from / size, size);
+ List compilations = (pinned != null)
+ ? compilationRepository.findByPinned(pinned, pageable).getContent()
+ : compilationRepository.findAll(pageable).getContent();
+ List result = compilations.stream()
+ .map(compilationMapper::toDto)
+ .map(this::addConfirmedRequestsAndViews)
+ .collect(Collectors.toList());
+ log.debug("Found {} compilations", result.size());
+ return result;
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public CompilationDto getCompilationById(Long compId) {
+ log.debug("Fetching compilation with id={}", compId);
+ Compilation compilation = compilationRepository.findById(compId)
+ .orElseThrow(() -> new EntityNotFoundException("Compilation", "Id", compId));
+ CompilationDto result = compilationMapper.toDto(compilation);
+ log.debug("Found compilation: {}", result);
+ return addConfirmedRequestsAndViews(result);
+ }
+
+ @Override
+ public CompilationDto saveCompilation(NewCompilationDto request) {
+ log.info("Creating new compilation: {}", request);
+ if (compilationRepository.existsByTitleIgnoreCaseAndTrim(request.getTitle())) {
+ throw new EntityAlreadyExistsException("Compilation", "title", request.getTitle());
+ }
+
+ Compilation compilation = compilationMapper.toCompilation(request);
+ Set events = (request.getEvents() != null && !request.getEvents().isEmpty())
+ ? loadEvents(request.getEvents())
+ : new HashSet<>();
+ compilation.setEvents(events);
+
+ Compilation savedCompilation = compilationRepository.save(compilation);
+ CompilationDto result = compilationMapper.toDto(savedCompilation);
+ log.info("Compilation created successfully: {}", result);
+ return addConfirmedRequestsAndViews(result);
+ }
+
+ @Override
+ public CompilationDto updateCompilation(Long compId, UpdateCompilationRequestDto request) {
+ log.info("Updating compilation id={} with data: {}", compId, request);
+ Compilation compilation = compilationRepository.findById(compId)
+ .orElseThrow(() -> new EntityNotFoundException("Compilation", "Id", compId));
+
+ if (request.getTitle() != null && !request.getTitle().isBlank() &&
+ compilationRepository.existsByTitleIgnoreCaseAndTrim(request.getTitle()) &&
+ !compilation.getTitle().equalsIgnoreCase(request.getTitle())) {
+ throw new EntityAlreadyExistsException("Compilation", "title", request.getTitle());
+ }
+
+ if (request.getTitle() != null && !request.getTitle().isBlank()) {
+ compilation.setTitle(request.getTitle());
+ }
+ if (request.getPinned() != null) {
+ compilation.setPinned(request.getPinned());
+ }
+ if (request.getEvents() != null) {
+ compilation.getEvents().clear();
+ Set events = (request.getEvents().isEmpty())
+ ? new HashSet<>()
+ : loadEvents(request.getEvents());
+ compilation.setEvents(events);
+ }
+
+ Compilation updatedCompilation = compilationRepository.save(compilation);
+ CompilationDto result = compilationMapper.toDto(updatedCompilation);
+ log.info("Compilation updated successfully: {}", result);
+ return addConfirmedRequestsAndViews(result);
+ }
+
+ @Override
+ public void deleteCompilation(Long compId) {
+ log.info("Deleting compilation with id={}", compId);
+ if (!compilationRepository.existsById(compId)) {
+ throw new EntityNotFoundException("Compilation", "Id", compId);
+ }
+ compilationRepository.deleteById(compId);
+ log.info("Compilation with id={} deleted successfully", compId);
+ }
+
+ private Set loadEvents(List eventIds) {
+ List events = eventRepository.findAllById(eventIds);
+ if (events.size() != eventIds.size()) {
+ throw new EntityNotFoundException("Some events not found for IDs: " + eventIds);
+ }
+ return new HashSet<>(events);
+ }
+
+ private CompilationDto addConfirmedRequestsAndViews(CompilationDto compilationDto) {
+ if (compilationDto.getEvents() != null) {
+ for (EventShortDto eventDto : compilationDto.getEvents()) {
+ eventDto.setConfirmedRequests(0L);
+ eventDto.setViews(0L);
+ }
+ }
+ return compilationDto;
+ }
+}
\ No newline at end of file
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/service/EventService.java b/main-service/src/main/java/ru/practicum/explorewithme/main/service/EventService.java
new file mode 100644
index 0000000..c786c49
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/service/EventService.java
@@ -0,0 +1,32 @@
+package ru.practicum.explorewithme.main.service;
+
+import java.util.List;
+import ru.practicum.explorewithme.main.dto.EventFullDto;
+import ru.practicum.explorewithme.main.dto.EventShortDto;
+import ru.practicum.explorewithme.main.dto.UpdateEventAdminRequestDto;
+import ru.practicum.explorewithme.main.dto.UpdateEventUserRequestDto;
+import ru.practicum.explorewithme.main.service.params.AdminEventSearchParams;
+import ru.practicum.explorewithme.main.dto.NewEventDto;
+import ru.practicum.explorewithme.main.service.params.PublicEventSearchParams;
+
+public interface EventService {
+ List getEventsAdmin(
+ AdminEventSearchParams params,
+ int from,
+ int size
+ );
+
+ List getEventsByOwner(Long userId, int from, int size);
+
+ EventFullDto getEventPrivate(Long userId, Long eventId);
+
+ EventFullDto addEventPrivate(Long userId, NewEventDto newEventDto);
+
+ EventFullDto updateEventByOwner(Long userId, Long eventId, UpdateEventUserRequestDto requestDto);
+
+ EventFullDto moderateEventByAdmin(Long eventId, UpdateEventAdminRequestDto requestDto);
+
+ List getEventsPublic(PublicEventSearchParams params, int from, int size);
+
+ EventFullDto getEventByIdPublic(Long eventId);
+}
\ No newline at end of file
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/service/EventServiceImpl.java b/main-service/src/main/java/ru/practicum/explorewithme/main/service/EventServiceImpl.java
new file mode 100644
index 0000000..ba546aa
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/service/EventServiceImpl.java
@@ -0,0 +1,482 @@
+package ru.practicum.explorewithme.main.service;
+
+import com.querydsl.core.BooleanBuilder;
+
+import java.time.LocalDateTime;
+import java.time.temporal.ChronoUnit;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+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.data.domain.Sort;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import ru.practicum.explorewithme.main.dto.EventFullDto;
+import ru.practicum.explorewithme.main.dto.EventShortDto;
+import ru.practicum.explorewithme.main.dto.NewEventDto;
+import ru.practicum.explorewithme.main.dto.UpdateEventAdminRequestDto;
+import ru.practicum.explorewithme.main.dto.UpdateEventUserRequestDto;
+import ru.practicum.explorewithme.main.error.BusinessRuleViolationException;
+import ru.practicum.explorewithme.main.error.EntityNotFoundException;
+import ru.practicum.explorewithme.main.mapper.EventMapper;
+import ru.practicum.explorewithme.main.model.Category;
+import ru.practicum.explorewithme.main.model.Event;
+import ru.practicum.explorewithme.main.model.EventState;
+import ru.practicum.explorewithme.main.model.QEvent;
+import ru.practicum.explorewithme.main.model.RequestStatus;
+import ru.practicum.explorewithme.main.model.User;
+import ru.practicum.explorewithme.main.repository.CategoryRepository;
+import ru.practicum.explorewithme.main.repository.EventRepository;
+import ru.practicum.explorewithme.main.repository.RequestRepository;
+import ru.practicum.explorewithme.main.repository.UserRepository;
+import ru.practicum.explorewithme.main.service.params.AdminEventSearchParams;
+import ru.practicum.explorewithme.main.service.params.PublicEventSearchParams;
+import ru.practicum.explorewithme.stats.client.StatsClient;
+import ru.practicum.explorewithme.stats.dto.ViewStatsDto;
+
+@Service
+@RequiredArgsConstructor
+@Transactional
+@Slf4j
+public class EventServiceImpl implements EventService {
+
+ private final EventRepository eventRepository;
+ private final EventMapper eventMapper;
+ private final UserRepository userRepository;
+ private final CategoryRepository categoryRepository;
+ private final RequestRepository requestRepository;
+ private final StatsClient statsClient;
+
+ private static final long MIN_HOURS_BEFORE_PUBLICATION_FOR_ADMIN = 1;
+
+ @Override
+ @Transactional(readOnly = true)
+ public List getEventsPublic(PublicEventSearchParams params, int from, int size) {
+ log.info("Public search for events with params: {}, from={}, size={}", params, from, size);
+
+ String text = params.getText();
+ List categories = params.getCategories();
+ Boolean paid = params.getPaid();
+ LocalDateTime rangeStart = params.getRangeStart();
+ LocalDateTime rangeEnd = params.getRangeEnd();
+ boolean onlyAvailable = params.isOnlyAvailable();
+ String sort = params.getSort();
+
+ if (rangeStart != null && rangeEnd != null && rangeStart.isAfter(rangeEnd)) {
+ throw new IllegalArgumentException("Validation Error: rangeStart cannot be after rangeEnd.");
+ }
+
+ QEvent qEvent = QEvent.event;
+ BooleanBuilder predicate = new BooleanBuilder();
+
+ predicate.and(qEvent.state.eq(EventState.PUBLISHED));
+
+ if (text != null && !text.isBlank()) {
+ String searchText = text.toLowerCase();
+ predicate.and(qEvent.annotation.lower().like("%" + searchText + "%")
+ .or(qEvent.description.lower().like("%" + searchText + "%")));
+ }
+
+ if (categories != null && !categories.isEmpty()) {
+ predicate.and(qEvent.category.id.in(categories));
+ }
+
+ if (paid != null) {
+ predicate.and(qEvent.paid.eq(paid));
+ }
+
+ if (rangeStart == null && rangeEnd == null) {
+ predicate.and(qEvent.eventDate.after(LocalDateTime.now()));
+ } else {
+ if (rangeStart != null) {
+ predicate.and(qEvent.eventDate.goe(rangeStart));
+ }
+ if (rangeEnd != null) {
+ predicate.and(qEvent.eventDate.loe(rangeEnd));
+ }
+ }
+
+ Sort sortOption = Sort.by(Sort.Direction.ASC, "eventDate");
+
+ Pageable pageable = PageRequest.of(from / size, size, sortOption);
+
+ Page eventPage = eventRepository.findAll(predicate, pageable);
+
+ if (eventPage.isEmpty()) {
+ return Collections.emptyList();
+ }
+
+ List foundEvents = eventPage.getContent();
+
+ Map viewsMap = getViewsForEvents(foundEvents);
+
+ Map eventMapById = foundEvents.stream()
+ .collect(Collectors.toMap(Event::getId, e -> e));
+
+ List eventDtos = foundEvents.stream()
+ .map(event -> {
+ EventShortDto dto = eventMapper.toEventShortDto(event);
+ dto.setViews(viewsMap.getOrDefault(event.getId(), 0L));
+ return dto;
+ })
+ .collect(Collectors.toList());
+
+ if (onlyAvailable) {
+ eventDtos = eventDtos.stream()
+ .filter(dto -> {
+ Event event = eventMapById.get(dto.getId());
+ if (event == null) return false;
+ return event.getParticipantLimit() == 0 || dto.getConfirmedRequests() < event.getParticipantLimit();
+ })
+ .collect(Collectors.toList());
+ }
+
+ if (sort != null && sort.equalsIgnoreCase("VIEWS")) {
+ eventDtos.sort(Comparator.comparing(EventShortDto::getViews).reversed());
+ }
+
+ log.info("Public search prepared {} DTOs after enrichment and filtering.", eventDtos.size());
+ return eventDtos;
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public EventFullDto getEventByIdPublic(Long eventId) {
+ log.info("Public: Fetching event id={}", eventId);
+
+ Event event = eventRepository.findByIdAndState(eventId, EventState.PUBLISHED)
+ .orElseThrow(() -> new EntityNotFoundException(
+ String.format("Event with id=%d not found or is not published.", eventId)));
+
+ long views = 0L;
+ try {
+ String eventUri = "/events/" + event.getId();
+ List stats = statsClient.getStats(
+ LocalDateTime.of(1970, 1, 1, 0, 0, 0), // Очень ранняя дата
+ LocalDateTime.now(),
+ List.of(eventUri),
+ true // Уникальные просмотры
+ );
+
+ if (stats != null && !stats.isEmpty()) {
+ Optional eventStat = stats.stream()
+ .filter(s -> eventUri.equals(s.getUri()))
+ .findFirst();
+ if (eventStat.isPresent()) {
+ views = eventStat.get().getHits();
+ }
+ }
+ log.debug("Public: Views for event id={}: {}", eventId, views);
+ } catch (Exception e) {
+ log.error("Public: Failed to retrieve views for event id={}. Error: {}", eventId, e.getMessage());
+ }
+
+ long confirmedRequestsCount = requestRepository.countByEventIdAndStatus(eventId, RequestStatus.CONFIRMED);
+ log.debug("Public: Confirmed requests for event id={}: {}", eventId, confirmedRequestsCount);
+
+ EventFullDto resultDto = eventMapper.toEventFullDto(event);
+ resultDto.setViews(views);
+ resultDto.setConfirmedRequests(confirmedRequestsCount);
+
+ log.info("Public: Found event id={} with title='{}', views={}, confirmedRequests={}",
+ eventId, resultDto.getTitle(), resultDto.getViews(), resultDto.getConfirmedRequests());
+ return resultDto;
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public List getEventsAdmin(AdminEventSearchParams params, int from, int size) {
+ List users = params.getUsers();
+ List states = params.getStates();
+ List categories = params.getCategories();
+ LocalDateTime rangeStart = params.getRangeStart();
+ LocalDateTime rangeEnd = params.getRangeEnd();
+
+ log.debug("Admin search for events with params: users={}, states={}, categories={}, rangeStart={}, rangeEnd={}, from={}, size={}",
+ users, states, categories, rangeStart, rangeEnd, from, size);
+
+ if (rangeStart != null && rangeEnd != null && rangeStart.isAfter(rangeEnd)) {
+ log.warn("Admin search: rangeStart cannot be after rangeEnd. rangeStart={}, rangeEnd={}", rangeStart, rangeEnd);
+ throw new IllegalArgumentException("Admin search: rangeStart cannot be after rangeEnd.");
+ }
+
+ QEvent qEvent = QEvent.event;
+ BooleanBuilder predicate = new BooleanBuilder();
+
+ if (users != null && !users.isEmpty()) {
+ predicate.and(qEvent.initiator.id.in(users));
+ }
+
+ if (states != null && !states.isEmpty()) {
+ predicate.and(qEvent.state.in(states));
+ }
+
+ if (categories != null && !categories.isEmpty()) {
+ predicate.and(qEvent.category.id.in(categories));
+ }
+
+ if (rangeStart != null) {
+ predicate.and(qEvent.eventDate.goe(rangeStart));
+ }
+
+ if (rangeEnd != null) {
+ predicate.and(qEvent.eventDate.loe(rangeEnd));
+ }
+
+ Pageable pageable = PageRequest.of(from / size, size, Sort.by(Sort.Direction.ASC, "id"));
+
+ Page eventPage = eventRepository.findAll(predicate, pageable);
+
+ if (eventPage.isEmpty()) {
+ return Collections.emptyList();
+ }
+
+ List result = eventMapper.toEventFullDtoList(eventPage.getContent());
+
+ Map viewsData = getViewsForEvents(eventPage.getContent());
+ result.forEach(dto -> dto.setViews(viewsData.get(dto.getId())));
+
+ log.debug("Admin search found {} events on page {}/{}", result.size(), pageable.getPageNumber(), eventPage.getTotalPages());
+ return result;
+ }
+
+ @Override
+ public EventFullDto moderateEventByAdmin(Long eventId, UpdateEventAdminRequestDto requestDto) {
+ log.info("Admin: Moderating event id={} with data: {}", eventId, requestDto);
+
+ Event event = eventRepository.findById(eventId)
+ .orElseThrow(() -> new EntityNotFoundException("Event with id=" + eventId + " not found."));
+
+ if (requestDto.getAnnotation() != null) {
+ event.setAnnotation(requestDto.getAnnotation());
+ }
+ if (requestDto.getCategory() != null) {
+ Category category = categoryRepository.findById(requestDto.getCategory())
+ .orElseThrow(() -> new EntityNotFoundException("Category with id=" + requestDto.getCategory() + " not found for event update."));
+ event.setCategory(category);
+ }
+ if (requestDto.getDescription() != null) {
+ event.setDescription(requestDto.getDescription());
+ }
+ if (requestDto.getEventDate() != null) {
+ event.setEventDate(requestDto.getEventDate());
+ }
+ if (requestDto.getLocation() != null) {
+ event.setLocation(requestDto.getLocation());
+ }
+ if (requestDto.getPaid() != null) {
+ event.setPaid(requestDto.getPaid());
+ }
+ if (requestDto.getParticipantLimit() != null) {
+ event.setParticipantLimit(requestDto.getParticipantLimit());
+ }
+ if (requestDto.getRequestModeration() != null) {
+ event.setRequestModeration(requestDto.getRequestModeration());
+ }
+ if (requestDto.getTitle() != null) {
+ event.setTitle(requestDto.getTitle());
+ }
+
+ if (requestDto.getStateAction() != null) {
+ switch (requestDto.getStateAction()) {
+ case PUBLISH_EVENT:
+ if (event.getState() != EventState.PENDING) {
+ throw new BusinessRuleViolationException(
+ "Cannot publish the event because it's not in the PENDING state. Current state: " + event.getState());
+ }
+ if (event.getEventDate().isBefore(LocalDateTime.now().plusHours(MIN_HOURS_BEFORE_PUBLICATION_FOR_ADMIN))) {
+ throw new BusinessRuleViolationException(
+ String.format("Cannot publish the event. Event date must be at least %d hour(s) in the future from the current moment. Event date: %s",
+ MIN_HOURS_BEFORE_PUBLICATION_FOR_ADMIN, event.getEventDate()));
+ }
+ event.setState(EventState.PUBLISHED);
+ event.setPublishedOn(LocalDateTime.now().truncatedTo(ChronoUnit.SECONDS));
+ break;
+ case REJECT_EVENT:
+ if (event.getState() == EventState.PUBLISHED) {
+ throw new BusinessRuleViolationException(
+ "Cannot reject the event because it has already been published. Current state: " + event.getState());
+ }
+ event.setState(EventState.CANCELED);
+ break;
+ default:
+ log.warn("Admin: Unknown state action for event update: {}", requestDto.getStateAction());
+ }
+ }
+
+ Event updatedEvent = eventRepository.save(event);
+ log.info("Admin: Event id={} moderated successfully. New state: {}", eventId, updatedEvent.getState());
+ return eventMapper.toEventFullDto(updatedEvent);
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public List getEventsByOwner(Long userId, int from, int size) {
+ log.debug("Fetching events for owner (user) id: {}, from: {}, size: {}", userId, from, size);
+
+ if (!userRepository.existsById(userId)) {
+ return Collections.emptyList(); // По спецификации API, если по заданным фильтрам не найдено ни одного события, возвращается пустой список
+ }
+
+ Pageable pageable = PageRequest.of(from / size, size, Sort.by(Sort.Direction.DESC, "eventDate"));
+
+ Page eventPage = eventRepository.findByInitiatorId(userId, pageable);
+
+ if (eventPage.isEmpty()) {
+ return Collections.emptyList();
+ }
+
+ List result = eventMapper.toEventShortDtoList(eventPage.getContent());
+ log.debug("Found {} events for owner id: {} on page {}/{}", result.size(), userId, pageable.getPageNumber(), eventPage.getTotalPages());
+ return result;
+ }
+
+ @Override
+ public EventFullDto updateEventByOwner(Long userId, Long eventId, UpdateEventUserRequestDto requestDto) {
+ log.info("User id={}: Updating event id={} with data: {}", userId, eventId, requestDto);
+
+ Event event = eventRepository.findByIdAndInitiatorId(eventId, userId)
+ .orElseThrow(() -> new EntityNotFoundException(
+ String.format("Event with id=%d and initiatorId=%d not found", eventId, userId)));
+
+ if (!(event.getState() == EventState.PENDING || event.getState() == EventState.CANCELED)) {
+ throw new BusinessRuleViolationException("Cannot update event: Only pending or canceled events can be changed. Current state: " + event.getState());
+ }
+
+ if (requestDto.getAnnotation() != null) {
+ event.setAnnotation(requestDto.getAnnotation());
+ }
+ if (requestDto.getCategory() != null) {
+ Category category = categoryRepository.findById(requestDto.getCategory())
+ .orElseThrow(() -> new EntityNotFoundException("Category with id=" + requestDto.getCategory() + " not found."));
+ event.setCategory(category);
+ }
+ if (requestDto.getDescription() != null) {
+ event.setDescription(requestDto.getDescription());
+ }
+ if (requestDto.getEventDate() != null) {
+ if (requestDto.getEventDate().isBefore(LocalDateTime.now().plusHours(2))) {
+ throw new BusinessRuleViolationException("Event date must be at least two hours in the future from the current moment.");
+ }
+ event.setEventDate(requestDto.getEventDate());
+ }
+ if (requestDto.getLocation() != null) {
+ event.setLocation(requestDto.getLocation());
+ }
+ if (requestDto.getPaid() != null) {
+ event.setPaid(requestDto.getPaid());
+ }
+ if (requestDto.getParticipantLimit() != null) {
+ event.setParticipantLimit(requestDto.getParticipantLimit());
+ }
+ if (requestDto.getRequestModeration() != null) {
+ event.setRequestModeration(requestDto.getRequestModeration());
+ }
+ if (requestDto.getTitle() != null) {
+ event.setTitle(requestDto.getTitle());
+ }
+
+ if (requestDto.getStateAction() != null) {
+ switch (requestDto.getStateAction()) {
+ case SEND_TO_REVIEW:
+ event.setState(EventState.PENDING);
+ break;
+ case CANCEL_REVIEW:
+ event.setState(EventState.CANCELED);
+ break;
+ default:
+ log.warn("Unknown state action for user update: {}", requestDto.getStateAction());
+ }
+ }
+
+ Event updatedEvent = eventRepository.save(event);
+ log.info("User id={}: Event id={} updated successfully.", userId, eventId);
+ return eventMapper.toEventFullDto(updatedEvent);
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public EventFullDto getEventPrivate(Long userId, Long eventId) {
+ log.debug("Fetching event id: {} for user id: {}", eventId, userId);
+
+ if (!userRepository.existsById(userId)) {
+ throw new EntityNotFoundException("User with id=" + userId + " not found.");
+ }
+
+ Event event = eventRepository.findByIdAndInitiatorId(eventId, userId)
+ .orElseThrow(() -> new EntityNotFoundException(
+ String.format("Event with id=%d and initiatorId=%d not found", eventId, userId)));
+
+ EventFullDto result = eventMapper.toEventFullDto(event);
+ log.debug("Found event: {}", result);
+ return result;
+ }
+
+ @Override
+ public EventFullDto addEventPrivate(Long userId, NewEventDto newEventDto) {
+ log.info("Добавление события {} пользователем {}", newEventDto, userId);
+
+ User user = userRepository.findById(userId).orElseThrow(() -> new EntityNotFoundException("Пользователь " +
+ "с id = " + userId + " не найден"));
+
+ Long categoryId = newEventDto.getCategory();
+ Category category = categoryRepository.findById(categoryId).orElseThrow(() -> new EntityNotFoundException("Категория " +
+ "с id = " + categoryId + " не найдена"));
+
+ LocalDateTime eventDate = newEventDto.getEventDate();
+ if (eventDate.isBefore(LocalDateTime.now().plusHours(2))) {
+ throw new BusinessRuleViolationException("Дата должна быть не ранее, чем через 2 часа от текущего момента");
+ }
+
+ Event event = eventMapper.toEvent(newEventDto);
+ event.setInitiator(user);
+ return eventMapper.toEventFullDto(eventRepository.save(event));
+ }
+
+ private Map getViewsForEvents(List events) {
+ if (events == null || events.isEmpty()) {
+ return Collections.emptyMap();
+ }
+ List uris = events.stream()
+ .map(event -> "/events/" + event.getId())
+ .distinct()
+ .collect(Collectors.toList());
+
+ LocalDateTime earliestCreation = events.stream()
+ .map(Event::getCreatedOn)
+ .min(LocalDateTime::compareTo)
+ .orElse(LocalDateTime.of(1970, 1, 1, 0, 0));
+
+ Map viewsMap = new HashMap<>();
+ try {
+ List stats = statsClient.getStats(
+ earliestCreation,
+ LocalDateTime.now(),
+ uris,
+ true // Уникальные просмотры
+ );
+ if (stats != null) {
+ for (ViewStatsDto stat : stats) {
+ try {
+ Long eventId = Long.parseLong(stat.getUri().substring("/events/".length()));
+ viewsMap.put(eventId, stat.getHits());
+ } catch (NumberFormatException | IndexOutOfBoundsException e) {
+ log.warn("Could not parse eventId from URI {} from stats service", stat.getUri());
+ }
+ }
+ }
+ } catch (Exception e) {
+ log.error("Failed to retrieve views for multiple events. Error: {}", e.getMessage());
+ }
+ return viewsMap;
+ }
+}
\ No newline at end of file
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/service/RequestService.java b/main-service/src/main/java/ru/practicum/explorewithme/main/service/RequestService.java
new file mode 100644
index 0000000..44aa356
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/service/RequestService.java
@@ -0,0 +1,22 @@
+package ru.practicum.explorewithme.main.service;
+
+import ru.practicum.explorewithme.main.dto.EventRequestStatusUpdateResultDto;
+import jakarta.validation.constraints.Positive;
+import ru.practicum.explorewithme.main.dto.ParticipationRequestDto;
+import ru.practicum.explorewithme.main.service.params.EventRequestStatusUpdateRequestParams;
+
+import java.util.List;
+
+public interface RequestService {
+
+ ParticipationRequestDto createRequest(Long userId,Long requestEventId);
+
+ List getRequests(Long userId);
+
+ ParticipationRequestDto cancelRequest(Long userId, Long requestId);
+
+ List getEventRequests(@Positive Long userId, @Positive Long eventId);
+
+ EventRequestStatusUpdateResultDto updateRequestsStatus(EventRequestStatusUpdateRequestParams requestParams);
+
+}
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/service/RequestServiceImpl.java b/main-service/src/main/java/ru/practicum/explorewithme/main/service/RequestServiceImpl.java
new file mode 100644
index 0000000..f4f9540
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/service/RequestServiceImpl.java
@@ -0,0 +1,174 @@
+package ru.practicum.explorewithme.main.service;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import ru.practicum.explorewithme.main.dto.EventRequestStatusUpdateResultDto;
+import ru.practicum.explorewithme.main.dto.ParticipationRequestDto;
+import ru.practicum.explorewithme.main.error.BusinessRuleViolationException;
+import ru.practicum.explorewithme.main.error.EntityNotFoundException;
+import ru.practicum.explorewithme.main.mapper.RequestMapper;
+import ru.practicum.explorewithme.main.model.*;
+import ru.practicum.explorewithme.main.repository.EventRepository;
+import ru.practicum.explorewithme.main.repository.RequestRepository;
+import ru.practicum.explorewithme.main.repository.UserRepository;
+import ru.practicum.explorewithme.main.service.params.EventRequestStatusUpdateRequestParams;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Comparator;
+import java.util.stream.Collectors;
+
+@Service
+@RequiredArgsConstructor
+public class RequestServiceImpl implements RequestService {
+
+ private final RequestRepository requestRepository;
+ private final RequestMapper requestMapper;
+ private final EventRepository eventRepository;
+ private final UserRepository userRepository;
+
+ @Override
+ @Transactional
+ public ParticipationRequestDto createRequest(Long userId, Long requestEventId) {
+ ParticipationRequest result = checkRequest(userId, requestEventId);
+ requestRepository.save(result);
+ return requestMapper.toRequestDto(result);
+ }
+
+ @Override
+ @Transactional
+ public ParticipationRequestDto cancelRequest(Long userId, Long requestId) {
+ ParticipationRequest result = requestRepository.findByIdAndRequester_Id(requestId, userId)
+ .orElseThrow(() ->
+ new EntityNotFoundException("User with Id = " + userId + " and Request", "Id", userId));
+ result.setStatus(RequestStatus.CANCELED);
+ requestRepository.save(result);
+ return requestMapper.toRequestDto(result);
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public List getRequests(Long userId) {
+ userRepository.findById(userId)
+ .orElseThrow(() -> new EntityNotFoundException("User", "Id", userId));
+ return requestRepository.findByRequester_Id(userId).stream()
+ .sorted(Comparator.comparing(ParticipationRequest::getCreated).reversed())
+ .map(requestMapper::toRequestDto).toList();
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public List getEventRequests(Long userId, Long eventId) {
+ if (!eventRepository.existsByIdAndInitiator_Id(eventId, userId))
+ throw new EntityNotFoundException("Event with Id = " + eventId + " when initiator", "Id", userId);
+ return requestRepository.findByEvent_Id(eventId).stream()
+ .sorted(Comparator.comparing(ParticipationRequest::getCreated).reversed())
+ .map(requestMapper::toRequestDto).toList();
+ }
+
+ @Override
+ @Transactional
+ public EventRequestStatusUpdateResultDto updateRequestsStatus(EventRequestStatusUpdateRequestParams requestParams) {
+ Long userId = requestParams.getUserId();
+ Long eventId = requestParams.getEventId();
+ List requestIdsForUpdate = requestParams.getRequestIds();
+ RequestStatus statusUpdate = requestParams.getStatus();
+ if (!statusUpdate.equals(RequestStatus.REJECTED) && !statusUpdate.equals(RequestStatus.CONFIRMED)) {
+ throw new BusinessRuleViolationException("Only REJECTED and CONFIRMED statuses are allowed");
+ }
+ Event event = eventRepository.findById(eventId)
+ .orElseThrow(() -> new EntityNotFoundException("Event", "Id", eventId));
+ if (!event.getInitiator().getId().equals(userId)) {
+ throw new EntityNotFoundException("Event with Id = " + eventId + " when initiator", "Id", userId);
+ }
+ if (!event.isRequestModeration() || event.getParticipantLimit() == 0) {
+ throw new BusinessRuleViolationException("Event moderation or participant limit is not set");
+ }
+ if (requestRepository.countByIdInAndEvent_Id(requestIdsForUpdate, eventId) != requestIdsForUpdate.size()) {
+ throw new BusinessRuleViolationException("Not all requests are for event with Id = " + eventId);
+ }
+ if (requestRepository
+ .countByEvent_IdAndStatusEquals(eventId, RequestStatus.CONFIRMED) >= event.getParticipantLimit()) {
+ throw new BusinessRuleViolationException("Event participant limit reached");
+ }
+ LinkedHashMap requestsMap = requestRepository.findAllByIdIn(requestIdsForUpdate).stream()
+ .sorted(Comparator.comparing(ParticipationRequest::getCreated))
+ .collect(Collectors.toMap(
+ ParticipationRequest::getId,
+ request -> request,
+ (existing, replacement) -> existing,
+ LinkedHashMap::new
+ ));
+ requestsMap.values().forEach(request -> {
+ if (request.getStatus() != RequestStatus.PENDING) {
+ throw new BusinessRuleViolationException("Cannot update request with status " + request.getStatus() +
+ ". Only requests with PENDING status can be updated.");
+ }
+ });
+ EventRequestStatusUpdateResultDto result = new EventRequestStatusUpdateResultDto();
+ if (statusUpdate == RequestStatus.REJECTED) {
+ requestsMap.values().forEach(request -> {
+ request.setStatus(RequestStatus.REJECTED);
+ result.getRejectedRequests().add(requestMapper.toRequestDto(request));
+ });
+ requestRepository.saveAll(requestsMap.values());
+ return result;
+ }
+
+ final int[] availableRequests = {event.getParticipantLimit() -
+ requestRepository.countByEvent_IdAndStatusEquals(eventId, RequestStatus.CONFIRMED)};
+ requestsMap.values().forEach(request -> {
+ if (availableRequests[0] > 0) {
+ request.setStatus(RequestStatus.CONFIRMED);
+ result.getConfirmedRequests().add(requestMapper.toRequestDto(request));
+ availableRequests[0]--;
+ } else {
+ request.setStatus(RequestStatus.REJECTED);
+ result.getRejectedRequests().add(requestMapper.toRequestDto(request));
+ }
+ });
+ requestRepository.saveAll(requestsMap.values());
+ if (availableRequests[0] == 0) {
+ List pendingRequests = requestRepository.findByEvent_IdAndStatus(eventId, RequestStatus.PENDING);
+ if (!pendingRequests.isEmpty()) {
+ pendingRequests.forEach(request -> request.setStatus(RequestStatus.REJECTED));
+ requestRepository.saveAll(pendingRequests);
+ result.getRejectedRequests().addAll(pendingRequests.stream()
+ .map(requestMapper::toRequestDto).toList());
+ }
+ }
+ return result;
+ }
+
+ private ParticipationRequest checkRequest(Long userId, Long requestEventId) {
+ User user = userRepository.findById(userId)
+ .orElseThrow(() -> new EntityNotFoundException("User", "Id", userId));
+ Event event = eventRepository.findById(requestEventId)
+ .orElseThrow(() -> new EntityNotFoundException("Event", "Id", requestEventId));
+ if (requestRepository.existsByEvent_IdAndRequester_Id(requestEventId, userId)) {
+ throw new BusinessRuleViolationException("User has already requested for this event");
+ }
+ if (event.getInitiator().getId().equals(userId)) {
+ throw new BusinessRuleViolationException("User cannot participate in his own event");
+ }
+ if (event.getState() != EventState.PUBLISHED) {
+ throw new BusinessRuleViolationException("Event must be published");
+ }
+ if (event.getParticipantLimit() > 0 &&
+ requestRepository.countByEvent_IdAndStatusEquals(requestEventId, RequestStatus.CONFIRMED) >=
+ event.getParticipantLimit()) {
+ throw new BusinessRuleViolationException("Event participant limit reached");
+ }
+ ParticipationRequest newRequest = new ParticipationRequest();
+ newRequest.setRequester(user);
+ newRequest.setEvent(event);
+ if (!event.isRequestModeration() || event.getParticipantLimit() == 0) {
+ newRequest.setStatus(RequestStatus.CONFIRMED);
+ } else {
+ newRequest.setStatus(RequestStatus.PENDING);
+ }
+ return newRequest;
+ }
+
+}
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/service/UserService.java b/main-service/src/main/java/ru/practicum/explorewithme/main/service/UserService.java
new file mode 100644
index 0000000..1162fbe
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/service/UserService.java
@@ -0,0 +1,17 @@
+package ru.practicum.explorewithme.main.service;
+
+import ru.practicum.explorewithme.main.dto.NewUserRequestDto;
+import ru.practicum.explorewithme.main.dto.UserDto;
+import ru.practicum.explorewithme.main.service.params.GetListUsersParameters;
+
+import java.util.List;
+
+public interface UserService {
+
+ UserDto createUser(NewUserRequestDto newUserDto);
+
+ void deleteUser(Long userId);
+
+ List getUsers(GetListUsersParameters parameters);
+
+}
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/service/UserServiceImpl.java b/main-service/src/main/java/ru/practicum/explorewithme/main/service/UserServiceImpl.java
new file mode 100644
index 0000000..2ffc259
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/service/UserServiceImpl.java
@@ -0,0 +1,74 @@
+package ru.practicum.explorewithme.main.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.explorewithme.main.dto.NewUserRequestDto;
+import ru.practicum.explorewithme.main.dto.UserDto;
+import ru.practicum.explorewithme.main.error.EntityAlreadyExistsException;
+import ru.practicum.explorewithme.main.error.EntityNotFoundException;
+import ru.practicum.explorewithme.main.mapper.UserMapper;
+import ru.practicum.explorewithme.main.model.User;
+import ru.practicum.explorewithme.main.repository.UserRepository;
+import ru.practicum.explorewithme.main.service.params.GetListUsersParameters;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+@Service
+@RequiredArgsConstructor
+public class UserServiceImpl implements UserService {
+
+ private final UserRepository userRepository;
+ private final UserMapper userMapper;
+
+ @Override
+ @Transactional
+ public UserDto createUser(NewUserRequestDto newUserDto) {
+
+ if (userRepository.existsByEmail(newUserDto.getEmail())) {
+ throw new EntityAlreadyExistsException("User", "email", newUserDto.getEmail());
+ }
+
+ return userMapper.toUserDto(userRepository.save(userMapper.toUser(newUserDto)));
+ }
+
+ @Override
+ @Transactional
+ public void deleteUser(Long userId) {
+
+ Optional existingUser = userRepository.findById(userId);
+
+ if (existingUser.isEmpty()) {
+ throw new EntityNotFoundException("User", "Id", userId);
+ }
+
+ userRepository.deleteById(userId);
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public List getUsers(GetListUsersParameters parameters) {
+
+ Pageable pageable = PageRequest.of(parameters.getFrom() / parameters.getSize(),
+ parameters.getSize());
+
+ List result;
+
+ if (parameters.getIds() == null || parameters.getIds().isEmpty()) {
+ result = userRepository.findAll(pageable).stream()
+ .map(userMapper::toUserDto)
+ .collect(Collectors.toList());
+ } else {
+ result = userRepository.findAllByIdIn(parameters.getIds(), pageable).stream()
+ .map(userMapper::toUserDto)
+ .collect(Collectors.toList());
+ }
+
+ return result;
+ }
+
+}
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/service/params/AdminCommentSearchParams.java b/main-service/src/main/java/ru/practicum/explorewithme/main/service/params/AdminCommentSearchParams.java
new file mode 100644
index 0000000..474c458
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/service/params/AdminCommentSearchParams.java
@@ -0,0 +1,16 @@
+package ru.practicum.explorewithme.main.service.params;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+
+@Getter
+@Builder
+@AllArgsConstructor
+@EqualsAndHashCode
+public class AdminCommentSearchParams {
+ private final Long userId;
+ private final Long eventId;
+ private final Boolean isDeleted;
+}
\ No newline at end of file
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/service/params/AdminEventSearchParams.java b/main-service/src/main/java/ru/practicum/explorewithme/main/service/params/AdminEventSearchParams.java
new file mode 100644
index 0000000..78b1262
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/service/params/AdminEventSearchParams.java
@@ -0,0 +1,19 @@
+package ru.practicum.explorewithme.main.service.params;
+
+import lombok.Builder;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import ru.practicum.explorewithme.main.model.EventState;
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Getter
+@Builder
+@EqualsAndHashCode(of = {"users", "states", "categories", "rangeStart", "rangeEnd"})
+public class AdminEventSearchParams {
+ private final List users;
+ private final List states;
+ private final List categories;
+ private final LocalDateTime rangeStart;
+ private final LocalDateTime rangeEnd;
+}
\ No newline at end of file
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/service/params/EventRequestStatusUpdateRequestParams.java b/main-service/src/main/java/ru/practicum/explorewithme/main/service/params/EventRequestStatusUpdateRequestParams.java
new file mode 100644
index 0000000..53b089b
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/service/params/EventRequestStatusUpdateRequestParams.java
@@ -0,0 +1,17 @@
+package ru.practicum.explorewithme.main.service.params;
+
+import lombok.*;
+import ru.practicum.explorewithme.main.model.RequestStatus;
+
+import java.util.List;
+
+@Getter
+@Builder
+@EqualsAndHashCode
+@AllArgsConstructor
+public class EventRequestStatusUpdateRequestParams {
+ private final Long userId;
+ private final Long eventId;
+ private final List requestIds;
+ private final RequestStatus status;
+}
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/service/params/GetListUsersParameters.java b/main-service/src/main/java/ru/practicum/explorewithme/main/service/params/GetListUsersParameters.java
new file mode 100644
index 0000000..5c625d8
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/service/params/GetListUsersParameters.java
@@ -0,0 +1,15 @@
+package ru.practicum.explorewithme.main.service.params;
+
+import lombok.*;
+
+import java.util.List;
+
+@Getter
+@Builder
+@EqualsAndHashCode
+@AllArgsConstructor
+public class GetListUsersParameters {
+ private final List ids;
+ private final int from;
+ private final int size;
+}
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/service/params/PublicCommentParameters.java b/main-service/src/main/java/ru/practicum/explorewithme/main/service/params/PublicCommentParameters.java
new file mode 100644
index 0000000..536e13b
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/service/params/PublicCommentParameters.java
@@ -0,0 +1,14 @@
+package ru.practicum.explorewithme.main.service.params;
+
+import lombok.*;
+import org.springframework.data.domain.Sort;
+
+@Getter
+@Builder(toBuilder = true)
+@EqualsAndHashCode
+@AllArgsConstructor
+public class PublicCommentParameters {
+ private final int from;
+ private final int size;
+ private final Sort sort;
+}
diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/service/params/PublicEventSearchParams.java b/main-service/src/main/java/ru/practicum/explorewithme/main/service/params/PublicEventSearchParams.java
new file mode 100644
index 0000000..fa08ef4
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/explorewithme/main/service/params/PublicEventSearchParams.java
@@ -0,0 +1,21 @@
+package ru.practicum.explorewithme.main.service.params;
+
+import lombok.Builder;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Getter
+@Builder
+@EqualsAndHashCode
+public class PublicEventSearchParams {
+ private final String text;
+ private final List categories;
+ private final Boolean paid;
+ private final LocalDateTime rangeStart;
+ private final LocalDateTime rangeEnd;
+ private final boolean onlyAvailable;
+ private final String sort;
+}
\ No newline at end of file
diff --git a/main-service/src/main/resources/application-local.yaml b/main-service/src/main/resources/application-local.yaml
new file mode 100644
index 0000000..43b09bd
--- /dev/null
+++ b/main-service/src/main/resources/application-local.yaml
@@ -0,0 +1,12 @@
+stats-server:
+ url: http://localhost:9090
+
+spring:
+ datasource:
+ url: jdbc:postgresql://localhost:5432/ewm_main_db
+ username: ewm_user
+ password: ewm_password
+logging:
+ level:
+ root: INFO
+ ru.practicum.explorewithme.main: DEBUG
\ No newline at end of file
diff --git a/main-service/src/main/resources/application-mapper_test.yaml b/main-service/src/main/resources/application-mapper_test.yaml
new file mode 100644
index 0000000..1bc1c12
--- /dev/null
+++ b/main-service/src/main/resources/application-mapper_test.yaml
@@ -0,0 +1,12 @@
+spring:
+ datasource:
+ url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1
+ username: sa
+ password: password
+ driverClassName: org.h2.Driver
+ jpa:
+ hibernate:
+ ddl-auto: create-drop
+ properties:
+ hibernate:
+ dialect: org.hibernate.dialect.H2Dialect
\ No newline at end of file
diff --git a/main-service/src/main/resources/application-test.yaml b/main-service/src/main/resources/application-test.yaml
new file mode 100644
index 0000000..a201509
--- /dev/null
+++ b/main-service/src/main/resources/application-test.yaml
@@ -0,0 +1,8 @@
+logging:
+ level:
+ org.hibernate.SQL: DEBUG
+ org.hibernate.type.descriptor.sql.BasicBinder: TRACE
+spring:
+ jpa:
+ properties:
+ hibernate.generate_statistics: true
\ No newline at end of file
diff --git a/main-service/src/main/resources/application.yaml b/main-service/src/main/resources/application.yaml
new file mode 100644
index 0000000..3b1d899
--- /dev/null
+++ b/main-service/src/main/resources/application.yaml
@@ -0,0 +1,20 @@
+server:
+ port: 8080
+
+stats-server:
+ url: http://stats-server:9090
+
+spring:
+ application:
+ name: main-service
+ jpa:
+ hibernate:
+ ddl-auto: validate
+ datasource:
+ url: ${SPRING_DATASOURCE_URL}
+ username: ${SPRING_DATASOURCE_USERNAME}
+ password: ${SPRING_DATASOURCE_PASSWORD}
+ driver-class-name: org.postgresql.Driver
+ sql:
+ init:
+ mode: always
\ No newline at end of file
diff --git a/main-service/src/main/resources/schema.sql b/main-service/src/main/resources/schema.sql
new file mode 100644
index 0000000..03af2df
--- /dev/null
+++ b/main-service/src/main/resources/schema.sql
@@ -0,0 +1,76 @@
+DROP TABLE IF EXISTS compilation_events CASCADE;
+DROP TABLE IF EXISTS requests CASCADE;
+DROP TABLE IF EXISTS events CASCADE;
+DROP TABLE IF EXISTS compilations CASCADE;
+DROP TABLE IF EXISTS categories CASCADE;
+DROP TABLE IF EXISTS users CASCADE;
+
+CREATE TABLE IF NOT EXISTS users (
+ id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ name VARCHAR(250) NOT NULL,
+ email VARCHAR(254) NOT NULL UNIQUE
+);
+
+CREATE TABLE IF NOT EXISTS categories (
+ id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ name VARCHAR(64) NOT NULL UNIQUE
+);
+
+CREATE TABLE IF NOT EXISTS events (
+ id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ annotation VARCHAR(2000) NOT NULL,
+ category_id BIGINT NOT NULL,
+ created_on TIMESTAMP WITHOUT TIME ZONE NOT NULL,
+ description VARCHAR(7000) NOT NULL,
+ event_date TIMESTAMP WITHOUT TIME ZONE NOT NULL,
+ initiator_id BIGINT NOT NULL,
+ lat REAL NOT NULL,
+ lon REAL NOT NULL,
+ paid BOOLEAN NOT NULL DEFAULT FALSE,
+ participant_limit INTEGER NOT NULL DEFAULT 0,
+ published_on TIMESTAMP WITHOUT TIME ZONE,
+ request_moderation BOOLEAN NOT NULL DEFAULT TRUE,
+ state VARCHAR(20) NOT NULL,
+ title VARCHAR(120) NOT NULL,
+ comments_enabled BOOLEAN NOT NULL DEFAULT TRUE,
+ CONSTRAINT fk_event_to_category FOREIGN KEY(category_id) REFERENCES categories(id),
+ CONSTRAINT fk_event_to_user FOREIGN KEY(initiator_id) REFERENCES users(id)
+);
+
+CREATE TABLE IF NOT EXISTS comments (
+ id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ text VARCHAR(2000) NOT NULL,
+ created_on TIMESTAMP WITHOUT TIME ZONE NOT NULL,
+ updated_on TIMESTAMP WITHOUT TIME ZONE,
+ author_id BIGINT NOT NULL,
+ event_id BIGINT NOT NULL,
+ is_edited BOOLEAN NOT NULL DEFAULT FALSE,
+ is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
+ CONSTRAINT fk_comment_to_author FOREIGN KEY(author_id) REFERENCES users(id) ON DELETE CASCADE,
+ CONSTRAINT fk_comment_to_event FOREIGN KEY(event_id) REFERENCES events(id) ON DELETE CASCADE
+);
+
+CREATE TABLE IF NOT EXISTS requests (
+ id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ created TIMESTAMP WITHOUT TIME ZONE NOT NULL,
+ event_id BIGINT NOT NULL,
+ requester_id BIGINT NOT NULL,
+ status VARCHAR(20) NOT NULL,
+ CONSTRAINT fk_request_to_event FOREIGN KEY(event_id) REFERENCES events(id),
+ CONSTRAINT fk_request_to_requester FOREIGN KEY(requester_id) REFERENCES users(id),
+ CONSTRAINT uq_request_requester_event UNIQUE(requester_id, event_id)
+);
+
+CREATE TABLE IF NOT EXISTS compilations (
+ id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ pinned BOOLEAN NOT NULL DEFAULT FALSE,
+ title VARCHAR(128) NOT NULL UNIQUE
+);
+
+CREATE TABLE IF NOT EXISTS compilation_events (
+ compilation_id BIGINT NOT NULL,
+ event_id BIGINT NOT NULL,
+ PRIMARY KEY (compilation_id, event_id),
+ CONSTRAINT fk_ce_to_compilation FOREIGN KEY(compilation_id) REFERENCES compilations(id),
+ CONSTRAINT fk_ce_to_event FOREIGN KEY(event_id) REFERENCES events(id)
+);
\ No newline at end of file
diff --git a/main-service/src/test/java/ru/practicum/explorewithme/main/aspect/StatsHitAspectTest.java b/main-service/src/test/java/ru/practicum/explorewithme/main/aspect/StatsHitAspectTest.java
new file mode 100644
index 0000000..e694d78
--- /dev/null
+++ b/main-service/src/test/java/ru/practicum/explorewithme/main/aspect/StatsHitAspectTest.java
@@ -0,0 +1,115 @@
+package ru.practicum.explorewithme.main.aspect;
+
+import org.aspectj.lang.JoinPoint;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
+import ru.practicum.explorewithme.stats.client.StatsClient;
+import ru.practicum.explorewithme.stats.dto.EndpointHitDto;
+
+import java.time.LocalDateTime;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+@DisplayName("Тесты для StatsHitAspect")
+class StatsHitAspectTest {
+
+ @Mock
+ private StatsClient statsClient;
+
+ @Mock
+ private JoinPoint joinPoint;
+
+ @Mock
+ private org.aspectj.lang.Signature signature;
+
+ @InjectMocks
+ private StatsHitAspect statsHitAspect;
+
+ private MockHttpServletRequest mockRequest;
+
+ @Captor
+ private ArgumentCaptor endpointHitDtoCaptor;
+
+ private final String testAppName = "test-app-for-aspect";
+
+ @BeforeEach
+ void setUp() {
+ try {
+ java.lang.reflect.Field appNameField = StatsHitAspect.class.getDeclaredField("appName");
+ appNameField.setAccessible(true);
+ appNameField.set(statsHitAspect, testAppName);
+ } catch (NoSuchFieldException | IllegalAccessException e) {
+ throw new RuntimeException("Failed to set appName for testing", e);
+ }
+
+ mockRequest = new MockHttpServletRequest();
+ RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(mockRequest));
+ }
+
+ @AfterEach
+ void tearDown() {
+ RequestContextHolder.resetRequestAttributes();
+ }
+
+ @Test
+ @DisplayName("logHit должен отправлять EndpointHitDto в StatsClient с корректными данными")
+ void logHit_whenRequestAvailable_shouldSendHitToStatsClient() {
+ String testUri = "/test/uri";
+ String testIp = "123.123.123.123";
+ mockRequest.setRequestURI(testUri);
+ mockRequest.setRemoteAddr(testIp);
+
+ statsHitAspect.logHit(joinPoint);
+
+ verify(statsClient, times(1)).saveHit(endpointHitDtoCaptor.capture());
+ EndpointHitDto capturedDto = endpointHitDtoCaptor.getValue();
+
+ assertNotNull(capturedDto);
+ assertEquals(testAppName, capturedDto.getApp());
+ assertEquals(testUri, capturedDto.getUri());
+ assertEquals(testIp, capturedDto.getIp());
+ assertNotNull(capturedDto.getTimestamp());
+ assertTrue(capturedDto.getTimestamp().isAfter(LocalDateTime.now().minusSeconds(5)));
+ assertTrue(capturedDto.getTimestamp().isBefore(LocalDateTime.now().plusSeconds(5)));
+ }
+
+ @Test
+ @DisplayName("logHit не должен вызывать StatsClient, если HttpServletRequest недоступен")
+ void logHit_whenRequestNotAvailable_shouldNotCallStatsClientAndLogWarning() {
+ when(joinPoint.getSignature()).thenReturn(signature);
+ when(signature.toShortString()).thenReturn("testMethod()");
+ RequestContextHolder.resetRequestAttributes();
+
+ statsHitAspect.logHit(joinPoint);
+
+ verifyNoInteractions(statsClient);
+ }
+
+ @Test
+ @DisplayName("logHit должен обрабатывать исключение от StatsClient и не пробрасывать его дальше")
+ void logHit_whenStatsClientThrowsException_shouldCatchAndLogError() {
+ String testUri = "/test/uri";
+ String testIp = "123.123.123.123";
+ mockRequest.setRequestURI(testUri);
+ mockRequest.setRemoteAddr(testIp);
+
+ doThrow(new RuntimeException("Stats service unavailable")).when(statsClient).saveHit(any(EndpointHitDto.class));
+
+ assertDoesNotThrow(() -> statsHitAspect.logHit(joinPoint));
+
+ verify(statsClient, times(1)).saveHit(any(EndpointHitDto.class));
+ }
+}
\ No newline at end of file
diff --git a/main-service/src/test/java/ru/practicum/explorewithme/main/controller/admin/AdminCategoryControllerTest.java b/main-service/src/test/java/ru/practicum/explorewithme/main/controller/admin/AdminCategoryControllerTest.java
new file mode 100644
index 0000000..af1e192
--- /dev/null
+++ b/main-service/src/test/java/ru/practicum/explorewithme/main/controller/admin/AdminCategoryControllerTest.java
@@ -0,0 +1,283 @@
+package ru.practicum.explorewithme.main.controller.admin;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+import ru.practicum.explorewithme.main.dto.CategoryDto;
+import ru.practicum.explorewithme.main.dto.NewCategoryDto;
+import ru.practicum.explorewithme.main.error.EntityDeletedException;
+import ru.practicum.explorewithme.main.error.EntityNotFoundException;
+import ru.practicum.explorewithme.main.service.CategoryService;
+
+import static org.hamcrest.Matchers.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.*;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
+import ru.practicum.explorewithme.main.error.EntityAlreadyExistsException;
+
+@WebMvcTest(AdminCategoryController.class)
+@DisplayName("Контроллер администрирования категорий должен")
+class AdminCategoryControllerTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private ObjectMapper objectMapper;
+
+ @MockitoBean
+ private CategoryService categoryService;
+
+ private NewCategoryDto newCategoryDto;
+ private CategoryDto categoryDto;
+
+ @BeforeEach
+ void setUp() {
+ newCategoryDto = new NewCategoryDto();
+ newCategoryDto.setName("Тестовая категория");
+
+ categoryDto = new CategoryDto();
+ categoryDto.setId(1L);
+ categoryDto.setName("Тестовая категория");
+ }
+
+ @Nested
+ @DisplayName("при создании категории")
+ class CreateCategoryTests {
+
+ @Test
+ @DisplayName("возвращать созданную категорию со статусом 201")
+ void createCategory_ReturnsCreatedCategory() throws Exception {
+ when(categoryService.createCategory(any(NewCategoryDto.class))).thenReturn(categoryDto);
+
+ mockMvc.perform(post("/admin/categories")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(newCategoryDto)))
+ .andExpect(status().isCreated())
+ .andExpect(jsonPath("$.id", is(1)))
+ .andExpect(jsonPath("$.name", is("Тестовая категория")));
+
+ verify(categoryService, times(1)).createCategory(any(NewCategoryDto.class));
+ }
+
+ @Test
+ @DisplayName("возвращать 400 при создании категории с невалидными данными")
+ void createCategory_WithInvalidData_ReturnsBadRequest() throws Exception {
+ NewCategoryDto invalidRequest = new NewCategoryDto();
+
+ mockMvc.perform(post("/admin/categories")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(invalidRequest)))
+ .andExpect(status().isBadRequest());
+
+ verify(categoryService, never()).createCategory(any(NewCategoryDto.class));
+ }
+
+ @Test
+ @DisplayName("возвращать корректный заголовок Content-Type в ответе")
+ void createCategory_WithValidRequest_HasCorrectContentTypeHeader() throws Exception {
+ when(categoryService.createCategory(any())).thenReturn(categoryDto);
+
+ mockMvc.perform(post("/admin/categories")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(newCategoryDto)))
+ .andExpect(status().isCreated())
+ .andExpect(header().string("Content-Type", containsString(MediaType.APPLICATION_JSON_VALUE)))
+ .andExpect(jsonPath("$.id", is(1)));
+
+ verify(categoryService, times(1)).createCategory(any());
+ }
+
+ @Test
+ @DisplayName("возвращать 409 при попытке создания уже существующей категории")
+ void createCategory_WithExistingName_ReturnsConflict() throws Exception {
+ when(categoryService.createCategory(any(NewCategoryDto.class)))
+ .thenThrow(new EntityAlreadyExistsException("Category", "name", newCategoryDto.getName()));
+
+ mockMvc.perform(post("/admin/categories")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(newCategoryDto)))
+ .andExpect(status().isConflict())
+ .andExpect(jsonPath("$.message", notNullValue()))
+ .andExpect(jsonPath("$.reason", containsString("already exists")));
+
+ verify(categoryService, times(1)).createCategory(any(NewCategoryDto.class));
+ }
+
+ }
+
+ @Nested
+ @DisplayName("при обновлении категории")
+ class UpdateCategoryTests {
+
+ @Test
+ @DisplayName("возвращать обновленную категорию со статусом 200")
+ void updateCategory_ReturnsUpdatedCategory() throws Exception {
+ CategoryDto updatedCategoryDto = new CategoryDto();
+ updatedCategoryDto.setId(1L);
+ updatedCategoryDto.setName("Обновленная категория");
+
+ when(categoryService.updateCategory(anyLong(), any(NewCategoryDto.class))).thenReturn(updatedCategoryDto);
+
+ mockMvc.perform(patch("/admin/categories/1")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(newCategoryDto)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.id", is(1)))
+ .andExpect(jsonPath("$.name", is("Обновленная категория")));
+
+ verify(categoryService, times(1)).updateCategory(eq(1L), any(NewCategoryDto.class));
+ }
+
+ @Test
+ @DisplayName("возвращать 404 при обновлении несуществующей категории")
+ void updateCategory_WithNonExistingId_ReturnsNotFound() throws Exception {
+ when(categoryService.updateCategory(anyLong(), any(NewCategoryDto.class)))
+ .thenThrow(new EntityNotFoundException("Category", "Id", 999L));
+
+ mockMvc.perform(patch("/admin/categories/999")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(newCategoryDto)))
+ .andExpect(status().isNotFound());
+
+ verify(categoryService, times(1)).updateCategory(eq(999L), any(NewCategoryDto.class));
+ }
+
+ @Test
+ @DisplayName("возвращать 400 при обновлении категории с невалидными данными")
+ void updateCategory_WithInvalidData_ReturnsBadRequest() throws Exception {
+ NewCategoryDto invalidRequest = new NewCategoryDto();
+ invalidRequest.setName(""); // Пустое имя
+
+ mockMvc.perform(patch("/admin/categories/1")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(invalidRequest)))
+ .andExpect(status().isBadRequest());
+
+ verify(categoryService, never()).updateCategory(anyLong(), any(NewCategoryDto.class));
+ }
+ }
+
+ @Nested
+ @DisplayName("при удалении категории")
+ class DeleteCategoryTests {
+
+ @Test
+ @DisplayName("возвращать статус 204 без тела ответа")
+ void deleteCategory_ReturnsNoContent() throws Exception {
+ doNothing().when(categoryService).deleteCategory(anyLong());
+
+ mockMvc.perform(delete("/admin/categories/1"))
+ .andExpect(status().isNoContent())
+ .andExpect(content().string(""));
+
+ verify(categoryService, times(1)).deleteCategory(1L);
+ }
+
+ @Test
+ @DisplayName("возвращать 404 при удалении несуществующей категории")
+ void deleteCategory_WithNonExistingId_ReturnsNotFound() throws Exception {
+ doThrow(new EntityNotFoundException("Category", "Id", 999L))
+ .when(categoryService).deleteCategory(999L);
+
+ mockMvc.perform(delete("/admin/categories/999"))
+ .andExpect(status().isNotFound());
+
+ verify(categoryService, times(1)).deleteCategory(999L);
+ }
+
+ @Test
+ @DisplayName("возвращать 409 при удалении категории, содержащей события")
+ void deleteCategory_WithEvents_ReturnsConflict() throws Exception {
+ doThrow(new EntityDeletedException("Category", "Id", 1L))
+ .when(categoryService).deleteCategory(1L);
+
+ mockMvc.perform(delete("/admin/categories/1"))
+ .andExpect(status().isConflict())
+ .andExpect(jsonPath("$.message", notNullValue()))
+ .andExpect(jsonPath("$.reason", containsString("Restriction")));
+
+ verify(categoryService, times(1)).deleteCategory(1L);
+ }
+
+ }
+
+ @Nested
+ @DisplayName("при обработке невалидного JSON")
+ class InvalidJsonTests {
+
+ @Test
+ @DisplayName("возвращать 400 при синтаксически некорректном JSON")
+ void request_WithInvalidJson_ReturnsBadRequest() throws Exception {
+ String invalidJson = "{\"name\":\"Тестовая категория\","; // Незакрытая скобка
+
+ mockMvc.perform(post("/admin/categories")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(invalidJson))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.message", notNullValue()))
+ .andExpect(jsonPath("$.reason", containsString("Malformed JSON")));
+
+ verify(categoryService, never()).createCategory(any());
+ }
+
+ @Test
+ @DisplayName("возвращать 400 при некорректном JSON-массиве")
+ void request_WithMalformedJsonArray_ReturnsBadRequest() throws Exception {
+ String invalidJsonArray = "[{\"name\":\"Тестовая категория\",}]"; // Ошибка - лишняя запятая
+
+ mockMvc.perform(post("/admin/categories")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(invalidJsonArray))
+ .andExpect(status().isBadRequest());
+
+ verify(categoryService, never()).createCategory(any());
+ }
+ }
+
+ @Nested
+ @DisplayName("при проверке формата запросов и ответов")
+ class RequestResponseFormatTests {
+
+ @Test
+ @DisplayName("обрабатывать запрос с пустым JSON-объектом")
+ void handleEmptyJsonObject() throws Exception {
+ String emptyJson = "{}";
+
+ mockMvc.perform(post("/admin/categories")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(emptyJson))
+ .andExpect(status().isBadRequest());
+
+ verify(categoryService, never()).createCategory(any());
+ }
+
+ @Test
+ @DisplayName("корректно обрабатывать имена категорий со специальными символами")
+ void handleCategoryNameWithSpecialCharacters() throws Exception {
+ NewCategoryDto specialCharsDto = new NewCategoryDto();
+ specialCharsDto.setName("Категория с !@#$%^&*()");
+
+ CategoryDto responseDto = new CategoryDto();
+ responseDto.setId(1L);
+ responseDto.setName("Категория с !@#$%^&*()");
+
+ when(categoryService.createCategory(any())).thenReturn(responseDto);
+
+ mockMvc.perform(post("/admin/categories")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(specialCharsDto)))
+ .andExpect(status().isCreated())
+ .andExpect(jsonPath("$.name", is("Категория с !@#$%^&*()")));
+ }
+ }
+}
\ No newline at end of file
diff --git a/main-service/src/test/java/ru/practicum/explorewithme/main/controller/admin/AdminCommentControllerTest.java b/main-service/src/test/java/ru/practicum/explorewithme/main/controller/admin/AdminCommentControllerTest.java
new file mode 100644
index 0000000..c65239d
--- /dev/null
+++ b/main-service/src/test/java/ru/practicum/explorewithme/main/controller/admin/AdminCommentControllerTest.java
@@ -0,0 +1,201 @@
+package ru.practicum.explorewithme.main.controller.admin;
+
+import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.is;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.nio.charset.StandardCharsets;
+import java.time.LocalDateTime;
+import java.util.Collections;
+import java.util.List;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+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.explorewithme.main.dto.CommentAdminDto;
+import ru.practicum.explorewithme.main.dto.UserShortDto;
+import ru.practicum.explorewithme.main.service.CommentService;
+import ru.practicum.explorewithme.main.service.params.AdminCommentSearchParams;
+
+@WebMvcTest(AdminCommentController.class)
+@DisplayName("Тесты для AdminCommentController")
+class AdminCommentControllerTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private ObjectMapper objectMapper;
+
+ @MockitoBean
+ private CommentService commentService;
+
+ @Captor
+ private ArgumentCaptor paramsCaptor;
+
+ @Nested
+ @DisplayName("Метод GET /admin/comments")
+ class GetAllCommentsAdminTests {
+
+ @Test
+ @DisplayName("Должен вернуть 200 OK и пустой список, если комментариев не найдено")
+ void whenNoCommentsFound_shouldReturnOkAndEmptyList() throws Exception {
+ when(commentService.getAllCommentsAdmin(any(AdminCommentSearchParams.class), eq(0), eq(10)))
+ .thenReturn(Collections.emptyList());
+
+ mockMvc.perform(get("/admin/comments")
+ .param("from", "0")
+ .param("size", "10")
+ .contentType(MediaType.APPLICATION_JSON)
+ .characterEncoding(StandardCharsets.UTF_8))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$", hasSize(0)));
+
+ verify(commentService).getAllCommentsAdmin(paramsCaptor.capture(), eq(0), eq(10));
+ AdminCommentSearchParams capturedParams = paramsCaptor.getValue();
+ assertNull(capturedParams.getUserId());
+ assertNull(capturedParams.getEventId());
+ assertNull(capturedParams.getIsDeleted());
+ }
+
+ @Test
+ @DisplayName("Должен вернуть 200 OK и список CommentDto, если комментарии найдены")
+ void whenCommentsFound_shouldReturnOkAndDtoList() throws Exception {
+ UserShortDto author = UserShortDto.builder().id(1L).name("Test Author").build();
+ CommentAdminDto comment1 = CommentAdminDto.builder().id(1L).text("Comment 1").author(author).eventId(100L).createdOn(LocalDateTime.now()).build();
+ CommentAdminDto comment2 = CommentAdminDto.builder().id(2L).text("Comment 2").author(author).eventId(101L).createdOn(LocalDateTime.now()).build();
+ List comments = List.of(comment1, comment2);
+
+ when(commentService.getAllCommentsAdmin(any(AdminCommentSearchParams.class), eq(0), eq(10)))
+ .thenReturn(comments);
+
+ mockMvc.perform(get("/admin/comments")
+ .param("from", "0")
+ .param("size", "10")
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$", hasSize(2)))
+ .andExpect(jsonPath("$[0].id", is(comment1.getId().intValue())))
+ .andExpect(jsonPath("$[0].text", is(comment1.getText())))
+ .andExpect(jsonPath("$[1].id", is(comment2.getId().intValue())))
+ .andExpect(jsonPath("$[1].text", is(comment2.getText())));
+
+ verify(commentService).getAllCommentsAdmin(any(AdminCommentSearchParams.class), eq(0), eq(10));
+ }
+
+ @Test
+ @DisplayName("Должен корректно передавать все параметры фильтрации в сервис")
+ void withAllFilters_shouldPassFiltersToService() throws Exception {
+ Long userIdFilter = 1L;
+ Long eventIdFilter = 10L;
+ Boolean isDeletedFilter = false;
+ int from = 5;
+ int size = 15;
+
+ AdminCommentSearchParams expectedSearchParams = AdminCommentSearchParams.builder()
+ .userId(userIdFilter)
+ .eventId(eventIdFilter)
+ .isDeleted(isDeletedFilter)
+ .build();
+
+ when(commentService.getAllCommentsAdmin(eq(expectedSearchParams), eq(from), eq(size)))
+ .thenReturn(Collections.emptyList());
+
+ mockMvc.perform(get("/admin/comments")
+ .param("userId", userIdFilter.toString())
+ .param("eventId", eventIdFilter.toString())
+ .param("isDeleted", isDeletedFilter.toString())
+ .param("from", String.valueOf(from))
+ .param("size", String.valueOf(size))
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$", hasSize(0)));
+
+ verify(commentService).getAllCommentsAdmin(eq(expectedSearchParams), eq(from), eq(size));
+ }
+
+ @Test
+ @DisplayName("Должен использовать значения по умолчанию для from и size")
+ void withDefaultPagination_shouldUseDefaultValues() throws Exception {
+ AdminCommentSearchParams expectedSearchParams = AdminCommentSearchParams.builder().build();
+ when(commentService.getAllCommentsAdmin(eq(expectedSearchParams), eq(0), eq(10)))
+ .thenReturn(Collections.emptyList());
+
+ mockMvc.perform(get("/admin/comments")
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isOk());
+
+ verify(commentService).getAllCommentsAdmin(eq(expectedSearchParams), eq(0), eq(10));
+ }
+
+ @Test
+ @DisplayName("Должен вернуть 400 Bad Request при невалидном 'from'")
+ void withInvalidFrom_shouldReturnBadRequest() throws Exception {
+ mockMvc.perform(get("/admin/comments")
+ .param("from", "-1")
+ .param("size", "10")
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isBadRequest());
+ verifyNoInteractions(commentService);
+ }
+
+ @Test
+ @DisplayName("Должен вернуть 400 Bad Request при невалидном 'size'")
+ void withInvalidSize_shouldReturnBadRequest() throws Exception {
+ mockMvc.perform(get("/admin/comments")
+ .param("from", "0")
+ .param("size", "0")
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isBadRequest());
+ verifyNoInteractions(commentService);
+ }
+
+ @Test
+ @DisplayName("Должен вернуть 400 Bad Request при невалидном 'userId'")
+ void withInvalidUserIdFormat_shouldReturnBadRequest() throws Exception {
+ mockMvc.perform(get("/admin/comments")
+ .param("userId", "notANumber")
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isBadRequest());
+ verifyNoInteractions(commentService);
+ }
+
+ @Test
+ @DisplayName("Должен вернуть 400 Bad Request при невалидном 'eventId'")
+ void withInvalidEventIdFormat_shouldReturnBadRequest() throws Exception {
+ mockMvc.perform(get("/admin/comments")
+ .param("eventId", "notANumber")
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isBadRequest());
+ verifyNoInteractions(commentService);
+ }
+
+ @Test
+ @DisplayName("Должен вернуть 400 Bad Request при невалидном 'isDeleted'")
+ void withInvalidIsDeletedFormat_shouldReturnBadRequest() throws Exception {
+ mockMvc.perform(get("/admin/comments")
+ .param("isDeleted", "notABoolean")
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isBadRequest());
+ verifyNoInteractions(commentService);
+ }
+ }
+
+ // TODO: тесты для DELETE /{commentId} (deleteCommentByAdmin)
+ // TODO: тесты для PATCH /{commentId}/restore (restoreCommentByAdmin)
+}
\ No newline at end of file
diff --git a/main-service/src/test/java/ru/practicum/explorewithme/main/controller/admin/AdminEventControllerTest.java b/main-service/src/test/java/ru/practicum/explorewithme/main/controller/admin/AdminEventControllerTest.java
new file mode 100644
index 0000000..d5bdf48
--- /dev/null
+++ b/main-service/src/test/java/ru/practicum/explorewithme/main/controller/admin/AdminEventControllerTest.java
@@ -0,0 +1,318 @@
+package ru.practicum.explorewithme.main.controller.admin;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+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.explorewithme.main.dto.EventFullDto;
+import ru.practicum.explorewithme.main.dto.UpdateEventAdminRequestDto;
+import ru.practicum.explorewithme.main.error.BusinessRuleViolationException;
+import ru.practicum.explorewithme.main.error.EntityNotFoundException;
+import ru.practicum.explorewithme.main.model.EventState;
+import ru.practicum.explorewithme.main.service.EventService;
+import ru.practicum.explorewithme.main.service.params.AdminEventSearchParams;
+
+import java.nio.charset.StandardCharsets;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.Collections;
+import java.util.List;
+
+import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.is;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+import static ru.practicum.explorewithme.common.constants.DateTimeConstants.DATE_TIME_FORMATTER;
+
+@WebMvcTest(AdminEventController.class)
+@DisplayName("Тесты для AdminEventController")
+class AdminEventControllerTest {
+
+ private final DateTimeFormatter formatter = DATE_TIME_FORMATTER;
+ @Autowired
+ private MockMvc mockMvc;
+ @Autowired
+ private ObjectMapper objectMapper;
+ @MockitoBean
+ private EventService eventService;
+
+ @Nested
+ @DisplayName("GET /admin/events: Поиск событий администратором")
+ class GetEventsByAdminTests {
+
+ @Test
+ @DisplayName("Должен вернуть 200 OK и пустой список, если событий не найдено")
+ void whenNoEventsFound_shouldReturnOkAndEmptyList() throws Exception {
+ when(eventService.getEventsAdmin(any(AdminEventSearchParams.class), anyInt(),
+ anyInt())).thenReturn(Collections.emptyList());
+
+ mockMvc.perform(get("/admin/events").param("from", "0").param("size", "10")
+ .contentType(MediaType.APPLICATION_JSON).characterEncoding(StandardCharsets.UTF_8))
+ .andExpect(status().isOk()).andExpect(jsonPath("$", hasSize(0)));
+
+ AdminEventSearchParams expectedParams = AdminEventSearchParams.builder()
+ .users(null)
+ .states(null)
+ .categories(null)
+ .rangeStart(null)
+ .rangeEnd(null)
+ .build();
+ verify(eventService).getEventsAdmin(eq(expectedParams), eq(0), eq(10));
+ }
+
+ @Test
+ @DisplayName("Должен вернуть 200 OK и список событий, если они найдены")
+ void whenEventsFound_shouldReturnOkAndEventList() throws Exception {
+ LocalDateTime eventTime = LocalDateTime.now().plusDays(5).withNano(0);
+ EventFullDto eventDto = EventFullDto.builder().id(1L).title("Test Event")
+ .annotation("Test Annotation").eventDate(eventTime).build();
+ List events = List.of(eventDto);
+
+ when(eventService.getEventsAdmin(any(AdminEventSearchParams.class), eq(0),
+ eq(10))).thenReturn(events);
+
+ mockMvc.perform(get("/admin/events").param("from", "0").param("size", "10")
+ .contentType(MediaType.APPLICATION_JSON)).andExpect(status().isOk())
+ .andExpect(jsonPath("$", hasSize(1)))
+ .andExpect(jsonPath("$[0].id", is(eventDto.getId().intValue())))
+ .andExpect(jsonPath("$[0].title", is(eventDto.getTitle()))).andExpect(
+ jsonPath("$[0].eventDate", is(eventTime.format(formatter))));
+
+ AdminEventSearchParams expectedParams = AdminEventSearchParams.builder()
+ .users(null)
+ .states(null)
+ .categories(null)
+ .rangeStart(null)
+ .rangeEnd(null)
+ .build();
+ verify(eventService).getEventsAdmin(eq(expectedParams), eq(0), eq(10));
+ }
+
+ @Test
+ @DisplayName("Должен корректно передавать все параметры фильтрации в сервис")
+ void withAllFilters_shouldPassFiltersToService() throws Exception {
+ List userIds = List.of(1L, 2L);
+ List states = List.of(EventState.PENDING, EventState.PUBLISHED);
+ List categoryIds = List.of(10L, 20L);
+ LocalDateTime rangeStart = LocalDateTime.now().minusDays(1).withNano(0);
+ LocalDateTime rangeEnd = LocalDateTime.now().plusDays(1).withNano(0);
+ int from = 5;
+ int size = 15;
+
+ AdminEventSearchParams expectedSearchParams = AdminEventSearchParams.builder()
+ .users(userIds)
+ .states(states)
+ .categories(categoryIds)
+ .rangeStart(rangeStart)
+ .rangeEnd(rangeEnd)
+ .build();
+
+ when(eventService.getEventsAdmin(eq(expectedSearchParams), eq(from), eq(size)))
+ .thenReturn(Collections.emptyList());
+
+ mockMvc.perform(
+ get("/admin/events").param("users", "1", "2").param("states", "PENDING",
+ "PUBLISHED")
+ .param("categories", "10", "20").param("rangeStart",
+ rangeStart.format(formatter))
+ .param("rangeEnd", rangeEnd.format(formatter)).param("from",
+ String.valueOf(from))
+ .param("size", String.valueOf(size)).contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isOk()).andExpect(jsonPath("$", hasSize(0)));
+
+ verify(eventService).getEventsAdmin(eq(expectedSearchParams), eq(from), eq(size));
+ }
+
+ @Test
+ @DisplayName("Должен использовать значения по умолчанию для from и size, если они не переданы")
+ void withDefaultPagination_shouldUseDefaultValues() throws Exception {
+ when(eventService.getEventsAdmin(any(AdminEventSearchParams.class), eq(0),
+ eq(10))).thenReturn(Collections.emptyList());
+
+ mockMvc.perform(get("/admin/events").contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isOk());
+
+ AdminEventSearchParams expectedParams = AdminEventSearchParams.builder()
+ .users(null)
+ .states(null)
+ .categories(null)
+ .rangeStart(null)
+ .rangeEnd(null)
+ .build();
+ verify(eventService).getEventsAdmin(eq(expectedParams), eq(0), eq(10));
+ }
+
+ @Test
+ @DisplayName("Должен вернуть 400 Bad Request при невалидном значении 'from'")
+ void withInvalidFrom_shouldReturnBadRequest() throws Exception {
+ mockMvc.perform(get("/admin/events").param("from", "-1") // Невалидное значение
+ .param("size", "10").contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isBadRequest());
+ verifyNoInteractions(eventService); // Сервис не должен вызываться
+ }
+
+ @Test
+ @DisplayName("Должен вернуть 400 Bad Request при невалидном значении 'size'")
+ void withInvalidSize_shouldReturnBadRequest() throws Exception {
+ mockMvc.perform(get("/admin/events").param("from", "0")
+ .param("size", "0") // Невалидное значение (@Positive)
+ .contentType(MediaType.APPLICATION_JSON)).andExpect(status().isBadRequest());
+ verifyNoInteractions(eventService);
+ }
+
+ @Test
+ @DisplayName("Должен вернуть 400 Bad Request при некорректном формате rangeStart")
+ void withInvalidRangeStartFormat_shouldReturnBadRequest() throws Exception {
+ mockMvc.perform(get("/admin/events").param("rangeStart", "invalid-date-format")
+ .param("rangeEnd", LocalDateTime.now().format(formatter)).param("from", "0")
+ .param("size", "10")).andExpect(status().isBadRequest());
+ verifyNoInteractions(eventService);
+ }
+ }
+
+
+ @Nested
+ @DisplayName("PATCH /admin/events/{eventId}: Модерация события администратором")
+ class ModerateEventByAdminTests {
+
+ private final Long testEventId = 1L;
+ private UpdateEventAdminRequestDto validPublishRequestDto;
+ private UpdateEventAdminRequestDto validRejectRequestDto;
+ private EventFullDto moderatedEventFullDto;
+
+ @BeforeEach
+ void setUpModerateTests() {
+ validPublishRequestDto = UpdateEventAdminRequestDto.builder()
+ .stateAction(UpdateEventAdminRequestDto.StateActionAdmin.PUBLISH_EVENT)
+ .title("Published by Admin")
+ .build();
+
+ validRejectRequestDto = UpdateEventAdminRequestDto.builder()
+ .stateAction(UpdateEventAdminRequestDto.StateActionAdmin.REJECT_EVENT)
+ .build();
+
+ moderatedEventFullDto = EventFullDto.builder()
+ .id(testEventId)
+ .title("Moderated Event")
+ .state(EventState.PUBLISHED)
+ .eventDate(LocalDateTime.now().plusDays(2).withNano(0))
+ .build();
+ }
+
+ @Test
+ @DisplayName("Должен вернуть 200 OK и обновленный EventFullDto при успешной публикации")
+ void whenPublishSuccessful_shouldReturnOkAndDto() throws Exception {
+ when(eventService.moderateEventByAdmin(eq(testEventId), any(UpdateEventAdminRequestDto.class)))
+ .thenReturn(moderatedEventFullDto);
+
+ mockMvc.perform(patch("/admin/events/{eventId}", testEventId)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(validPublishRequestDto))
+ .characterEncoding(StandardCharsets.UTF_8))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.id", is(testEventId.intValue())))
+ .andExpect(jsonPath("$.title", is(moderatedEventFullDto.getTitle())))
+ .andExpect(jsonPath("$.state", is(EventState.PUBLISHED.toString())));
+
+ verify(eventService).moderateEventByAdmin(eq(testEventId), eq(validPublishRequestDto));
+ }
+
+ @Test
+ @DisplayName("Должен вернуть 200 OK и обновленный EventFullDto при успешном отклонении")
+ void whenRejectSuccessful_shouldReturnOkAndDto() throws Exception {
+ moderatedEventFullDto.setState(EventState.CANCELED);
+ when(eventService.moderateEventByAdmin(eq(testEventId), any(UpdateEventAdminRequestDto.class)))
+ .thenReturn(moderatedEventFullDto);
+
+ mockMvc.perform(patch("/admin/events/{eventId}", testEventId)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(validRejectRequestDto))
+ .characterEncoding(StandardCharsets.UTF_8))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.id", is(testEventId.intValue())))
+ .andExpect(jsonPath("$.state", is(EventState.CANCELED.toString())));
+
+ verify(eventService).moderateEventByAdmin(eq(testEventId), eq(validRejectRequestDto));
+ }
+
+ @Test
+ @DisplayName("Должен вернуть 400 Bad Request, если DTO обновления невалиден (например, слишком короткий title)")
+ void whenDtoIsInvalid_shouldReturnBadRequest() throws Exception {
+ UpdateEventAdminRequestDto invalidDto = UpdateEventAdminRequestDto.builder()
+ .title("S")
+ .stateAction(UpdateEventAdminRequestDto.StateActionAdmin.PUBLISH_EVENT)
+ .build();
+
+ mockMvc.perform(patch("/admin/events/{eventId}", testEventId)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(invalidDto))
+ .characterEncoding(StandardCharsets.UTF_8))
+ .andExpect(status().isBadRequest());
+
+ verifyNoInteractions(eventService);
+ }
+
+ @Test
+ @DisplayName("Должен вернуть 404 Not Found, если событие для модерации не найдено")
+ void whenEventNotFound_shouldReturnNotFound() throws Exception {
+ String errorMessage = "Event with id=" + testEventId + " not found.";
+ when(eventService.moderateEventByAdmin(eq(testEventId), any(UpdateEventAdminRequestDto.class)))
+ .thenThrow(new EntityNotFoundException(errorMessage));
+
+ mockMvc.perform(patch("/admin/events/{eventId}", testEventId)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(validPublishRequestDto))
+ .characterEncoding(StandardCharsets.UTF_8))
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.reason", is("Requested object not found")))
+ .andExpect(jsonPath("$.message", is(errorMessage)));
+
+ verify(eventService).moderateEventByAdmin(eq(testEventId), eq(validPublishRequestDto));
+ }
+
+ @Test
+ @DisplayName("Должен вернуть 409 Conflict, если событие нельзя модерировать (например, неверное состояние)")
+ void whenModerationNotAllowed_shouldReturnConflict() throws Exception {
+ String errorMessage = "Cannot publish the event because it's not in the PENDING state.";
+ when(eventService.moderateEventByAdmin(eq(testEventId), any(UpdateEventAdminRequestDto.class)))
+ .thenThrow(new BusinessRuleViolationException(errorMessage));
+
+ mockMvc.perform(patch("/admin/events/{eventId}", testEventId)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(validPublishRequestDto))
+ .characterEncoding(StandardCharsets.UTF_8))
+ .andExpect(status().isConflict())
+ .andExpect(jsonPath("$.reason", is("Conditions not met for requested operation")))
+ .andExpect(jsonPath("$.message", is(errorMessage)));
+
+ verify(eventService).moderateEventByAdmin(eq(testEventId), eq(validPublishRequestDto));
+ }
+
+ @Test
+ @DisplayName("Должен вернуть 400 Bad Request при невалидном eventId в пути")
+ void withInvalidEventIdPath_shouldReturnBadRequest() throws Exception {
+ UpdateEventAdminRequestDto dto = UpdateEventAdminRequestDto.builder()
+ .stateAction(UpdateEventAdminRequestDto.StateActionAdmin.PUBLISH_EVENT).build();
+
+ mockMvc.perform(patch("/admin/events/{eventId}", "invalidEventId")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(dto)))
+ .andExpect(status().isBadRequest());
+ verifyNoInteractions(eventService);
+ }
+ }
+}
\ No newline at end of file
diff --git a/main-service/src/test/java/ru/practicum/explorewithme/main/controller/admin/AdminUserControllerTest.java b/main-service/src/test/java/ru/practicum/explorewithme/main/controller/admin/AdminUserControllerTest.java
new file mode 100644
index 0000000..65c1b6c
--- /dev/null
+++ b/main-service/src/test/java/ru/practicum/explorewithme/main/controller/admin/AdminUserControllerTest.java
@@ -0,0 +1,264 @@
+package ru.practicum.explorewithme.main.controller.admin;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+import ru.practicum.explorewithme.main.dto.NewUserRequestDto;
+import ru.practicum.explorewithme.main.dto.UserDto;
+import ru.practicum.explorewithme.main.error.EntityAlreadyExistsException;
+import ru.practicum.explorewithme.main.error.EntityNotFoundException;
+import ru.practicum.explorewithme.main.service.UserService;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.hamcrest.Matchers.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.*;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
+
+@WebMvcTest(AdminUserController.class)
+@DisplayName("Контроллер администрирования пользователей должен")
+class AdminUserControllerTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private ObjectMapper objectMapper;
+
+ @MockitoBean
+ private UserService userService;
+
+ private NewUserRequestDto newUserRequestDto;
+ private UserDto userDto;
+
+ @BeforeEach
+ void setUp() {
+ newUserRequestDto = new NewUserRequestDto();
+ newUserRequestDto.setName("Тестовый пользователь");
+ newUserRequestDto.setEmail("test@example.com");
+
+ userDto = new UserDto();
+ userDto.setId(1L);
+ userDto.setName("Тестовый пользователь");
+ userDto.setEmail("test@example.com");
+ }
+
+ @Nested
+ @DisplayName("при создании пользователя")
+ class CreateUserTests {
+
+ @Test
+ @DisplayName("возвращать созданного пользователя со статусом 201")
+ void createUser_ReturnsCreatedUser() throws Exception {
+ when(userService.createUser(any(NewUserRequestDto.class))).thenReturn(userDto);
+
+ mockMvc.perform(post("/admin/users")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(newUserRequestDto)))
+ .andExpect(status().isCreated())
+ .andExpect(jsonPath("$.id", is(1)))
+ .andExpect(jsonPath("$.name", is("Тестовый пользователь")))
+ .andExpect(jsonPath("$.email", is("test@example.com")));
+
+ verify(userService, times(1)).createUser(any(NewUserRequestDto.class));
+ }
+
+ @Test
+ @DisplayName("возвращать 409 при попытке создать пользователя с существующим email")
+ void createUser_WithExistingEmail_ReturnsConflict() throws Exception {
+ when(userService.createUser(any(NewUserRequestDto.class)))
+ .thenThrow(new EntityAlreadyExistsException("Пользователь с email test@example.com уже существует"));
+
+ mockMvc.perform(post("/admin/users")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(newUserRequestDto)))
+ .andExpect(status().isConflict())
+ .andExpect(jsonPath("$.message", containsString("уже существует")));
+
+ verify(userService, times(1)).createUser(any(NewUserRequestDto.class));
+ }
+
+ @Test
+ @DisplayName("возвращать 400 при создании пользователя с невалидными данными")
+ void createUser_WithInvalidData_ReturnsBadRequest() throws Exception {
+ NewUserRequestDto invalidRequest = new NewUserRequestDto();
+
+ mockMvc.perform(post("/admin/users")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(invalidRequest)))
+ .andExpect(status().isBadRequest());
+
+ verify(userService, never()).createUser(any(NewUserRequestDto.class));
+ }
+
+ @Test
+ @DisplayName("возвращать корректный заголовок Content-Type в ответе")
+ void createUser_WithValidRequest_HasCorrectContentTypeHeader() throws Exception {
+ when(userService.createUser(any())).thenReturn(userDto);
+
+ mockMvc.perform(post("/admin/users")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(newUserRequestDto)))
+ .andExpect(status().isCreated())
+ .andExpect(header().string("Content-Type", containsString(MediaType.APPLICATION_JSON_VALUE)))
+ .andExpect(jsonPath("$.id", is(1)));
+
+ verify(userService, times(1)).createUser(any());
+ }
+
+ }
+
+ @Nested
+ @DisplayName("при удалении пользователя")
+ class DeleteUserTests {
+
+ @Test
+ @DisplayName("возвращать статус 204 без тела ответа")
+ void deleteUser_ReturnsNoContent() throws Exception {
+ doNothing().when(userService).deleteUser(anyLong());
+
+ mockMvc.perform(delete("/admin/users/1"))
+ .andExpect(status().isNoContent())
+ .andExpect(content().string(""));
+
+ verify(userService, times(1)).deleteUser(1L);
+ }
+
+ @Test
+ @DisplayName("возвращать 404 при удалении несуществующего пользователя")
+ void deleteUser_WithNonExistingId_ReturnsNotFound() throws Exception {
+ doThrow(new EntityNotFoundException("Пользователь","Id", 999L))
+ .when(userService).deleteUser(999L);
+
+ mockMvc.perform(delete("/admin/users/999"))
+ .andExpect(status().isNotFound());
+
+ verify(userService, times(1)).deleteUser(999L);
+ }
+ }
+
+ @Nested
+ @DisplayName("при получении пользователей")
+ class GetUsersTests {
+
+ @Test
+ @DisplayName("возвращать список всех пользователей без фильтрации по id")
+ void getUsers_WithoutIds_ReturnsAllUsers() throws Exception {
+ List users = Arrays.asList(
+ userDto,
+ createUserDto(2L, "Второй пользователь", "user2@example.com")
+ );
+
+ when(userService.getUsers(any())).thenReturn(users);
+
+ mockMvc.perform(get("/admin/users")
+ .param("from", "0")
+ .param("size", "10"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$", hasSize(2)))
+ .andExpect(jsonPath("$[0].id", is(1)))
+ .andExpect(jsonPath("$[1].id", is(2)));
+
+ verify(userService, times(1)).getUsers(any());
+ }
+
+ @Test
+ @DisplayName("возвращать отфильтрованный список пользователей при указании id")
+ void getUsers_WithIds_ReturnsFilteredUsers() throws Exception {
+ List users = Collections.singletonList(userDto);
+
+ when(userService.getUsers(any())).thenReturn(users);
+
+ mockMvc.perform(get("/admin/users")
+ .param("ids", "1")
+ .param("from", "0")
+ .param("size", "10"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$", hasSize(1)))
+ .andExpect(jsonPath("$[0].id", is(1)));
+
+ verify(userService, times(1)).getUsers(any());
+ }
+
+ @Test
+ @DisplayName("возвращать 400 при невалидных параметрах пагинации")
+ void getUsers_WithInvalidPaginationParams_ReturnsBadRequest() throws Exception {
+ mockMvc.perform(get("/admin/users")
+ .param("from", "-1")
+ .param("size", "0"))
+ .andExpect(status().isBadRequest());
+
+ verify(userService, never()).getUsers(any());
+ }
+
+ @Test
+ @DisplayName("возвращать корректный заголовок Content-Type в ответе")
+ void getUsers_ResponseHasCorrectContentTypeHeader() throws Exception {
+ List users = Arrays.asList(userDto);
+ when(userService.getUsers(any())).thenReturn(users);
+
+ mockMvc.perform(get("/admin/users")
+ .param("from", "0")
+ .param("size", "10"))
+ .andExpect(status().isOk())
+ .andExpect(header().string("Content-Type", containsString(MediaType.APPLICATION_JSON_VALUE)))
+ .andExpect(jsonPath("$", hasSize(1)));
+
+ verify(userService, times(1)).getUsers(any());
+ }
+ }
+
+ @Nested
+ @DisplayName("при обработке невалидного JSON")
+ class InvalidJsonTests {
+
+ @Test
+ @DisplayName("возвращать 400 при синтаксически некорректном JSON")
+ void request_WithInvalidJson_ReturnsBadRequest() throws Exception {
+ String invalidJson = "{\"name\":\"Тестовый пользователь\", \"email\":\"invalid-json";
+
+ mockMvc.perform(post("/admin/users")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(invalidJson))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.message", notNullValue()))
+ .andExpect(jsonPath("$.reason", containsString("Malformed JSON")));
+
+ verify(userService, never()).createUser(any());
+ }
+
+ @Test
+ @DisplayName("возвращать 400 при некорректном JSON-массиве")
+ void request_WithMalformedJsonArray_ReturnsBadRequest() throws Exception {
+ String invalidJsonArray = "[{\"name\":\"Тестовый пользователь\",}]"; // Ошибка - лишняя запятая
+
+ mockMvc.perform(post("/admin/users")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(invalidJsonArray))
+ .andExpect(status().isBadRequest());
+
+ verify(userService, never()).createUser(any());
+ }
+ }
+
+ // Вспомогательный метод для создания UserDto
+ private UserDto createUserDto(Long id, String name, String email) {
+ UserDto dto = new UserDto();
+ dto.setId(id);
+ dto.setName(name);
+ dto.setEmail(email);
+ return dto;
+ }
+}
\ No newline at end of file
diff --git a/main-service/src/test/java/ru/practicum/explorewithme/main/controller/priv/PrivateCommentControllerTest.java b/main-service/src/test/java/ru/practicum/explorewithme/main/controller/priv/PrivateCommentControllerTest.java
new file mode 100644
index 0000000..88f0583
--- /dev/null
+++ b/main-service/src/test/java/ru/practicum/explorewithme/main/controller/priv/PrivateCommentControllerTest.java
@@ -0,0 +1,271 @@
+package ru.practicum.explorewithme.main.controller.priv;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+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.explorewithme.main.dto.CommentDto;
+import ru.practicum.explorewithme.main.dto.NewCommentDto;
+import ru.practicum.explorewithme.main.dto.UpdateCommentDto;
+import ru.practicum.explorewithme.main.dto.UserShortDto;
+import ru.practicum.explorewithme.main.service.CommentService;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
+
+
+@WebMvcTest(PrivateCommentController.class)
+public class PrivateCommentControllerTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @MockitoBean
+ private CommentService commentService;
+
+ private ObjectMapper objectMapper;
+
+ private final Long userId = 1L;
+ private final Long eventId = 100L;
+
+ private NewCommentDto newCommentDto;
+ private CommentDto commentDto;
+
+ @BeforeEach
+ void setUp() {
+ objectMapper = new ObjectMapper();
+ objectMapper.findAndRegisterModules();
+
+ newCommentDto = NewCommentDto.builder()
+ .text("Test comment text")
+ .build();
+
+ UserShortDto author = UserShortDto.builder()
+ .id(2L)
+ .name("testUser")
+ .build();
+
+ commentDto = CommentDto.builder()
+ .id(10L)
+ .text(newCommentDto.getText())
+ .author(author)
+ .eventId(eventId)
+ .createdOn(LocalDateTime.now())
+ .updatedOn(LocalDateTime.now())
+ .isEdited(false)
+ .build();
+ }
+
+ @Nested
+ @DisplayName("Набор тестов для метода createComment")
+ class CreateComment {
+
+ @Test
+ void createComment_whenValidInput_thenReturnsCreatedComment() throws Exception {
+ when(commentService.addComment(eq(userId), eq(eventId), any(NewCommentDto.class)))
+ .thenReturn(commentDto);
+
+ mockMvc.perform(post("/users/{userId}/comments?eventId={eventId}", userId, eventId)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(newCommentDto)))
+ .andExpect(status().isCreated())
+ .andExpect(jsonPath("$.id").value(commentDto.getId()))
+ .andExpect(jsonPath("$.text").value(commentDto.getText()))
+ .andExpect(jsonPath("$.eventId").value(eventId))
+ .andExpect(jsonPath("$.author.id").value(commentDto.getAuthor().getId()))
+ .andExpect(jsonPath("$.author.name").value(commentDto.getAuthor().getName()))
+ .andExpect(jsonPath("$.isEdited").value(false));
+ }
+
+ @Test
+ void createComment_whenInvalidText_thenReturnsBadRequest() throws Exception {
+ NewCommentDto invalidDto = NewCommentDto.builder()
+ .text("")
+ .build();
+
+ mockMvc.perform(post("/users/{userId}/comments?eventId={eventId}", userId, eventId)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(invalidDto)))
+ .andExpect(status().isBadRequest());
+ }
+
+ @Test
+ void createComment_whenNegativeUserId_thenReturnsBadRequest() throws Exception {
+ mockMvc.perform(post("/users/{userId}/comments?eventId={eventId}", -1, eventId)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(newCommentDto)))
+ .andExpect(status().isBadRequest());
+ }
+
+ @Test
+ void createComment_whenNegativeEventId_thenReturnsBadRequest() throws Exception {
+ mockMvc.perform(post("/users/{userId}/comments?eventId={eventId}", userId, -1)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(newCommentDto)))
+ .andExpect(status().isBadRequest());
+ }
+ }
+
+ @Nested
+ @DisplayName("Набор тестов для метода updateComment")
+ class UpdateComment {
+
+ @Test
+ void updateComment_shouldReturnUpdatedComment_whenInputIsValid() throws Exception {
+ Long commentId = commentDto.getId();
+
+ UpdateCommentDto updateCommentDto = UpdateCommentDto.builder()
+ .text("Updated text")
+ .build();
+
+ CommentDto updatedComment = CommentDto.builder()
+ .id(commentId)
+ .text(updateCommentDto.getText())
+ .author(commentDto.getAuthor())
+ .eventId(eventId)
+ .createdOn(commentDto.getCreatedOn())
+ .updatedOn(commentDto.getUpdatedOn())
+ .isEdited(true)
+ .build();
+
+ when(commentService.updateUserComment(eq(userId), eq(commentId), any(UpdateCommentDto.class)))
+ .thenReturn(updatedComment);
+
+ mockMvc.perform(patch("/users/{userId}/comments/{commentId}", userId, commentId)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(updateCommentDto)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.id").value(commentId))
+ .andExpect(jsonPath("$.text").value(updateCommentDto.getText()))
+ .andExpect(jsonPath("$.author.id").value(commentDto.getAuthor().getId()))
+ .andExpect(jsonPath("$.isEdited").value(true));
+
+ verify(commentService, times(1))
+ .updateUserComment(eq(userId), eq(commentId), any(UpdateCommentDto.class));
+ }
+
+ @Test
+ void updateComment_shouldReturnBadRequest_whenPathVariablesInvalid() throws Exception {
+ UpdateCommentDto updateCommentDto = UpdateCommentDto.builder()
+ .text("Comment text")
+ .build();
+
+ mockMvc.perform(patch("/users/{userId}/comments/{commentId}", -1, 10)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(updateCommentDto)))
+ .andExpect(status().isBadRequest());
+
+ mockMvc.perform(patch("/users/{userId}/comments/{commentId}", 1, -10)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(updateCommentDto)))
+ .andExpect(status().isBadRequest());
+ }
+
+ @Test
+ void updateComment_shouldReturnBadRequest_whenBodyTextBlank() throws Exception {
+ UpdateCommentDto updateCommentDto = UpdateCommentDto.builder()
+ .text(" ")
+ .build();
+
+ mockMvc.perform(patch("/users/{userId}/comments/{commentId}", userId, commentDto.getId())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(updateCommentDto)))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.errors[0]").exists())
+ .andExpect(jsonPath("$.errors[0]").value("text: Comment text cannot be blank."));
+ }
+
+ @Test
+ void updateComment_shouldReturnBadRequest_whenBodyTextTooLong() throws Exception {
+ String longText = "a".repeat(2001);
+ UpdateCommentDto updateCommentDto = UpdateCommentDto.builder()
+ .text(longText)
+ .build();
+
+ mockMvc.perform(patch("/users/{userId}/comments/{commentId}", userId, commentDto.getId())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(updateCommentDto)))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.errors[0]").exists())
+ .andExpect(jsonPath("$.errors[0]").value("text: Comment text must be between 1 and 2000 characters."));
+ }
+
+ @Test
+ void updateComment_shouldReturnBadRequest_whenBodyEmpty() throws Exception {
+ mockMvc.perform(patch("/users/{userId}/comments/{commentId}", userId, commentDto.getId())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{}"))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.errors[0]").exists())
+ .andExpect(jsonPath("$.errors[0]").value("text: Comment text cannot be blank."));
+ }
+ }
+
+ @Nested
+ @DisplayName("Набор тестов для метода getUserComments")
+ class GetUserComments {
+
+ @Test
+ void getUserCommentsReturnsCommentsListWithStatusOk() throws Exception {
+
+ List comments = List.of(commentDto);
+
+ when(commentService.getUserComments(userId, 0, 10)).thenReturn(comments);
+
+ mockMvc.perform(get("/users/{userId}/comments", userId)
+ .param("from", "0")
+ .param("size", "10")
+ .accept(MediaType.APPLICATION_JSON))
+ .andExpect(status().isOk())
+ .andExpect(content().json(objectMapper.writeValueAsString(comments)));
+
+ verify(commentService, times(1)).getUserComments(userId, 0, 10);
+ }
+
+ @Test
+ void getUserCommentsReturnsEmptyListWhenNoComments() throws Exception {
+
+ when(commentService.getUserComments(userId, 0, 10)).thenReturn(List.of());
+
+ mockMvc.perform(get("/users/{userId}/comments", userId)
+ .param("from", "0")
+ .param("size", "10")
+ .accept(MediaType.APPLICATION_JSON))
+ .andExpect(status().isOk())
+ .andExpect(content().json("[]"));
+ }
+
+ @Test
+ void getUserCommentsReturnsBadRequestWhenFromIsNegative() throws Exception {
+
+ mockMvc.perform(get("/users/{userId}/comments", userId)
+ .param("from", "-1")
+ .param("size", "10")
+ .accept(MediaType.APPLICATION_JSON))
+ .andExpect(status().isBadRequest());
+ }
+
+ @Test
+ void getUserCommentsReturnsBadRequestWhenSizeIsZero() throws Exception {
+
+ mockMvc.perform(get("/users/{userId}/comments", userId)
+ .param("from", "0")
+ .param("size", "0")
+ .accept(MediaType.APPLICATION_JSON))
+ .andExpect(status().isBadRequest());
+ }
+
+ }
+}
diff --git a/main-service/src/test/java/ru/practicum/explorewithme/main/controller/priv/PrivateEventControllerTest.java b/main-service/src/test/java/ru/practicum/explorewithme/main/controller/priv/PrivateEventControllerTest.java
new file mode 100644
index 0000000..ff1be9b
--- /dev/null
+++ b/main-service/src/test/java/ru/practicum/explorewithme/main/controller/priv/PrivateEventControllerTest.java
@@ -0,0 +1,302 @@
+package ru.practicum.explorewithme.main.controller.priv;
+
+import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.is;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+import static ru.practicum.explorewithme.common.constants.DateTimeConstants.DATE_TIME_FORMATTER;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.nio.charset.StandardCharsets;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.Collections;
+import java.util.List;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+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.explorewithme.main.dto.EventFullDto;
+import ru.practicum.explorewithme.main.dto.EventShortDto;
+import ru.practicum.explorewithme.main.dto.UpdateEventUserRequestDto;
+import ru.practicum.explorewithme.main.error.BusinessRuleViolationException;
+import ru.practicum.explorewithme.main.error.EntityNotFoundException;
+import ru.practicum.explorewithme.main.service.EventService;
+import ru.practicum.explorewithme.main.service.RequestService;
+
+@WebMvcTest(PrivateEventController.class)
+@DisplayName("Тесты для PrivateEventController")
+class PrivateEventControllerTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private ObjectMapper objectMapper; // Может пригодиться в дальнейших тестах
+
+ @MockitoBean
+ private EventService eventService;
+
+ @MockitoBean
+ private RequestService requestService;
+
+ private final Long testUserId = 1L;
+ private final DateTimeFormatter formatter = DATE_TIME_FORMATTER;
+
+
+ @Nested
+ @DisplayName("GET /users/{userId}/events: Получение списка событий пользователя")
+ class GetUserEventsListTest {
+ @Test
+ @DisplayName("должен вернуть 200 OK и пустой список, если событий не найдено")
+ void getEventsAddedByCurrentUser_whenNoEventsFound_shouldReturnOkAndEmptyList() throws Exception {
+ when(eventService.getEventsByOwner(eq(testUserId), eq(0), eq(10)))
+ .thenReturn(Collections.emptyList());
+
+ mockMvc.perform(get("/users/{userId}/events", testUserId)
+ .param("from", "0")
+ .param("size", "10")
+ .contentType(MediaType.APPLICATION_JSON)
+ .characterEncoding(StandardCharsets.UTF_8))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$", hasSize(0)));
+
+ verify(eventService).getEventsByOwner(testUserId, 0, 10);
+ }
+
+ @Test
+ @DisplayName("должен вернуть 200 OK и список событий, если они найдены")
+ void getEventsAddedByCurrentUser_whenEventsFound_shouldReturnOkAndEventList() throws Exception {
+ LocalDateTime now = LocalDateTime.now().withNano(0);
+ EventShortDto eventDto1 = EventShortDto.builder().id(1L).title("Event 1").eventDate(now).build();
+ EventShortDto eventDto2 = EventShortDto.builder().id(2L).title("Event 2").eventDate(now).build();
+ List events = List.of(eventDto1, eventDto2);
+
+ when(eventService.getEventsByOwner(eq(testUserId), eq(0), eq(20)))
+ .thenReturn(events);
+
+ mockMvc.perform(get("/users/{userId}/events", testUserId)
+ .param("from", "0")
+ .param("size", "20")
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$", hasSize(2)))
+ .andExpect(jsonPath("$[0].id", is(eventDto1.getId().intValue())))
+ .andExpect(jsonPath("$[0].title", is(eventDto1.getTitle())))
+ .andExpect(jsonPath("$[0].eventDate", is(now.format(formatter))))
+ .andExpect(jsonPath("$[1].id", is(eventDto2.getId().intValue())))
+ .andExpect(jsonPath("$[1].title", is(eventDto2.getTitle())));
+
+ verify(eventService).getEventsByOwner(testUserId, 0, 20);
+ }
+
+ @Test
+ @DisplayName("должен использовать значения по умолчанию для from и size")
+ void getEventsAddedByCurrentUser_withDefaultPagination_shouldUseDefaultValues() throws Exception {
+ when(eventService.getEventsByOwner(eq(testUserId), eq(0), eq(10)))
+ .thenReturn(Collections.emptyList());
+
+ mockMvc.perform(get("/users/{userId}/events", testUserId)
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isOk());
+
+ verify(eventService).getEventsByOwner(testUserId, 0, 10);
+ }
+
+ @Test
+ @DisplayName("должен вернуть 400 Bad Request при невалидном from")
+ void getEventsAddedByCurrentUser_withInvalidFrom_shouldReturnBadRequest() throws Exception {
+ mockMvc.perform(get("/users/{userId}/events", testUserId)
+ .param("from", "-1")
+ .param("size", "10")
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isBadRequest());
+ verifyNoInteractions(eventService); // Валидация происходит на уровне контроллера
+ }
+
+ @Test
+ @DisplayName("должен вернуть 400 Bad Request при невалидном size")
+ void getEventsAddedByCurrentUser_withInvalidSize_shouldReturnBadRequest() throws Exception {
+ mockMvc.perform(get("/users/{userId}/events", testUserId)
+ .param("from", "0")
+ .param("size", "0")
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isBadRequest());
+ verifyNoInteractions(eventService); // Валидация происходит на уровне контроллера
+ }
+ }
+
+ @Nested
+ @DisplayName("GET /users/{userId}/events/{eventId}: Получение полной информации о событии пользователя")
+ class GetFullEventInfoByOwnerTest {
+
+ private final Long testEventId = 100L;
+
+ @Test
+ @DisplayName("должен вернуть 200 OK и EventFullDto, если событие найдено и принадлежит пользователю")
+ void getFullEventInfoByOwner_whenEventFound_shouldReturnOkAndEventFullDto() throws Exception {
+ LocalDateTime now = LocalDateTime.now().withNano(0);
+ EventFullDto expectedDto = EventFullDto.builder()
+ .id(testEventId)
+ .title("Full Event Title")
+ .annotation("Full Annotation")
+ .description("Full Description")
+ .eventDate(now.plusDays(1))
+ .createdOn(now.minusHours(5))
+ .paid(true)
+ .participantLimit(50)
+ .build();
+
+ when(eventService.getEventPrivate(eq(testUserId), eq(testEventId))).thenReturn(expectedDto);
+
+ mockMvc.perform(get("/users/{userId}/events/{eventId}", testUserId, testEventId)
+ .contentType(MediaType.APPLICATION_JSON)
+ .characterEncoding(StandardCharsets.UTF_8))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.id", is(expectedDto.getId().intValue())))
+ .andExpect(jsonPath("$.title", is(expectedDto.getTitle())))
+ .andExpect(jsonPath("$.annotation", is(expectedDto.getAnnotation())))
+ .andExpect(jsonPath("$.eventDate", is(expectedDto.getEventDate().format(formatter))));
+
+ verify(eventService).getEventPrivate(testUserId, testEventId);
+ }
+
+ @Test
+ @DisplayName("должен вернуть 404 Not Found, если событие не найдено или не принадлежит пользователю")
+ void getFullEventInfoByOwner_whenEventNotFound_shouldReturnNotFound() throws Exception {
+ String errorMessage = String.format("Event with id=%d and initiatorId=%d not found", testEventId, testUserId);
+ when(eventService.getEventPrivate(eq(testUserId), eq(testEventId)))
+ .thenThrow(new EntityNotFoundException(errorMessage));
+
+ mockMvc.perform(get("/users/{userId}/events/{eventId}", testUserId, testEventId)
+ .contentType(MediaType.APPLICATION_JSON)
+ .characterEncoding(StandardCharsets.UTF_8))
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.reason", is("Requested object not found")))
+ .andExpect(jsonPath("$.message", is(errorMessage)));
+
+ verify(eventService).getEventPrivate(testUserId, testEventId);
+ }
+
+ @Test
+ @DisplayName("должен вернуть 400 Bad Request при невалидном userId в пути")
+ void getFullEventInfoByOwner_withInvalidUserIdPath_shouldReturnBadRequest() throws Exception {
+ mockMvc.perform(get("/users/{userId}/events/{eventId}", "invalidUserId", testEventId))
+ .andExpect(status().isBadRequest()); // Ошибка преобразования типа для @PathVariable Long userId
+ verifyNoInteractions(eventService);
+ }
+
+ @Test
+ @DisplayName("должен вернуть 400 Bad Request при невалидном eventId в пути")
+ void getFullEventInfoByOwner_withInvalidEventIdPath_shouldReturnBadRequest() throws Exception {
+ mockMvc.perform(get("/users/{userId}/events/{eventId}", testUserId, "invalidEventId"))
+ .andExpect(status().isBadRequest());
+ verifyNoInteractions(eventService);
+ }
+ }
+
+ @Nested
+ @DisplayName("PATCH /users/{userId}/events/{eventId}: Изменение события пользователем")
+ class UpdateEventByOwnerTest {
+
+ private final Long testEventId = 200L;
+
+ private UpdateEventUserRequestDto createValidUpdateDto() {
+ return UpdateEventUserRequestDto.builder()
+ .title("Updated Event Title")
+ .annotation("Valid Updated Annotation")
+ .eventDate(LocalDateTime.now().plusHours(3).withNano(0))
+ .build();
+ }
+
+ @Test
+ @DisplayName("должен вернуть 200 OK и обновленный EventFullDto при успешном обновлении")
+ void updateEventByOwner_whenUpdateSuccessful_shouldReturnOkAndUpdatedDto() throws Exception {
+ UpdateEventUserRequestDto updateDto = createValidUpdateDto();
+ EventFullDto updatedEventFullDto = EventFullDto.builder()
+ .id(testEventId)
+ .title(updateDto.getTitle())
+ .annotation(updateDto.getAnnotation())
+ .eventDate(updateDto.getEventDate())
+ .build();
+
+ when(eventService.updateEventByOwner(eq(testUserId), eq(testEventId), any(UpdateEventUserRequestDto.class)))
+ .thenReturn(updatedEventFullDto);
+
+ mockMvc.perform(patch("/users/{userId}/events/{eventId}", testUserId, testEventId)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(updateDto))
+ .characterEncoding(StandardCharsets.UTF_8))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.id", is(testEventId.intValue())))
+ .andExpect(jsonPath("$.title", is(updateDto.getTitle())))
+ .andExpect(jsonPath("$.annotation", is(updateDto.getAnnotation())))
+ .andExpect(jsonPath("$.eventDate", is(updateDto.getEventDate().format(formatter))));
+
+ verify(eventService).updateEventByOwner(testUserId, testEventId, updateDto);
+ }
+
+ @Test
+ @DisplayName("должен вернуть 400 Bad Request, если DTO обновления невалиден (например, короткое название)")
+ void updateEventByOwner_whenDtoIsInvalid_shouldReturnBadRequest() throws Exception {
+ UpdateEventUserRequestDto invalidUpdateDto = UpdateEventUserRequestDto.builder()
+ .title("S")
+ .build();
+
+ mockMvc.perform(patch("/users/{userId}/events/{eventId}", testUserId, testEventId)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(invalidUpdateDto))
+ .characterEncoding(StandardCharsets.UTF_8))
+ .andExpect(status().isBadRequest());
+
+ verifyNoInteractions(eventService);
+ }
+
+ @Test
+ @DisplayName("должен вернуть 404 Not Found, если событие не найдено или не принадлежит пользователю")
+ void updateEventByOwner_whenEventNotFound_shouldReturnNotFound() throws Exception {
+ UpdateEventUserRequestDto updateDto = createValidUpdateDto();
+ String errorMessage = "Event or user not found";
+ when(eventService.updateEventByOwner(eq(testUserId), eq(testEventId), any(UpdateEventUserRequestDto.class)))
+ .thenThrow(new EntityNotFoundException(errorMessage));
+
+ mockMvc.perform(patch("/users/{userId}/events/{eventId}", testUserId, testEventId)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(updateDto))
+ .characterEncoding(StandardCharsets.UTF_8))
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.message", is(errorMessage)));
+
+ verify(eventService).updateEventByOwner(testUserId, testEventId, updateDto);
+ }
+
+ @Test
+ @DisplayName("должен вернуть 409 Conflict, если событие нельзя обновить (например, уже опубликовано)")
+ void updateEventByOwner_whenUpdateNotAllowed_shouldReturnConflict() throws Exception {
+ UpdateEventUserRequestDto updateDto = createValidUpdateDto();
+ String errorMessage = "Cannot update published event";
+ when(eventService.updateEventByOwner(eq(testUserId), eq(testEventId), any(UpdateEventUserRequestDto.class)))
+ .thenThrow(new BusinessRuleViolationException(errorMessage));
+
+ mockMvc.perform(patch("/users/{userId}/events/{eventId}", testUserId, testEventId)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(updateDto))
+ .characterEncoding(StandardCharsets.UTF_8))
+ .andExpect(status().isConflict())
+ .andExpect(jsonPath("$.message", is(errorMessage)));
+
+ verify(eventService).updateEventByOwner(testUserId, testEventId, updateDto);
+ }
+ }
+}
\ No newline at end of file
diff --git a/main-service/src/test/java/ru/practicum/explorewithme/main/controller/pub/PublicCategoryControllerTest.java b/main-service/src/test/java/ru/practicum/explorewithme/main/controller/pub/PublicCategoryControllerTest.java
new file mode 100644
index 0000000..0765829
--- /dev/null
+++ b/main-service/src/test/java/ru/practicum/explorewithme/main/controller/pub/PublicCategoryControllerTest.java
@@ -0,0 +1,187 @@
+package ru.practicum.explorewithme.main.controller.pub;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+import ru.practicum.explorewithme.main.dto.CategoryDto;
+import ru.practicum.explorewithme.main.error.EntityNotFoundException;
+import ru.practicum.explorewithme.main.service.CategoryService;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.hamcrest.Matchers.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
+
+@WebMvcTest(PublicCategoryController.class)
+@DisplayName("Публичный контроллер категорий должен")
+class PublicCategoryControllerTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private ObjectMapper objectMapper;
+
+ @MockitoBean
+ private CategoryService categoryService;
+
+ private CategoryDto categoryDto;
+ private CategoryDto anotherCategoryDto;
+
+ @BeforeEach
+ void setUp() {
+ categoryDto = new CategoryDto();
+ categoryDto.setId(1L);
+ categoryDto.setName("Тестовая категория");
+
+ anotherCategoryDto = new CategoryDto();
+ anotherCategoryDto.setId(2L);
+ anotherCategoryDto.setName("Другая категория");
+ }
+
+ @Nested
+ @DisplayName("при получении списка категорий")
+ class GetAllCategoriesTests {
+
+ @Test
+ @DisplayName("возвращать список всех категорий со статусом 200")
+ void getAllCategories_ReturnsListOfCategories() throws Exception {
+ List categories = Arrays.asList(categoryDto, anotherCategoryDto);
+
+ when(categoryService.getAllCategories(anyInt(), anyInt())).thenReturn(categories);
+
+ mockMvc.perform(get("/categories")
+ .param("from", "0")
+ .param("size", "10"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$", hasSize(2)))
+ .andExpect(jsonPath("$[0].id", is(1)))
+ .andExpect(jsonPath("$[0].name", is("Тестовая категория")))
+ .andExpect(jsonPath("$[1].id", is(2)))
+ .andExpect(jsonPath("$[1].name", is("Другая категория")));
+
+ verify(categoryService, times(1)).getAllCategories(0, 10);
+ }
+
+ @Test
+ @DisplayName("возвращать пустой список, если категорий нет")
+ void getAllCategories_WhenNoCategories_ReturnsEmptyList() throws Exception {
+ when(categoryService.getAllCategories(anyInt(), anyInt())).thenReturn(Collections.emptyList());
+
+ mockMvc.perform(get("/categories")
+ .param("from", "0")
+ .param("size", "10"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$", hasSize(0)));
+
+ verify(categoryService, times(1)).getAllCategories(0, 10);
+ }
+
+ @Test
+ @DisplayName("применять параметры пагинации")
+ void getAllCategories_WithPaginationParams_UsesThem() throws Exception {
+ List categories = Collections.singletonList(categoryDto);
+
+ when(categoryService.getAllCategories(eq(5), eq(3))).thenReturn(categories);
+
+ mockMvc.perform(get("/categories")
+ .param("from", "5")
+ .param("size", "3"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$", hasSize(1)));
+
+ verify(categoryService, times(1)).getAllCategories(5, 3);
+ }
+
+ @Test
+ @DisplayName("возвращать 400 при невалидных параметрах пагинации")
+ void getAllCategories_WithInvalidPaginationParams_ReturnsBadRequest() throws Exception {
+ mockMvc.perform(get("/categories")
+ .param("from", "-1")
+ .param("size", "0"))
+ .andExpect(status().isBadRequest());
+
+ verify(categoryService, never()).getAllCategories(anyInt(), anyInt());
+ }
+
+ @Test
+ @DisplayName("возвращать корректный заголовок Content-Type в ответе")
+ void getAllCategories_ResponseHasCorrectContentTypeHeader() throws Exception {
+ List categories = Collections.singletonList(categoryDto);
+ when(categoryService.getAllCategories(anyInt(), anyInt())).thenReturn(categories);
+
+ mockMvc.perform(get("/categories")
+ .param("from", "0")
+ .param("size", "10"))
+ .andExpect(status().isOk())
+ .andExpect(header().string("Content-Type", containsString(MediaType.APPLICATION_JSON_VALUE)))
+ .andExpect(jsonPath("$", hasSize(1)));
+
+ verify(categoryService, times(1)).getAllCategories(0, 10);
+ }
+ }
+
+ @Nested
+ @DisplayName("при получении категории по ID")
+ class GetCategoryByIdTests {
+
+ @Test
+ @DisplayName("возвращать категорию со статусом 200")
+ void getCategoryById_ReturnsCategoryWithStatus200() throws Exception {
+ when(categoryService.getCategoryById(eq(1L))).thenReturn(categoryDto);
+
+ mockMvc.perform(get("/categories/1"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.id", is(1)))
+ .andExpect(jsonPath("$.name", is("Тестовая категория")));
+
+ verify(categoryService, times(1)).getCategoryById(1L);
+ }
+
+ @Test
+ @DisplayName("возвращать 404 при запросе несуществующей категории")
+ void getCategoryById_WithNonExistingId_ReturnsNotFound() throws Exception {
+ when(categoryService.getCategoryById(eq(999L)))
+ .thenThrow(new EntityNotFoundException("Category", "Id", 999L));
+
+ mockMvc.perform(get("/categories/999"))
+ .andExpect(status().isNotFound());
+
+ verify(categoryService, times(1)).getCategoryById(999L);
+ }
+
+ @Test
+ @DisplayName("возвращать 400 при невалидном ID в пути")
+ void getCategoryById_WithInvalidIdFormat_ReturnsBadRequest() throws Exception {
+ mockMvc.perform(get("/categories/invalid-id"))
+ .andExpect(status().isBadRequest());
+
+ verify(categoryService, never()).getCategoryById(anyLong());
+ }
+
+ @Test
+ @DisplayName("возвращать корректный заголовок Content-Type в ответе")
+ void getCategoryById_ResponseHasCorrectContentTypeHeader() throws Exception {
+ when(categoryService.getCategoryById(anyLong())).thenReturn(categoryDto);
+
+ mockMvc.perform(get("/categories/1"))
+ .andExpect(status().isOk())
+ .andExpect(header().string("Content-Type", containsString(MediaType.APPLICATION_JSON_VALUE)));
+
+ verify(categoryService, times(1)).getCategoryById(1L);
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/main-service/src/test/java/ru/practicum/explorewithme/main/controller/pub/PublicCommentControllerTest.java b/main-service/src/test/java/ru/practicum/explorewithme/main/controller/pub/PublicCommentControllerTest.java
new file mode 100644
index 0000000..44897e3
--- /dev/null
+++ b/main-service/src/test/java/ru/practicum/explorewithme/main/controller/pub/PublicCommentControllerTest.java
@@ -0,0 +1,170 @@
+package ru.practicum.explorewithme.main.controller.pub;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+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.explorewithme.main.dto.CommentDto;
+import ru.practicum.explorewithme.main.service.CommentService;
+import ru.practicum.explorewithme.main.dto.UserShortDto;
+import ru.practicum.explorewithme.main.service.params.PublicCommentParameters;
+
+import java.time.LocalDateTime;
+import java.util.Collections;
+import java.util.List;
+
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
+import static org.assertj.core.api.Assertions.assertThat;
+
+@WebMvcTest(PublicCommentController.class)
+@DisplayName("Публичный контроллер комментариев должен")
+class PublicCommentControllerTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private ObjectMapper objectMapper;
+
+ @MockitoBean
+ private CommentService commentService;
+
+ private CommentDto commentDto;
+ private CommentDto anotherCommentDto;
+
+ @BeforeEach
+ void setUp() {
+ UserShortDto firstAuthor = UserShortDto.builder()
+ .id(100L)
+ .name("Автор 1")
+ .build();
+
+ UserShortDto secondAuthor = UserShortDto.builder()
+ .id(101L)
+ .name("Автор 2")
+ .build();
+
+ commentDto = CommentDto.builder()
+ .id(1L)
+ .text("Первый комментарий")
+ .author(firstAuthor)
+ .createdOn(LocalDateTime.now().minusDays(1))
+ .build();
+
+ anotherCommentDto = CommentDto.builder()
+ .id(2L)
+ .text("Второй комментарий")
+ .author(secondAuthor)
+ .createdOn(LocalDateTime.now())
+ .build();
+ }
+
+ @Nested
+ @DisplayName("при получении списка комментариев события")
+ class GetCommentsTests {
+
+ @Test
+ @DisplayName("возвращать список комментариев со статусом 200")
+ void getComments_ReturnsList() throws Exception {
+ long eventId = 5L;
+ List expected = List.of(commentDto, anotherCommentDto);
+
+ when(commentService.getCommentsForEvent(eq(eventId), any(PublicCommentParameters.class)))
+ .thenReturn(expected);
+
+ mockMvc.perform(get("/events/{eventId}/comments", eventId)
+ .param("sort", "createdOn,DESC")
+ .accept(MediaType.APPLICATION_JSON))
+ .andExpect(status().isOk())
+ .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
+ .andExpect(jsonPath("$.length()").value(2))
+ .andExpect(jsonPath("$[0].id").value(commentDto.getId()))
+ .andExpect(jsonPath("$[1].id").value(anotherCommentDto.getId()));
+ }
+
+ @Test
+ @DisplayName("возвращать пустой список, если комментариев нет")
+ void getComments_WhenNone_ReturnsEmptyList() throws Exception {
+ long eventId = 7L;
+
+ when(commentService.getCommentsForEvent(eq(eventId), any(PublicCommentParameters.class)))
+ .thenReturn(Collections.emptyList());
+
+ mockMvc.perform(get("/events/{eventId}/comments", eventId)
+ .param("sort", "createdOn,DESC"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.length()").value(0));
+ }
+
+ @Test
+ @DisplayName("применять параметры пагинации и сортировки")
+ void getComments_UsesPaginationAndSorting() throws Exception {
+ long eventId = 11L;
+
+ when(commentService.getCommentsForEvent(eq(eventId), any(PublicCommentParameters.class)))
+ .thenReturn(List.of(commentDto));
+
+ mockMvc.perform(get("/events/{eventId}/comments", eventId)
+ .param("from", "20")
+ .param("size", "5")
+ .param("sort", "createdOn,ASC"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.length()").value(1));
+ }
+
+ @Test
+ @DisplayName("использовать значения по умолчанию, когда параметры не переданы")
+ void getComments_DefaultParamsAreUsed() throws Exception {
+ long eventId = 13L;
+
+ when(commentService.getCommentsForEvent(eq(eventId), any(PublicCommentParameters.class)))
+ .thenReturn(Collections.emptyList());
+
+ mockMvc.perform(get("/events/{eventId}/comments", eventId))
+ .andExpect(status().isOk());
+
+ var captor = ArgumentCaptor.forClass(PublicCommentParameters.class);
+ verify(commentService).getCommentsForEvent(eq(eventId), captor.capture());
+
+ PublicCommentParameters params = captor.getValue();
+ assertThat(params.getFrom()).isZero();
+ assertThat(params.getSize()).isEqualTo(10);
+ assertThat(params.getSort().toString()).isEqualTo("createdOn: DESC");
+ }
+
+ @Test
+ @DisplayName("возвращать 400, если параметр sort не соответствует паттерну")
+ void getComments_InvalidSort_ReturnsBadRequest() throws Exception {
+ mockMvc.perform(get("/events/{eventId}/comments", 1)
+ .param("sort", "wrong,value"))
+ .andExpect(status().isBadRequest());
+ }
+
+ @Test
+ @DisplayName("возвращать 400 при отрицательном 'from'")
+ void getComments_NegativeFrom_ReturnsBadRequest() throws Exception {
+ mockMvc.perform(get("/events/{eventId}/comments", 1)
+ .param("from", "-1"))
+ .andExpect(status().isBadRequest());
+ }
+
+ @Test
+ @DisplayName("возвращать 400 при отрицательном 'size'")
+ void getComments_NegativeSize_ReturnsBadRequest() throws Exception {
+ mockMvc.perform(get("/events/{eventId}/comments", 1)
+ .param("size", "-5"))
+ .andExpect(status().isBadRequest());
+ }
+ }
+}
\ No newline at end of file
diff --git a/main-service/src/test/java/ru/practicum/explorewithme/main/controller/pub/PublicEventControllerTest.java b/main-service/src/test/java/ru/practicum/explorewithme/main/controller/pub/PublicEventControllerTest.java
new file mode 100644
index 0000000..1d2a6d0
--- /dev/null
+++ b/main-service/src/test/java/ru/practicum/explorewithme/main/controller/pub/PublicEventControllerTest.java
@@ -0,0 +1,379 @@
+package ru.practicum.explorewithme.main.controller.pub;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.util.Arrays;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+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.context.annotation.EnableAspectJAutoProxy;
+import org.springframework.context.annotation.Import;
+import org.springframework.http.MediaType;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.test.web.servlet.MockMvc;
+import ru.practicum.explorewithme.main.aspect.StatsHitAspect;
+import ru.practicum.explorewithme.main.dto.EventFullDto;
+import ru.practicum.explorewithme.main.dto.EventShortDto;
+import ru.practicum.explorewithme.main.model.EventState;
+import ru.practicum.explorewithme.main.service.EventService;
+import ru.practicum.explorewithme.main.service.params.PublicEventSearchParams;
+import ru.practicum.explorewithme.stats.client.StatsClient;
+import ru.practicum.explorewithme.stats.dto.EndpointHitDto;
+
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.Collections;
+import java.util.List;
+
+import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.is;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+import static ru.practicum.explorewithme.common.constants.DateTimeConstants.DATE_TIME_FORMATTER;
+
+
+@WebMvcTest(PublicEventController.class)
+@Import({StatsHitAspect.class})
+@EnableAspectJAutoProxy
+@TestPropertySource(properties = {"spring.application.name=test-main-service-for-aspect"})
+@DisplayName("Тесты для PublicEventController и срабатывания StatsHitAspect")
+class PublicEventControllerTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private ObjectMapper objectMapper;
+
+ @MockitoBean
+ private EventService eventService;
+
+ @MockitoBean
+ private StatsClient statsClient;
+
+ @Value("${spring.application.name}")
+ private String configuredAppName;
+
+ @Captor
+ private ArgumentCaptor hitDtoCaptor;
+
+ private final DateTimeFormatter formatter = DATE_TIME_FORMATTER;
+ private final String testIpAddress = "192.168.0.1";
+
+
+ @BeforeEach
+ void setUp() {
+ }
+
+ @Nested
+ @DisplayName("GET /events")
+ class EventsEndpointTests {
+
+ @Test
+ @DisplayName("должен успешно возвращать список событий и отправлять хит в статистику")
+ void shouldReturnEventsAndLogHit() throws Exception {
+ EventShortDto event1 = EventShortDto.builder().id(1L).title("Event Alpha").build();
+ List mockEvents = List.of(event1);
+
+ when(eventService.getEventsPublic(any(PublicEventSearchParams.class), anyInt(), anyInt()))
+ .thenReturn(mockEvents);
+
+ mockMvc.perform(get("/events")
+ .param("text", "search text")
+ .param("from", "0")
+ .param("size", "10")
+ .header("X-Real-IP", testIpAddress)
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$", hasSize(1)))
+ .andExpect(jsonPath("$[0].title", is("Event Alpha")));
+
+ PublicEventSearchParams expectedSearchParams = PublicEventSearchParams.builder()
+ .text("search text")
+ .categories(null)
+ .paid(null)
+ .rangeStart(null)
+ .rangeEnd(null)
+ .onlyAvailable(false)
+ .sort("EVENT_DATE")
+ .build();
+ verify(eventService).getEventsPublic(eq(expectedSearchParams), eq(0), eq(10));
+
+ verify(statsClient, times(1)).saveHit(hitDtoCaptor.capture());
+ EndpointHitDto capturedHit = hitDtoCaptor.getValue();
+ assertEquals(configuredAppName, capturedHit.getApp());
+ assertEquals("/events", capturedHit.getUri());
+ assertEquals(testIpAddress, capturedHit.getIp());
+ assertNotNull(capturedHit.getTimestamp());
+ }
+
+ @Test
+ @DisplayName("должен отправлять хит даже если сервис событий вернул пустой список")
+ void whenServiceReturnsEmpty_shouldStillLogHit() throws Exception {
+ when(eventService.getEventsPublic(any(PublicEventSearchParams.class), anyInt(), anyInt()))
+ .thenReturn(Collections.emptyList());
+
+ mockMvc.perform(get("/events")
+ .header("X-Real-IP", testIpAddress)
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$", hasSize(0)));
+
+ verify(statsClient, times(1)).saveHit(any(EndpointHitDto.class));
+ }
+
+ @Test
+ @DisplayName("должен использовать IP из заголовка X-Real-IP, если он есть")
+ void withXRealIpHeader_shouldUseHeaderIpForHit() throws Exception {
+ when(eventService.getEventsPublic(any(), anyInt(), anyInt())).thenReturn(Collections.emptyList());
+
+ mockMvc.perform(get("/events")
+ .header("X-Real-IP", "10.0.0.1")
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isOk());
+
+ verify(statsClient).saveHit(hitDtoCaptor.capture());
+ assertEquals("10.0.0.1", hitDtoCaptor.getValue().getIp());
+ }
+
+ @Test
+ @DisplayName("должен использовать IP из request.getRemoteAddr(), если заголовок X-Real-IP отсутствует")
+ void withoutXRealIpHeader_shouldUseRemoteAddrForHit() throws Exception {
+ String defaultMockIp = "127.0.0.1";
+ when(eventService.getEventsPublic(any(), anyInt(), anyInt()))
+ .thenReturn(Collections.emptyList());
+
+ mockMvc.perform(get("/events")
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isOk());
+
+ verify(statsClient).saveHit(hitDtoCaptor.capture());
+ assertEquals(defaultMockIp, hitDtoCaptor.getValue().getIp());
+ }
+
+ @Test
+ @DisplayName("должен корректно передавать все параметры фильтрации в сервис")
+ void withAllFilters_shouldPassAllParamsToService() throws Exception {
+ String text = "party";
+ List categories = Arrays.asList(1L, 2L);
+ Boolean paid = true;
+ LocalDateTime rangeStart = LocalDateTime.now().plusDays(1).withNano(0);
+ LocalDateTime rangeEnd = LocalDateTime.now().plusDays(2).withNano(0);
+ boolean onlyAvailable = true;
+ String sort = "VIEWS";
+ int from = 5;
+ int size = 15;
+ String ip = "10.0.0.2";
+
+ PublicEventSearchParams expectedSearchParams = PublicEventSearchParams.builder()
+ .text(text)
+ .categories(categories)
+ .paid(paid)
+ .rangeStart(rangeStart)
+ .rangeEnd(rangeEnd)
+ .onlyAvailable(onlyAvailable)
+ .sort(sort)
+ .build();
+
+ when(eventService.getEventsPublic(eq(expectedSearchParams), eq(from), eq(size)))
+ .thenReturn(Collections.emptyList());
+
+ mockMvc.perform(get("/events")
+ .param("text", text)
+ .param("categories", "1", "2")
+ .param("paid", paid.toString())
+ .param("rangeStart", rangeStart.format(formatter))
+ .param("rangeEnd", rangeEnd.format(formatter))
+ .param("onlyAvailable", String.valueOf(onlyAvailable))
+ .param("sort", sort)
+ .param("from", String.valueOf(from))
+ .param("size", String.valueOf(size))
+ .header("X-Real-IP", ip)
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isOk());
+
+ verify(eventService).getEventsPublic(eq(expectedSearchParams), eq(from), eq(size));
+ verify(statsClient).saveHit(any(EndpointHitDto.class));
+ }
+
+ @Test
+ @DisplayName("должен использовать значения по умолчанию для onlyAvailable и sort, если они не переданы")
+ void withDefaultSortAndAvailability_shouldUseDefaultValuesInServiceCall() throws Exception {
+ int from = 0;
+ int size = 10;
+ String defaultMockIp = "127.0.0.1";
+
+
+ PublicEventSearchParams expectedSearchParams = PublicEventSearchParams.builder()
+ .text(null)
+ .categories(null)
+ .paid(null)
+ .rangeStart(null)
+ .rangeEnd(null)
+ .onlyAvailable(false)
+ .sort("EVENT_DATE")
+ .build();
+
+ when(eventService.getEventsPublic(eq(expectedSearchParams), eq(from), eq(size)))
+ .thenReturn(Collections.emptyList());
+
+ mockMvc.perform(get("/events")
+ .param("from", String.valueOf(from))
+ .param("size", String.valueOf(size))
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isOk());
+
+ verify(eventService).getEventsPublic(eq(expectedSearchParams), eq(from), eq(size));
+ verify(statsClient).saveHit(any(EndpointHitDto.class));
+ }
+
+ @Test
+ @DisplayName("должен вернуть 400 Bad Request при невалидном значении 'from'")
+ void withInvalidFrom_shouldReturnBadRequest() throws Exception {
+ mockMvc.perform(get("/events")
+ .param("from", "-1")
+ .param("size", "10")
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isBadRequest());
+ verifyNoInteractions(eventService);
+ verify(statsClient, never()).saveHit(any(EndpointHitDto.class));
+ }
+
+ @Test
+ @DisplayName("должен вернуть 400 Bad Request при невалидном значении 'size'")
+ void withInvalidSize_shouldReturnBadRequest() throws Exception {
+ mockMvc.perform(get("/events")
+ .param("from", "0")
+ .param("size", "0")
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isBadRequest());
+ verifyNoInteractions(eventService);
+ verify(statsClient, never()).saveHit(any(EndpointHitDto.class));
+ }
+ }
+
+ @Nested
+ @DisplayName("GET /events/{eventId}")
+ class EventsByIdEndpointTests {
+
+ @Test
+ @DisplayName("должен успешно возвращать событие и отправлять хит в статистику")
+ void shouldReturnEventAndLogHit() throws Exception {
+ Long eventId = 1L;
+ EventFullDto mockEvent = EventFullDto.builder()
+ .id(eventId)
+ .title("Specific Event")
+ .eventDate(LocalDateTime.now().plusDays(1).withNano(0))
+ .build();
+
+ when(eventService.getEventByIdPublic(eq(eventId))).thenReturn(mockEvent);
+
+ mockMvc.perform(get("/events/{eventId}", eventId)
+ .header("X-Real-IP", testIpAddress)
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.id", is(eventId.intValue())))
+ .andExpect(jsonPath("$.title", is("Specific Event")));
+
+ verify(eventService).getEventByIdPublic(eq(eventId));
+
+ verify(statsClient, times(1)).saveHit(hitDtoCaptor.capture());
+ EndpointHitDto capturedHit = hitDtoCaptor.getValue();
+ assertEquals(configuredAppName, capturedHit.getApp());
+ assertEquals("/events/" + eventId, capturedHit.getUri());
+ assertEquals(testIpAddress, capturedHit.getIp());
+ assertNotNull(capturedHit.getTimestamp());
+ }
+
+ @Test
+ @DisplayName("должен отправлять хит даже если сервис событий выбросил NotFoundException")
+ void whenServiceThrowsNotFound_shouldStillLogHitAndReturn404() throws Exception {
+ Long eventId = 999L;
+ when(eventService.getEventByIdPublic(eq(eventId)))
+ .thenThrow(new ru.practicum.explorewithme.main.error.EntityNotFoundException("Event not found"));
+
+ mockMvc.perform(get("/events/{eventId}", eventId)
+ .header("X-Real-IP", testIpAddress)
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isNotFound());
+
+ verify(statsClient, never()).saveHit(any(EndpointHitDto.class));
+ }
+
+ @Test
+ @DisplayName("должен вернуть 400 Bad Request при невалидном eventId в пути")
+ void withInvalidEventIdPath_shouldReturnBadRequest() throws Exception {
+ mockMvc.perform(get("/events/{eventId}", "notANumber")
+ .header("X-Real-IP", testIpAddress)
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isBadRequest());
+
+ verifyNoInteractions(eventService);
+ verify(statsClient, never()).saveHit(any(EndpointHitDto.class));
+ }
+
+ @Test
+ @DisplayName("должен проверять полные данные в EventFullDto при успешном ответе")
+ void whenEventFound_shouldReturnCorrectEventFullDtoFields() throws Exception {
+ Long eventId = 1L;
+ LocalDateTime eventDate = LocalDateTime.now().plusDays(1).withNano(0);
+ LocalDateTime createdOn = LocalDateTime.now().minusHours(5).withNano(0);
+ LocalDateTime publishedOn = LocalDateTime.now().minusHours(1).withNano(0);
+
+ EventFullDto mockEvent = EventFullDto.builder()
+ .id(eventId)
+ .title("Specific Event Title")
+ .annotation("Specific Annotation")
+ .description("Specific Description")
+ .eventDate(eventDate)
+ .createdOn(createdOn)
+ .publishedOn(publishedOn)
+ .paid(true)
+ .participantLimit(100)
+ .requestModeration(false)
+ .state(EventState.PUBLISHED)
+ .views(1000L)
+ .confirmedRequests(50L)
+ .build();
+
+ when(eventService.getEventByIdPublic(eq(eventId))).thenReturn(mockEvent);
+
+ mockMvc.perform(get("/events/{eventId}", eventId)
+ .header("X-Real-IP", testIpAddress)
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.id", is(eventId.intValue())))
+ .andExpect(jsonPath("$.title", is("Specific Event Title")))
+ .andExpect(jsonPath("$.annotation", is("Specific Annotation")))
+ .andExpect(jsonPath("$.description", is("Specific Description")))
+ .andExpect(jsonPath("$.eventDate", is(eventDate.format(formatter))))
+ .andExpect(jsonPath("$.createdOn", is(createdOn.format(formatter))))
+ .andExpect(jsonPath("$.publishedOn", is(publishedOn.format(formatter))))
+ .andExpect(jsonPath("$.paid", is(true)))
+ .andExpect(jsonPath("$.participantLimit", is(100)))
+ .andExpect(jsonPath("$.requestModeration", is(false)))
+ .andExpect(jsonPath("$.state", is("PUBLISHED")))
+ .andExpect(jsonPath("$.views", is(1000)))
+ .andExpect(jsonPath("$.confirmedRequests", is(50)));
+
+ verify(statsClient, times(1)).saveHit(any(EndpointHitDto.class));
+ }
+ }
+}
\ No newline at end of file
diff --git a/main-service/src/test/java/ru/practicum/explorewithme/main/mapper/CategoryMapperTest.java b/main-service/src/test/java/ru/practicum/explorewithme/main/mapper/CategoryMapperTest.java
new file mode 100644
index 0000000..1487c9a
--- /dev/null
+++ b/main-service/src/test/java/ru/practicum/explorewithme/main/mapper/CategoryMapperTest.java
@@ -0,0 +1,130 @@
+package ru.practicum.explorewithme.main.mapper;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.mapstruct.factory.Mappers;
+import ru.practicum.explorewithme.main.dto.CategoryDto;
+import ru.practicum.explorewithme.main.dto.NewCategoryDto;
+import ru.practicum.explorewithme.main.model.Category;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@DisplayName("Маппер категорий должен")
+class CategoryMapperTest {
+
+ private final CategoryMapper categoryMapper = Mappers.getMapper(CategoryMapper.class);
+
+ @Nested
+ @DisplayName("при преобразовании Category в CategoryDto")
+ class ToCategoryDtoTests {
+
+ @Test
+ @DisplayName("корректно маппить все поля")
+ void toDto_ShouldMapAllFields() {
+
+ Category category = new Category();
+ category.setId(1L);
+ category.setName("Тестовая категория");
+
+ CategoryDto result = categoryMapper.toDto(category);
+
+ assertThat(result).isNotNull();
+ assertThat(result.getId()).isEqualTo(category.getId());
+ assertThat(result.getName()).isEqualTo(category.getName());
+ }
+
+ @Test
+ @DisplayName("возвращать null при преобразовании null")
+ void toDto_ShouldReturnNullWhenCategoryIsNull() {
+
+ CategoryDto result = categoryMapper.toDto(null);
+
+ assertThat(result).isNull();
+ }
+ }
+
+ @Nested
+ @DisplayName("при преобразовании NewCategoryDto в Category")
+ class ToCategoryTests {
+
+ @Test
+ @DisplayName("корректно маппить все поля")
+ void toCategory_ShouldMapAllFields() {
+
+ NewCategoryDto newCategoryDto = new NewCategoryDto();
+ newCategoryDto.setName("Новая категория");
+
+ Category result = categoryMapper.toCategory(newCategoryDto);
+
+ assertThat(result).isNotNull();
+ assertThat(result.getName()).isEqualTo(newCategoryDto.getName());
+ // Id должен игнорироваться маппером согласно аннотации @Mapping(target = "id", ignore = true)
+ assertThat(result.getId()).isNull();
+ }
+
+ @Test
+ @DisplayName("возвращать null при преобразовании null")
+ void toCategory_ShouldReturnNullWhenNewCategoryDtoIsNull() {
+
+ Category result = categoryMapper.toCategory(null);
+
+ assertThat(result).isNull();
+ }
+ }
+
+ @Nested
+ @DisplayName("при сквозных тестах маппинга")
+ class IntegrationTests {
+
+ @Test
+ @DisplayName("сохранять все поля при цепочке преобразований")
+ void mapper_ShouldPreserveAllFieldsInConversionChain() {
+
+ NewCategoryDto newCategoryDto = new NewCategoryDto();
+ newCategoryDto.setName("Тестовая категория");
+
+ Category category = categoryMapper.toCategory(newCategoryDto);
+ category.setId(1L);
+
+ CategoryDto categoryDto = categoryMapper.toDto(category);
+
+ // Проверка полного цикла преобразования
+ assertThat(categoryDto.getId()).isEqualTo(category.getId());
+ assertThat(categoryDto.getName()).isEqualTo(newCategoryDto.getName());
+ }
+ }
+
+ @Nested
+ @DisplayName("при работе с граничными случаями")
+ class EdgeCasesTests {
+
+ @Test
+ @DisplayName("корректно обрабатывать пустые строки")
+ void mapper_ShouldHandleEmptyStrings() {
+
+ NewCategoryDto newCategoryDto = new NewCategoryDto();
+ newCategoryDto.setName("");
+
+ Category category = categoryMapper.toCategory(newCategoryDto);
+
+ assertThat(category).isNotNull();
+ assertThat(category.getName()).isEmpty();
+ }
+
+ @Test
+ @DisplayName("корректно обрабатывать специальные символы")
+ void mapper_ShouldHandleSpecialCharacters() {
+
+ String specialName = "Категория с !@#$%^&*()_+";
+
+ NewCategoryDto newCategoryDto = new NewCategoryDto();
+ newCategoryDto.setName(specialName);
+
+ Category category = categoryMapper.toCategory(newCategoryDto);
+ CategoryDto categoryDto = categoryMapper.toDto(category);
+
+ assertThat(categoryDto.getName()).isEqualTo(specialName);
+ }
+ }
+}
\ No newline at end of file
diff --git a/main-service/src/test/java/ru/practicum/explorewithme/main/mapper/CommentMapperTest.java b/main-service/src/test/java/ru/practicum/explorewithme/main/mapper/CommentMapperTest.java
new file mode 100644
index 0000000..0f84cb5
--- /dev/null
+++ b/main-service/src/test/java/ru/practicum/explorewithme/main/mapper/CommentMapperTest.java
@@ -0,0 +1,270 @@
+package ru.practicum.explorewithme.main.mapper;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.ActiveProfiles;
+import ru.practicum.explorewithme.main.dto.CommentAdminDto;
+import ru.practicum.explorewithme.main.dto.CommentDto;
+import ru.practicum.explorewithme.main.dto.NewCommentDto;
+import ru.practicum.explorewithme.main.model.Category;
+import ru.practicum.explorewithme.main.model.Comment;
+import ru.practicum.explorewithme.main.model.Event;
+import ru.practicum.explorewithme.main.model.User;
+
+import java.time.LocalDateTime;
+import java.time.temporal.ChronoUnit;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+@DisplayName("Тесты для CommentMapper")
+@ActiveProfiles("mapper_test")
+@SpringBootTest
+class CommentMapperTest {
+
+ @Autowired
+ private CommentMapper commentMapper;
+
+ private User testAuthor;
+ private Event testEvent;
+ private LocalDateTime now;
+
+ @BeforeEach
+ void setUp() {
+ now = LocalDateTime.now().truncatedTo(ChronoUnit.SECONDS);
+ testAuthor = User.builder().id(1L).name("Comment Author").email("author@test.com").build();
+ testEvent = Event.builder().id(100L).title("Test Event for Comments").build();
+ }
+
+ @Nested
+ @DisplayName("Метод toDto (маппинг Comment -> CommentDto)")
+ class ToDtoTests {
+
+ @Test
+ @DisplayName("Должен корректно маппить все поля Comment в CommentDto")
+ void toDto_whenCommentIsValid_shouldMapAllFields() {
+ Comment comment = Comment.builder()
+ .id(1L)
+ .text("This is a test comment.")
+ .author(testAuthor)
+ .event(testEvent)
+ .createdOn(now.minusHours(1))
+ .updatedOn(now)
+ .isEdited(true)
+ .isDeleted(false)
+ .build();
+
+ CommentDto dto = commentMapper.toDto(comment);
+
+ assertNotNull(dto);
+ assertEquals(comment.getId(), dto.getId());
+ assertEquals(comment.getText(), dto.getText());
+ assertEquals(comment.getCreatedOn(), dto.getCreatedOn());
+ assertEquals(comment.getUpdatedOn(), dto.getUpdatedOn());
+ assertEquals(comment.isEdited(), dto.getIsEdited());
+
+ assertNotNull(dto.getAuthor());
+ assertEquals(testAuthor.getId(), dto.getAuthor().getId());
+ assertEquals(testAuthor.getName(), dto.getAuthor().getName());
+
+ assertNotNull(dto.getEventId());
+ assertEquals(testEvent.getId(), dto.getEventId());
+ }
+
+ @Test
+ @DisplayName("Должен возвращать null, если на вход подан null Comment")
+ void toDto_whenCommentIsNull_shouldReturnNull() {
+ CommentDto dto = commentMapper.toDto(null);
+ assertNull(dto);
+ }
+
+ @Test
+ @DisplayName("Должен корректно обрабатывать null для вложенных author и event в Comment")
+ void toDto_whenNestedAuthorOrEventIsNull_shouldMapAccordingly() {
+ Comment commentWithNullAuthor = Comment.builder()
+ .id(2L)
+ .text("Comment with null author")
+ .author(null)
+ .event(testEvent)
+ .createdOn(now)
+ .build();
+
+ Comment commentWithNullEvent = Comment.builder()
+ .id(3L)
+ .text("Comment with null event")
+ .author(testAuthor)
+ .event(null)
+ .createdOn(now)
+ .build();
+
+ CommentDto dtoWithNullAuthor = commentMapper.toDto(commentWithNullAuthor);
+ CommentDto dtoWithNullEvent = commentMapper.toDto(commentWithNullEvent);
+
+ assertNotNull(dtoWithNullAuthor);
+ assertNull(dtoWithNullAuthor.getAuthor(), "Author DTO should be null if source author is null");
+ assertNotNull(dtoWithNullAuthor.getEventId());
+
+ assertNotNull(dtoWithNullEvent);
+ assertNotNull(dtoWithNullEvent.getAuthor());
+ assertNull(dtoWithNullEvent.getEventId(), "eventId should be null if source event is null (or handle as error)");
+ }
+
+ @Test
+ @DisplayName("Должен корректно маппить, если updatedOn в Comment равен null")
+ void toDto_whenUpdatedOnIsNull_shouldMapUpdatedOnAsNull() {
+ Comment comment = Comment.builder()
+ .id(4L)
+ .text("Never updated comment")
+ .author(testAuthor)
+ .event(testEvent)
+ .createdOn(now.minusDays(1))
+ .updatedOn(null)
+ .isEdited(false)
+ .build();
+
+ CommentDto dto = commentMapper.toDto(comment);
+
+ assertNotNull(dto);
+ assertNull(dto.getUpdatedOn());
+ assertFalse(dto.getIsEdited());
+ }
+ }
+
+ @Nested
+ @DisplayName("Метод toComment (маппинг NewCommentDto -> Comment)")
+ class ToCommentTests {
+
+ @Test
+ @DisplayName("Должен корректно маппить текст из NewCommentDto в Comment")
+ void toComment_fromNewCommentDto_shouldMapText() {
+ NewCommentDto newDto = NewCommentDto.builder().text("New comment text").build();
+
+ Comment entity = commentMapper.toComment(newDto);
+
+ assertNotNull(entity);
+ assertEquals(newDto.getText(), entity.getText());
+
+ assertNull(entity.getId());
+ assertNull(entity.getCreatedOn());
+ assertNull(entity.getUpdatedOn());
+ assertNull(entity.getAuthor());
+ assertNull(entity.getEvent());
+ assertFalse(entity.isEdited());
+ assertFalse(entity.isDeleted());
+ }
+
+ @Test
+ @DisplayName("Должен возвращать null, если на вход подан null NewCommentDto")
+ void toComment_whenNewCommentDtoIsNull_shouldReturnNull() {
+ Comment entity = commentMapper.toComment(null);
+
+ assertNull(entity);
+ }
+ }
+
+ @Nested
+ @DisplayName("Метод toDtoList (маппинг List -> List)")
+ class ToDtoListTests {
+
+ @Test
+ @DisplayName("Должен корректно маппить список Comment в список CommentDto")
+ void toDtoList_shouldMapListOfComments() {
+ Comment comment1 = Comment.builder().id(1L).text("First").author(testAuthor).event(testEvent).createdOn(now).build();
+ Comment comment2 = Comment.builder().id(2L).text("Second").author(testAuthor).event(testEvent).createdOn(now).build();
+ List comments = Arrays.asList(comment1, comment2);
+
+ List dtoList = commentMapper.toDtoList(comments);
+
+ assertNotNull(dtoList);
+ assertEquals(2, dtoList.size());
+ assertEquals(comment1.getText(), dtoList.get(0).getText());
+ assertEquals(comment2.getText(), dtoList.get(1).getText());
+ assertEquals(testAuthor.getName(), dtoList.get(0).getAuthor().getName());
+ assertEquals(testEvent.getId(), dtoList.get(1).getEventId());
+ }
+
+ @Test
+ @DisplayName("Должен возвращать null, если на вход подан null список")
+ void toDtoList_whenListIsNull_shouldReturnNull() {
+ List dtoList = commentMapper.toDtoList(null);
+
+ assertNull(dtoList);
+ }
+
+ @Test
+ @DisplayName("Должен возвращать пустой список, если на вход подан пустой список")
+ void toDtoList_whenListIsEmpty_shouldReturnEmptyList() {
+ List dtoList = commentMapper.toDtoList(Collections.emptyList());
+
+ assertNotNull(dtoList);
+ assertTrue(dtoList.isEmpty());
+ }
+ }
+
+ @Nested
+ @DisplayName("Метод toAdminDto (маппинг Comment -> CommentAdminDto)")
+ class ToAdminDtoTests {
+
+ @Test
+ @DisplayName("Должен корректно маппить все поля, включая isDeleted")
+ void toAdminDto_shouldMapAllFieldsIncludingIsDeleted() {
+ User authorModel = User.builder().id(1L).name("Admin Test Author").build();
+ Category categoryModel = Category.builder().id(1L).name("Admin Test Category").build();
+ Event eventModel = Event.builder().id(1L).category(categoryModel).initiator(authorModel).build();
+
+
+ Comment comment = Comment.builder()
+ .id(1L)
+ .text("Admin DTO test comment")
+ .author(authorModel)
+ .event(eventModel)
+ .createdOn(LocalDateTime.now().minusHours(1))
+ .updatedOn(LocalDateTime.now())
+ .isEdited(true)
+ .isDeleted(true)
+ .build();
+
+ CommentAdminDto dto = commentMapper.toAdminDto(comment);
+
+ assertNotNull(dto);
+ assertEquals(comment.getId(), dto.getId());
+ assertEquals(comment.getText(), dto.getText());
+ assertEquals(comment.getCreatedOn(), dto.getCreatedOn());
+ assertEquals(comment.getUpdatedOn(), dto.getUpdatedOn());
+ assertEquals(comment.isEdited(), dto.getIsEdited());
+ assertEquals(comment.isDeleted(), dto.getIsDeleted()); // Проверяем isDeleted
+
+ assertNotNull(dto.getAuthor());
+ assertEquals(authorModel.getId(), dto.getAuthor().getId());
+
+ assertNotNull(dto.getEventId());
+ assertEquals(eventModel.getId(), dto.getEventId());
+ }
+
+ @Test
+ @DisplayName("Должен корректно маппить isDeleted=false")
+ void toAdminDto_withIsDeletedFalse_shouldMapCorrectly() {
+ User authorModel = User.builder().id(1L).name("Admin Test Author").build();
+ Event eventModel = Event.builder().id(1L).build();
+
+ Comment comment = Comment.builder()
+ .id(2L)
+ .text("Not deleted comment")
+ .author(authorModel)
+ .event(eventModel)
+ .isDeleted(false)
+ .build();
+
+ CommentAdminDto dto = commentMapper.toAdminDto(comment);
+
+ assertNotNull(dto);
+ assertEquals(false, dto.getIsDeleted());
+ }
+ }
+}
\ No newline at end of file
diff --git a/main-service/src/test/java/ru/practicum/explorewithme/main/mapper/EventMapperTest.java b/main-service/src/test/java/ru/practicum/explorewithme/main/mapper/EventMapperTest.java
new file mode 100644
index 0000000..4d457db
--- /dev/null
+++ b/main-service/src/test/java/ru/practicum/explorewithme/main/mapper/EventMapperTest.java
@@ -0,0 +1,312 @@
+package ru.practicum.explorewithme.main.mapper;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.time.LocalDateTime;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.ActiveProfiles;
+import ru.practicum.explorewithme.main.dto.EventFullDto;
+import ru.practicum.explorewithme.main.dto.EventShortDto;
+import ru.practicum.explorewithme.main.model.Category;
+import ru.practicum.explorewithme.main.model.Event;
+import ru.practicum.explorewithme.main.model.EventState;
+import ru.practicum.explorewithme.main.model.Location;
+import ru.practicum.explorewithme.main.model.User;
+
+@DisplayName("Тесты для EventMapper")
+@ActiveProfiles("mapper_test")
+@SpringBootTest
+class EventMapperTest {
+
+ @Autowired // Внедряем экземпляр, созданный Spring и MapStruct
+ private EventMapper eventMapper;
+
+ @Nested
+ @DisplayName("Метод toEventFullDto (маппинг одиночного события в EventFullDto)")
+ class ToEventFullDtoTests {
+
+ @Test
+ @DisplayName("Должен корректно маппить все поля, когда все данные присутствуют")
+ void toEventFullDto_shouldMapAllFieldsCorrectly() {
+ User initiatorModel = User.builder().id(1L).name("Test User").email("user@test.com").build();
+ Category categoryModel = Category.builder().id(10L).name("Test Category").build();
+ Location locationModel = Location.builder().lat(55.75f).lon(37.62f).build();
+
+ Event event = Event.builder()
+ .id(1L)
+ .annotation("Test Annotation")
+ .category(categoryModel)
+ .createdOn(LocalDateTime.now().minusDays(1))
+ .description("Test Description")
+ .eventDate(LocalDateTime.now().plusDays(5))
+ .initiator(initiatorModel)
+ .location(locationModel)
+ .paid(true)
+ .participantLimit(100)
+ .publishedOn(LocalDateTime.now())
+ .requestModeration(true)
+ .state(EventState.PUBLISHED)
+ .title("Test Event Title")
+ .confirmedRequestsCount(42L)
+ .build();
+
+ EventFullDto dto = eventMapper.toEventFullDto(event);
+
+ assertNotNull(dto);
+ assertEquals(event.getId(), dto.getId());
+ assertEquals(event.getAnnotation(), dto.getAnnotation());
+ assertEquals(event.getCreatedOn(), dto.getCreatedOn());
+ assertEquals(event.getDescription(), dto.getDescription());
+ assertEquals(event.getEventDate(), dto.getEventDate());
+ assertEquals(event.isPaid(), dto.isPaid());
+ assertEquals(event.getParticipantLimit(), dto.getParticipantLimit());
+ assertEquals(event.getPublishedOn(), dto.getPublishedOn());
+ assertEquals(event.isRequestModeration(), dto.isRequestModeration());
+ assertEquals(event.getState(), dto.getState());
+ assertEquals(event.getTitle(), dto.getTitle());
+
+
+ assertNotNull(dto.getCategory());
+ assertEquals(categoryModel.getId(), dto.getCategory().getId());
+ assertEquals(categoryModel.getName(), dto.getCategory().getName());
+
+ assertNotNull(dto.getInitiator());
+ assertEquals(initiatorModel.getId(), dto.getInitiator().getId());
+ assertEquals(initiatorModel.getName(), dto.getInitiator().getName());
+
+ assertNotNull(dto.getLocation());
+ assertEquals(locationModel.getLat(), dto.getLocation().getLat());
+ assertEquals(locationModel.getLon(), dto.getLocation().getLon());
+
+ assertEquals(event.getConfirmedRequestsCount(), dto.getConfirmedRequests());
+
+ // Не мапит просмотры и потдверждённые запросы.
+ assertNull(dto.getViews());
+ }
+
+ @Test
+ @DisplayName("Должен возвращать null, если на вход подан null Event")
+ void toEventFullDto_shouldHandleNullEvent() {
+ EventFullDto dto = eventMapper.toEventFullDto(null);
+ assertNull(dto);
+ }
+
+ @Test
+ @DisplayName("Должен корректно обрабатывать null для вложенных объектов (категория, инициатор, локация)")
+ void toEventFullDto_shouldHandleNullNestedObjects() {
+ Event event = Event.builder()
+ .id(1L)
+ .annotation("Test Annotation")
+ // category, initiator, location остаются null
+ .createdOn(LocalDateTime.now().minusDays(1))
+ .description("Test Description")
+ .eventDate(LocalDateTime.now().plusDays(5))
+ .paid(true)
+ .participantLimit(100)
+ .publishedOn(LocalDateTime.now())
+ .requestModeration(true)
+ .state(EventState.PUBLISHED)
+ .title("Test Event Title")
+ .build();
+
+ EventFullDto dto = eventMapper.toEventFullDto(event);
+
+ assertNotNull(dto);
+ assertNull(dto.getCategory());
+ assertNull(dto.getInitiator());
+ assertNull(dto.getLocation());
+ }
+ }
+
+
+ @Nested
+ @DisplayName("Метод toEventFullDtoList (маппинг списка событий в список EventFullDto)")
+ class ToEventFullDtoListTests {
+
+ @Test
+ @DisplayName("должен корректно маппить список событий")
+ void toEventFullDtoList_shouldMapListOfEvents() {
+ User initiatorModel = User.builder().id(1L).name("Test User").build();
+ Category categoryModel = Category.builder().id(10L).name("Test Category").build();
+ Location locationModel = Location.builder().lat(55.75f).lon(37.62f).build();
+
+ Event event1 = Event.builder().id(1L).title("Event 1").category(categoryModel).initiator(initiatorModel).location(locationModel).eventDate(LocalDateTime.now()).createdOn(LocalDateTime.now()).annotation("A1").description("D1").state(EventState.PENDING).paid(false).participantLimit(10).requestModeration(false).publishedOn(null).build();
+ Event event2 = Event.builder().id(2L).title("Event 2").category(categoryModel).initiator(initiatorModel).location(locationModel).eventDate(LocalDateTime.now()).createdOn(LocalDateTime.now()).annotation("A2").description("D2").state(EventState.PUBLISHED).paid(true).participantLimit(20).requestModeration(true).publishedOn(LocalDateTime.now()).build();
+ List events = Arrays.asList(event1, event2);
+
+ List dtoList = eventMapper.toEventFullDtoList(events);
+
+ assertNotNull(dtoList);
+ assertEquals(2, dtoList.size());
+
+ // Проверки для первого элемента списка
+ EventFullDto dto1 = dtoList.get(0);
+ assertEquals(event1.getTitle(), dto1.getTitle());
+ assertNotNull(dto1.getCategory());
+ assertEquals(categoryModel.getName(), dto1.getCategory().getName());
+ assertNotNull(dto1.getInitiator());
+ assertEquals(initiatorModel.getName(), dto1.getInitiator().getName());
+
+ // Проверки для второго элемента списка
+ EventFullDto dto2 = dtoList.get(1);
+ assertEquals(event2.getTitle(), dto2.getTitle());
+ assertNotNull(dto2.getCategory());
+ assertEquals(categoryModel.getName(), dto2.getCategory().getName());
+ assertNotNull(dto2.getInitiator());
+ assertEquals(initiatorModel.getName(), dto2.getInitiator().getName());
+ }
+
+ @Test
+ @DisplayName("должен возвращать null, если на вход подан null список")
+ void toEventFullDtoList_shouldHandleNullList() {
+ List dtoList = eventMapper.toEventFullDtoList(null);
+ assertNull(dtoList);
+ }
+
+ @Test
+ @DisplayName("должен возвращать пустой список, если на вход подан пустой список")
+ void toEventFullDtoList_shouldHandleEmptyList() {
+ List dtoList = eventMapper.toEventFullDtoList(Collections.emptyList());
+ assertNotNull(dtoList);
+ assertTrue(dtoList.isEmpty());
+ }
+ }
+
+ @Nested
+ @DisplayName("Метод toEventShortDto (маппинг одиночного события в EventShortDto)")
+ class ToEventShortDtoTests {
+
+ @Test
+ @DisplayName("Должен корректно маппить поля в EventShortDto")
+ void toEventShortDto_shouldMapFieldsCorrectly() {
+ User initiatorModel = User.builder().id(1L).name("Test User").build();
+ Category categoryModel = Category.builder().id(10L).name("Test Category").build();
+
+ Event event = Event.builder()
+ .id(1L)
+ .annotation("Short Test Annotation")
+ .category(categoryModel)
+ .eventDate(LocalDateTime.now().plusDays(5))
+ .initiator(initiatorModel)
+ .paid(true)
+ .title("Short Event Title")
+ .confirmedRequestsCount(5L)
+ .description("Full description not needed for short dto")
+ .state(EventState.PUBLISHED)
+ .build();
+
+ EventShortDto dto = eventMapper.toEventShortDto(event);
+
+ assertNotNull(dto);
+ assertEquals(event.getId(), dto.getId());
+ assertEquals(event.getAnnotation(), dto.getAnnotation());
+ assertEquals(event.getEventDate(), dto.getEventDate());
+ assertEquals(event.isPaid(), dto.getPaid()); // Используем getPaid() для Boolean из EventShortDto
+ assertEquals(event.getTitle(), dto.getTitle());
+
+ assertNotNull(dto.getCategory());
+ assertEquals(categoryModel.getId(), dto.getCategory().getId());
+ assertEquals(categoryModel.getName(), dto.getCategory().getName());
+
+ assertNotNull(dto.getInitiator());
+ assertEquals(initiatorModel.getId(), dto.getInitiator().getId());
+ assertEquals(initiatorModel.getName(), dto.getInitiator().getName());
+
+ assertEquals(event.getConfirmedRequestsCount(), dto.getConfirmedRequests());
+
+ assertNull(dto.getViews(), "Views should be null as ignored by mapper and set by service");
+ }
+
+ @Test
+ @DisplayName("Должен возвращать null, если на вход подан null Event")
+ void toEventShortDto_shouldHandleNullEvent() {
+ EventShortDto dto = eventMapper.toEventShortDto(null);
+
+ assertNull(dto);
+ }
+
+ @Test
+ @DisplayName("Должен корректно обрабатывать null для вложенных Category и Initiator")
+ void toEventShortDto_shouldHandleNullNestedCategoryAndInitiator() {
+ Event event = Event.builder()
+ .id(1L)
+ .annotation("Annotation with nulls")
+ .category(null)
+ .eventDate(LocalDateTime.now().plusDays(5))
+ .initiator(null)
+ .paid(false)
+ .title("Event with Nulls")
+ .confirmedRequestsCount(33L)
+ .build();
+
+ EventShortDto dto = eventMapper.toEventShortDto(event);
+
+ assertNotNull(dto);
+ assertNull(dto.getCategory(), "CategoryDto should be null if source category is null");
+ assertNull(dto.getInitiator(), "UserShortDto should be null if source initiator is null");
+ assertEquals(33L, dto.getConfirmedRequests()); // Проверяем confirmedRequests
+ }
+ }
+
+ @Nested
+ @DisplayName("Метод toEventShortDtoList (маппинг списка событий в список EventShortDto)")
+ class ToEventShortDtoListTests {
+
+ @Test
+ @DisplayName("Должен корректно маппить список событий в список EventShortDto")
+ void toEventShortDtoList_shouldMapListOfEvents() {
+ User initiatorModel = User.builder().id(1L).name("Test User").build();
+ Category categoryModel = Category.builder().id(10L).name("Test Category").build();
+
+ Event event1 = Event.builder().id(1L).title("Short Event 1").category(categoryModel).initiator(initiatorModel)
+ .eventDate(LocalDateTime.now()).annotation("A1").paid(true).confirmedRequestsCount(2L).build();
+ Event event2 = Event.builder().id(2L).title("Short Event 2").category(categoryModel).initiator(initiatorModel)
+ .eventDate(LocalDateTime.now()).annotation("A2").paid(false).confirmedRequestsCount(5L).build();
+ List events = Arrays.asList(event1, event2);
+
+ List dtoList = eventMapper.toEventShortDtoList(events);
+
+ assertNotNull(dtoList);
+ assertEquals(2, dtoList.size());
+
+ EventShortDto dto1 = dtoList.get(0);
+ assertEquals(event1.getTitle(), dto1.getTitle());
+ assertEquals(event1.getConfirmedRequestsCount(), dto1.getConfirmedRequests());
+ assertNotNull(dto1.getCategory());
+ assertEquals(categoryModel.getName(), dto1.getCategory().getName());
+
+ EventShortDto dto2 = dtoList.get(1);
+ assertEquals(event2.getTitle(), dto2.getTitle());
+ assertEquals(event2.getConfirmedRequestsCount(), dto2.getConfirmedRequests());
+ assertNotNull(dto2.getInitiator());
+ assertEquals(initiatorModel.getName(), dto2.getInitiator().getName());
+ }
+
+ @Test
+ @DisplayName("Должен возвращать null, если на вход подан null список")
+ void toEventShortDtoList_shouldHandleNullList() {
+ List dtoList = eventMapper.toEventShortDtoList(null);
+
+ assertNull(dtoList);
+ }
+
+ @Test
+ @DisplayName("Должен возвращать пустой список, если на вход подан пустой список")
+ void toEventShortDtoList_shouldHandleEmptyList() {
+ List dtoList = eventMapper.toEventShortDtoList(Collections.emptyList());
+
+ assertNotNull(dtoList);
+ assertTrue(dtoList.isEmpty());
+ }
+ }
+}
\ No newline at end of file
diff --git a/main-service/src/test/java/ru/practicum/explorewithme/main/mapper/UserMapperTest.java b/main-service/src/test/java/ru/practicum/explorewithme/main/mapper/UserMapperTest.java
new file mode 100644
index 0000000..7751474
--- /dev/null
+++ b/main-service/src/test/java/ru/practicum/explorewithme/main/mapper/UserMapperTest.java
@@ -0,0 +1,188 @@
+
+package ru.practicum.explorewithme.main.mapper;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.mapstruct.factory.Mappers;
+import ru.practicum.explorewithme.main.dto.NewUserRequestDto;
+import ru.practicum.explorewithme.main.dto.UserDto;
+import ru.practicum.explorewithme.main.dto.UserShortDto;
+import ru.practicum.explorewithme.main.model.User;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@DisplayName("Маппер пользователей должен")
+class UserMapperTest {
+
+ private final UserMapper userMapper = Mappers.getMapper(UserMapper.class);
+
+ @Nested
+ @DisplayName("при преобразовании User в UserShortDto")
+ class ToShortDtoTests {
+
+ @Test
+ @DisplayName("корректно маппить все поля")
+ void toShortDto_ShouldMapAllFields() {
+
+ User user = new User();
+ user.setId(1L);
+ user.setName("Тестовый пользователь");
+ user.setEmail("test@example.com");
+
+ UserShortDto result = userMapper.toShortDto(user);
+
+ assertThat(result).isNotNull();
+ assertThat(result.getId()).isEqualTo(user.getId());
+ assertThat(result.getName()).isEqualTo(user.getName());
+ }
+
+ @Test
+ @DisplayName("возвращать null при преобразовании null")
+ void toShortDto_ShouldReturnNullWhenUserIsNull() {
+
+ UserShortDto result = userMapper.toShortDto(null);
+
+ assertThat(result).isNull();
+ }
+ }
+
+ @Nested
+ @DisplayName("при преобразовании User в UserDto")
+ class ToUserDtoTests {
+
+ @Test
+ @DisplayName("корректно маппить все поля")
+ void toUserDto_ShouldMapAllFields() {
+
+ User user = new User();
+ user.setId(1L);
+ user.setName("Тестовый пользователь");
+ user.setEmail("test@example.com");
+
+ UserDto result = userMapper.toUserDto(user);
+
+ assertThat(result).isNotNull();
+ assertThat(result.getId()).isEqualTo(user.getId());
+ assertThat(result.getName()).isEqualTo(user.getName());
+ assertThat(result.getEmail()).isEqualTo(user.getEmail());
+ }
+
+ @Test
+ @DisplayName("возвращать null при преобразовании null")
+ void toUserDto_ShouldReturnNullWhenUserIsNull() {
+
+ UserDto result = userMapper.toUserDto(null);
+
+ assertThat(result).isNull();
+ }
+ }
+
+ @Nested
+ @DisplayName("при преобразовании NewUserRequestDto в User")
+ class ToUserTests {
+
+ @Test
+ @DisplayName("корректно маппить все поля")
+ void toUser_ShouldMapAllFields() {
+
+ NewUserRequestDto request = new NewUserRequestDto();
+ request.setName("Тестовый пользователь");
+ request.setEmail("test@example.com");
+
+ User result = userMapper.toUser(request);
+
+ assertThat(result).isNotNull();
+ assertThat(result.getName()).isEqualTo(request.getName());
+ assertThat(result.getEmail()).isEqualTo(request.getEmail());
+ assertThat(result.getId()).isNull();
+ }
+
+ @Test
+ @DisplayName("возвращать null при преобразовании null")
+ void toUser_ShouldReturnNullWhenNewUserRequestIsNull() {
+
+ User result = userMapper.toUser((NewUserRequestDto) null);
+
+ assertThat(result).isNull();
+ }
+ }
+
+ @Nested
+ @DisplayName("при сквозных тестах маппинга")
+ class IntegrationTests {
+
+ @Test
+ @DisplayName("сохранять все поля при цепочке преобразований")
+ void mapper_ShouldPreserveAllFieldsInConversionChain() {
+
+ NewUserRequestDto request = new NewUserRequestDto();
+ request.setName("Тестовый пользователь");
+ request.setEmail("test@example.com");
+
+ User user = userMapper.toUser(request);
+ user.setId(1L);
+
+ UserDto userDto = userMapper.toUserDto(user);
+
+ assertThat(userDto.getId()).isEqualTo(user.getId());
+ assertThat(userDto.getName()).isEqualTo(request.getName());
+ assertThat(userDto.getEmail()).isEqualTo(request.getEmail());
+ }
+
+ @Test
+ @DisplayName("корректно преобразовывать в UserShortDto сохраняя нужные поля")
+ void mapper_ShouldCorrectlyMapToShortDto() {
+
+ NewUserRequestDto request = new NewUserRequestDto();
+ request.setName("Тестовый пользователь");
+ request.setEmail("test@example.com");
+
+ User user = userMapper.toUser(request);
+ user.setId(1L);
+ UserShortDto shortDto = userMapper.toShortDto(user);
+
+ assertThat(shortDto.getId()).isEqualTo(user.getId());
+ assertThat(shortDto.getName()).isEqualTo(request.getName());
+
+ }
+ }
+
+ @Nested
+ @DisplayName("при работе с граничными случаями")
+ class EdgeCasesTests {
+
+ @Test
+ @DisplayName("корректно обрабатывать пустые строки")
+ void mapper_ShouldHandleEmptyStrings() {
+
+ NewUserRequestDto request = new NewUserRequestDto();
+ request.setName("");
+ request.setEmail("");
+
+ User user = userMapper.toUser(request);
+
+ assertThat(user).isNotNull();
+ assertThat(user.getName()).isEmpty();
+ assertThat(user.getEmail()).isEmpty();
+ }
+
+ @Test
+ @DisplayName("корректно обрабатывать специальные символы")
+ void mapper_ShouldHandleSpecialCharacters() {
+
+ String specialName = "Имя с !@#$%^&*()_+";
+ String specialEmail = "special!@example.com";
+
+ NewUserRequestDto request = new NewUserRequestDto();
+ request.setName(specialName);
+ request.setEmail(specialEmail);
+
+ User user = userMapper.toUser(request);
+ UserDto userDto = userMapper.toUserDto(user);
+
+ assertThat(userDto.getName()).isEqualTo(specialName);
+ assertThat(userDto.getEmail()).isEqualTo(specialEmail);
+ }
+ }
+}
\ No newline at end of file
diff --git a/main-service/src/test/java/ru/practicum/explorewithme/main/repository/EventRepositoryTest.java b/main-service/src/test/java/ru/practicum/explorewithme/main/repository/EventRepositoryTest.java
new file mode 100644
index 0000000..22dedae
--- /dev/null
+++ b/main-service/src/test/java/ru/practicum/explorewithme/main/repository/EventRepositoryTest.java
@@ -0,0 +1,253 @@
+package ru.practicum.explorewithme.main.repository;
+
+import com.querydsl.core.BooleanBuilder;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
+import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
+import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.testcontainers.containers.PostgreSQLContainer;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+import org.testcontainers.utility.DockerImageName;
+import ru.practicum.explorewithme.main.model.*;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+@DataJpaTest
+@Testcontainers
+@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
+@DisplayName("Интеграционные тесты для EventRepository с QueryDSL")
+class EventRepositoryTest {
+
+ @Container
+ private static final PostgreSQLContainer> postgresqlContainer = new PostgreSQLContainer<>(
+ DockerImageName.parse("postgres:16.1"));
+
+ @DynamicPropertySource
+ static void postgresqlProperties(DynamicPropertyRegistry registry) {
+ registry.add("spring.datasource.url", postgresqlContainer::getJdbcUrl);
+ registry.add("spring.datasource.username", postgresqlContainer::getUsername);
+ registry.add("spring.datasource.password", postgresqlContainer::getPassword);
+ registry.add("spring.jpa.hibernate.ddl-auto", () -> "create-drop");
+ }
+
+ @Autowired
+ private TestEntityManager entityManager;
+
+ @Autowired
+ private EventRepository eventRepository;
+
+ private User user1, user2;
+ private Category category1, category2;
+ private Location location1, location2;
+ private Event event1, event2, event3, event4;
+ private LocalDateTime now;
+
+ @BeforeEach
+ void setUp() {
+ now = LocalDateTime.now().withNano(0);
+
+ // Создаем и сохраняем пользователей
+ user1 = User.builder().name("User One").email("user1@test.com").build();
+ user2 = User.builder().name("User Two").email("user2@test.com").build();
+ entityManager.persist(user1);
+ entityManager.persist(user2);
+
+ // Создаем и сохраняем категории
+ category1 = Category.builder().name("Category One").build();
+ category2 = Category.builder().name("Category Two").build();
+ entityManager.persist(category1);
+ entityManager.persist(category2);
+
+ // Создаем локации (они @Embeddable, не сохраняются отдельно)
+ location1 = Location.builder().lat(10.0f).lon(20.0f).build();
+ location2 = Location.builder().lat(30.0f).lon(40.0f).build();
+
+ // Создаем и сохраняем события
+ event1 = Event.builder()
+ .title("Event Alpha")
+ .annotation("Annotation for Alpha")
+ .description("Description for Alpha")
+ .category(category1)
+ .initiator(user1)
+ .location(location1)
+ .eventDate(now.plusDays(5))
+ .createdOn(now.minusDays(1))
+ .state(EventState.PENDING)
+ .paid(false)
+ .participantLimit(10)
+ .requestModeration(true)
+ .build();
+
+ event2 = Event.builder()
+ .title("Event Beta")
+ .annotation("Annotation for Beta")
+ .description("Description for Beta")
+ .category(category2)
+ .initiator(user1) // тот же user1
+ .location(location2)
+ .eventDate(now.plusDays(10))
+ .createdOn(now.minusDays(2))
+ .state(EventState.PUBLISHED)
+ .paid(true)
+ .participantLimit(0) // без лимита
+ .requestModeration(false)
+ .build();
+
+ event3 = Event.builder()
+ .title("Event Gamma")
+ .annotation("Annotation for Gamma")
+ .description("Description for Gamma")
+ .category(category1)
+ .initiator(user2)
+ .location(location1)
+ .eventDate(now.plusDays(15))
+ .createdOn(now.minusDays(3))
+ .state(EventState.PUBLISHED)
+ .paid(false)
+ .participantLimit(5)
+ .requestModeration(true)
+ .build();
+
+ event4 = Event.builder()
+ .title("Event Delta Past Published")
+ .annotation("Annotation for Delta")
+ .description("Description for Delta")
+ .category(category2)
+ .initiator(user2)
+ .location(location2)
+ .eventDate(now.minusDays(1))
+ .publishedOn(now.minusDays(2))
+ .createdOn(now.minusDays(3))
+ .state(EventState.PUBLISHED)
+ .paid(true)
+ .participantLimit(20)
+ .requestModeration(true)
+ .build();
+
+ eventRepository.saveAll(List.of(event1, event2, event3, event4));
+ entityManager.flush();
+ entityManager.clear();
+ }
+
+ @Test
+ @DisplayName("Поиск без фильтров должен вернуть все события с пагинацией")
+ void findAll_withNoFilters_shouldReturnAllEventsPaged() {
+ Pageable pageable = PageRequest.of(0, 2);
+ BooleanBuilder predicate = new BooleanBuilder();
+
+ Page result = eventRepository.findAll(predicate, pageable);
+
+ assertEquals(2, result.getContent().size());
+ assertEquals(4, result.getTotalElements());
+ assertTrue(result.getContent().stream().anyMatch(e -> e.getTitle().equals("Event Alpha")));
+ }
+
+ @Test
+ @DisplayName("Фильтрация по ID пользователей (users)")
+ void findAll_withUserFilter_shouldReturnUserEvents() {
+ QEvent qEvent = QEvent.event;
+ BooleanBuilder predicate = new BooleanBuilder();
+ predicate.and(qEvent.initiator.id.in(List.of(user1.getId())));
+
+ assertNotNull(predicate.getValue());
+ Page result = eventRepository.findAll(predicate.getValue(), PageRequest.of(0, 10));
+
+ assertEquals(2, result.getTotalElements());
+ assertTrue(result.getContent().stream().allMatch(e -> e.getInitiator().getId().equals(user1.getId())));
+ assertTrue(result.getContent().stream().anyMatch(e -> e.getTitle().equals("Event Alpha")));
+ assertTrue(result.getContent().stream().anyMatch(e -> e.getTitle().equals("Event Beta")));
+ }
+
+ @Test
+ @DisplayName("Фильтрация по состояниям (states)")
+ void findAll_withStateFilter_shouldReturnMatchingStateEvents() {
+ QEvent qEvent = QEvent.event;
+ BooleanBuilder predicate = new BooleanBuilder();
+ predicate.and(qEvent.state.in(List.of(EventState.PUBLISHED)));
+
+ assertNotNull(predicate.getValue());
+ Page result = eventRepository.findAll(predicate.getValue(), PageRequest.of(0, 10));
+
+ assertEquals(3, result.getTotalElements()); // event2, event3, event4
+ assertTrue(result.getContent().stream().allMatch(e -> e.getState() == EventState.PUBLISHED));
+ }
+
+ @Test
+ @DisplayName("Фильтрация по ID категорий (categories)")
+ void findAll_withCategoryFilter_shouldReturnMatchingCategoryEvents() {
+ QEvent qEvent = QEvent.event;
+ BooleanBuilder predicate = new BooleanBuilder();
+ predicate.and(qEvent.category.id.in(List.of(category1.getId())));
+
+ assertNotNull(predicate.getValue());
+ Page result = eventRepository.findAll(predicate.getValue(), PageRequest.of(0, 10));
+
+ assertEquals(2, result.getTotalElements()); // event1, event3
+ assertTrue(result.getContent().stream().allMatch(e -> e.getCategory().getId().equals(category1.getId())));
+ }
+
+ @Test
+ @DisplayName("Фильтрация по начальной дате диапазона (rangeStart)")
+ void findAll_withRangeStartFilter_shouldReturnEventsAfterOrOnDate() {
+ QEvent qEvent = QEvent.event;
+ BooleanBuilder predicate = new BooleanBuilder();
+ LocalDateTime rangeStart = now.plusDays(12); // Только event3 должен попасть
+ predicate.and(qEvent.eventDate.goe(rangeStart));
+
+ assertNotNull(predicate.getValue());
+ Page result = eventRepository.findAll(predicate.getValue(), PageRequest.of(0, 10));
+
+ assertEquals(1, result.getTotalElements());
+ assertEquals("Event Gamma", result.getContent().getFirst().getTitle());
+ }
+
+ @Test
+ @DisplayName("Фильтрация по конечной дате диапазона (rangeEnd)")
+ void findAll_withRangeEndFilter_shouldReturnEventsBeforeOrOnDate() {
+ QEvent qEvent = QEvent.event;
+ BooleanBuilder predicate = new BooleanBuilder();
+ LocalDateTime rangeEnd = now.plusDays(7); // event1 и event4 (если бы не был в прошлом для другого теста)
+ // но event4 уже в прошлом, так что только event1
+ predicate.and(qEvent.eventDate.loe(rangeEnd));
+
+ assertNotNull(predicate.getValue());
+ Page result = eventRepository.findAll(predicate.getValue(), PageRequest.of(0, 10));
+
+ // event1 (now + 5 days)
+ // event4 (now - 1 day)
+ assertEquals(2, result.getTotalElements());
+ assertTrue(result.getContent().stream().anyMatch(e -> e.getTitle().equals("Event Alpha")));
+ assertTrue(result.getContent().stream().anyMatch(e -> e.getTitle().equals("Event Delta Past Published")));
+ }
+
+ @Test
+ @DisplayName("Комплексная фильтрация (user, state, category, range)")
+ void findAll_withMultipleFilters_shouldReturnCorrectEvents() {
+ QEvent qEvent = QEvent.event;
+ BooleanBuilder predicate = new BooleanBuilder();
+
+ // Ищем события user2, в состоянии PUBLISHED, в category1, в диапазоне дат
+ predicate.and(qEvent.initiator.id.eq(user2.getId()));
+ predicate.and(qEvent.state.eq(EventState.PUBLISHED));
+ predicate.and(qEvent.category.id.eq(category1.getId()));
+ predicate.and(qEvent.eventDate.between(now.plusDays(14), now.plusDays(16))); // event3
+
+ assertNotNull(predicate.getValue());
+ Page result = eventRepository.findAll(predicate.getValue(), PageRequest.of(0, 10));
+
+ assertEquals(1, result.getTotalElements());
+ assertEquals("Event Gamma", result.getContent().getFirst().getTitle());
+ }
+}
\ No newline at end of file
diff --git a/main-service/src/test/java/ru/practicum/explorewithme/main/service/CategoryServiceIntegrationTest.java b/main-service/src/test/java/ru/practicum/explorewithme/main/service/CategoryServiceIntegrationTest.java
new file mode 100644
index 0000000..5cdb52d
--- /dev/null
+++ b/main-service/src/test/java/ru/practicum/explorewithme/main/service/CategoryServiceIntegrationTest.java
@@ -0,0 +1,439 @@
+package ru.practicum.explorewithme.main.service;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.transaction.annotation.Transactional;
+import org.testcontainers.containers.PostgreSQLContainer;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+import ru.practicum.explorewithme.main.dto.CategoryDto;
+import ru.practicum.explorewithme.main.dto.NewCategoryDto;
+import ru.practicum.explorewithme.main.dto.NewUserRequestDto;
+import ru.practicum.explorewithme.main.error.EntityAlreadyExistsException;
+import ru.practicum.explorewithme.main.error.EntityDeletedException;
+import ru.practicum.explorewithme.main.error.EntityNotFoundException;
+import ru.practicum.explorewithme.main.mapper.UserMapper;
+import ru.practicum.explorewithme.main.model.*;
+import ru.practicum.explorewithme.main.repository.CategoryRepository;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import ru.practicum.explorewithme.main.repository.EventRepository;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+@SpringBootTest
+@Testcontainers
+@Transactional
+@DisplayName("Интеграционное тестирование CategoryServiceImpl")
+class CategoryServiceIntegrationTest {
+
+ @Container
+ static PostgreSQLContainer> postgresContainer = new PostgreSQLContainer<>("postgres:16.1")
+ .withDatabaseName("explorewithme_test")
+ .withUsername("test")
+ .withPassword("test");
+
+ @DynamicPropertySource
+ static void registerPgProperties(DynamicPropertyRegistry registry) {
+ registry.add("spring.datasource.url", postgresContainer::getJdbcUrl);
+ registry.add("spring.datasource.username", postgresContainer::getUsername);
+ registry.add("spring.datasource.password", postgresContainer::getPassword);
+ }
+
+ @Autowired
+ private CategoryService categoryService;
+
+ @Autowired
+ private CategoryRepository categoryRepository;
+
+ @Autowired
+ private EventRepository eventRepository;
+
+ @Autowired
+ private UserService userService;
+
+ @Autowired
+ private UserMapper userMapper;
+
+ private NewCategoryDto newCategoryDto;
+ private NewCategoryDto anotherCategoryDto;
+
+ @BeforeEach
+ void setUp() {
+ categoryRepository.deleteAll();
+
+ newCategoryDto = new NewCategoryDto();
+ newCategoryDto.setName("Тестовая категория");
+
+ anotherCategoryDto = new NewCategoryDto();
+ anotherCategoryDto.setName("Другая категория");
+ }
+
+ @Nested
+ @DisplayName("Создание категории")
+ class CreateCategoryTests {
+
+ @Test
+ @DisplayName("Успешное создание категории")
+ void createCategory_Success() {
+
+ CategoryDto createdCategory = categoryService.createCategory(newCategoryDto);
+
+ assertNotNull(createdCategory);
+ assertNotNull(createdCategory.getId());
+ assertEquals(newCategoryDto.getName(), createdCategory.getName());
+
+ Optional categoryFromDb = categoryRepository.findById(createdCategory.getId());
+ assertTrue(categoryFromDb.isPresent());
+ assertEquals(newCategoryDto.getName(), categoryFromDb.get().getName());
+ }
+
+ @Test
+ @DisplayName("Исключение при создании категории с уже существующим именем")
+ void createCategory_WithExistingName_ThrowsException() {
+
+ CategoryDto createdCategory = categoryService.createCategory(newCategoryDto);
+ assertNotNull(createdCategory);
+
+ NewCategoryDto duplicateCategoryDto = new NewCategoryDto();
+ duplicateCategoryDto.setName(newCategoryDto.getName());
+
+ EntityAlreadyExistsException exception = assertThrows(EntityAlreadyExistsException.class, () -> {
+ categoryService.createCategory(duplicateCategoryDto);
+ });
+
+ assertTrue(exception.getMessage().contains("Category"));
+ assertTrue(exception.getMessage().contains(duplicateCategoryDto.getName()));
+ }
+
+ }
+
+ @Nested
+ @DisplayName("Обновление категории")
+ class UpdateCategoryTests {
+
+ @Test
+ @DisplayName("Успешное обновление категории")
+ void updateCategory_Success() {
+
+ CategoryDto createdCategory = categoryService.createCategory(newCategoryDto);
+
+ NewCategoryDto updateDto = new NewCategoryDto();
+ updateDto.setName("Обновленная категория");
+
+ CategoryDto updatedCategory = categoryService.updateCategory(createdCategory.getId(), updateDto);
+
+ assertNotNull(updatedCategory);
+ assertEquals(createdCategory.getId(), updatedCategory.getId());
+ assertEquals(updateDto.getName(), updatedCategory.getName());
+
+ Optional categoryFromDb = categoryRepository.findById(createdCategory.getId());
+ assertTrue(categoryFromDb.isPresent());
+ assertEquals(updateDto.getName(), categoryFromDb.get().getName());
+ }
+
+ @Test
+ @DisplayName("Исключение при обновлении несуществующей категории")
+ void updateCategory_CategoryNotFound_ThrowsException() {
+
+ Long nonExistentCategoryId = 999L;
+
+ EntityNotFoundException exception = assertThrows(EntityNotFoundException.class, () -> {
+ categoryService.updateCategory(nonExistentCategoryId, newCategoryDto);
+ });
+
+ assertTrue(exception.getMessage().contains("Category"));
+ assertTrue(exception.getMessage().contains(nonExistentCategoryId.toString()));
+ }
+
+ @Test
+ @DisplayName("Обновление категории с пустым именем не меняет существующее значение")
+ void updateCategory_WithBlankName_PreservesExistingName() {
+
+ CategoryDto createdCategory = categoryService.createCategory(newCategoryDto);
+
+ NewCategoryDto updateDto = new NewCategoryDto();
+ updateDto.setName(""); // Пустое имя
+
+ CategoryDto updatedCategory = categoryService.updateCategory(createdCategory.getId(), updateDto);
+
+ assertEquals(newCategoryDto.getName(), updatedCategory.getName());
+ }
+
+ @Test
+ @DisplayName("Обновление категории с тем же самым именем не вызывает ошибок")
+ void updateCategory_WithSameName_Success() {
+
+ CategoryDto createdCategory = categoryService.createCategory(newCategoryDto);
+
+ NewCategoryDto updateDto = new NewCategoryDto();
+ updateDto.setName(createdCategory.getName());
+
+ CategoryDto updatedCategory = categoryService.updateCategory(createdCategory.getId(), updateDto);
+
+ assertNotNull(updatedCategory);
+ assertEquals(createdCategory.getId(), updatedCategory.getId());
+ assertEquals(createdCategory.getName(), updatedCategory.getName());
+
+ Optional categoryFromDb = categoryRepository.findById(createdCategory.getId());
+ assertTrue(categoryFromDb.isPresent());
+ assertEquals(createdCategory.getName(), categoryFromDb.get().getName());
+ }
+
+ @Test
+ @DisplayName("Исключение при обновлении категории с именем, которое уже существует")
+ void updateCategory_WithExistingName_ThrowsException() {
+
+ CategoryDto firstCategory = categoryService.createCategory(newCategoryDto);
+ CategoryDto secondCategory = categoryService.createCategory(anotherCategoryDto);
+
+ NewCategoryDto updateDto = new NewCategoryDto();
+ updateDto.setName(secondCategory.getName());
+
+ EntityAlreadyExistsException exception = assertThrows(EntityAlreadyExistsException.class, () -> {
+ categoryService.updateCategory(firstCategory.getId(), updateDto);
+ });
+
+ assertTrue(exception.getMessage().contains("Category"));
+ assertTrue(exception.getMessage().contains(updateDto.getName()));
+ }
+
+ }
+
+ @Nested
+ @DisplayName("Удаление категории")
+ class DeleteCategoryTests {
+
+ @Test
+ @DisplayName("Успешное удаление категории")
+ void deleteCategory_Success() {
+
+ CategoryDto createdCategory = categoryService.createCategory(newCategoryDto);
+
+ assertTrue(categoryRepository.existsById(createdCategory.getId()));
+
+ categoryService.deleteCategory(createdCategory.getId());
+
+ assertFalse(categoryRepository.existsById(createdCategory.getId()));
+ }
+
+ @Test
+ @DisplayName("Исключение при удалении несуществующей категории")
+ void deleteCategory_CategoryNotFound_ThrowsException() {
+
+ Long nonExistentCategoryId = 999L;
+
+ EntityNotFoundException exception = assertThrows(EntityNotFoundException.class, () -> {
+ categoryService.deleteCategory(nonExistentCategoryId);
+ });
+
+ assertTrue(exception.getMessage().contains("Category"));
+ assertTrue(exception.getMessage().contains(nonExistentCategoryId.toString()));
+ }
+
+ @Test
+ @DisplayName("Исключение при удалении категории, содержащей события")
+ void deleteCategory_WithEvents_ThrowsException() {
+
+ CategoryDto createdCategory = categoryService.createCategory(newCategoryDto);
+ assertNotNull(createdCategory);
+
+ User user = userMapper.toUser(userService
+ .createUser(new NewUserRequestDto("Test name", "Test email")));
+
+ Event event = Event.builder()
+ .annotation("Test annotation")
+ .createdOn(java.time.LocalDateTime.now())
+ .category(new Category(createdCategory.getId(), createdCategory.getName()))
+ .description("Test description")
+ .eventDate(java.time.LocalDateTime.now())
+ .initiator(user)
+ .location(new Location(5555.55F, 5555.555F))
+ .title("Test title")
+ .publishedOn(java.time.LocalDateTime.now())
+ .state(EventState.PENDING)
+ .build();
+
+ eventRepository.save(event);
+
+ EntityDeletedException exception = assertThrows(EntityDeletedException.class, () -> {
+ categoryService.deleteCategory(createdCategory.getId());
+ });
+
+ assertTrue(exception.getMessage().contains("Category"));
+ assertTrue(exception.getMessage().contains(createdCategory.getId().toString()));
+ }
+
+ }
+
+ @Nested
+ @DisplayName("Получение списка категорий")
+ class GetCategoriesTests {
+
+ @Test
+ @DisplayName("Получение всех категорий")
+ void getAllCategories_ReturnsAllCategories() {
+
+ CategoryDto category1 = categoryService.createCategory(newCategoryDto);
+ CategoryDto category2 = categoryService.createCategory(anotherCategoryDto);
+
+ List categories = categoryService.getAllCategories(0, 10);
+
+ assertNotNull(categories);
+ assertEquals(2, categories.size());
+
+ List categoryIds = categories.stream()
+ .map(CategoryDto::getId)
+ .collect(Collectors.toList());
+ assertTrue(categoryIds.contains(category1.getId()));
+ assertTrue(categoryIds.contains(category2.getId()));
+ }
+
+ @Test
+ @DisplayName("Корректная работа пагинации при получении категорий")
+ void getAllCategories_Pagination_ReturnsCorrectPage() {
+
+ List createdCategories = IntStream.range(0, 5)
+ .mapToObj(i -> {
+ NewCategoryDto request = new NewCategoryDto();
+ request.setName("Category " + i);
+ return categoryService.createCategory(request);
+ })
+ .collect(Collectors.toList());
+
+ List page1 = categoryService.getAllCategories(0, 2);
+
+ List page2 = categoryService.getAllCategories(2, 2);
+
+ List page3 = categoryService.getAllCategories(4, 2);
+
+ assertEquals(2, page1.size());
+ assertEquals(2, page2.size());
+ assertEquals(1, page3.size());
+
+ List allCategoryIds = new java.util.ArrayList<>();
+ allCategoryIds.addAll(page1.stream().map(CategoryDto::getId).collect(Collectors.toList()));
+ allCategoryIds.addAll(page2.stream().map(CategoryDto::getId).collect(Collectors.toList()));
+ allCategoryIds.addAll(page3.stream().map(CategoryDto::getId).collect(Collectors.toList()));
+
+ assertEquals(5, allCategoryIds.size());
+ assertEquals(5, allCategoryIds.stream().distinct().count());
+ }
+
+ @Test
+ @DisplayName("Получение пустого списка при отсутствии категорий")
+ void getAllCategories_EmptyRepository_ReturnsEmptyList() {
+
+ List categories = categoryService.getAllCategories(0, 10);
+
+ assertNotNull(categories);
+ assertTrue(categories.isEmpty());
+ }
+
+ @Test
+ @DisplayName("Исключение при создании категории с уже существующим именем")
+ void createCategory_WithExistingName_ThrowsException() {
+
+ CategoryDto createdCategory = categoryService.createCategory(newCategoryDto);
+ assertNotNull(createdCategory);
+
+ NewCategoryDto duplicateCategoryDto = new NewCategoryDto();
+ duplicateCategoryDto.setName(newCategoryDto.getName());
+
+ EntityAlreadyExistsException exception = assertThrows(EntityAlreadyExistsException.class, () -> {
+ categoryService.createCategory(duplicateCategoryDto);
+ });
+
+ assertTrue(exception.getMessage().contains("Category"));
+ assertTrue(exception.getMessage().contains(duplicateCategoryDto.getName()));
+ }
+ }
+
+ @Nested
+ @DisplayName("Получение категории по ID")
+ class GetCategoryByIdTests {
+
+ @Test
+ @DisplayName("Успешное получение категории по ID")
+ void getCategoryById_Success() {
+
+ CategoryDto createdCategory = categoryService.createCategory(newCategoryDto);
+
+ CategoryDto retrievedCategory = categoryService.getCategoryById(createdCategory.getId());
+
+ assertNotNull(retrievedCategory);
+ assertEquals(createdCategory.getId(), retrievedCategory.getId());
+ assertEquals(createdCategory.getName(), retrievedCategory.getName());
+ }
+
+ @Test
+ @DisplayName("Исключение при запросе несуществующей категории")
+ void getCategoryById_CategoryNotFound_ThrowsException() {
+
+ Long nonExistentCategoryId = 999L;
+
+ EntityNotFoundException exception = assertThrows(EntityNotFoundException.class, () -> {
+ categoryService.getCategoryById(nonExistentCategoryId);
+ });
+
+ assertTrue(exception.getMessage().contains("Category"));
+ assertTrue(exception.getMessage().contains(nonExistentCategoryId.toString()));
+ }
+ }
+
+ @Nested
+ @DisplayName("Тесты производительности")
+ class PerformanceTests {
+
+ @Test
+ @DisplayName("Эффективная работа с большим количеством данных")
+ void getAllCategories_WithLargeDataset_PerformsEfficiently() {
+
+ for (int i = 0; i < 100; i++) {
+ NewCategoryDto request = new NewCategoryDto();
+ request.setName("Category " + i);
+ categoryService.createCategory(request);
+ }
+
+ long startTime = System.currentTimeMillis();
+ List categories = categoryService.getAllCategories(0, 50);
+ long endTime = System.currentTimeMillis();
+
+ assertEquals(50, categories.size());
+ assertTrue((endTime - startTime) < 1000); // Ожидаем выполнение менее чем за секунду
+
+ System.out.println("Время выполнения запроса для 50 категорий из 100: " + (endTime - startTime) + " мс");
+ }
+ }
+
+ @Nested
+ @DisplayName("Тесты обработки граничных случаев")
+ class EdgeCaseTests {
+
+ @Test
+ @DisplayName("Корректная обработка запроса страницы за пределами допустимого диапазона")
+ void getAllCategories_PageOutOfRange_ReturnsEmptyList() {
+
+ IntStream.range(0, 3)
+ .forEach(i -> {
+ NewCategoryDto request = new NewCategoryDto();
+ request.setName("Category " + i);
+ categoryService.createCategory(request);
+ });
+
+ List result = categoryService.getAllCategories(10, 5);
+
+ assertNotNull(result);
+ assertTrue(result.isEmpty());
+ }
+ }
+}
\ No newline at end of file
diff --git a/main-service/src/test/java/ru/practicum/explorewithme/main/service/CommentServiceImplTest.java b/main-service/src/test/java/ru/practicum/explorewithme/main/service/CommentServiceImplTest.java
new file mode 100644
index 0000000..8edf63a
--- /dev/null
+++ b/main-service/src/test/java/ru/practicum/explorewithme/main/service/CommentServiceImplTest.java
@@ -0,0 +1,568 @@
+package ru.practicum.explorewithme.main.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import com.querydsl.core.BooleanBuilder;
+import com.querydsl.core.types.Predicate;
+import java.time.LocalDateTime;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageImpl;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Sort;
+import ru.practicum.explorewithme.main.dto.CommentAdminDto;
+import ru.practicum.explorewithme.main.dto.CommentDto;
+import ru.practicum.explorewithme.main.dto.NewCommentDto;
+import ru.practicum.explorewithme.main.dto.UpdateCommentDto;
+import ru.practicum.explorewithme.main.error.BusinessRuleViolationException;
+import ru.practicum.explorewithme.main.error.EntityNotFoundException;
+import ru.practicum.explorewithme.main.mapper.CommentMapper;
+import ru.practicum.explorewithme.main.model.Comment;
+import ru.practicum.explorewithme.main.model.Event;
+import ru.practicum.explorewithme.main.model.EventState;
+import ru.practicum.explorewithme.main.model.User;
+import ru.practicum.explorewithme.main.repository.CommentRepository;
+import ru.practicum.explorewithme.main.repository.EventRepository;
+import ru.practicum.explorewithme.main.repository.UserRepository;
+import ru.practicum.explorewithme.main.service.params.AdminCommentSearchParams;
+
+@ExtendWith(MockitoExtension.class)
+class CommentServiceImplTest {
+
+ @Mock
+ private UserRepository userRepository;
+ @Mock
+ private EventRepository eventRepository;
+ @Mock
+ private CommentMapper commentMapper;
+ @Mock
+ private CommentRepository commentRepository;
+
+ @InjectMocks
+ private CommentServiceImpl commentService;
+
+ @Captor
+ private ArgumentCaptor commentCaptor;
+ @Captor
+ private ArgumentCaptor predicateCaptor;
+
+ private long userId;
+ private long eventId;
+ private long commentId;
+ private User user;
+ private Event event;
+ private Comment comment;
+
+ @BeforeEach
+ void setUp() {
+ userId = 1L;
+ eventId = 2L;
+ commentId = 10L;
+ user = new User();
+ user.setId(userId);
+
+ event = new Event();
+ event.setId(eventId);
+
+ comment = new Comment();
+ comment.setId(commentId);
+ comment.setAuthor(user);
+ comment.setDeleted(false);
+ comment.setEdited(false);
+ comment.setText("Old text");
+ comment.setCreatedOn(LocalDateTime.now().minusHours(5));
+ }
+
+ @Nested
+ @DisplayName("Набор тестов для метода addComment")
+ class AddComment {
+
+ @Test
+ void addComment_success() {
+ NewCommentDto newCommentDto = new NewCommentDto();
+ event.setState(EventState.PUBLISHED);
+ event.setCommentsEnabled(true);
+
+ CommentDto commentDto = new CommentDto();
+
+ when(userRepository.findById(userId)).thenReturn(Optional.of(user));
+ when(eventRepository.findById(eventId)).thenReturn(Optional.of(event));
+ when(commentMapper.toComment(newCommentDto)).thenReturn(comment);
+ when(commentRepository.save(any(Comment.class))).thenReturn(comment);
+ when(commentMapper.toDto(any(Comment.class))).thenReturn(commentDto);
+
+ CommentDto result = commentService.addComment(userId, eventId, newCommentDto);
+
+ assertEquals(commentDto, result);
+ verify(commentRepository, times(1)).save(comment);
+ assertEquals(user, comment.getAuthor());
+ assertEquals(event, comment.getEvent());
+ }
+
+ @Test
+ void addComment_userNotFound() {
+ when(userRepository.findById(userId)).thenReturn(Optional.empty());
+
+ EntityNotFoundException ex = assertThrows(EntityNotFoundException.class,
+ () -> commentService.addComment(userId, 2L, new NewCommentDto()));
+ assertTrue(ex.getMessage().contains("Пользователь с id " + userId + " не найден"));
+ }
+
+ @Test
+ void addComment_eventNotFound() {
+ when(userRepository.findById(userId)).thenReturn(Optional.of(user));
+ when(eventRepository.findById(eventId)).thenReturn(Optional.empty());
+
+ EntityNotFoundException ex = assertThrows(EntityNotFoundException.class,
+ () -> commentService.addComment(userId, eventId, new NewCommentDto()));
+ assertTrue(ex.getMessage().contains("Событие с id " + eventId + " не найден"));
+ }
+
+ @Test
+ void addComment_eventNotPublished() {
+ event.setState(EventState.PENDING); // не опубликовано
+ when(userRepository.findById(userId)).thenReturn(Optional.of(user));
+ when(eventRepository.findById(eventId)).thenReturn(Optional.of(event));
+
+ BusinessRuleViolationException ex = assertThrows(BusinessRuleViolationException.class,
+ () -> commentService.addComment(userId, eventId, new NewCommentDto()));
+ assertEquals("Событие еще не опубликовано", ex.getMessage());
+ }
+
+ @Test
+ void addComment_commentsDisabled() {
+ event.setState(EventState.PUBLISHED);
+ event.setCommentsEnabled(false); // Комментарии запрещены
+ when(userRepository.findById(userId)).thenReturn(Optional.of(user));
+ when(eventRepository.findById(eventId)).thenReturn(Optional.of(event));
+
+ BusinessRuleViolationException ex = assertThrows(BusinessRuleViolationException.class,
+ () -> commentService.addComment(userId, eventId, new NewCommentDto()));
+ assertEquals("Комментарии запрещены", ex.getMessage());
+ }
+ }
+
+ @Nested
+ @DisplayName("Набор тестов для метода updateUserComment")
+ class UpdateUserComment {
+
+ @Test
+ void updateUserComment_shouldUpdateCommentAndReturnDto() {
+ UpdateCommentDto updateCommentDto = new UpdateCommentDto();
+ updateCommentDto.setText("Updated text");
+
+ CommentDto expectedDto = new CommentDto();
+ expectedDto.setId(commentId);
+ expectedDto.setText("Updated text");
+
+ when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment));
+ when(commentMapper.toDto(any(Comment.class))).thenReturn(expectedDto);
+ when(commentRepository.saveAndFlush(any(Comment.class))).thenAnswer(invocation -> invocation.getArgument(0));
+
+ CommentDto result = commentService.updateUserComment(userId, commentId, updateCommentDto);
+
+ Assertions.assertEquals("Updated text", result.getText());
+ Assertions.assertTrue(comment.isEdited());
+ verify(commentRepository).saveAndFlush(comment);
+ }
+
+ @Test
+ void updateUserComment_shouldThrowIfCommentNotFound() {
+ when(commentRepository.findById(commentId)).thenReturn(Optional.empty());
+ UpdateCommentDto dto = new UpdateCommentDto();
+
+ EntityNotFoundException ex = Assertions.assertThrows(
+ EntityNotFoundException.class,
+ () -> commentService.updateUserComment(userId, commentId, dto)
+ );
+ Assertions.assertTrue(ex.getMessage().contains("не найден"));
+ }
+
+ @Test
+ void updateUserComment_shouldThrowIfUserIsNotAuthor() {
+ User anotherUser = new User();
+ anotherUser.setId(111L);
+ comment.setAuthor(anotherUser);
+
+ when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment));
+ UpdateCommentDto dto = new UpdateCommentDto();
+
+ EntityNotFoundException ex = Assertions.assertThrows(
+ EntityNotFoundException.class,
+ () -> commentService.updateUserComment(userId, commentId, dto)
+ );
+ Assertions.assertTrue(ex.getMessage().contains("пользователя с id"));
+ }
+
+ @Test
+ void updateUserComment_shouldThrowIfDeleted() {
+ comment.setDeleted(true);
+
+ when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment));
+ UpdateCommentDto dto = new UpdateCommentDto();
+
+ BusinessRuleViolationException ex = Assertions.assertThrows(
+ BusinessRuleViolationException.class,
+ () -> commentService.updateUserComment(userId, commentId, dto)
+ );
+ Assertions.assertTrue(ex.getMessage().contains("удален"));
+ }
+
+ @Test
+ void updateUserComment_shouldThrowIfTooLate() {
+
+ comment.setCreatedOn(LocalDateTime.now().minusHours(7));
+ when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment));
+ UpdateCommentDto dto = new UpdateCommentDto();
+
+ BusinessRuleViolationException ex = Assertions.assertThrows(
+ BusinessRuleViolationException.class,
+ () -> commentService.updateUserComment(userId, commentId, dto)
+ );
+ Assertions.assertTrue(ex.getMessage().contains("Время для редактирования истекло"));
+ }
+ }
+
+ @Nested
+ @DisplayName("Набор тестов для метода getUserComments")
+ class GetUserComments {
+
+ @Test
+ void getUserCommentsshouldReturnEmptyListwhenNoComments() {
+
+ when(userRepository.existsById(userId)).thenReturn(true);
+ when(commentRepository.findByAuthorIdAndIsDeletedFalse(eq(userId), any(Pageable.class)))
+ .thenReturn(new PageImpl<>(List.of()));
+ when(commentMapper.toDtoList(List.of())).thenReturn(List.of());
+
+ List result = commentService.getUserComments(userId, 0, 10);
+
+ assertThat(result).isEmpty();
+
+ verify(userRepository).existsById(userId);
+ verify(commentRepository).findByAuthorIdAndIsDeletedFalse(eq(userId), any(Pageable.class));
+ verify(commentMapper).toDtoList(List.of());
+ }
+
+ @Test
+ void getUserCommentsshouldReturnCommentsDtoListwhenCommentsExist() {
+
+ List comments = List.of(comment);
+ CommentDto commentDto = new CommentDto();
+ List commentDtos = List.of(commentDto);
+
+ when(userRepository.existsById(userId)).thenReturn(true);
+ when(commentRepository.findByAuthorIdAndIsDeletedFalse(eq(userId), any(Pageable.class)))
+ .thenReturn(new PageImpl<>(comments));
+ when(commentMapper.toDtoList(comments)).thenReturn(commentDtos);
+
+ List result = commentService.getUserComments(userId, 0, 10);
+
+ assertThat(result).isEqualTo(commentDtos);
+
+ verify(userRepository).existsById(userId);
+ verify(commentRepository).findByAuthorIdAndIsDeletedFalse(eq(userId), any(Pageable.class));
+ verify(commentMapper).toDtoList(comments);
+ }
+
+ @Test
+ void getUserCommentsshouldThrowExceptionwhenUserNotFound() {
+
+ when(userRepository.existsById(userId)).thenReturn(false);
+
+ EntityNotFoundException exception = assertThrows(
+ EntityNotFoundException.class,
+ () -> commentService.getUserComments(userId, 0, 10)
+ );
+
+ assertThat(exception.getMessage()).contains("Пользователь с id " + userId + " не найден");
+ verify(userRepository).existsById(userId);
+ verifyNoInteractions(commentRepository, commentMapper);
+ }
+ }
+
+ @Nested
+ @DisplayName("Метод deleteCommentByAdmin")
+ class DeleteCommentByAdminTests {
+ private Comment existingComment;
+
+ @BeforeEach
+ void setUpDeleteAdmin() {
+ existingComment = new Comment();
+ existingComment.setId(commentId);
+ existingComment.setDeleted(false);
+ }
+
+ @Test
+ @DisplayName("Должен пометить комментарий как удаленный, если он не был удален")
+ void deleteCommentByAdmin_whenNotDeleted_shouldMarkAsDeletedAndSave() {
+ when(commentRepository.findById(commentId)).thenReturn(Optional.of(existingComment));
+ when(commentRepository.save(any(Comment.class))).thenReturn(existingComment);
+
+ commentService.deleteCommentByAdmin(commentId);
+
+ verify(commentRepository).findById(commentId);
+ verify(commentRepository).save(commentCaptor.capture());
+ Comment savedComment = commentCaptor.getValue();
+ assertTrue(savedComment.isDeleted());
+ }
+
+ @Test
+ @DisplayName("Не должен вызывать save, если комментарий уже удален")
+ void deleteCommentByAdmin_whenAlreadyDeleted_shouldDoNothing() {
+ existingComment.setDeleted(true);
+ when(commentRepository.findById(commentId)).thenReturn(Optional.of(existingComment));
+
+ commentService.deleteCommentByAdmin(commentId);
+
+ verify(commentRepository).findById(commentId);
+ verify(commentRepository, never()).save(any(Comment.class));
+ }
+
+ @Test
+ @DisplayName("Должен выбросить EntityNotFoundException, если комментарий не найден")
+ void deleteCommentByAdmin_whenCommentNotFound_shouldThrowEntityNotFoundException() {
+ when(commentRepository.findById(commentId)).thenReturn(Optional.empty());
+
+ EntityNotFoundException ex = assertThrows(EntityNotFoundException.class,
+ () -> commentService.deleteCommentByAdmin(commentId));
+ assertTrue(ex.getMessage().contains("Comment with id=" + commentId + " not found"));
+ verify(commentRepository).findById(commentId);
+ verify(commentRepository, never()).save(any(Comment.class));
+ }
+ }
+
+ @Nested
+ @DisplayName("Метод deleteUserComment")
+ class DeleteUserCommentTests {
+ private Comment userComment;
+ private final Long nonAuthorUserId = 999L;
+
+ @BeforeEach
+ void setUpDeleteUser() {
+ userComment = new Comment();
+ userComment.setId(commentId);
+ userComment.setAuthor(user);
+ userComment.setDeleted(false);
+ }
+
+ @Test
+ @DisplayName("Пользователь должен успешно 'мягко' удалять свой комментарий")
+ void deleteUserComment_whenUserIsAuthor_shouldMarkAsDeleted() {
+ when(commentRepository.findById(commentId)).thenReturn(Optional.of(userComment));
+ when(commentRepository.save(any(Comment.class))).thenReturn(userComment);
+
+ commentService.deleteUserComment(userId, commentId);
+
+ verify(commentRepository).findById(commentId);
+ verify(commentRepository).save(commentCaptor.capture());
+ Comment savedComment = commentCaptor.getValue();
+ assertTrue(savedComment.isDeleted());
+ }
+
+ @Test
+ @DisplayName("Должен выбросить EntityNotFoundException, если пользователь не автор")
+ void deleteUserComment_whenUserIsNotAuthor_shouldThrowException() {
+ when(commentRepository.findById(commentId)).thenReturn(Optional.of(userComment));
+
+ EntityNotFoundException ex = assertThrows(EntityNotFoundException.class,
+ () -> commentService.deleteUserComment(nonAuthorUserId, commentId));
+ assertTrue(ex.getMessage().contains("not found for user"));
+
+ verify(commentRepository).findById(commentId);
+ verify(commentRepository, never()).save(any(Comment.class));
+ }
+
+ @Test
+ @DisplayName("Не должен вызывать save, если комментарий уже удален пользователем")
+ void deleteUserComment_whenAlreadyDeleted_shouldDoNothing() {
+ userComment.setDeleted(true);
+ when(commentRepository.findById(commentId)).thenReturn(Optional.of(userComment));
+
+ commentService.deleteUserComment(userId, commentId);
+
+ verify(commentRepository).findById(commentId);
+ verify(commentRepository, never()).save(any(Comment.class));
+ }
+
+ @Test
+ @DisplayName("Должен выбросить EntityNotFoundException, если комментарий не найден")
+ void deleteUserComment_whenCommentNotFound_shouldThrowEntityNotFoundException() {
+ when(commentRepository.findById(commentId)).thenReturn(Optional.empty());
+
+ EntityNotFoundException ex = assertThrows(EntityNotFoundException.class,
+ () -> commentService.deleteUserComment(userId, commentId));
+ assertTrue(ex.getMessage().contains("Comment with id=" + commentId + " not found"));
+ }
+ }
+
+ @Nested
+ @DisplayName("Метод restoreCommentByAdmin")
+ class RestoreCommentByAdminTests {
+ private Comment deletedComment;
+ private Comment notDeletedComment;
+ private CommentAdminDto mappedDto;
+
+ @BeforeEach
+ void setUpRestore() {
+ deletedComment = new Comment();
+ deletedComment.setId(commentId);
+ deletedComment.setDeleted(true);
+ deletedComment.setText("Some text");
+
+ notDeletedComment = new Comment();
+ notDeletedComment.setId(commentId + 1);
+ notDeletedComment.setDeleted(false);
+ notDeletedComment.setText("Another text");
+
+ mappedDto = CommentAdminDto.builder().id(commentId).text("Some text").isEdited(false).build();
+ }
+
+ @Test
+ @DisplayName("Должен восстанавливать удаленный комментарий и возвращать DTO")
+ void restoreCommentByAdmin_whenCommentIsDeleted_shouldRestoreAndReturnDto() {
+ when(commentRepository.findById(commentId)).thenReturn(Optional.of(deletedComment));
+ when(commentRepository.save(any(Comment.class))).thenAnswer(invocation -> invocation.getArgument(0));
+ when(commentMapper.toAdminDto(any(Comment.class))).thenReturn(mappedDto);
+
+ CommentAdminDto result = commentService.restoreCommentByAdmin(commentId);
+
+ assertNotNull(result);
+ assertEquals(mappedDto.getText(), result.getText());
+ verify(commentRepository).findById(commentId);
+ verify(commentRepository).save(commentCaptor.capture());
+ Comment savedComment = commentCaptor.getValue();
+ assertFalse(savedComment.isDeleted());
+ verify(commentMapper).toAdminDto(savedComment);
+ }
+
+ @Test
+ @DisplayName("Должен возвращать DTO без изменений, если комментарий не был удален")
+ void restoreCommentByAdmin_whenCommentIsNotDeleted_shouldReturnDtoWithoutSaving() {
+ when(commentRepository.findById(notDeletedComment.getId())).thenReturn(Optional.of(notDeletedComment));
+ when(commentMapper.toAdminDto(notDeletedComment)).thenReturn(
+ CommentAdminDto.builder().id(notDeletedComment.getId()).text(notDeletedComment.getText()).build()
+ );
+
+ CommentAdminDto result = commentService.restoreCommentByAdmin(notDeletedComment.getId());
+
+ assertNotNull(result);
+ assertEquals(notDeletedComment.getText(), result.getText());
+ verify(commentRepository).findById(notDeletedComment.getId());
+ verify(commentRepository, never()).save(any(Comment.class));
+ verify(commentMapper).toAdminDto(notDeletedComment);
+ }
+
+ @Test
+ @DisplayName("Должен выбросить EntityNotFoundException, если комментарий для восстановления не найден")
+ void restoreCommentByAdmin_whenCommentNotFound_shouldThrowEntityNotFoundException() {
+ when(commentRepository.findById(commentId)).thenReturn(Optional.empty());
+
+ EntityNotFoundException ex = assertThrows(EntityNotFoundException.class,
+ () -> commentService.restoreCommentByAdmin(commentId));
+ assertTrue(ex.getMessage().contains("Comment with id=" + commentId + " not found"));
+ }
+ }
+
+ @Nested
+ @DisplayName("Метод getAllCommentsAdmin")
+ class GetAllCommentsAdminServiceTests {
+ private Pageable defaultPageable;
+
+ @BeforeEach
+ void setUpAdminSearch() {
+ defaultPageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "createdOn"));
+ }
+
+ @Test
+ @DisplayName("Должен вызывать commentRepository.findAll с предикатом и пагинацией")
+ void getAllCommentsAdmin_withFilters_shouldCallRepositoryWithPredicate() {
+ AdminCommentSearchParams params = AdminCommentSearchParams.builder()
+ .userId(1L)
+ .eventId(2L)
+ .isDeleted(false)
+ .build();
+ Page emptyPage = new PageImpl<>(Collections.emptyList(), defaultPageable, 0);
+ when(commentRepository.findAll(any(Predicate.class), eq(defaultPageable))).thenReturn(emptyPage);
+
+ commentService.getAllCommentsAdmin(params, 0, 10);
+
+ verify(commentRepository).findAll(predicateCaptor.capture(), eq(defaultPageable));
+ Predicate capturedPredicate = predicateCaptor.getValue();
+ assertNotNull(capturedPredicate);
+ String predicateStr = capturedPredicate.toString();
+ assertTrue(predicateStr.contains("comment.author.id = 1"));
+ assertTrue(predicateStr.contains("comment.event.id = 2"));
+ assertTrue(predicateStr.contains("comment.isDeleted = false"));
+ }
+
+ @Test
+ @DisplayName("Должен вызывать commentRepository.findAll с пагинацией, если фильтры не заданы")
+ void getAllCommentsAdmin_noFilters_shouldCallRepositoryWithoutPredicateParts() {
+ AdminCommentSearchParams params = AdminCommentSearchParams.builder().build();
+ Page emptyPage = new PageImpl<>(Collections.emptyList(), defaultPageable, 0);
+ when(commentRepository.findAll(any(Predicate.class), eq(defaultPageable))).thenReturn(emptyPage);
+
+ commentService.getAllCommentsAdmin(params, 0, 10);
+
+ verify(commentRepository).findAll(predicateCaptor.capture(), eq(defaultPageable));
+ Predicate capturedPredicate = predicateCaptor.getValue();
+ assertNotNull(capturedPredicate);
+ }
+
+ @Test
+ @DisplayName("getAllCommentsAdmin БЕЗ ФИЛЬТРОВ: должен вызывать commentRepository.findAll(Pageable)")
+ void getAllCommentsAdmin_noFiltersAtAll_shouldCallRepositoryFindAllWithPageable() {
+ AdminCommentSearchParams params = AdminCommentSearchParams.builder().build();
+ Page emptyPage = new PageImpl<>(Collections.emptyList(), defaultPageable, 0);
+
+ when(commentRepository.findAll(any(Predicate.class), eq(defaultPageable))).thenReturn(emptyPage);
+
+ commentService.getAllCommentsAdmin(params, 0, 10);
+
+ verify(commentRepository, times(1)).findAll(predicateCaptor.capture(), eq(defaultPageable));
+ Predicate capturedPredicate = predicateCaptor.getValue();
+ assertEquals(new BooleanBuilder(), capturedPredicate); // Проверяем, что предикат был пустым.
+ }
+
+
+ @Test
+ @DisplayName("Должен возвращать пустой список, если репозиторий вернул пустую страницу")
+ void getAllCommentsAdmin_whenRepositoryReturnsEmptyPage_shouldReturnEmptyList() {
+ AdminCommentSearchParams params = AdminCommentSearchParams.builder().userId(1L).build();
+ Page emptyPage = new PageImpl<>(Collections.emptyList(), defaultPageable, 0);
+ when(commentRepository.findAll(any(Predicate.class), eq(defaultPageable))).thenReturn(emptyPage);
+
+ List result = commentService.getAllCommentsAdmin(params, 0, 10);
+
+ verify(commentMapper, times(1)).toAdminDtoList(Collections.emptyList());
+ assertTrue(result.isEmpty());
+ }
+ }
+}
\ No newline at end of file
diff --git a/main-service/src/test/java/ru/practicum/explorewithme/main/service/CommentServiceIntegrationTest.java b/main-service/src/test/java/ru/practicum/explorewithme/main/service/CommentServiceIntegrationTest.java
new file mode 100644
index 0000000..240ffed
--- /dev/null
+++ b/main-service/src/test/java/ru/practicum/explorewithme/main/service/CommentServiceIntegrationTest.java
@@ -0,0 +1,457 @@
+package ru.practicum.explorewithme.main.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertAll;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.data.domain.Sort;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.springframework.transaction.annotation.Transactional;
+import org.testcontainers.containers.PostgreSQLContainer;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+import ru.practicum.explorewithme.main.dto.CommentAdminDto;
+import ru.practicum.explorewithme.main.dto.CommentDto;
+import ru.practicum.explorewithme.main.error.EntityNotFoundException;
+import ru.practicum.explorewithme.main.model.Category;
+import ru.practicum.explorewithme.main.model.Comment;
+import ru.practicum.explorewithme.main.model.Event;
+import ru.practicum.explorewithme.main.model.EventState;
+import ru.practicum.explorewithme.main.model.Location;
+import ru.practicum.explorewithme.main.model.User;
+import ru.practicum.explorewithme.main.repository.CategoryRepository;
+import ru.practicum.explorewithme.main.repository.CommentRepository;
+import ru.practicum.explorewithme.main.repository.EventRepository;
+import ru.practicum.explorewithme.main.repository.UserRepository;
+import ru.practicum.explorewithme.main.service.params.AdminCommentSearchParams;
+import ru.practicum.explorewithme.main.service.params.PublicCommentParameters;
+
+@SpringBootTest
+@Testcontainers
+@Transactional
+@DisplayName("Интеграционное тестирование CommentServiceImpl")
+class CommentServiceIntegrationTest {
+
+ @Container
+ static final PostgreSQLContainer> POSTGRES = new PostgreSQLContainer<>("postgres:16.1")
+ .withDatabaseName("ewm")
+ .withUsername("test")
+ .withPassword("test");
+
+ @DynamicPropertySource
+ static void registerPgProperties(DynamicPropertyRegistry registry) {
+ registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
+ registry.add("spring.datasource.username", POSTGRES::getUsername);
+ registry.add("spring.datasource.password", POSTGRES::getPassword);
+ }
+
+ @Autowired
+ private CommentService commentService;
+ @Autowired
+ private EventRepository eventRepository;
+ @Autowired
+ private CommentRepository commentRepository;
+ @Autowired
+ private CategoryRepository categoryRepository;
+ @Autowired
+ private UserRepository userRepository;
+
+ private Event event1, event2;
+ private Comment comment1User1Event1, comment2User2Event1, comment3User1Event1Deleted, comment4User2Event2;
+ private LocalDateTime now;
+ private User user1, user2;
+ private Category category1, category2;
+
+ @BeforeEach
+ void setUpEntities() {
+ now = LocalDateTime.now();
+
+ user1 = userRepository.save(
+ User.builder().name("User One").email("user1@example.com").build());
+ user2 = userRepository.save(
+ User.builder().name("User Two").email("user2@example.com").build());
+ category1 = categoryRepository.save(Category.builder().name("Первая категория").build());
+ category2 = categoryRepository.save(Category.builder().name("Вторая категория").build());
+ event1 = saveEvent(true, EventState.PUBLISHED, "Первое событие", user1,
+ category1, now.plusDays(1));
+ event2 = saveEvent(true, EventState.PUBLISHED, "Второе событие", user2,
+ category2, now.plusDays(2));
+
+ comment1User1Event1 = saveComment(event1, user1,
+ "Первый комментарий", now.minusHours(3), false, false);
+ comment2User2Event1 = saveComment(event1, user2,
+ "Второй комментарий", now.minusHours(1), false, false);
+ comment3User1Event1Deleted = saveComment(event1, user1,
+ "Третий комментарий", now.minusHours(2), true,
+ true);
+ comment4User2Event2 = saveComment(event2, user2,
+ "Четвёртый комментарий", now.plusSeconds(1), false,
+ false);
+ }
+
+ @Nested
+ @DisplayName("Метод getCommentsForEvent")
+ class GetCommentsForEvent {
+
+ @Test
+ @DisplayName("Возвращает комментарии опубликованного события с включёнными комментариями")
+ void shouldReturnComments_whenEventPublishedAndCommentsEnabled() {
+
+ Event event = event1;
+
+ PublicCommentParameters params = PublicCommentParameters.builder()
+ .from(0)
+ .size(10)
+ .sort(Sort.by(Sort.Direction.DESC, "createdOn"))
+ .build();
+
+ List result = commentService.getCommentsForEvent(event.getId(), params);
+
+ assertThat(result)
+ .hasSize(2)
+ .extracting(CommentDto::getText)
+ .containsExactly("Второй комментарий", "Первый комментарий"); // DESC-сортировка
+ }
+
+ @Test
+ @DisplayName("Возвращает пустой список, если комментарии отключены")
+ void shouldReturnEmptyList_whenCommentsDisabled() {
+
+ Event event = saveEvent(false, EventState.PUBLISHED,
+ "Событие с отключёнными комментариями", user2, category2, now.plusDays(11));
+ saveComment(event2, user1, "Отключённый комментарий", now, false, false);
+
+ PublicCommentParameters params = PublicCommentParameters.builder()
+ .from(0)
+ .size(10)
+ .sort(Sort.unsorted())
+ .build();
+
+ List result = commentService.getCommentsForEvent(event.getId(), params);
+
+ assertThat(result).isEmpty();
+ }
+
+ @Test
+ @DisplayName("Бросает EntityNotFoundException, когда событие не опубликовано")
+ void shouldThrowException_whenEventNotPublished() {
+
+ Event event = saveEvent(true, EventState.CANCELED, "Отменённое событие", user1,
+ category1, now.plusDays(12));
+
+ PublicCommentParameters params = PublicCommentParameters.builder()
+ .from(0)
+ .size(10)
+ .sort(Sort.unsorted())
+ .build();
+
+ assertThrows(EntityNotFoundException.class,
+ () -> commentService.getCommentsForEvent(event.getId(), params));
+ }
+
+ @Test
+ @DisplayName("Корректная пагинация")
+ void shouldApplyPagination() {
+
+ Event event = event1;
+ for (int i = 0; i < 3; i++) { // Добавляем ещё 3 комментария к 2 уже существующим.
+ saveComment(event, user1, "Комментарий " + i, LocalDateTime.now().plusSeconds(i), false, false);
+ }
+
+ PublicCommentParameters params = PublicCommentParameters.builder()
+ .from(0)
+ .size(2)
+ .sort(Sort.by(Sort.Direction.ASC, "createdOn"))
+ .build();
+
+ List page1 = commentService.getCommentsForEvent(event.getId(), params);
+
+ params = params.toBuilder().from(2).build();
+ List page2 = commentService.getCommentsForEvent(event.getId(), params);
+
+ assertThat(page1).hasSize(2);
+ assertThat(page2).hasSize(2);
+
+ params = params.toBuilder().from(4).build();
+ List page3 = commentService.getCommentsForEvent(event.getId(), params);
+ assertThat(page3).hasSize(1);
+ }
+
+ @Test
+ @DisplayName("Пустой список, когда у опубликованного события нет комментариев")
+ void shouldReturnEmptyList_whenNoComments() {
+ Event event = saveEvent(true, EventState.PUBLISHED, "Событие без комментариев", user1, category2,
+ now.plusDays(12)); // комментарии включены, но мы их не создаём
+
+ PublicCommentParameters params = PublicCommentParameters.builder()
+ .from(0).size(10).sort(Sort.unsorted()).build();
+
+ List result = commentService.getCommentsForEvent(event.getId(), params);
+
+ assertThat(result).isEmpty();
+ }
+
+ @Test
+ @DisplayName("Комментарий, помеченный как удалённый, не возвращается")
+ void shouldIgnoreDeletedComments() {
+ Event event = event2;
+
+ Comment deletedComment = Comment.builder()
+ .event(event)
+ .author(userRepository.save(
+ User.builder()
+ .name("X")
+ .email("x@example.com")
+ .build()))
+ .text("Удалённый")
+ .createdOn(LocalDateTime.now())
+ .isDeleted(true)
+ .build();
+ commentRepository.save(deletedComment);
+
+ PublicCommentParameters params = PublicCommentParameters.builder()
+ .from(0).size(10).sort(Sort.unsorted()).build();
+
+ List result = commentService.getCommentsForEvent(event.getId(), params);
+
+ assertThat(result).size().isEqualTo(1); // Новый комментарий не добавился
+ }
+
+ @Test
+ @DisplayName("EntityNotFoundException, когда событие не найдено")
+ void shouldThrowException_whenEventNotFound() {
+ PublicCommentParameters params = PublicCommentParameters.builder()
+ .from(0).size(10).sort(Sort.unsorted()).build();
+
+ assertThrows(EntityNotFoundException.class,
+ () -> commentService.getCommentsForEvent(9999L, params));
+ }
+
+ }
+
+ @Nested
+ @DisplayName("Метод getAllCommentsAdmin")
+ class GetAllCommentsAdminIntegrationTests {
+
+ @Test
+ @DisplayName("Должен возвращать все комментарии (включая удаленные), если фильтры не указаны, с пагинацией")
+ void getAllCommentsAdmin_noFilters_shouldReturnAllCommentsPaged() {
+ AdminCommentSearchParams params = AdminCommentSearchParams.builder().build();
+
+ List resultsPage1 = commentService.getAllCommentsAdmin(params, 0, 2);
+ List resultsPage2 = commentService.getAllCommentsAdmin(params, 2, 2);
+
+ assertAll(
+ () -> assertThat(resultsPage1)
+ .as("Страница 1: проверка количества комментариев")
+ .hasSize(2),
+ () -> assertThat(resultsPage1.get(0))
+ .as("Страница 1, Комментарий 1: должен быть comment4User2Event2 (ID и Текст)")
+ .hasFieldOrPropertyWithValue("id", comment4User2Event2.getId())
+ .hasFieldOrPropertyWithValue("text", comment4User2Event2.getText()),
+ () -> assertThat(resultsPage1.get(1))
+ .as("Страница 1, Комментарий 2: должен быть comment3User1Event1Deleted (ID и Текст)")
+ .hasFieldOrPropertyWithValue("id", comment3User1Event1Deleted.getId())
+ .hasFieldOrPropertyWithValue("text", comment3User1Event1Deleted.getText()),
+
+ () -> assertThat(resultsPage2)
+ .as("Страница 2: проверка количества комментариев")
+ .hasSize(2),
+ () -> assertThat(resultsPage2.get(0))
+ .as("Страница 2, Комментарий 1: должен быть comment2User2Event1 (ID и Текст)")
+ .hasFieldOrPropertyWithValue("id", comment2User2Event1.getId())
+ .hasFieldOrPropertyWithValue("text", comment2User2Event1.getText()),
+ () -> assertThat(resultsPage2.get(1))
+ .as("Страница 2, Комментарий 2: должен быть comment1User1Event1 (ID и Текст)")
+ .hasFieldOrPropertyWithValue("id", comment1User1Event1.getId())
+ .hasFieldOrPropertyWithValue("text", comment1User1Event1.getText())
+ );
+ }
+
+ @Test
+ @DisplayName("Должен фильтровать по userId")
+ void getAllCommentsAdmin_withUserIdFilter_shouldReturnUserComments() {
+ AdminCommentSearchParams params = AdminCommentSearchParams.builder()
+ .userId(user1.getId()).build();
+ List results = commentService.getAllCommentsAdmin(params, 0, 10);
+
+ assertAll(
+ () -> assertThat(results)
+ .as("Должен вернуть 2 комментария для user1")
+ .hasSize(2),
+ () -> assertThat(results)
+ .as("Все возвращенные комментарии должны принадлежать user1")
+ .allSatisfy(commentDto ->
+ assertThat(commentDto.getAuthor().getId())
+ .isEqualTo(user1.getId())),
+ () -> assertThat(results)
+ .as("Комментарий 1 для user1 (ID: %s) должен присутствовать", comment1User1Event1.getId())
+ .extracting(CommentAdminDto::getId)
+ .contains(comment1User1Event1.getId()),
+ () -> assertThat(results)
+ .as("Комментарий 2 для user1 (удален) (ID: %s) должен присутствовать", comment3User1Event1Deleted.getId())
+ .extracting(CommentAdminDto::getId)
+ .contains(comment3User1Event1Deleted.getId())
+ );
+ }
+
+ @Test
+ @DisplayName("Должен фильтровать по eventId")
+ void getAllCommentsAdmin_withEventIdFilter_shouldReturnEventComments() {
+ AdminCommentSearchParams params = AdminCommentSearchParams.builder()
+ .eventId(event1.getId()).build();
+ List results = commentService.getAllCommentsAdmin(params, 0, 10);
+
+ assertAll(
+ () -> assertThat(results)
+ .as("Должен вернуть 3 комментария для event1")
+ .hasSize(3),
+ () -> assertThat(results)
+ .as("Все возвращенные комментарии должны принадлежать event1 (ID: %s)", event1.getId())
+ .allSatisfy(commentDto ->
+ assertThat(commentDto.getEventId())
+ .isEqualTo(event1.getId()))
+ );
+ }
+
+ @Test
+ @DisplayName("Должен фильтровать по isDeleted = false (только не удаленные)")
+ void getAllCommentsAdmin_withIsDeletedFalse_shouldReturnNotDeletedComments() {
+ AdminCommentSearchParams params = AdminCommentSearchParams.builder().isDeleted(false)
+ .build();
+ List results = commentService.getAllCommentsAdmin(params, 0, 10);
+
+ assertAll(
+ () -> assertThat(results)
+ .as("Должен вернуть 3 неудаленных комментария")
+ .hasSize(3),
+ () -> assertThat(results)
+ .as("Удаленный комментарий (comment2 ID: %s) НЕ должен присутствовать в результатах", comment3User1Event1Deleted.getId())
+ .extracting(CommentAdminDto::getId)
+ .doesNotContain(comment3User1Event1Deleted.getId()),
+ () -> assertThat(results)
+ .as("Комментарий1 (ID: %s) должен присутствовать в результатах", comment1User1Event1.getId())
+ .extracting(CommentAdminDto::getId)
+ .contains(comment1User1Event1.getId()),
+ () -> assertThat(results)
+ .as("Комментарий3 (ID: %s) должен присутствовать в результатах", comment2User2Event1.getId())
+ .extracting(CommentAdminDto::getId)
+ .contains(comment2User2Event1.getId()),
+ () -> assertThat(results)
+ .as("Комментарий4 (ID: %s) должен присутствовать в результатах", comment4User2Event2.getId())
+ .extracting(CommentAdminDto::getId)
+ .contains(comment4User2Event2.getId()),
+ () -> assertThat(results)
+ .as("Все полученные экземпляры CommentDto должны иметь ненулевой ID типа Long")
+ .allSatisfy(dto -> assertThat(dto.getId())
+ .as("ID для DTO с текстом '%s'", dto.getText())
+ .isNotNull()
+ .isInstanceOf(Long.class))
+ );
+
+ results.forEach(commentDto ->
+ assertThat(findCommentInDb(commentDto.getId()).isDeleted())
+ .as("Комментарий %s (проверка БД) не должен быть помечен как удаленный", commentDto.getId())
+ .isFalse()
+ );
+ }
+
+ @Test
+ @DisplayName("Должен фильтровать по isDeleted = true (только удаленные)")
+ void getAllCommentsAdmin_withIsDeletedTrue_shouldReturnDeletedComments() {
+ AdminCommentSearchParams params = AdminCommentSearchParams.builder().isDeleted(true)
+ .build();
+ List results = commentService.getAllCommentsAdmin(params, 0, 10);
+
+ assertAll(
+ () -> assertThat(results)
+ .as("Должен вернуть ровно 1 удаленный комментарий")
+ .hasSize(1),
+ () -> assertThat(results.getFirst())
+ .as("Возвращенный комментарий должен быть comment2User1Event1Deleted (ID: %s)", comment3User1Event1Deleted.getId())
+ .hasFieldOrPropertyWithValue("id", comment3User1Event1Deleted.getId())
+ );
+
+ CommentAdminDto resultDto = results.getFirst();
+ assertThat(findCommentInDb(resultDto.getId()).isDeleted())
+ .as("Комментарий %s (проверка БД) должен быть помечен как удаленный", resultDto.getId())
+ .isTrue();
+ }
+
+ @Test
+ @DisplayName("Должен корректно работать с комбинацией фильтров (userId, eventId, isDeleted=false)")
+ void getAllCommentsAdmin_withCombinedFilters_shouldReturnMatchingComments() {
+ AdminCommentSearchParams params = AdminCommentSearchParams.builder()
+ .userId(user1.getId()).eventId(event1.getId()).isDeleted(false).build();
+ List results = commentService.getAllCommentsAdmin(params, 0, 10);
+
+ assertAll(
+ () -> assertThat(results)
+ .as("Должен вернуть ровно 1 комментарий по заданным фильтрам")
+ .hasSize(1),
+ () -> {
+ CommentAdminDto resultDto = results.getFirst();
+ assertThat(resultDto)
+ .as("Возвращенный комментарий (ID: %s) должен соответствовать comment1User1Event1", resultDto.getId())
+ .hasFieldOrPropertyWithValue("id", comment1User1Event1.getId())
+ .hasFieldOrPropertyWithValue("text", comment1User1Event1.getText())
+ .hasFieldOrPropertyWithValue("eventId", event1.getId());
+ assertThat(resultDto)
+ .as("Автор возвращенного комментария (ID: %s) должен быть user1 (ID: %s)", resultDto.getId(), user1.getId())
+ .hasFieldOrPropertyWithValue("author.id", user1.getId());
+ }
+ );
+
+ CommentAdminDto resultDtoForDbCheck = results.getFirst();
+ assertThat(findCommentInDb(resultDtoForDbCheck.getId()).isDeleted())
+ .as("Комментарий %s (проверка БД) не должен быть помечен как удаленный", resultDtoForDbCheck.getId())
+ .isFalse();
+ }
+
+ @Test
+ @DisplayName("Должен возвращать пустой список, если по фильтрам ничего не найдено")
+ void getAllCommentsAdmin_whenNoMatch_shouldReturnEmptyList() {
+ AdminCommentSearchParams params = AdminCommentSearchParams.builder().userId(999L)
+ .build();
+ List results = commentService.getAllCommentsAdmin(params, 0, 10);
+
+ assertThat(results).isEmpty();
+ }
+ }
+
+ /* ---------- Вспомогательные методы ---------- */
+
+ private Comment saveComment(Event event, User author, String text,
+ LocalDateTime createdOn, boolean isEdited, boolean isDeleted) {
+ Comment comment = Comment.builder().event(event).author(author).text(text)
+ .createdOn(createdOn)
+ .isEdited(isEdited).isDeleted(isDeleted).build();
+ return commentRepository.saveAndFlush(
+ comment);
+ }
+
+ private Event saveEvent(boolean commentsEnabled, EventState state, String title,
+ User initiator, Category category, LocalDateTime eventDate) {
+ Event event = Event.builder().title(title).annotation("Annotation for " + title)
+ .description("Description for " + title).state(state)
+ .commentsEnabled(commentsEnabled).category(category).initiator(initiator)
+ .eventDate(eventDate).location(new Location(55.75f, 37.61f))
+ .paid(false).participantLimit(0).requestModeration(true)
+ .build();
+ return eventRepository.saveAndFlush(event);
+ }
+
+ private Comment findCommentInDb(Long commentId) {
+ return commentRepository.findById(commentId).orElseThrow(() -> new AssertionError(
+ "Комментарий не найден в БД для проверки тестом: " + commentId));
+ }
+}
\ No newline at end of file
diff --git a/main-service/src/test/java/ru/practicum/explorewithme/main/service/EventServiceImplTest.java b/main-service/src/test/java/ru/practicum/explorewithme/main/service/EventServiceImplTest.java
new file mode 100644
index 0000000..8a73363
--- /dev/null
+++ b/main-service/src/test/java/ru/practicum/explorewithme/main/service/EventServiceImplTest.java
@@ -0,0 +1,1046 @@
+package ru.practicum.explorewithme.main.service;
+
+import static org.junit.jupiter.api.Assertions.assertAll;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import com.querydsl.core.types.Predicate;
+import java.time.LocalDateTime;
+import java.time.temporal.ChronoUnit;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageImpl;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Sort;
+import ru.practicum.explorewithme.main.dto.EventFullDto;
+import ru.practicum.explorewithme.main.dto.EventShortDto;
+import ru.practicum.explorewithme.main.dto.NewEventDto;
+import ru.practicum.explorewithme.main.dto.UpdateEventAdminRequestDto;
+import ru.practicum.explorewithme.main.dto.UpdateEventUserRequestDto;
+import ru.practicum.explorewithme.main.error.BusinessRuleViolationException;
+import ru.practicum.explorewithme.main.error.EntityNotFoundException;
+import ru.practicum.explorewithme.main.mapper.EventMapper;
+import ru.practicum.explorewithme.main.model.*;
+import ru.practicum.explorewithme.main.repository.CategoryRepository;
+import ru.practicum.explorewithme.main.repository.EventRepository;
+import ru.practicum.explorewithme.main.repository.UserRepository;
+import ru.practicum.explorewithme.main.service.params.AdminEventSearchParams;
+
+@ExtendWith(MockitoExtension.class)
+@DisplayName("Тесты для реализации EventService")
+class EventServiceImplTest {
+
+ @Mock
+ private EventRepository eventRepository;
+
+ @Mock
+ private EventMapper eventMapper;
+
+ @Mock
+ private UserRepository userRepository;
+
+ @Mock
+ private CategoryRepository categoryRepository;
+
+ @InjectMocks
+ private EventServiceImpl eventService;
+
+ @Captor
+ private ArgumentCaptor predicateCaptor;
+
+ @Captor
+ private ArgumentCaptor eventArgumentCaptor;
+
+ private LocalDateTime now;
+ private LocalDateTime plusOneHour;
+ private LocalDateTime plusTwoHours;
+ private LocalDateTime plusThreeHours;
+ private QEvent qEvent;
+
+ private User testUser;
+ private Category testCategory;
+ private NewEventDto newEventDto;
+ private Event mappedEventFromDto;
+ private Event savedEvent;
+ private EventFullDto eventFullDto;
+
+ @BeforeEach
+ void setUp() {
+ now = LocalDateTime.now().truncatedTo(ChronoUnit.SECONDS);
+ plusOneHour = now.plusHours(1);
+ plusTwoHours = now.plusHours(2);
+ plusThreeHours = now.plusHours(3);
+ qEvent = QEvent.event;
+
+ testUser = User.builder().id(1L).name("Test User").build();
+ testCategory = Category.builder().id(10L).name("Test Category").build();
+
+ newEventDto = NewEventDto.builder()
+ .annotation("New Event Annotation")
+ .category(testCategory.getId())
+ .description("New Event Description")
+ .eventDate(plusThreeHours)
+ .location(Location.builder().lat(10f).lon(20f).build())
+ .paid(false)
+ .participantLimit(0L)
+ .requestModeration(true)
+ .title("New Event Title")
+ .build();
+
+ mappedEventFromDto = Event.builder()
+ .annotation(newEventDto.getAnnotation())
+ .category(Category.builder().id(newEventDto.getCategory()).build())
+ .description(newEventDto.getDescription())
+ .eventDate(newEventDto.getEventDate())
+ .location(newEventDto.getLocation())
+ .paid(newEventDto.getPaid())
+ .participantLimit(newEventDto.getParticipantLimit().intValue())
+ .requestModeration(newEventDto.getRequestModeration())
+ .title(newEventDto.getTitle())
+ .build();
+
+ savedEvent = Event.builder()
+ .id(1L)
+ .annotation(newEventDto.getAnnotation())
+ .category(testCategory)
+ .description(newEventDto.getDescription())
+ .eventDate(newEventDto.getEventDate())
+ .initiator(testUser)
+ .location(newEventDto.getLocation())
+ .paid(newEventDto.getPaid())
+ .participantLimit(newEventDto.getParticipantLimit().intValue())
+ .requestModeration(newEventDto.getRequestModeration())
+ .title(newEventDto.getTitle())
+ .createdOn(now)
+ .state(EventState.PENDING)
+ .build();
+
+ eventFullDto = EventFullDto.builder().id(1L).title("New Event Title").build();
+ }
+
+ @Nested
+ @DisplayName("Метод getEventsAdmin")
+ class GetEventsAdminTests {
+
+ @Test
+ @DisplayName("Должен формировать предикат, если передан фильтр по пользователям")
+ void getEventsAdmin_withUserFilter_shouldApplyUserPredicate() {
+ List users = List.of(1L, 2L);
+ Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.ASC, "id"));
+ Page emptyPage = new PageImpl<>(Collections.emptyList(), pageable, 0);
+
+ when(eventRepository.findAll(predicateCaptor.capture(), eq(pageable))).thenReturn(
+ emptyPage);
+
+ AdminEventSearchParams params = AdminEventSearchParams.builder().users(users).build();
+ eventService.getEventsAdmin(params, 0, 10);
+
+ Predicate capturedPredicate = predicateCaptor.getValue();
+ assertNotNull(capturedPredicate, "Предикат не должен быть null, если есть фильтры");
+
+ String predicateString = capturedPredicate.toString();
+ assertTrue(predicateString.contains(qEvent.initiator.id.toString())
+ && predicateString.contains("in [1, 2]"),
+ "Предикат должен содержать фильтр по ID пользователей");
+ verify(eventRepository).findAll(capturedPredicate, pageable);
+ }
+
+ @Test
+ @DisplayName("Должен формировать предикат, если передан фильтр по состояниям")
+ void getEventsAdmin_withStateFilter_shouldApplyStatePredicate() {
+ List states = List.of(EventState.PENDING, EventState.PUBLISHED);
+ Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.ASC, "id"));
+ Page emptyPage = new PageImpl<>(Collections.emptyList(), pageable, 0);
+ when(eventRepository.findAll(predicateCaptor.capture(), eq(pageable))).thenReturn(
+ emptyPage);
+
+ AdminEventSearchParams params = AdminEventSearchParams.builder().states(states).build();
+ eventService.getEventsAdmin(params, 0, 10);
+
+ Predicate capturedPredicate = predicateCaptor.getValue();
+ assertNotNull(capturedPredicate);
+ String predicateString = capturedPredicate.toString();
+ assertTrue(
+ predicateString.contains(qEvent.state.toString()) && predicateString.contains(
+ "in [" + EventState.PENDING + ", " + EventState.PUBLISHED + "]"),
+ "Предикат должен содержать фильтр по состояниям");
+ verify(eventRepository).findAll(capturedPredicate, pageable);
+ }
+
+ @Test
+ @DisplayName("Должен формировать предикат, если передан фильтр по категориям")
+ void getEventsAdmin_withCategoryFilter_shouldApplyCategoryPredicate() {
+ List categories = List.of(5L);
+ Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.ASC, "id"));
+ Page emptyPage = new PageImpl<>(Collections.emptyList(), pageable, 0);
+ when(eventRepository.findAll(predicateCaptor.capture(), eq(pageable))).thenReturn(
+ emptyPage);
+
+ AdminEventSearchParams params = AdminEventSearchParams.builder().categories(categories).build();
+ eventService.getEventsAdmin(params, 0, 10);
+
+ Predicate capturedPredicate = predicateCaptor.getValue();
+ assertNotNull(capturedPredicate);
+ String predicateString = capturedPredicate.toString();
+
+ String categoryIdPath = qEvent.category.id.toString();
+
+ assertTrue(predicateString.contains(categoryIdPath) && predicateString.contains("5"),
+ "Предикат должен содержать фильтр по ID категорий: " + predicateString);
+ verify(eventRepository).findAll(capturedPredicate, pageable);
+ }
+
+
+ @Test
+ @DisplayName("Должен формировать предикат, если передана начальная дата диапазона")
+ void getEventsAdmin_withRangeStart_shouldApplyRangeStartPredicate() {
+ Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.ASC, "id"));
+ Page emptyPage = new PageImpl<>(Collections.emptyList(), pageable, 0);
+ when(eventRepository.findAll(predicateCaptor.capture(), eq(pageable))).thenReturn(
+ emptyPage);
+
+ AdminEventSearchParams params = AdminEventSearchParams.builder().rangeStart(now).build();
+ eventService.getEventsAdmin(params, 0, 10);
+
+ Predicate capturedPredicate = predicateCaptor.getValue();
+ assertNotNull(capturedPredicate);
+ String predicateString = capturedPredicate.toString();
+ assertTrue(
+ predicateString.contains(qEvent.eventDate.toString()) && predicateString.contains(
+ now.toString()), // goe(now)
+ "Предикат должен содержать фильтр по начальной дате");
+ verify(eventRepository).findAll(capturedPredicate, pageable);
+ }
+
+ @Test
+ @DisplayName("Должен формировать предикат, если передана конечная дата диапазона")
+ void getEventsAdmin_withRangeEnd_shouldApplyRangeEndPredicate() {
+ Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.ASC, "id"));
+ Page emptyPage = new PageImpl<>(Collections.emptyList(), pageable, 0);
+ when(eventRepository.findAll(predicateCaptor.capture(), eq(pageable))).thenReturn(
+ emptyPage);
+
+ AdminEventSearchParams params = AdminEventSearchParams.builder().rangeEnd(plusTwoHours).build();
+ eventService.getEventsAdmin(params, 0, 10);
+
+ Predicate capturedPredicate = predicateCaptor.getValue();
+ assertNotNull(capturedPredicate);
+ String predicateString = capturedPredicate.toString();
+ assertTrue(
+ predicateString.contains(qEvent.eventDate.toString()) && predicateString.contains(
+ plusTwoHours.toString()), // loe(plusTwoHours)
+ "Предикат должен содержать фильтр по конечной дате");
+ verify(eventRepository).findAll(capturedPredicate, pageable);
+ }
+
+ @Test
+ @DisplayName("Поиск без фильтров должен вызывать eventRepository.findAll с 'пустым' "
+ + "предикатом")
+ void getEventsAdmin_whenNoFilters_shouldCallRepositoryWithEmptyPredicate() {
+ Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.ASC, "id"));
+ Page emptyPage = new PageImpl<>(Collections.emptyList(), pageable, 0);
+
+ when(eventRepository.findAll(any(Predicate.class), eq(pageable))).thenReturn(emptyPage);
+
+ AdminEventSearchParams params = AdminEventSearchParams.builder().build();
+ eventService.getEventsAdmin(params, 0, 10);
+
+ ArgumentCaptor localPredicateCaptor = ArgumentCaptor.forClass(Predicate.class);
+ verify(eventRepository).findAll(localPredicateCaptor.capture(), eq(pageable));
+
+ Predicate capturedPredicate = localPredicateCaptor.getValue();
+ assertNotNull(capturedPredicate);
+ }
+
+ @Test
+ @DisplayName("Должен корректно формировать предикат со всеми фильтрами одновременно")
+ void getEventsAdmin_withAllFilters_shouldApplyAllPredicates() {
+ List users = List.of(1L);
+ List states = List.of(EventState.PUBLISHED);
+ List categories = List.of(10L);
+ LocalDateTime rangeStart = now;
+ LocalDateTime rangeEnd = plusTwoHours;
+ Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.ASC, "id"));
+ Page emptyPage = new PageImpl<>(Collections.emptyList(), pageable, 0);
+
+ when(eventRepository.findAll(predicateCaptor.capture(), eq(pageable))).thenReturn(
+ emptyPage);
+
+ AdminEventSearchParams params = AdminEventSearchParams.builder()
+ .users(users)
+ .states(states)
+ .categories(categories)
+ .rangeStart(rangeStart)
+ .rangeEnd(rangeEnd)
+ .build();
+ eventService.getEventsAdmin(params, 0, 10);
+
+ Predicate capturedPredicate = predicateCaptor.getValue();
+ assertNotNull(capturedPredicate);
+ String predicateString = capturedPredicate.toString();
+
+ String initiatorIdPath = qEvent.initiator.id.toString();
+ String statePath = qEvent.state.toString();
+ String categoryIdPath = qEvent.category.id.toString();
+ String eventDatePath = qEvent.eventDate.toString();
+
+ assertAll("Проверка всех частей предиката",
+ () -> assertTrue(
+ predicateString.contains(initiatorIdPath) && predicateString.contains(users.getFirst().toString()),
+ "Фильтр по пользователям: " + predicateString),
+ () -> assertTrue(
+ predicateString.contains(statePath) && predicateString.contains(states.getFirst().toString()),
+ "Фильтр по состояниям: " + predicateString),
+ () -> assertTrue(
+ predicateString.contains(categoryIdPath) && predicateString.contains(categories.getFirst().toString()),
+ "Фильтр по категориям: " + predicateString),
+ () -> assertTrue(
+ predicateString.contains(eventDatePath) && predicateString.contains(">= " + rangeStart.toString()),
+ "Фильтр по начальной дате: " + predicateString),
+ () -> assertTrue(
+ predicateString.contains(eventDatePath) && predicateString.contains("<= " + rangeEnd.toString()),
+ "Фильтр по конечной дате: " + predicateString)
+ );
+ verify(eventRepository).findAll(capturedPredicate, pageable);
+ }
+
+ @Test
+ @DisplayName("Должен выбросить IllegalArgumentException, если rangeStart после rangeEnd")
+ void getEventsAdmin_whenRangeStartIsAfterRangeEnd_shouldThrowIllegalArgumentException() {
+ LocalDateTime rangeStart = plusTwoHours; // now.plusHours(2)
+ LocalDateTime rangeEnd = plusOneHour; // now.plusHours(1)
+ int from = 0;
+ int size = 10;
+
+ AdminEventSearchParams params = AdminEventSearchParams.builder()
+ .rangeStart(rangeStart)
+ .rangeEnd(rangeEnd)
+ .build();
+
+ IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
+ () -> eventService.getEventsAdmin(params, from, size));
+
+ assertEquals("Admin search: rangeStart cannot be after rangeEnd.", exception.getMessage());
+
+ verifyNoInteractions(eventRepository);
+ verifyNoInteractions(eventMapper);
+ }
+ }
+
+ @Nested
+ @DisplayName("Метод addEventPrivate")
+ class AddEventPrivateTests {
+
+ @Test
+ @DisplayName("Должен успешно создавать событие")
+ void addEventPrivate_whenDataIsValid_shouldCreateAndReturnEventFullDto() {
+ when(userRepository.findById(testUser.getId())).thenReturn(Optional.of(testUser));
+ when(categoryRepository.findById(testCategory.getId())).thenReturn(Optional.of(testCategory));
+ when(eventMapper.toEvent(newEventDto)).thenReturn(mappedEventFromDto);
+ when(eventRepository.save(any(Event.class))).thenReturn(savedEvent);
+ when(eventMapper.toEventFullDto(savedEvent)).thenReturn(eventFullDto);
+
+ EventFullDto result = eventService.addEventPrivate(testUser.getId(), newEventDto);
+
+ assertNotNull(result);
+ assertEquals(eventFullDto.getId(), result.getId());
+ assertEquals(eventFullDto.getTitle(), result.getTitle());
+
+ verify(userRepository).findById(testUser.getId());
+ verify(categoryRepository).findById(testCategory.getId());
+ verify(eventMapper).toEvent(newEventDto);
+ verify(eventRepository).save(eventArgumentCaptor.capture());
+ Event capturedEvent = eventArgumentCaptor.getValue();
+ assertEquals(testUser, capturedEvent.getInitiator(), "Инициатор должен быть установлен в сервисе");
+
+ verify(eventMapper).toEventFullDto(savedEvent);
+ }
+
+ @Test
+ @DisplayName("Должен выбрасывать EntityNotFoundException, если пользователь не найден")
+ void addEventPrivate_whenUserNotFound_shouldThrowEntityNotFoundException() {
+ Long nonExistentUserId = 999L;
+ when(userRepository.findById(nonExistentUserId)).thenReturn(Optional.empty());
+
+ EntityNotFoundException exception = assertThrows(EntityNotFoundException.class,
+ () -> eventService.addEventPrivate(nonExistentUserId, newEventDto));
+
+ assertTrue(exception.getMessage().contains("Пользователь"));
+ assertTrue(exception.getMessage().contains(nonExistentUserId.toString()));
+ verify(userRepository).findById(nonExistentUserId);
+ verifyNoInteractions(categoryRepository, eventRepository, eventMapper);
+ }
+
+ @Test
+ @DisplayName("Должен выбрасывать EntityNotFoundException, если категория не найдена")
+ void addEventPrivate_whenCategoryNotFound_shouldThrowEntityNotFoundException() {
+ Long nonExistentCategoryId = 888L;
+ NewEventDto dtoWithNonExistentCategory = NewEventDto.builder()
+ .category(nonExistentCategoryId)
+ .annotation("A").description("D").title("T").eventDate(plusThreeHours)
+ .location(Location.builder().lat(1f).lon(1f).build())
+ .build();
+
+ when(userRepository.findById(testUser.getId())).thenReturn(Optional.of(testUser));
+ when(categoryRepository.findById(nonExistentCategoryId)).thenReturn(Optional.empty());
+
+ EntityNotFoundException exception = assertThrows(EntityNotFoundException.class,
+ () -> eventService.addEventPrivate(testUser.getId(), dtoWithNonExistentCategory));
+
+ assertTrue(exception.getMessage().contains("Категория"));
+ assertTrue(exception.getMessage().contains(nonExistentCategoryId.toString()));
+ verify(userRepository).findById(testUser.getId());
+ verify(categoryRepository).findById(nonExistentCategoryId);
+ verifyNoInteractions(eventRepository, eventMapper);
+ }
+
+ @Test
+ @DisplayName("Должен выбрасывать BusinessRuleViolationException, если дата события слишком ранняя")
+ void addEventPrivate_whenEventDateIsTooSoon_shouldThrowBusinessRuleViolationException() {
+ NewEventDto dtoWithEarlyDate = NewEventDto.builder()
+ .category(testCategory.getId())
+ .eventDate(now.plusHours(1)) // Меньше чем через 2 часа
+ .annotation("A").description("D").title("T")
+ .location(Location.builder().lat(1f).lon(1f).build())
+ .build();
+
+ when(userRepository.findById(testUser.getId())).thenReturn(Optional.of(testUser));
+ when(categoryRepository.findById(testCategory.getId())).thenReturn(Optional.of(testCategory));
+
+ BusinessRuleViolationException exception = assertThrows(
+ BusinessRuleViolationException.class,
+ () -> eventService.addEventPrivate(testUser.getId(), dtoWithEarlyDate));
+ assertTrue(exception.getMessage().contains("должна быть не ранее, чем через 2 часа"));
+
+ verify(userRepository).findById(testUser.getId());
+ verify(categoryRepository).findById(testCategory.getId());
+ verifyNoInteractions(eventRepository, eventMapper);
+ }
+
+ @Test
+ @DisplayName("Должен корректно устанавливать инициатора и категорию в событие перед сохранением")
+ void addEventPrivate_shouldSetInitiatorAndCategoryCorrectly() {
+ when(userRepository.findById(testUser.getId())).thenReturn(Optional.of(testUser));
+ when(categoryRepository.findById(testCategory.getId())).thenReturn(Optional.of(testCategory));
+ when(eventMapper.toEvent(newEventDto)).thenReturn(mappedEventFromDto);
+ when(eventRepository.save(any(Event.class))).thenAnswer(invocation -> invocation.getArgument(0));
+ when(eventMapper.toEventFullDto(any(Event.class))).thenReturn(eventFullDto);
+
+ eventService.addEventPrivate(testUser.getId(), newEventDto);
+
+ verify(eventRepository).save(eventArgumentCaptor.capture());
+ Event capturedEvent = eventArgumentCaptor.getValue();
+
+ assertEquals(testUser, capturedEvent.getInitiator(), "Инициатор должен быть корректно установлен.");
+ assertEquals(testCategory.getId(), capturedEvent.getCategory().getId(), "ID категории должен быть корректно установлен маппером.");
+ }
+ }
+
+ @Nested
+ @DisplayName("Метод getEventsByOwner")
+ class GetEventsByOwnerTests {
+ private Long ownerId;
+ private Long nonExistentOwnerId;
+ private Pageable defaultPageable;
+ private Event event1Owned, event2Owned;
+
+ @BeforeEach
+ void setUpOwnerEvents() {
+ ownerId = testUser.getId();
+ nonExistentOwnerId = 999L;
+ defaultPageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "eventDate"));
+
+ event1Owned = Event.builder().id(101L).title("Owned Event 1").initiator(testUser).category(testCategory)
+ .eventDate(now.plusDays(1)).state(EventState.PENDING).createdOn(now).build();
+ event2Owned = Event.builder().id(102L).title("Owned Event 2").initiator(testUser).category(testCategory)
+ .eventDate(now.plusDays(2)).state(EventState.PUBLISHED).createdOn(now).build();
+ }
+
+ @Test
+ @DisplayName("Должен возвращать список EventShortDto событий пользователя с пагинацией")
+ void getEventsByOwner_whenUserExistsAndHasEvents_shouldReturnEventShortDtoList() {
+ List eventsFromRepo = List.of(event2Owned, event1Owned);
+ Page eventPage = new PageImpl<>(eventsFromRepo, defaultPageable, eventsFromRepo.size());
+
+ List expectedDtos = List.of(
+ EventShortDto.builder().id(102L).title("Owned Event 2").build(),
+ EventShortDto.builder().id(101L).title("Owned Event 1").build()
+ );
+
+ when(userRepository.existsById(ownerId)).thenReturn(true);
+ when(eventRepository.findByInitiatorId(ownerId, defaultPageable)).thenReturn(eventPage);
+ when(eventMapper.toEventShortDtoList(eventsFromRepo)).thenReturn(expectedDtos);
+
+ List result = eventService.getEventsByOwner(ownerId, 0, 10);
+
+ assertNotNull(result);
+ assertEquals(2, result.size());
+ assertEquals(expectedDtos.get(0).getTitle(), result.get(0).getTitle());
+ assertEquals(expectedDtos.get(1).getTitle(), result.get(1).getTitle());
+
+ verify(userRepository).existsById(ownerId);
+ verify(eventRepository).findByInitiatorId(ownerId, defaultPageable);
+ verify(eventMapper).toEventShortDtoList(eventsFromRepo);
+ }
+
+ @Test
+ @DisplayName("Должен возвращать пустой список, если у пользователя нет событий")
+ void getEventsByOwner_whenUserHasNoEvents_shouldReturnEmptyList() {
+ when(userRepository.existsById(ownerId)).thenReturn(true);
+ when(eventRepository.findByInitiatorId(ownerId, defaultPageable))
+ .thenReturn(new PageImpl<>(Collections.emptyList(), defaultPageable, 0));
+
+ List result = eventService.getEventsByOwner(ownerId, 0, 10);
+
+ assertNotNull(result);
+ assertTrue(result.isEmpty());
+ verify(userRepository).existsById(ownerId);
+ verify(eventRepository).findByInitiatorId(ownerId, defaultPageable);
+ }
+
+ @Test
+ @DisplayName("Должен возвращать пустой список, если пользователь не найден")
+ void getEventsByOwner_whenUserNotFound_shouldThrowEntityNotFoundException() {
+ when(userRepository.existsById(nonExistentOwnerId)).thenReturn(false);
+
+ List result = eventService.getEventsByOwner(nonExistentOwnerId, 0, 10);
+ assertNotNull(result);
+ assertTrue(result.isEmpty());
+ }
+ }
+
+ @Nested
+ @DisplayName("Метод getEventPrivate")
+ class GetEventPrivateTests {
+ private Long ownerId;
+ private Long eventIdOwned;
+ private Long eventIdNotOwned;
+ private Long nonExistentEventId;
+ private Event ownedEvent;
+ private EventFullDto ownedEventFullDto;
+
+ @BeforeEach
+ void setUpPrivateEvent() {
+ ownerId = testUser.getId();
+ eventIdOwned = savedEvent.getId();
+ eventIdNotOwned = 998L;
+ nonExistentEventId = 999L;
+
+ ownedEvent = Event.builder()
+ .id(eventIdOwned)
+ .title(savedEvent.getTitle())
+ .initiator(testUser)
+ .category(testCategory)
+ .eventDate(savedEvent.getEventDate())
+ .state(EventState.PENDING)
+ .build();
+
+ ownedEventFullDto = EventFullDto.builder()
+ .id(eventIdOwned)
+ .title(ownedEvent.getTitle())
+ .build();
+ }
+
+ @Test
+ @DisplayName("Должен возвращать EventFullDto, если событие найдено и принадлежит пользователю")
+ void getEventPrivate_whenEventFoundAndOwned_shouldReturnEventFullDto() {
+ when(userRepository.existsById(ownerId)).thenReturn(true);
+ when(eventRepository.findByIdAndInitiatorId(eventIdOwned, ownerId))
+ .thenReturn(Optional.of(ownedEvent));
+ when(eventMapper.toEventFullDto(ownedEvent)).thenReturn(ownedEventFullDto);
+
+ EventFullDto result = eventService.getEventPrivate(ownerId, eventIdOwned);
+
+ assertNotNull(result);
+ assertEquals(ownedEventFullDto.getId(), result.getId());
+ assertEquals(ownedEventFullDto.getTitle(), result.getTitle());
+
+ verify(userRepository).existsById(ownerId);
+ verify(eventRepository).findByIdAndInitiatorId(eventIdOwned, ownerId);
+ verify(eventMapper).toEventFullDto(ownedEvent);
+ }
+
+ @Test
+ @DisplayName("Должен выбросить EntityNotFoundException, если пользователь не найден")
+ void getEventPrivate_whenUserNotFound_shouldThrowEntityNotFoundException() {
+ Long nonExistentUserId = 888L;
+ when(userRepository.existsById(nonExistentUserId)).thenReturn(false);
+
+ EntityNotFoundException exception = assertThrows(EntityNotFoundException.class, () -> {
+ eventService.getEventPrivate(nonExistentUserId, eventIdOwned);
+ });
+ assertTrue(exception.getMessage().contains("User with id=" + nonExistentUserId + " not found"));
+ verify(userRepository).existsById(nonExistentUserId);
+ verifyNoInteractions(eventRepository, eventMapper);
+ }
+
+ @Test
+ @DisplayName("Должен выбросить EntityNotFoundException, если событие не найдено (или не принадлежит пользователю)")
+ void getEventPrivate_whenEventNotFoundOrNotOwned_shouldThrowEntityNotFoundException() {
+ when(userRepository.existsById(ownerId)).thenReturn(true);
+ when(eventRepository.findByIdAndInitiatorId(nonExistentEventId, ownerId))
+ .thenReturn(Optional.empty());
+
+ EntityNotFoundException exception = assertThrows(EntityNotFoundException.class, () -> {
+ eventService.getEventPrivate(ownerId, nonExistentEventId);
+ });
+ assertTrue(exception.getMessage().contains("Event with id=" + nonExistentEventId));
+ assertTrue(exception.getMessage().contains("initiatorId=" + ownerId));
+
+ verify(userRepository).existsById(ownerId);
+ verify(eventRepository).findByIdAndInitiatorId(nonExistentEventId, ownerId);
+ verifyNoInteractions(eventMapper);
+ }
+ }
+
+ @Nested
+ @DisplayName("Метод updateEventByOwner")
+ class UpdateEventByOwnerTests {
+
+ private Long existingEventId;
+ private UpdateEventUserRequestDto validUpdateDto;
+ private Event existingEvent;
+ private Event updatedEventFromRepo;
+ private EventFullDto updatedEventFullDto;
+
+ @BeforeEach
+ void setUpUpdateTests() {
+ existingEventId = savedEvent.getId();
+
+ validUpdateDto = UpdateEventUserRequestDto.builder()
+ .title("Updated Event Title")
+ .annotation("Updated Annotation")
+ .description("Updated Description")
+ .eventDate(now.plusDays(10)) // Валидная дата (дальше чем +2 часа от now)
+ .paid(true)
+ .participantLimit(50)
+ .requestModeration(false)
+ .stateAction(UpdateEventUserRequestDto.StateActionUser.SEND_TO_REVIEW)
+ .build();
+
+ existingEvent = Event.builder()
+ .id(existingEventId)
+ .title("Original Title")
+ .annotation("Original Annotation")
+ .description("Original Description")
+ .eventDate(now.plusDays(5))
+ .initiator(testUser)
+ .category(testCategory)
+ .location(Location.builder().lat(10f).lon(10f).build())
+ .paid(false)
+ .participantLimit(10)
+ .requestModeration(true)
+ .state(EventState.PENDING)
+ .createdOn(now.minusDays(1))
+ .build();
+
+ updatedEventFromRepo = Event.builder()
+ .id(existingEvent.getId())
+ .title(validUpdateDto.getTitle())
+ .annotation(validUpdateDto.getAnnotation())
+ .description(validUpdateDto.getDescription())
+ .eventDate(validUpdateDto.getEventDate())
+ .paid(validUpdateDto.getPaid())
+ .participantLimit(validUpdateDto.getParticipantLimit())
+ .requestModeration(validUpdateDto.getRequestModeration())
+ .state(EventState.PENDING) // SEND_TO_REVIEW оставляет PENDING
+ .initiator(existingEvent.getInitiator())
+ .category(existingEvent.getCategory())
+ .location(existingEvent.getLocation())
+ .createdOn(existingEvent.getCreatedOn())
+ .build();
+
+ updatedEventFullDto = EventFullDto.builder()
+ .id(updatedEventFromRepo.getId())
+ .title(updatedEventFromRepo.getTitle())
+ .build();
+ }
+
+ @Test
+ @DisplayName("Должен успешно обновлять событие, если все условия соблюдены")
+ void updateEventByOwner_whenValidRequestAndState_shouldUpdateAndReturnDto() {
+ when(eventRepository.findByIdAndInitiatorId(existingEventId, testUser.getId()))
+ .thenReturn(Optional.of(existingEvent));
+ when(eventRepository.save(any(Event.class))).thenReturn(updatedEventFromRepo);
+ when(eventMapper.toEventFullDto(updatedEventFromRepo)).thenReturn(updatedEventFullDto);
+
+ EventFullDto result = eventService.updateEventByOwner(testUser.getId(), existingEventId, validUpdateDto);
+
+ assertNotNull(result);
+ assertEquals(updatedEventFullDto.getId(), result.getId());
+ assertEquals(validUpdateDto.getTitle(), result.getTitle());
+
+ verify(eventRepository).findByIdAndInitiatorId(existingEventId, testUser.getId());
+ verify(eventRepository).save(eventArgumentCaptor.capture());
+ Event savedEntity = eventArgumentCaptor.getValue();
+ assertEquals(validUpdateDto.getTitle(), savedEntity.getTitle());
+ assertEquals(EventState.PENDING, savedEntity.getState()); // SEND_TO_REVIEW
+ assertEquals(validUpdateDto.getPaid(), savedEntity.isPaid());
+ assertEquals(validUpdateDto.getParticipantLimit().intValue(), savedEntity.getParticipantLimit());
+
+ verify(eventMapper).toEventFullDto(updatedEventFromRepo);
+ }
+
+ @Test
+ @DisplayName("Должен обновлять категорию, если она указана в DTO")
+ void updateEventByOwner_whenCategoryInDto_shouldUpdateCategory() {
+ Category newCategory = Category.builder().id(20L).name("New Test Category").build();
+ UpdateEventUserRequestDto dtoWithCategory = UpdateEventUserRequestDto.builder()
+ .category(newCategory.getId())
+ .stateAction(UpdateEventUserRequestDto.StateActionUser.SEND_TO_REVIEW)
+ .build();
+
+ Event eventToUpdate = Event.builder() // Копия existingEvent для этого теста
+ .id(existingEventId).title("T").annotation("A").description("D").eventDate(now.plusDays(5))
+ .initiator(testUser).category(testCategory).location(Location.builder().lat(1f).lon(1f).build())
+ .state(EventState.PENDING).build();
+
+ when(eventRepository.findByIdAndInitiatorId(existingEventId, testUser.getId()))
+ .thenReturn(Optional.of(eventToUpdate));
+ when(categoryRepository.findById(newCategory.getId())).thenReturn(Optional.of(newCategory));
+ when(eventRepository.save(any(Event.class))).thenAnswer(invocation -> invocation.getArgument(0)); // Возвращаем измененный event
+ when(eventMapper.toEventFullDto(any(Event.class))).thenReturn(updatedEventFullDto);
+
+ eventService.updateEventByOwner(testUser.getId(), existingEventId, dtoWithCategory);
+
+ verify(eventRepository).save(eventArgumentCaptor.capture());
+ Event savedEntity = eventArgumentCaptor.getValue();
+ assertEquals(newCategory.getId(), savedEntity.getCategory().getId());
+ }
+
+
+ @Test
+ @DisplayName("Должен изменять состояние на CANCELED при stateAction = CANCEL_REVIEW")
+ void updateEventByOwner_whenStateActionIsCancelReview_shouldSetStateToCanceled() {
+ UpdateEventUserRequestDto dtoCancel = UpdateEventUserRequestDto.builder()
+ .stateAction(UpdateEventUserRequestDto.StateActionUser.CANCEL_REVIEW)
+ .build();
+ // existingEvent уже в PENDING, что позволяет отмену
+
+ when(eventRepository.findByIdAndInitiatorId(existingEventId, testUser.getId()))
+ .thenReturn(Optional.of(existingEvent));
+ when(eventRepository.save(any(Event.class))).thenAnswer(invocation -> invocation.getArgument(0));
+ when(eventMapper.toEventFullDto(any(Event.class))).thenReturn(updatedEventFullDto);
+
+ eventService.updateEventByOwner(testUser.getId(), existingEventId, dtoCancel);
+
+ verify(eventRepository).save(eventArgumentCaptor.capture());
+ Event savedEntity = eventArgumentCaptor.getValue();
+ assertEquals(EventState.CANCELED, savedEntity.getState());
+ }
+
+
+ @Test
+ @DisplayName("Должен выбросить EntityNotFoundException, если событие не найдено или не принадлежит пользователю")
+ void updateEventByOwner_whenEventNotFoundOrNotOwned_shouldThrowEntityNotFoundException() {
+ when(eventRepository.findByIdAndInitiatorId(existingEventId, testUser.getId()))
+ .thenReturn(Optional.empty());
+
+ EntityNotFoundException exception = assertThrows(EntityNotFoundException.class, () -> {
+ eventService.updateEventByOwner(testUser.getId(), existingEventId, validUpdateDto);
+ });
+ assertTrue(exception.getMessage().contains("Event with id=" + existingEventId));
+ assertTrue(exception.getMessage().contains("initiatorId=" + testUser.getId()));
+
+ verify(eventRepository).findByIdAndInitiatorId(existingEventId, testUser.getId());
+ verifyNoInteractions(eventMapper); // save не должен вызываться
+ }
+
+ @Test
+ @DisplayName("Должен выбросить BusinessRuleViolationException, если пытаются обновить опубликованное событие")
+ void updateEventByOwner_whenEventIsPublished_shouldThrowBusinessRuleViolationException() {
+ existingEvent.setState(EventState.PUBLISHED); // Меняем состояние на PUBLISHED
+ when(eventRepository.findByIdAndInitiatorId(existingEventId, testUser.getId()))
+ .thenReturn(Optional.of(existingEvent));
+
+ BusinessRuleViolationException exception = assertThrows(BusinessRuleViolationException.class, () -> {
+ eventService.updateEventByOwner(testUser.getId(), existingEventId, validUpdateDto);
+ });
+ assertTrue(exception.getMessage().contains("Only pending or canceled events can be changed"));
+
+ verify(eventRepository).findByIdAndInitiatorId(existingEventId, testUser.getId());
+ verifyNoInteractions(eventMapper);
+ }
+
+ @Test
+ @DisplayName("Должен выбросить BusinessRuleViolationException, если eventDate слишком ранняя")
+ void updateEventByOwner_whenEventDateIsTooSoon_shouldThrowException() {
+ UpdateEventUserRequestDto dtoWithEarlyDate = UpdateEventUserRequestDto.builder()
+ .eventDate(now.plusMinutes(30)) // Менее 2 часов
+ .stateAction(UpdateEventUserRequestDto.StateActionUser.SEND_TO_REVIEW)
+ .build();
+
+ when(eventRepository.findByIdAndInitiatorId(existingEventId, testUser.getId()))
+ .thenReturn(Optional.of(existingEvent));
+ BusinessRuleViolationException exception = assertThrows(BusinessRuleViolationException.class, () -> {
+ eventService.updateEventByOwner(testUser.getId(), existingEventId, dtoWithEarlyDate);
+ });
+ assertTrue(exception.getMessage().contains("must be at least two hours in the future"));
+
+ verify(eventRepository).findByIdAndInitiatorId(existingEventId, testUser.getId());
+ verifyNoInteractions(eventMapper);
+ }
+
+ @Test
+ @DisplayName("Должен выбросить EntityNotFoundException, если указана несуществующая категория")
+ void updateEventByOwner_whenCategoryNotFound_shouldThrowEntityNotFoundException() {
+ Long nonExistentCategoryId = 999L;
+ UpdateEventUserRequestDto dtoWithNonExistentCategory = UpdateEventUserRequestDto.builder()
+ .category(nonExistentCategoryId)
+ .stateAction(UpdateEventUserRequestDto.StateActionUser.SEND_TO_REVIEW)
+ .build();
+
+ when(eventRepository.findByIdAndInitiatorId(existingEventId, testUser.getId()))
+ .thenReturn(Optional.of(existingEvent));
+ when(categoryRepository.findById(nonExistentCategoryId)).thenReturn(Optional.empty());
+
+ EntityNotFoundException exception = assertThrows(EntityNotFoundException.class, () -> {
+ eventService.updateEventByOwner(testUser.getId(), existingEventId, dtoWithNonExistentCategory);
+ });
+ assertTrue(exception.getMessage().contains("Category with id=" + nonExistentCategoryId));
+
+ verify(eventRepository).findByIdAndInitiatorId(existingEventId, testUser.getId());
+ verify(categoryRepository).findById(nonExistentCategoryId);
+ verifyNoInteractions(eventMapper);
+ }
+ }
+
+ @Nested
+ @DisplayName("Метод moderateEventByAdmin")
+ class ModerateEventByAdminTests {
+
+ private Long existingEventId;
+ private Event existingPendingEvent;
+ private Event existingPublishedEvent;
+ private UpdateEventAdminRequestDto publishRequestDto;
+ private UpdateEventAdminRequestDto rejectRequestDto;
+ private EventFullDto mappedEventFullDto;
+ private Category newCategory;
+
+ @BeforeEach
+ void setUpModerateTests() {
+ existingEventId = 1L;
+
+ existingPendingEvent = Event.builder()
+ .id(existingEventId)
+ .title("Pending Event")
+ .annotation("Pending annotation")
+ .description("Pending description")
+ .category(testCategory)
+ .initiator(testUser)
+ .location(Location.builder().lat(30f).lon(30f).build())
+ .eventDate(now.plusDays(2))
+ .createdOn(now.minusDays(1))
+ .state(EventState.PENDING)
+ .paid(false)
+ .participantLimit(10)
+ .requestModeration(true)
+ .build();
+
+ existingPublishedEvent = Event.builder()
+ .id(2L) // Другой ID
+ .title("Published Event")
+ .state(EventState.PUBLISHED)
+ .eventDate(now.plusDays(3))
+ .category(testCategory)
+ .initiator(testUser)
+ .location(Location.builder().lat(40f).lon(40f).build())
+ .createdOn(now.minusDays(2))
+ .publishedOn(now.minusDays(1))
+ .build();
+
+ publishRequestDto = UpdateEventAdminRequestDto.builder()
+ .stateAction(UpdateEventAdminRequestDto.StateActionAdmin.PUBLISH_EVENT)
+ .build();
+
+ rejectRequestDto = UpdateEventAdminRequestDto.builder()
+ .stateAction(UpdateEventAdminRequestDto.StateActionAdmin.REJECT_EVENT)
+ .build();
+
+ mappedEventFullDto = EventFullDto.builder().id(existingEventId).title("Some Title").build();
+
+ newCategory = Category.builder().id(99L).name("New Category For Admin Update").build();
+
+ }
+
+ @Test
+ @DisplayName("Должен успешно публиковать PENDING событие, если дата валидна")
+ void moderateEventByAdmin_whenPublishPendingEventWithValidDate_shouldPublish() {
+ when(eventRepository.findById(existingEventId)).thenReturn(Optional.of(existingPendingEvent));
+ when(eventRepository.save(any(Event.class))).thenAnswer(invocation -> invocation.getArgument(0));
+ when(eventMapper.toEventFullDto(any(Event.class))).thenReturn(mappedEventFullDto);
+
+ EventFullDto result = eventService.moderateEventByAdmin(existingEventId, publishRequestDto);
+
+ assertNotNull(result);
+ verify(eventRepository).save(eventArgumentCaptor.capture());
+ Event savedEvent = eventArgumentCaptor.getValue();
+ assertEquals(EventState.PUBLISHED, savedEvent.getState());
+ assertNotNull(savedEvent.getPublishedOn());
+ assertTrue(savedEvent.getPublishedOn().isAfter(now.minusSeconds(5)) &&
+ savedEvent.getPublishedOn().isBefore(now.plusSeconds(5)));
+ }
+
+ @Test
+ @DisplayName("Должен успешно отклонять PENDING событие")
+ void moderateEventByAdmin_whenRejectPendingEvent_shouldCancel() {
+ when(eventRepository.findById(existingEventId)).thenReturn(Optional.of(existingPendingEvent));
+ when(eventRepository.save(any(Event.class))).thenAnswer(invocation -> invocation.getArgument(0));
+ when(eventMapper.toEventFullDto(any(Event.class))).thenReturn(mappedEventFullDto);
+
+ EventFullDto result = eventService.moderateEventByAdmin(existingEventId, rejectRequestDto);
+
+ assertNotNull(result);
+ verify(eventRepository).save(eventArgumentCaptor.capture());
+ Event savedEvent = eventArgumentCaptor.getValue();
+ assertEquals(EventState.CANCELED, savedEvent.getState());
+ assertNull(savedEvent.getPublishedOn()); // publishedOn не должен быть установлен при отклонении PENDING
+ }
+
+ @Test
+ @DisplayName("Должен обновлять поля события при модерации, если они переданы в DTO")
+ void moderateEventByAdmin_whenDtoHasUpdates_shouldUpdateEventFields() {
+ UpdateEventAdminRequestDto updateWithFieldsDto = UpdateEventAdminRequestDto.builder()
+ .title("Admin Updated Title")
+ .annotation("Admin Updated Annotation")
+ .category(newCategory.getId())
+ .stateAction(UpdateEventAdminRequestDto.StateActionAdmin.PUBLISH_EVENT)
+ .build();
+
+ existingPendingEvent.setEventDate(now.plusHours(2));
+
+ when(eventRepository.findById(existingEventId)).thenReturn(Optional.of(existingPendingEvent));
+ when(categoryRepository.findById(newCategory.getId())).thenReturn(Optional.of(newCategory));
+ when(eventRepository.save(any(Event.class))).thenAnswer(invocation -> invocation.getArgument(0));
+ when(eventMapper.toEventFullDto(any(Event.class))).thenReturn(mappedEventFullDto);
+
+ eventService.moderateEventByAdmin(existingEventId, updateWithFieldsDto);
+
+ verify(eventRepository).save(eventArgumentCaptor.capture());
+ Event savedEvent = eventArgumentCaptor.getValue();
+ assertEquals("Admin Updated Title", savedEvent.getTitle());
+ assertEquals("Admin Updated Annotation", savedEvent.getAnnotation());
+ assertEquals(newCategory.getId(), savedEvent.getCategory().getId());
+ assertEquals(EventState.PUBLISHED, savedEvent.getState());
+ }
+
+ @Test
+ @DisplayName("Должен выбросить BusinessRuleViolationException при попытке опубликовать не PENDING событие")
+ void moderateEventByAdmin_whenPublishNonPendingEvent_shouldThrowBusinessRuleViolationException() {
+ existingPendingEvent.setState(EventState.CANCELED);
+ when(eventRepository.findById(existingEventId)).thenReturn(Optional.of(existingPendingEvent));
+
+ BusinessRuleViolationException exception = assertThrows(BusinessRuleViolationException.class, () -> {
+ eventService.moderateEventByAdmin(existingEventId, publishRequestDto);
+ });
+ assertTrue(exception.getMessage().contains("not in the PENDING state"));
+ verify(eventRepository).findById(existingEventId);
+ verify(eventRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("Должен выбросить BusinessRuleViolationException при попытке опубликовать событие со слишком ранней eventDate")
+ void moderateEventByAdmin_whenPublishEventWithTooSoonEventDate_shouldThrowBusinessRuleViolationException() {
+ existingPendingEvent.setEventDate(now.plusMinutes(30)); // Менее чем за час до "сейчас"
+ when(eventRepository.findById(existingEventId)).thenReturn(Optional.of(existingPendingEvent));
+
+ BusinessRuleViolationException exception = assertThrows(BusinessRuleViolationException.class, () -> {
+ eventService.moderateEventByAdmin(existingEventId, publishRequestDto);
+ });
+ assertTrue(exception.getMessage().contains("Event date must be at least"));
+ verify(eventRepository).findById(existingEventId);
+ verify(eventRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("Должен выбросить BusinessRuleViolationException при попытке опубликовать событие с eventDate из DTO, которая слишком ранняя")
+ void moderateEventByAdmin_whenPublishEventWithDtoEventDateTooSoon_shouldThrowBusinessRuleViolationException() {
+ UpdateEventAdminRequestDto dtoWithEarlyDate = UpdateEventAdminRequestDto.builder()
+ .eventDate(now.plusMinutes(30))
+ .stateAction(UpdateEventAdminRequestDto.StateActionAdmin.PUBLISH_EVENT)
+ .build();
+
+ when(eventRepository.findById(existingEventId)).thenReturn(Optional.of(existingPendingEvent));
+
+ BusinessRuleViolationException exception = assertThrows(BusinessRuleViolationException.class, () -> {
+ eventService.moderateEventByAdmin(existingEventId, dtoWithEarlyDate);
+ });
+ assertTrue(exception.getMessage().contains("Event date must be at least"));
+ verify(eventRepository).findById(existingEventId);
+ verify(eventRepository, never()).save(any());
+ }
+
+
+ @Test
+ @DisplayName("Должен выбросить BusinessRuleViolationException при попытке отклонить уже PUBLISHED событие")
+ void moderateEventByAdmin_whenRejectPublishedEvent_shouldThrowBusinessRuleViolationException() {
+ when(eventRepository.findById(existingPublishedEvent.getId())).thenReturn(Optional.of(existingPublishedEvent));
+
+ BusinessRuleViolationException exception = assertThrows(BusinessRuleViolationException.class, () -> {
+ eventService.moderateEventByAdmin(existingPublishedEvent.getId(), rejectRequestDto);
+ });
+ assertTrue(exception.getMessage().contains("already been published"));
+ verify(eventRepository).findById(existingPublishedEvent.getId());
+ verify(eventRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("Должен выбросить EntityNotFoundException, если событие для модерации не найдено")
+ void moderateEventByAdmin_whenEventNotFound_shouldThrowEntityNotFoundException() {
+ Long nonExistentEventId = 999L;
+ when(eventRepository.findById(nonExistentEventId)).thenReturn(Optional.empty());
+
+ EntityNotFoundException exception = assertThrows(EntityNotFoundException.class, () -> {
+ eventService.moderateEventByAdmin(nonExistentEventId, publishRequestDto);
+ });
+ assertTrue(exception.getMessage().contains("Event with id=" + nonExistentEventId + " not found"));
+ verify(eventRepository).findById(nonExistentEventId);
+ verify(eventRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("Должен выбросить EntityNotFoundException при обновлении, если категория из DTO не найдена")
+ void moderateEventByAdmin_whenUpdateWithNonExistentCategory_shouldThrowEntityNotFoundException() {
+ Long nonExistentCategoryId = 888L;
+ UpdateEventAdminRequestDto updateDtoWithBadCategory = UpdateEventAdminRequestDto.builder()
+ .category(nonExistentCategoryId)
+ .stateAction(UpdateEventAdminRequestDto.StateActionAdmin.PUBLISH_EVENT)
+ .build();
+
+ existingPendingEvent.setEventDate(now.plusHours(2));
+
+ when(eventRepository.findById(existingEventId)).thenReturn(Optional.of(existingPendingEvent));
+ when(categoryRepository.findById(nonExistentCategoryId)).thenReturn(Optional.empty());
+
+ EntityNotFoundException exception = assertThrows(EntityNotFoundException.class, () -> {
+ eventService.moderateEventByAdmin(existingEventId, updateDtoWithBadCategory);
+ });
+ assertTrue(exception.getMessage().contains("Category with id=" + nonExistentCategoryId));
+ verify(eventRepository).findById(existingEventId);
+ verify(categoryRepository).findById(nonExistentCategoryId);
+ verify(eventRepository, never()).save(any());
+ }
+ }
+}
\ No newline at end of file
diff --git a/main-service/src/test/java/ru/practicum/explorewithme/main/service/EventServiceIntegrationTest.java b/main-service/src/test/java/ru/practicum/explorewithme/main/service/EventServiceIntegrationTest.java
new file mode 100644
index 0000000..86c8fc4
--- /dev/null
+++ b/main-service/src/test/java/ru/practicum/explorewithme/main/service/EventServiceIntegrationTest.java
@@ -0,0 +1,905 @@
+package ru.practicum.explorewithme.main.service;
+
+import jakarta.persistence.EntityManager;
+import java.util.Collections;
+import java.util.Optional;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.transaction.annotation.Transactional;
+import org.testcontainers.containers.PostgreSQLContainer;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+import ru.practicum.explorewithme.main.dto.EventFullDto;
+import ru.practicum.explorewithme.main.dto.EventShortDto;
+import ru.practicum.explorewithme.main.dto.NewEventDto;
+import ru.practicum.explorewithme.main.dto.UpdateEventAdminRequestDto;
+import ru.practicum.explorewithme.main.dto.UpdateEventUserRequestDto;
+import ru.practicum.explorewithme.main.error.BusinessRuleViolationException;
+import ru.practicum.explorewithme.main.error.EntityNotFoundException;
+import ru.practicum.explorewithme.main.model.*;
+import ru.practicum.explorewithme.main.repository.CategoryRepository;
+import ru.practicum.explorewithme.main.repository.EventRepository;
+import ru.practicum.explorewithme.main.repository.RequestRepository;
+import ru.practicum.explorewithme.main.repository.UserRepository;
+import ru.practicum.explorewithme.main.service.params.AdminEventSearchParams;
+
+import java.time.LocalDateTime;
+import java.time.temporal.ChronoUnit;
+import java.util.List;
+import ru.practicum.explorewithme.main.service.params.PublicEventSearchParams;
+import ru.practicum.explorewithme.stats.client.StatsClient;
+import ru.practicum.explorewithme.stats.dto.ViewStatsDto;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.when;
+
+@SpringBootTest
+@Testcontainers
+@Transactional
+@DisplayName("Интеграционное тестирование EventServiceImpl")
+class EventServiceIntegrationTest {
+
+ @Container
+ static PostgreSQLContainer> postgresContainer = new PostgreSQLContainer<>("postgres:16.1");
+
+ @DynamicPropertySource
+ static void registerPgProperties(DynamicPropertyRegistry registry) {
+ registry.add("spring.datasource.url", postgresContainer::getJdbcUrl);
+ registry.add("spring.datasource.username", postgresContainer::getUsername);
+ registry.add("spring.datasource.password", postgresContainer::getPassword);
+ registry.add("spring.jpa.hibernate.ddl-auto", () -> "create");
+ }
+
+ @Autowired
+ private EntityManager entityManager;
+
+ @Autowired
+ private EventService eventService;
+
+ @Autowired
+ private EventRepository eventRepository;
+
+ @Autowired
+ private UserRepository userRepository;
+
+ @Autowired
+ private CategoryRepository categoryRepository;
+
+ @Autowired
+ private RequestRepository requestRepository;
+
+ @MockitoBean
+ private StatsClient statsClient;
+
+ private User user1, user2, user3;
+ private Category category1, category2;
+ private Location location1, location2;
+ private LocalDateTime now;
+
+ @BeforeEach
+ void setUp() {
+ requestRepository.deleteAllInBatch();
+ eventRepository.deleteAllInBatch();
+ categoryRepository.deleteAllInBatch();
+ userRepository.deleteAllInBatch();
+
+ now = LocalDateTime.now().truncatedTo(ChronoUnit.SECONDS);
+
+ user1 = userRepository.save(User.builder().name("User One").email("user1@events.com").build());
+ user2 = userRepository.save(User.builder().name("User Two").email("user2@events.com").build());
+ user3 = userRepository.save(User.builder().name("User Three").email("user3@events.com").build());
+
+ category1 = categoryRepository.save(Category.builder().name("Category A").build());
+ category2 = categoryRepository.save(Category.builder().name("Category B").build());
+
+ location1 = Location.builder().lat(10f).lon(10f).build();
+ location2 = Location.builder().lat(20f).lon(20f).build();
+ }
+
+ @Nested
+ @DisplayName("Метод addEventPrivate")
+ class AddEventPrivateTests {
+
+ @Test
+ @DisplayName("Должен успешно создавать событие")
+ void addEventPrivate_whenDataIsValid_thenEventIsCreated() {
+ NewEventDto newEventDto = NewEventDto.builder()
+ .annotation("Valid Annotation")
+ .category(category1.getId())
+ .description("Valid Description")
+ .eventDate(now.plusHours(3))
+ .location(location1)
+ .paid(false)
+ .participantLimit(10L)
+ .requestModeration(true)
+ .title("Valid Event Title")
+ .build();
+
+ EventFullDto createdEventDto = eventService.addEventPrivate(user1.getId(), newEventDto);
+
+ assertNotNull(createdEventDto);
+ assertNotNull(createdEventDto.getId());
+ assertEquals(newEventDto.getAnnotation(), createdEventDto.getAnnotation());
+ assertEquals(user1.getId(), createdEventDto.getInitiator().getId());
+ assertEquals(category1.getId(), createdEventDto.getCategory().getId());
+ assertEquals(EventState.PENDING, createdEventDto.getState());
+ assertNotNull(createdEventDto.getCreatedOn()); // Проверяем, что дата создания установлена (JPA Auditing)
+
+ assertTrue(eventRepository.existsById(createdEventDto.getId()));
+ }
+
+ @Test
+ @DisplayName("Должен выбрасывать EntityNotFoundException, если пользователь не найден")
+ void addEventPrivate_whenUserNotFound_thenThrowsEntityNotFoundException() {
+ Long nonExistentUserId = 999L;
+ NewEventDto newEventDto = NewEventDto.builder().category(category1.getId()).eventDate(now.plusHours(3))
+ .annotation("A").description("D").title("T").location(location1).build();
+
+ assertThrows(EntityNotFoundException.class, () ->
+ eventService.addEventPrivate(nonExistentUserId, newEventDto));
+ }
+
+ @Test
+ @DisplayName("Должен выбрасывать EntityNotFoundException, если категория не найдена")
+ void addEventPrivate_whenCategoryNotFound_thenThrowsEntityNotFoundException() {
+ Long nonExistentCategoryId = 888L;
+ NewEventDto newEventDto = NewEventDto.builder().category(nonExistentCategoryId).eventDate(now.plusHours(3))
+ .annotation("A").description("D").title("T").location(location1).build();
+
+ assertThrows(EntityNotFoundException.class, () ->
+ eventService.addEventPrivate(user1.getId(), newEventDto));
+ }
+
+ @Test
+ @DisplayName("Должен выбрасывать BusinessRuleViolationException, если дата события слишком ранняя")
+ void addEventPrivate_whenEventDateIsTooSoon_thenThrowsBusinessRuleViolationException() {
+ NewEventDto newEventDto = NewEventDto.builder().category(category1.getId()).eventDate(now.plusHours(1))
+ .annotation("A").description("D").title("T").location(location1).build();
+
+ assertThrows(BusinessRuleViolationException.class, () ->
+ eventService.addEventPrivate(user1.getId(), newEventDto));
+ }
+ }
+
+ @Nested
+ @DisplayName("Метод getEventsAdmin")
+ class GetEventsAdminTests {
+
+ @BeforeEach
+ void setUpAdminEvents() {
+ Event event1 = Event.builder().title("Admin Event 1").annotation("A1").description("D1")
+ .category(category1).initiator(user1).location(location1)
+ .eventDate(now.plusDays(5)).state(EventState.PENDING).createdOn(now.minusDays(1)).build();
+ Event event2 = Event.builder().title("Admin Event 2").annotation("A2").description("D2")
+ .category(category2).initiator(user2).location(location1)
+ .eventDate(now.plusDays(10)).state(EventState.PUBLISHED).createdOn(now.minusDays(2)).build();
+ Event event3 = Event.builder().title("Admin Event 3").annotation("Another A").description("Another D")
+ .category(category1).initiator(user1).location(location1)
+ .eventDate(now.plusDays(15)).state(EventState.PUBLISHED).createdOn(now.minusDays(3)).build();
+ eventRepository.saveAll(List.of(event1, event2, event3));
+ }
+
+ @Test
+ @DisplayName("Должен вернуть все события с пагинацией при отсутствии фильтров")
+ void getEventsAdmin_whenNoFilters_thenReturnsAllEventsPaged() {
+ AdminEventSearchParams params = AdminEventSearchParams.builder().build();
+ List result = eventService.getEventsAdmin(params, 0, 2);
+ assertEquals(2, result.size());
+
+ List resultNextPage = eventService.getEventsAdmin(params, 2, 2);
+ assertEquals(1, resultNextPage.size());
+ }
+
+ @Test
+ @DisplayName("Должен вернуть соответствующие события при поиске с фильтром по пользователям")
+ void getEventsAdmin_whenUserFilterApplied_thenReturnsMatchingEvents() {
+ AdminEventSearchParams params = AdminEventSearchParams.builder().users(List.of(user1.getId())).build();
+ List result = eventService.getEventsAdmin(params, 0, 10);
+ assertEquals(2, result.size());
+ assertTrue(result.stream().allMatch(e -> e.getInitiator().getId().equals(user1.getId())));
+ }
+
+ @Test
+ @DisplayName("Должен вернуть соответствующие события при поиске с фильтром по состояниям")
+ void getEventsAdmin_whenStateFilterApplied_thenReturnsMatchingEvents() {
+ AdminEventSearchParams params = AdminEventSearchParams.builder().states(List.of(EventState.PUBLISHED)).build();
+ List result = eventService.getEventsAdmin(params, 0, 10);
+ assertEquals(2, result.size());
+ assertTrue(result.stream().allMatch(e -> e.getState() == EventState.PUBLISHED));
+ }
+
+ @Test
+ @DisplayName("Должен вернуть соответствующие события при поиске с фильтром по категориям")
+ void getEventsAdmin_whenCategoryFilterApplied_thenReturnsMatchingEvents() {
+ AdminEventSearchParams params = AdminEventSearchParams.builder().categories(List.of(category1.getId())).build();
+ List result = eventService.getEventsAdmin(params, 0, 10);
+ assertEquals(2, result.size());
+ assertTrue(result.stream().allMatch(e -> e.getCategory().getId().equals(category1.getId())));
+ }
+
+ @Test
+ @DisplayName("Должен вернуть соответствующие события при поиске с фильтром по диапазону дат")
+ void getEventsAdmin_whenDateRangeFilterApplied_thenReturnsMatchingEvents() {
+ AdminEventSearchParams params = AdminEventSearchParams.builder()
+ .rangeStart(now.plusDays(7))
+ .rangeEnd(now.plusDays(12))
+ .build();
+ List result = eventService.getEventsAdmin(params, 0, 10);
+ assertEquals(1, result.size());
+ assertEquals("Admin Event 2", result.getFirst().getTitle());
+ }
+
+ @Test
+ @DisplayName("Должен выбрасывать IllegalArgumentException при поиске с невалидным диапазоном дат")
+ void getEventsAdmin_whenInvalidDateRange_thenThrowsIllegalArgumentException() {
+ AdminEventSearchParams params = AdminEventSearchParams.builder()
+ .rangeStart(now.plusDays(10))
+ .rangeEnd(now.plusDays(5))
+ .build();
+ assertThrows(IllegalArgumentException.class, () -> eventService.getEventsAdmin(params, 0, 10));
+ }
+
+ @Test
+ @DisplayName("Должен вернуть пустой список при поиске без совпадающих критериев")
+ void getEventsAdmin_whenNoEventsMatchCriteria_thenReturnsEmptyList() {
+ AdminEventSearchParams params = AdminEventSearchParams.builder().users(List.of(999L)).build();
+ List result = eventService.getEventsAdmin(params, 0, 10);
+ assertTrue(result.isEmpty());
+ }
+ }
+
+ @Nested
+ @DisplayName("Метод getEventsByOwner")
+ class GetEventsByOwnerTests {
+ private Event eventUser1Cat1, eventUser1Cat2, eventUser2Cat1;
+
+ @BeforeEach
+ void setUpOwnerEvents() {
+ // Создаем события для разных пользователей
+ eventUser1Cat1 = Event.builder().title("User1 Event Cat1").annotation("A").description("D")
+ .category(category1).initiator(user1).location(location1)
+ .eventDate(now.plusDays(1)).state(EventState.PENDING).createdOn(now).build();
+ eventUser1Cat2 = Event.builder().title("User1 Event Cat2").annotation("A").description("D")
+ .category(category2).initiator(user1).location(location1)
+ .eventDate(now.plusDays(2)).state(EventState.PUBLISHED).createdOn(now).build();
+ eventUser2Cat1 = Event.builder().title("User2 Event Cat1").annotation("A").description("D")
+ .category(category1).initiator(user2).location(location1)
+ .eventDate(now.plusDays(3)).state(EventState.PENDING).createdOn(now).build();
+ eventRepository.saveAll(List.of(eventUser1Cat1, eventUser1Cat2, eventUser2Cat1));
+ }
+
+ @Test
+ @DisplayName("Должен возвращать события только указанного пользователя с пагинацией")
+ void getEventsByOwner_whenUserHasEvents_thenReturnsTheirEventsPaged() {
+ List resultPage1 = eventService.getEventsByOwner(user1.getId(), 0, 1);
+ assertEquals(1, resultPage1.size());
+ assertEquals(eventUser1Cat2.getTitle(), resultPage1.getFirst().getTitle());
+
+
+ List