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 resultPage2 = eventService.getEventsByOwner(user1.getId(), 1, 1); + assertEquals(1, resultPage2.size()); + assertEquals(eventUser1Cat1.getTitle(), resultPage2.getFirst().getTitle()); + + List allUser1Events = eventService.getEventsByOwner(user1.getId(), 0, 10); + assertEquals(2, allUser1Events.size()); + } + + @Test + @DisplayName("Должен возвращать пустой список, если у пользователя нет событий") + void getEventsByOwner_whenUserHasNoEvents_thenReturnsEmptyList() { + User userWithNoEvents = user3; // У user3 нет событий. + List result = eventService.getEventsByOwner(userWithNoEvents.getId(), 0, 10); + assertTrue(result.isEmpty()); + } + + @Test + @DisplayName("Должен возвращать пустой список, если пользователь не найден") + void getEventsByOwner_whenUserNotFound_thenReturnsEmptyListOrThrows() { + Long nonExistentUserId = 999L; + assertTrue(eventService.getEventsByOwner(nonExistentUserId, 0, 10).isEmpty()); + } + } + + @Nested + @DisplayName("Метод getEventPrivate") + class GetEventPrivateTests { + private Event user1Event; + + @BeforeEach + void setUpPrivateEvent() { + user1Event = Event.builder().title("User1 Specific Event").annotation("A").description("D") + .category(category1).initiator(user1).location(location1) + .eventDate(now.plusDays(1)).state(EventState.PENDING).createdOn(now).build(); + user1Event = eventRepository.save(user1Event); + } + + @Test + @DisplayName("Должен возвращать EventFullDto, если событие найдено и принадлежит пользователю") + void getEventPrivate_whenEventExistsAndBelongsToUser_thenReturnsEventFullDto() { + EventFullDto result = eventService.getEventPrivate(user1.getId(), user1Event.getId()); + + assertNotNull(result); + assertEquals(user1Event.getId(), result.getId()); + assertEquals(user1Event.getTitle(), result.getTitle()); + assertEquals(user1.getId(), result.getInitiator().getId()); + assertEquals(category1.getId(), result.getCategory().getId()); + } + + @Test + @DisplayName("Должен выбросить EntityNotFoundException, если событие не найдено") + void getEventPrivate_whenEventNotFound_thenThrowsEntityNotFoundException() { + Long nonExistentEventId = 999L; + assertThrows(EntityNotFoundException.class, () -> eventService.getEventPrivate(user1.getId(), nonExistentEventId)); + } + + @Test + @DisplayName("Должен выбросить EntityNotFoundException, если событие не принадлежит пользователю") + void getEventPrivate_whenEventDoesNotBelongToUser_thenThrowsEntityNotFoundException() { + assertThrows(EntityNotFoundException.class, () -> eventService.getEventPrivate(user2.getId(), user1Event.getId())); // user2 пытается получить событие user1 + } + + @Test + @DisplayName("Должен выбросить EntityNotFoundException, если пользователь не найден") + void getEventPrivate_whenUserNotFound_thenThrowsEntityNotFoundException() { + Long nonExistentUserId = 999L; + assertThrows(EntityNotFoundException.class, () -> eventService.getEventPrivate(nonExistentUserId, user1Event.getId())); + } + } + + @Nested + @DisplayName("Метод updateEventByOwner") + class UpdateEventByOwnerTests { + private Event eventToUpdate; + + @BeforeEach + void setUpUpdateEvent() { + eventToUpdate = Event.builder().title("Event to Update").annotation("Initial Annotation") + .category(category1).initiator(user1).location(location1).description("Event Description") + .eventDate(now.plusDays(5)).state(EventState.PENDING) // Можно обновлять PENDING + .createdOn(now.minusDays(1)).participantLimit(10).paid(false).requestModeration(true) + .build(); + eventToUpdate = eventRepository.save(eventToUpdate); + } + + @Test + @DisplayName("Должен успешно обновлять событие (название, аннотация, дата, состояние в PENDING)") + void updateEventByOwner_whenValidUpdate_thenEventIsUpdated() { + UpdateEventUserRequestDto updateDto = UpdateEventUserRequestDto.builder() + .title("Updated Title by Owner") + .annotation("Updated Annotation by Owner") + .eventDate(now.plusHours(3)) // Валидная дата + .stateAction(UpdateEventUserRequestDto.StateActionUser.SEND_TO_REVIEW) + .build(); + + EventFullDto updatedEventDto = eventService.updateEventByOwner(user1.getId(), eventToUpdate.getId(), updateDto); + + assertNotNull(updatedEventDto); + assertEquals("Updated Title by Owner", updatedEventDto.getTitle()); + assertEquals("Updated Annotation by Owner", updatedEventDto.getAnnotation()); + assertEquals(now.plusHours(3), updatedEventDto.getEventDate()); + assertEquals(EventState.PENDING, updatedEventDto.getState()); + + Optional found = eventRepository.findById(eventToUpdate.getId()); + assertTrue(found.isPresent()); + assertEquals("Updated Title by Owner", found.get().getTitle()); + } + + @Test + @DisplayName("Должен изменять состояние на CANCELED при stateAction = CANCEL_REVIEW") + void updateEventByOwner_whenCancelReview_thenStateIsCanceled() { + UpdateEventUserRequestDto updateDto = UpdateEventUserRequestDto.builder() + .stateAction(UpdateEventUserRequestDto.StateActionUser.CANCEL_REVIEW) + .build(); + + EventFullDto updatedEventDto = eventService.updateEventByOwner(user1.getId(), eventToUpdate.getId(), updateDto); + assertEquals(EventState.CANCELED, updatedEventDto.getState()); + + Optional found = eventRepository.findById(eventToUpdate.getId()); + assertTrue(found.isPresent()); + assertEquals(EventState.CANCELED, found.get().getState()); + } + + @Test + @DisplayName("Должен выбросить BusinessRuleViolationException при попытке обновить PUBLISHED событие") + void updateEventByOwner_whenEventIsPublished_thenThrowsBusinessRuleViolationException() { + eventToUpdate.setState(EventState.PUBLISHED); + eventRepository.saveAndFlush(eventToUpdate); // Сохраняем измененное состояние + + UpdateEventUserRequestDto updateDto = UpdateEventUserRequestDto.builder().title("Try to update").build(); + + assertThrows(BusinessRuleViolationException.class, () -> eventService.updateEventByOwner(user1.getId(), eventToUpdate.getId(), updateDto)); + } + + @Test + @DisplayName("Должен выбросить BusinessRuleViolationException, если новая дата события слишком ранняя") + void updateEventByOwner_whenNewEventDateIsTooSoon_thenThrowsBusinessRuleViolationException() { + UpdateEventUserRequestDto updateDto = UpdateEventUserRequestDto.builder() + .eventDate(now.plusHours(1)) // Менее 2 часов + .build(); + + assertThrows(BusinessRuleViolationException.class, () -> eventService.updateEventByOwner(user1.getId(), eventToUpdate.getId(), updateDto)); + } + + @Test + @DisplayName("Должен выбросить EntityNotFoundException, если событие не принадлежит пользователю") + void updateEventByOwner_whenEventNotOwnedByUser_thenThrowsEntityNotFoundException() { + UpdateEventUserRequestDto updateDto = UpdateEventUserRequestDto.builder().title("New title").build(); + // user2 пытается обновить событие user1 + assertThrows(EntityNotFoundException.class, () -> eventService.updateEventByOwner(user2.getId(), eventToUpdate.getId(), updateDto)); + } + + @Test + @DisplayName("Должен выбросить EntityNotFoundException, если категория для обновления не найдена") + void updateEventByOwner_whenCategoryForUpdateNotFound_thenThrowsEntityNotFoundException() { + Long nonExistentCategoryId = 999L; + UpdateEventUserRequestDto updateDto = UpdateEventUserRequestDto.builder() + .category(nonExistentCategoryId) + .build(); + + assertThrows(EntityNotFoundException.class, () -> eventService.updateEventByOwner(user1.getId(), eventToUpdate.getId(), updateDto)); + } + } + + @Nested + @DisplayName("Метод moderateEventByAdmin (интеграционные тесты)") + class ModerateEventByAdminIntegrationTests { + private Event pendingEvent; + private Event publishedEventForRejectTest; + private Category anotherCategory; + + @BeforeEach + void setUpModerateIntegrationTests() { + + pendingEvent = Event.builder() + .title("Pending Event for Moderation") + .annotation("Annotation for pending moderation") + .description("Description") + .category(category1) + .initiator(user1) + .location(Location.builder().lat(50f).lon(50f).build()) + .eventDate(now.plusDays(3)) + .createdOn(now.minusDays(1)) + .state(EventState.PENDING) + .build(); + pendingEvent = eventRepository.save(pendingEvent); + + publishedEventForRejectTest = Event.builder() + .title("Published Event to Test Rejection") + .annotation("Annotation") + .description("Description") + .category(category2) + .initiator(user2) + .location(Location.builder().lat(51f).lon(51f).build()) + .eventDate(now.plusDays(4)) + .createdOn(now.minusDays(2)) + .state(EventState.PENDING) // Сначала PENDING + .build(); + publishedEventForRejectTest = eventRepository.save(publishedEventForRejectTest); + publishedEventForRejectTest.setState(EventState.PUBLISHED); + publishedEventForRejectTest.setPublishedOn(now.minusHours(1)); // Опубликовано час назад + publishedEventForRejectTest = eventRepository.save(publishedEventForRejectTest); + + + anotherCategory = categoryRepository.save(Category.builder().name("Another Category for Update").build()); + } + + @Test + @DisplayName("Должен успешно публиковать PENDING событие") + void moderateEventByAdmin_whenPublishPendingEvent_thenStateIsPublishedAndPublishedOnSet() { + UpdateEventAdminRequestDto publishDto = UpdateEventAdminRequestDto.builder() + .stateAction(UpdateEventAdminRequestDto.StateActionAdmin.PUBLISH_EVENT) + .build(); + + EventFullDto resultDto = eventService.moderateEventByAdmin(pendingEvent.getId(), publishDto); + + assertNotNull(resultDto); + assertEquals(EventState.PUBLISHED, resultDto.getState()); + assertNotNull(resultDto.getPublishedOn()); + // Проверяем, что publishedOn примерно равен now (в пределах нескольких секунд из-за выполнения кода) + assertTrue(resultDto.getPublishedOn().isAfter(now.minusSeconds(5)) && + resultDto.getPublishedOn().isBefore(now.plusSeconds(5))); + + Optional foundEvent = eventRepository.findById(pendingEvent.getId()); + assertTrue(foundEvent.isPresent()); + assertEquals(EventState.PUBLISHED, foundEvent.get().getState()); + assertNotNull(foundEvent.get().getPublishedOn()); + } + + @Test + @DisplayName("Должен успешно отклонять PENDING событие") + void moderateEventByAdmin_whenRejectPendingEvent_thenStateIsCanceled() { + UpdateEventAdminRequestDto rejectDto = UpdateEventAdminRequestDto.builder() + .stateAction(UpdateEventAdminRequestDto.StateActionAdmin.REJECT_EVENT) + .build(); + + EventFullDto resultDto = eventService.moderateEventByAdmin(pendingEvent.getId(), rejectDto); + + assertNotNull(resultDto); + assertEquals(EventState.CANCELED, resultDto.getState()); + assertNull(resultDto.getPublishedOn()); // Для PENDING -> CANCELED publishedOn должен быть null + + Optional foundEvent = eventRepository.findById(pendingEvent.getId()); + assertTrue(foundEvent.isPresent()); + assertEquals(EventState.CANCELED, foundEvent.get().getState()); + assertNull(foundEvent.get().getPublishedOn()); + } + + @Test + @DisplayName("Должен обновлять поля события (например, title, category) при публикации") + void moderateEventByAdmin_whenPublishWithFieldUpdates_thenFieldsAreUpdated() { + UpdateEventAdminRequestDto updateAndPublishDto = UpdateEventAdminRequestDto.builder() + .title("Admin Updated Published Title") + .annotation("Admin new annotation") + .category(anotherCategory.getId()) + .eventDate(now.plusDays(2)) + .stateAction(UpdateEventAdminRequestDto.StateActionAdmin.PUBLISH_EVENT) + .build(); + + EventFullDto resultDto = eventService.moderateEventByAdmin(pendingEvent.getId(), updateAndPublishDto); + + assertNotNull(resultDto); + assertEquals("Admin Updated Published Title", resultDto.getTitle()); + assertEquals("Admin new annotation", resultDto.getAnnotation()); + assertEquals(anotherCategory.getId(), resultDto.getCategory().getId()); + assertEquals(now.plusDays(2), resultDto.getEventDate()); + assertEquals(EventState.PUBLISHED, resultDto.getState()); + + Optional foundEvent = eventRepository.findById(pendingEvent.getId()); + assertTrue(foundEvent.isPresent()); + assertEquals("Admin Updated Published Title", foundEvent.get().getTitle()); + assertEquals(anotherCategory.getId(), foundEvent.get().getCategory().getId()); + } + + + @Test + @DisplayName("Должен выбросить BusinessRuleViolationException при попытке опубликовать не PENDING событие (например, CANCELED)") + void moderateEventByAdmin_whenPublishCanceledEvent_thenThrowsBusinessRuleViolationException() { + pendingEvent.setState(EventState.CANCELED); + eventRepository.saveAndFlush(pendingEvent); + + UpdateEventAdminRequestDto publishDto = UpdateEventAdminRequestDto.builder() + .stateAction(UpdateEventAdminRequestDto.StateActionAdmin.PUBLISH_EVENT) + .build(); + + assertThrows(BusinessRuleViolationException.class, () -> eventService.moderateEventByAdmin(pendingEvent.getId(), publishDto)); + } + + @Test + @DisplayName("Должен выбросить BusinessRuleViolationException при публикации события со слишком ранней eventDate") + void moderateEventByAdmin_whenPublishEventWithTooSoonDate_thenThrowsBusinessRuleViolationException() { + pendingEvent.setEventDate(LocalDateTime.now().plusMinutes(30)); + eventRepository.saveAndFlush(pendingEvent); + + UpdateEventAdminRequestDto publishDto = UpdateEventAdminRequestDto.builder() + .stateAction(UpdateEventAdminRequestDto.StateActionAdmin.PUBLISH_EVENT) + .build(); + + assertThrows(BusinessRuleViolationException.class, () -> eventService.moderateEventByAdmin(pendingEvent.getId(), publishDto)); + } + + @Test + @DisplayName("Должен выбросить BusinessRuleViolationException при публикации, если eventDate из DTO слишком ранняя") + void moderateEventByAdmin_whenPublishWithDtoEventDateTooSoon_thenThrowsBusinessRuleViolationException() { + UpdateEventAdminRequestDto publishDtoWithEarlyDate = UpdateEventAdminRequestDto.builder() + .eventDate(LocalDateTime.now().plusMinutes(30)) + .stateAction(UpdateEventAdminRequestDto.StateActionAdmin.PUBLISH_EVENT) + .build(); + // pendingEvent.eventDate (now.plusDays(3)) сама по себе валидна, но DTO ее переопределит + + assertThrows(BusinessRuleViolationException.class, () -> eventService.moderateEventByAdmin(pendingEvent.getId(), publishDtoWithEarlyDate)); + } + + @Test + @DisplayName("Должен выбросить BusinessRuleViolationException при попытке отклонить уже PUBLISHED событие") + void moderateEventByAdmin_whenRejectAlreadyPublishedEvent_thenThrowsBusinessRuleViolationException() { + UpdateEventAdminRequestDto rejectDto = UpdateEventAdminRequestDto.builder() + .stateAction(UpdateEventAdminRequestDto.StateActionAdmin.REJECT_EVENT) + .build(); + + assertThrows(BusinessRuleViolationException.class, () -> eventService.moderateEventByAdmin(publishedEventForRejectTest.getId(), rejectDto)); + } + + @Test + @DisplayName("Должен выбросить EntityNotFoundException, если событие для модерации не найдено") + void moderateEventByAdmin_whenEventNotFound_thenThrowsEntityNotFoundException() { + Long nonExistentEventId = 9999L; + UpdateEventAdminRequestDto publishDto = UpdateEventAdminRequestDto.builder() + .stateAction(UpdateEventAdminRequestDto.StateActionAdmin.PUBLISH_EVENT) + .build(); + + assertThrows(EntityNotFoundException.class, () -> eventService.moderateEventByAdmin(nonExistentEventId, publishDto)); + } + + @Test + @DisplayName("Должен выбросить EntityNotFoundException при обновлении, если категория из DTO не найдена") + void moderateEventByAdmin_whenUpdatingWithNonExistentCategory_thenThrowsEntityNotFoundException() { + Long nonExistentCategoryId = 8888L; + UpdateEventAdminRequestDto updateDtoWithBadCategory = UpdateEventAdminRequestDto.builder() + .category(nonExistentCategoryId) + .stateAction(UpdateEventAdminRequestDto.StateActionAdmin.PUBLISH_EVENT) + .build(); + + pendingEvent.setEventDate(now.plusHours(2)); + eventRepository.saveAndFlush(pendingEvent); + + assertThrows(EntityNotFoundException.class, () -> eventService.moderateEventByAdmin(pendingEvent.getId(), updateDtoWithBadCategory)); + } + } + + @Nested + @DisplayName("Метод getEventByIdPublic") + class GetEventByIdPublicIntegrationTests { + private Event publishedEvent; + + @BeforeEach + void setUpPublicEvent() { + publishedEvent = Event.builder().title("Public Event Alpha").annotation("A_pub").description("D_pub") + .category(category1).initiator(user1).location(location1) + .eventDate(now.plusDays(1)).state(EventState.PENDING).createdOn(now.minusDays(10)) + .participantLimit(10) + .build(); + publishedEvent = eventRepository.save(publishedEvent); + publishedEvent.setState(EventState.PUBLISHED); + publishedEvent.setPublishedOn(now.minusDays(1)); + publishedEvent = eventRepository.save(publishedEvent); + + ParticipationRequest req1 = ParticipationRequest.builder().event(publishedEvent).requester(user2).status(RequestStatus.CONFIRMED).created(now).build(); + ParticipationRequest req2 = ParticipationRequest.builder().event(publishedEvent).requester(user3).status(RequestStatus.CONFIRMED).created(now).build(); + requestRepository.saveAll(List.of(req1, req2)); + } + + @Test + @DisplayName("Должен возвращать EventFullDto с просмотрами и подтвержденными запросами") + void getEventByIdPublic_whenEventExistsAndPublished_thenReturnsDtoWithViewsAndRequests() { + String eventUri = "/events/" + publishedEvent.getId(); + ViewStatsDto viewStat = new ViewStatsDto("ewm-main-service", eventUri, 5L); + when(statsClient.getStats(any(LocalDateTime.class), any(LocalDateTime.class), eq(List.of(eventUri)), eq(true))) + .thenReturn(List.of(viewStat)); + + EventFullDto resultDto = eventService.getEventByIdPublic(publishedEvent.getId()); + + assertNotNull(resultDto); + assertEquals(publishedEvent.getId(), resultDto.getId()); + assertEquals(publishedEvent.getTitle(), resultDto.getTitle()); + assertEquals(5L, resultDto.getViews()); + assertEquals(2L, resultDto.getConfirmedRequests()); + assertEquals(EventState.PUBLISHED, resultDto.getState()); + } + + @Test + @DisplayName("Должен выбросить EntityNotFoundException, если событие не опубликовано") + void getEventByIdPublic_whenEventNotPublished_thenThrowsEntityNotFoundException() { + Event pendingEvent = Event.builder().title("Pending Event").annotation("A").description("D") + .category(category1).initiator(user1).location(location1) + .eventDate(now.plusDays(1)).state(EventState.PENDING).createdOn(now).build(); + pendingEvent = eventRepository.save(pendingEvent); + Long pendingEventId = pendingEvent.getId(); + + assertThrows(EntityNotFoundException.class, () -> eventService.getEventByIdPublic(pendingEventId)); + } + + @Test + @DisplayName("Просмотры должны быть 0, если сервис статистики вернул пустой список или ошибку") + void getEventByIdPublic_whenStatsServiceFailsOrReturnsEmpty_thenViewsAreZero() { + String eventUri = "/events/" + publishedEvent.getId(); + when(statsClient.getStats(any(LocalDateTime.class), any(LocalDateTime.class), eq(List.of(eventUri)), eq(true))) + .thenReturn(Collections.emptyList()); + + EventFullDto resultDtoEmptyStats = eventService.getEventByIdPublic(publishedEvent.getId()); + assertEquals(0L, resultDtoEmptyStats.getViews(), "Views should be 0 if stats service returns empty"); + + when(statsClient.getStats(any(LocalDateTime.class), any(LocalDateTime.class), eq(List.of(eventUri)), eq(true))) + .thenThrow(new RuntimeException("Stats service error")); + + EventFullDto resultDtoErrorStats = eventService.getEventByIdPublic(publishedEvent.getId()); + assertEquals(0L, resultDtoErrorStats.getViews(), "Views should be 0 if stats service throws error"); + assertEquals(2L, resultDtoErrorStats.getConfirmedRequests()); + } + } + + @Nested + @DisplayName("Метод getEventsPublic") + class GetEventsPublicIntegrationTests { + private Event event1Pub, event2Pub, event3Pending, event4PastPub; + + @BeforeEach + void setUpPublicEvents() { + event1Pub = Event.builder().title("Public Search Event Alpha") + .annotation("Alpha sports concert") + .description("Description for Public Search Event Alpha") + .category(category1).initiator(user1).location(location1).paid(false) + .eventDate(now.plusDays(5)).state(EventState.PUBLISHED) + .publishedOn(now.minusDays(1)).participantLimit(10).createdOn(now.minusDays(2)) + .build(); // 5 подтверждённых запросов + event2Pub = Event.builder().title("Public Search Event Beta") + .annotation("Beta culture festival") + .description("Description for Public Search Event Beta") + .category(category2).initiator(user2).location(location2).paid(true) + .eventDate(now.plusDays(2)).state(EventState.PUBLISHED) + .publishedOn(now.minusDays(2)).participantLimit(3).createdOn(now.minusDays(3)) + .build(); // 1 подтверждённый запрос + event3Pending = Event.builder().title("Public Search Event Gamma (Pending)") + .annotation("Gamma").description( + "Description for Public Search Event Gamma (Pending)") + .category(category1).initiator(user1).location(location1).eventDate(now.plusDays(3)) + .state(EventState.PENDING).createdOn(now.minusDays(1)).build(); + event4PastPub = Event.builder().title("Past Public Event Delta") + .annotation("Delta retro") + .description("Description for Past Public Event Delta") + .category(category2).initiator(user2).location(location2).paid(false) + .eventDate(now.minusDays(1)).state(EventState.PUBLISHED) + .publishedOn(now.minusDays(2)).createdOn(now.minusDays(3)).build(); + + eventRepository.saveAll(List.of(event1Pub, event2Pub, event3Pending, event4PastPub)); + + requestRepository.save(ParticipationRequest.builder().event(event1Pub).requester(user2).status(RequestStatus.CONFIRMED).created(now).build()); + requestRepository.save(ParticipationRequest.builder().event(event1Pub).requester(user3).status(RequestStatus.CONFIRMED).created(now).build()); + for (int i = 0; i < 3; i++) { + User tempUser = userRepository.save(User.builder().name("Temp User " + i).email("temp" + i + "@mail.com").build()); + requestRepository.save(ParticipationRequest.builder().event(event1Pub).requester(tempUser).status(RequestStatus.CONFIRMED).created(now).build()); + } + + requestRepository.save(ParticipationRequest.builder().event(event2Pub).requester(user1).status(RequestStatus.CONFIRMED).created(now).build()); + } + + @Test + @DisplayName("Должен возвращать только PUBLISHED события, если диапазон дат не указан (т.е. будущие)") + void getEventsPublic_noDateRange_shouldReturnFuturePublishedEvents() { + PublicEventSearchParams params = PublicEventSearchParams.builder().build(); + when(statsClient.getStats(any(LocalDateTime.class), any(LocalDateTime.class), anyList(), anyBoolean())) + .thenReturn(Collections.emptyList()); + + List results = eventService.getEventsPublic(params, 0, 10); + + assertEquals(2, results.size(), "Should find 2 future published events (event1Pub, event2Pub)"); + assertTrue(results.stream().anyMatch(e -> e.getId().equals(event1Pub.getId()))); + assertTrue(results.stream().anyMatch(e -> e.getId().equals(event2Pub.getId()))); + } + + @Test + @DisplayName("Должен корректно фильтровать по тексту в аннотации или описании (регистронезависимо)") + void getEventsPublic_withTextFilter_shouldReturnMatchingEvents() { + PublicEventSearchParams params = PublicEventSearchParams.builder().text("alpha SpOrTs").build(); + when(statsClient.getStats(any(), any(), anyList(), eq(true))).thenReturn(Collections.emptyList()); + + List results = eventService.getEventsPublic(params, 0, 10); + assertEquals(1, results.size()); + assertEquals(event1Pub.getId(), results.getFirst().getId()); + } + + @Test + @DisplayName("Должен корректно фильтровать по категориям") + void getEventsPublic_withCategoriesFilter_shouldReturnMatchingEvents() { + PublicEventSearchParams params = PublicEventSearchParams.builder().categories(List.of(category2.getId())).build(); + when(statsClient.getStats(any(), any(), anyList(), eq(true))).thenReturn(Collections.emptyList()); + + List results = eventService.getEventsPublic(params, 0, 10); + // event4PastPub не попадет в выборку, так как по умолчанию отбираются будущие события. + assertEquals(1, results.size(), "Expected 1 event in category B that is in the future and published"); + assertEquals(event2Pub.getId(), results.getFirst().getId()); + } + + @Test + @DisplayName("Должен корректно фильтровать по платному участию (paid=true)") + void getEventsPublic_withPaidTrueFilter_shouldReturnPaidEvents() { + PublicEventSearchParams params = PublicEventSearchParams.builder().paid(true).build(); + when(statsClient.getStats(any(), any(), anyList(), eq(true))).thenReturn(Collections.emptyList()); + + List results = eventService.getEventsPublic(params, 0, 10); + assertEquals(1, results.size()); + assertEquals(event2Pub.getId(), results.getFirst().getId()); + assertTrue(results.getFirst().getPaid()); + } + + @Test + @DisplayName("Должен корректно фильтровать по диапазону дат, включая прошлое, если указан rangeStart") + void getEventsPublic_withDateRangeIncludingPast_shouldReturnMatchingEvents() { + PublicEventSearchParams params = PublicEventSearchParams.builder() + .rangeStart(now.minusDays(2)) + .rangeEnd(now.plusDays(6)) + .build(); + when(statsClient.getStats(any(), any(), anyList(), eq(true))).thenReturn(Collections.emptyList()); + + List results = eventService.getEventsPublic(params, 0, 10); + + assertEquals(3, results.size(), "Should find event1Pub, event2Pub and event4PastPub"); + assertTrue(results.stream().anyMatch(e -> e.getId().equals(event1Pub.getId()))); + assertTrue(results.stream().anyMatch(e -> e.getId().equals(event4PastPub.getId()))); + } + + @Test + @DisplayName("Должен корректно фильтровать по onlyAvailable (только доступные)") + void getEventsPublic_withOnlyAvailableTrue_shouldReturnAvailableEvents() { + event4PastPub.setParticipantLimit(5); + requestRepository.save(ParticipationRequest.builder().event(event4PastPub).requester(user1).status(RequestStatus.CONFIRMED).created(now).build()); + eventRepository.save(event4PastPub); + + entityManager.flush(); + entityManager.clear(); + + PublicEventSearchParams params = PublicEventSearchParams.builder() + .onlyAvailable(true) + .rangeStart(now.minusDays(5)) + .build(); + when(statsClient.getStats(any(), any(), anyList(), eq(true))).thenReturn(Collections.emptyList()); + + List results = eventService.getEventsPublic(params, 0, 10); + + assertEquals(3, results.size()); + assertTrue(results.stream().anyMatch(e -> e.getId().equals(event1Pub.getId()) && e.getConfirmedRequests() == 5L)); + assertTrue(results.stream().anyMatch(e -> e.getId().equals(event2Pub.getId()) && e.getConfirmedRequests() == 1L)); + assertTrue(results.stream().anyMatch(e -> e.getId().equals(event4PastPub.getId()) && e.getConfirmedRequests() == 1L)); + + requestRepository.save(ParticipationRequest.builder().event(event2Pub).requester(user2).status(RequestStatus.CONFIRMED).created(now).build()); + requestRepository.save(ParticipationRequest.builder().event(event2Pub).requester(user3).status(RequestStatus.CONFIRMED).created(now).build()); + + entityManager.flush(); + entityManager.clear(); + + results = eventService.getEventsPublic(params, 0, 10); + assertEquals(2, results.size(), "Event2Pub should now be unavailable"); + assertFalse(results.stream().anyMatch(e -> e.getId().equals(event2Pub.getId()))); + } + + @Test + @DisplayName("Должен сортировать по просмотрам (VIEWS), если указано") + void getEventsPublic_withSortByViews_shouldSortByViewsDesc() { + String applicationName = "test-app-name"; + String uri1 = "/events/" + event1Pub.getId(); + String uri2 = "/events/" + event2Pub.getId(); + PublicEventSearchParams params = PublicEventSearchParams.builder() + .sort("VIEWS") + .rangeStart(now.minusDays(5)) + .build(); + + ViewStatsDto stat1 = new ViewStatsDto(applicationName, uri1, 100L); + ViewStatsDto stat2 = new ViewStatsDto(applicationName, uri2, 200L); + String uri4 = "/events/" + event4PastPub.getId(); + ViewStatsDto stat4 = new ViewStatsDto(applicationName, uri4, 50L); + + when(statsClient.getStats(any(LocalDateTime.class), any(LocalDateTime.class), anyList(), eq(true))) + .thenReturn(List.of(stat1, stat2, stat4)); + + List results = eventService.getEventsPublic(params, 0, 10); + + assertEquals(3, results.size()); + assertEquals(event2Pub.getId(), results.get(0).getId(), "Event2 (200 views) should be first"); + assertEquals(200L, results.get(0).getViews()); + assertEquals(event1Pub.getId(), results.get(1).getId(), "Event1 (100 views) should be second"); + assertEquals(100L, results.get(1).getViews()); + assertEquals(event4PastPub.getId(), results.get(2).getId(), "Event4 (50 views) should be third"); + assertEquals(50L, results.get(2).getViews()); + } + + @Test + @DisplayName("Должен сортировать по дате события (EVENT_DATE) по умолчанию или если указано") + void getEventsPublic_withSortByEventDate_shouldSortByEventDate() { + PublicEventSearchParams paramsDefaultSort = PublicEventSearchParams.builder().build(); + PublicEventSearchParams paramsExplicitSort = PublicEventSearchParams.builder().sort("EVENT_DATE").build(); + + when(statsClient.getStats(any(), any(), anyList(), eq(true))).thenReturn(Collections.emptyList()); + + List resultsDefault = eventService.getEventsPublic(paramsDefaultSort, 0, 10); + List resultsExplicit = eventService.getEventsPublic(paramsExplicitSort, 0, 10); + + assertEquals(2, resultsDefault.size()); + assertEquals(event2Pub.getId(), resultsDefault.get(0).getId()); + assertEquals(event1Pub.getId(), resultsDefault.get(1).getId()); + + assertEquals(2, resultsExplicit.size()); + assertEquals(event2Pub.getId(), resultsExplicit.get(0).getId()); + assertEquals(event1Pub.getId(), resultsExplicit.get(1).getId()); + } + } +} \ No newline at end of file diff --git a/main-service/src/test/java/ru/practicum/explorewithme/main/service/UserServiceIntegrationTest.java b/main-service/src/test/java/ru/practicum/explorewithme/main/service/UserServiceIntegrationTest.java new file mode 100644 index 0000000..b62a26f --- /dev/null +++ b/main-service/src/test/java/ru/practicum/explorewithme/main/service/UserServiceIntegrationTest.java @@ -0,0 +1,278 @@ +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.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 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.model.User; +import ru.practicum.explorewithme.main.repository.UserRepository; + +import org.springframework.transaction.annotation.Transactional; +import ru.practicum.explorewithme.main.service.params.GetListUsersParameters; + +import java.util.Arrays; +import java.util.Collections; +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("Интеграционное тестирование UserServiceImpl") +class UserServiceIntegrationTest { + + @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 UserService userService; + + @Autowired + private UserRepository userRepository; + + private NewUserRequestDto newUserRequestDto; + private NewUserRequestDto anotherUserRequest; + + @BeforeEach + void setUp() { + userRepository.deleteAll(); + + newUserRequestDto = new NewUserRequestDto(); + newUserRequestDto.setName("Тестовый пользователь"); + newUserRequestDto.setEmail("test@example.com"); + + anotherUserRequest = new NewUserRequestDto(); + anotherUserRequest.setName("Другой пользователь"); + anotherUserRequest.setEmail("another@example.com"); + } + + @Nested + @DisplayName("Создание пользователя") + class CreateUserTests { + + @Test + @DisplayName("Успешное создание пользователя") + void createUser_Success() { + + UserDto createdUser = userService.createUser(newUserRequestDto); + + assertNotNull(createdUser); + assertNotNull(createdUser.getId()); + assertEquals(newUserRequestDto.getName(), createdUser.getName()); + assertEquals(newUserRequestDto.getEmail(), createdUser.getEmail()); + + Optional userFromDb = userRepository.findById(createdUser.getId()); + assertTrue(userFromDb.isPresent()); + assertEquals(newUserRequestDto.getName(), userFromDb.get().getName()); + assertEquals(newUserRequestDto.getEmail(), userFromDb.get().getEmail()); + } + + @Test + @DisplayName("Исключение при создании пользователя с дублирующимся email") + void createUser_DuplicateEmail_ThrowsException() { + + userService.createUser(newUserRequestDto); + + EntityAlreadyExistsException exception = assertThrows(EntityAlreadyExistsException.class, () -> { + userService.createUser(newUserRequestDto); + }); + + assertTrue(exception.getMessage().contains(newUserRequestDto.getEmail())); + } + } + + @Nested + @DisplayName("Удаление пользователя") + class DeleteUserTests { + + @Test + @DisplayName("Успешное удаление пользователя") + void deleteUser_Success() { + + UserDto createdUser = userService.createUser(newUserRequestDto); + + assertTrue(userRepository.existsById(createdUser.getId())); + + userService.deleteUser(createdUser.getId()); + + assertFalse(userRepository.existsById(createdUser.getId())); + } + + @Test + @DisplayName("Исключение при удалении несуществующего пользователя") + void deleteUser_UserNotFound_ThrowsException() { + + Long nonExistentUserId = 999L; + + EntityNotFoundException exception = assertThrows(EntityNotFoundException.class, () -> { + userService.deleteUser(nonExistentUserId); + }); + + assertTrue(exception.getMessage().contains(nonExistentUserId.toString())); + } + } + + @Nested + @DisplayName("Получение списка пользователей") + class GetUsersTests { + + @Test + @DisplayName("Получение всех пользователей без фильтрации по ID") + void getUsers_WithoutIds_ReturnsAllUsers() { + + UserDto user1 = userService.createUser(newUserRequestDto); + UserDto user2 = userService.createUser(anotherUserRequest); + + GetListUsersParameters parameters = new GetListUsersParameters(null, 0, 10); + + List users = userService.getUsers(parameters); + + assertNotNull(users); + assertEquals(2, users.size()); + + List userIds = Arrays.asList(users.get(0).getId(), users.get(1).getId()); + assertTrue(userIds.contains(user1.getId())); + assertTrue(userIds.contains(user2.getId())); + } + + @Test + @DisplayName("Получение пользователей с фильтрацией по ID") + void getUsers_WithIds_ReturnsSpecificUsers() { + + UserDto user1 = userService.createUser(newUserRequestDto); + userService.createUser(anotherUserRequest); // user2 не должен попасть в выборку + + GetListUsersParameters parameters = new GetListUsersParameters( + Collections.singletonList(user1.getId()), 0, 10); + + List users = userService.getUsers(parameters); + + assertNotNull(users); + assertEquals(1, users.size()); + assertEquals(user1.getId(), users.get(0).getId()); + } + + @Test + @DisplayName("Корректная работа пагинации при получении пользователей") + void getUsers_Pagination_ReturnsCorrectPage() { + + List createdUsers = IntStream.range(0, 5) + .mapToObj(i -> { + NewUserRequestDto request = new NewUserRequestDto(); + request.setName("User " + i); + request.setEmail("user" + i + "@example.com"); + return userService.createUser(request); + }) + .collect(Collectors.toList()); + + GetListUsersParameters page1Params = new GetListUsersParameters(null, 0, 2); + List page1 = userService.getUsers(page1Params); + + GetListUsersParameters page2Params = new GetListUsersParameters(null, 2, 2); + List page2 = userService.getUsers(page2Params); + + GetListUsersParameters page3Params = new GetListUsersParameters(null, 4, 2); + List page3 = userService.getUsers(page3Params); + + assertEquals(2, page1.size()); + assertEquals(2, page2.size()); + assertEquals(1, page3.size()); + + List allUserIds = new java.util.ArrayList<>(); + allUserIds.addAll(page1.stream().map(UserDto::getId).collect(Collectors.toList())); + allUserIds.addAll(page2.stream().map(UserDto::getId).collect(Collectors.toList())); + allUserIds.addAll(page3.stream().map(UserDto::getId).collect(Collectors.toList())); + + assertEquals(5, allUserIds.size()); + assertEquals(5, allUserIds.stream().distinct().count()); + } + + @Test + @DisplayName("Получение пустого списка при отсутствии пользователей") + void getUsers_EmptyRepository_ReturnsEmptyList() { + + GetListUsersParameters parameters = new GetListUsersParameters(null, 0, 10); + List users = userService.getUsers(parameters); + + assertNotNull(users); + assertTrue(users.isEmpty()); + } + } + + @Nested + @DisplayName("Тесты производительности") + class PerformanceTests { + + @Test + @DisplayName("Эффективная работа с большим количеством данных") + void getUsers_WithLargeDataset_PerformsEfficiently() { + + for (int i = 0; i < 100; i++) { + NewUserRequestDto request = new NewUserRequestDto(); + request.setName("User " + i); + request.setEmail("user" + i + "@example.com"); + userService.createUser(request); + } + + long startTime = System.currentTimeMillis(); + GetListUsersParameters parameters = new GetListUsersParameters(null, 0, 50); + List users = userService.getUsers(parameters); + long endTime = System.currentTimeMillis(); + + assertEquals(50, users.size()); + assertTrue((endTime - startTime) < 1000); // Ожидаем выполнение менее чем за секунду + + System.out.println("Время выполнения запроса для 50 пользователей из 100: " + (endTime - startTime) + " мс"); + } + } + + @Nested + @DisplayName("Тесты обработки граничных случаев") + class EdgeCaseTests { + + @Test + @DisplayName("Корректная обработка запроса страницы за пределами допустимого диапазона") + void getUsers_PageOutOfRange_ReturnsEmptyList() { + + IntStream.range(0, 3) + .forEach(i -> { + NewUserRequestDto request = new NewUserRequestDto(); + request.setName("User " + i); + request.setEmail("user" + i + "@example.com"); + userService.createUser(request); + }); + + GetListUsersParameters outOfRangeParams = new GetListUsersParameters(null, 10, 5); + List result = userService.getUsers(outOfRangeParams); + + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + } +} \ No newline at end of file diff --git a/pom.xml b/pom.xml index 5b3a851..216701c 100644 --- a/pom.xml +++ b/pom.xml @@ -6,11 +6,11 @@ org.springframework.boot spring-boot-starter-parent - 3.3.4 + 3.4.5 - Explore With Me + Explore With Me ru.practicum explore-with-me @@ -18,17 +18,193 @@ pom - 21 UTF-8 + 21 + 3.4.5 + 4.12.0 + 1.21.0 + 5.14.2 + 5.1.0 + 1.6.3 + ${java.version} + ${java.version} + + stats-service + main-service + ewm-common + + + + + + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} + pom + import + + + org.postgresql + postgresql + 42.7.5 + + + com.squareup.okhttp3 + mockwebserver + ${okhttp3.version} + test + + + org.testcontainers + testcontainers + ${testcontainers.version} + test + + + org.testcontainers + junit-jupiter + ${testcontainers.version} + test + + + org.testcontainers + postgresql + ${testcontainers.version} + test + + + org.projectlombok + lombok + 1.18.38 + provided + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + 2.19.0 + + + org.jetbrains + annotations + 24.0.1 + compile + + + com.google.code.findbugs + jsr305 + 3.0.2 + + + com.fasterxml.jackson.core + jackson-databind + 2.15.3 + + + org.junit.jupiter + junit-jupiter + 5.10.1 + test + + + jakarta.validation + jakarta.validation-api + 3.1.0 + + + org.mockito + mockito-inline + 5.2.0 + test + + + com.querydsl + querydsl-core + ${querydsl.version} + + + com.querydsl + querydsl-jpa + ${querydsl.version} + jakarta + + + com.querydsl + querydsl-sql + ${querydsl.version} + + + com.querydsl + querydsl-sql-spring + ${querydsl.version} + + + com.querydsl + querydsl-apt + ${querydsl.version} + jakarta + + + org.mapstruct + mapstruct + ${org.mapstruct.version} + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + ${java.version} + ${java.version} + + + org.projectlombok + lombok + ${lombok.version} + + + org.projectlombok + lombok-mapstruct-binding + 0.2.0 + + + org.mapstruct + mapstruct-processor + ${org.mapstruct.version} + + + com.querydsl + querydsl-apt + ${querydsl.version} + jakarta + + + jakarta.persistence + jakarta.persistence-api + 3.1.0 + + + jakarta.annotation + jakarta.annotation-api + 2.1.1 + + + ${project.build.directory}/generated-sources/annotations + + org.apache.maven.plugins maven-surefire-plugin + 3.5.3 + 1 + false test diff --git a/postman/feature.json b/postman/feature.json new file mode 100644 index 0000000..743ef85 --- /dev/null +++ b/postman/feature.json @@ -0,0 +1,4699 @@ +{ + "info": { + "_postman_id": "94b0e60d-ca4c-46d6-8c32-64ea51923d37", + "name": "Feature – Comments", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "41167888" + }, + "item": [ + { + "name": "API – Private", + "item": [ + { + "name": "Добавление комментария", + "item": [ + { + "name": "Добавление комментария – Успех – 201", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "const randomSuffix = Math.floor(Math.random() * 100000);\r", + "\r", + "const baseUrl = pm.collectionVariables.get(\"baseUrl\");\r", + "\r", + "function getFormattedFutureDate(hoursToAdd) {\r", + " const date = new Date();\r", + " date.setHours(date.getHours() + hoursToAdd);\r", + " const year = date.getFullYear();\r", + " const month = String(date.getMonth() + 1).padStart(2, '0');\r", + " const day = String(date.getDate()).padStart(2, '0');\r", + " const hours = String(date.getHours()).padStart(2, '0');\r", + " const minutes = String(date.getMinutes()).padStart(2, '0');\r", + " const seconds = String(date.getSeconds()).padStart(2, '0');\r", + " return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;\r", + "}\r", + "\r", + "pm.sendRequest({\r", + " url: `${baseUrl}/admin/users`,\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: {\r", + " mode: 'raw',\r", + " raw: JSON.stringify({\r", + " email: `testuser_commenter_${randomSuffix}@example.com`,\r", + " name: `Test Commenter ${randomSuffix}`\r", + " })\r", + " }\r", + "}, (err, response) => {\r", + " if (err) {\r", + " console.error(\"Pre-request: Failed to create user:\", err);\r", + " throw new Error(\"Pre-request: Failed to create user\");\r", + " }\r", + " if (response.code !== 201) {\r", + " console.error(\"Pre-request: User creation failed with status \" + response.code + \":\", response.text());\r", + " throw new Error(`Pre-request: User creation failed with status ${response.code}`);\r", + " }\r", + " const userData = response.json();\r", + " pm.environment.set(\"createdUserId\", userData.id);\r", + " pm.environment.set(\"createdUserName\", userData.name);\r", + " console.log(\"Pre-request: User created:\", userData.id);\r", + "\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/admin/categories`,\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: {\r", + " mode: 'raw',\r", + " raw: JSON.stringify({\r", + " name: `Test Category For Comment ${randomSuffix}`\r", + " })\r", + " }\r", + " }, (errCat, responseCat) => {\r", + " if (errCat) {\r", + " console.error(\"Pre-request: Failed to create category:\", errCat);\r", + " throw new Error(\"Pre-request: Failed to create category\");\r", + " }\r", + " if (responseCat.code !== 201) {\r", + " console.error(\"Pre-request: Category creation failed with status \" + responseCat.code + \":\", responseCat.text());\r", + " throw new Error(`Pre-request: Category creation failed with status ${responseCat.code}`);\r", + " }\r", + " const categoryData = responseCat.json();\r", + " console.log(\"Pre-request: Category created:\", categoryData.id);\r", + "\r", + " const eventDateForCreation = getFormattedFutureDate(3);\r", + "\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/users/${userData.id}/events`,\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: {\r", + " mode: 'raw',\r", + " raw: JSON.stringify({\r", + " annotation: `Annotation for event with comments ${randomSuffix}`,\r", + " category: categoryData.id,\r", + " description: `Detailed description for event with comments ${randomSuffix}`,\r", + " eventDate: eventDateForCreation,\r", + " location: { lat: 55.751 + (randomSuffix / 1000000), lon: 37.612 + (randomSuffix / 1000000) },\r", + " paid: false,\r", + " participantLimit: 10,\r", + " requestModeration: true,\r", + " title: `Event with Comments ${randomSuffix}`,\r", + " commentsEnabled: true\r", + " })\r", + " }\r", + " }, (errEvent, responseEvent) => {\r", + " if (errEvent) {\r", + " console.error(\"Pre-request: Failed to create event:\", errEvent);\r", + " throw new Error(\"Pre-request: Failed to create event\");\r", + " }\r", + " if (responseEvent.code !== 201) {\r", + " console.error(\"Pre-request: Event creation failed with status \" + responseEvent.code + \":\", responseEvent.text());\r", + " throw new Error(`Pre-request: Event creation failed with status ${responseEvent.code}`);\r", + " }\r", + " const eventData = responseEvent.json();\r", + " pm.environment.set(\"createdEventId\", eventData.id);\r", + " console.log(\"Pre-request: Event created:\", eventData.id);\r", + "\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/admin/events/${eventData.id}`,\r", + " method: 'PATCH',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: {\r", + " mode: 'raw',\r", + " raw: JSON.stringify({\r", + " stateAction: \"PUBLISH_EVENT\"\r", + " })\r", + " }\r", + " }, (errPublish, responsePublish) => {\r", + " if (errPublish) {\r", + " console.error(\"Pre-request: Failed to publish event:\", errPublish);\r", + " throw new Error(\"Pre-request: Failed to publish event\");\r", + " }\r", + " if (responsePublish.code !== 200) {\r", + " console.error(\"Pre-request: Event publishing failed with status \" + responsePublish.code + \":\", responsePublish.text());\r", + " let errorMessage = responsePublish.text();\r", + " try { const errorJson = JSON.parse(errorMessage); errorMessage = errorJson.message || errorMessage; } catch(e) {}\r", + " throw new Error(`Pre-request: Event publishing failed with status ${responsePublish.code}: ${errorMessage}`);\r", + " }\r", + " console.log(\"Pre-request: Event published:\", eventData.id);\r", + "\r", + " pm.environment.set(\"commentText\", `This is a lovely test comment ${randomSuffix}`);\r", + " console.log(\"Pre-request: Setup complete for successful comment creation.\");\r", + " });\r", + " });\r", + " });\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Код ответа 201 Created\", function () {\r", + " pm.response.to.have.status(201);\r", + "});\r", + "\r", + "pm.test(\"Ответ является валидным JSON\", function () {\r", + " pm.response.to.be.json;\r", + "});\r", + "\r", + "const jsonData = pm.response.json();\r", + "const expectedUserId = parseInt(pm.environment.get(\"createdUserId\"));\r", + "const expectedUserName = pm.environment.get(\"createdUserName\");\r", + "const expectedEventId = parseInt(pm.environment.get(\"createdEventId\"));\r", + "const expectedCommentText = pm.environment.get(\"commentText\");\r", + "\r", + "pm.test(\"ID комментария присутствует и является числом\", function () {\r", + " pm.expect(jsonData).to.have.property('id');\r", + " pm.expect(jsonData.id).to.be.a('number');\r", + " pm.environment.set(\"createdCommentId\", jsonData.id);\r", + "});\r", + "\r", + "pm.test(\"Текст комментария совпадает с отправленным текстом\", function () {\r", + " pm.expect(jsonData.text).to.equal(expectedCommentText);\r", + "});\r", + "\r", + "pm.test(\"ID автора совпадает с ID созданного пользователя\", function () {\r", + " pm.expect(jsonData.author).to.be.an('object');\r", + " pm.expect(jsonData.author.id).to.equal(expectedUserId);\r", + "});\r", + "\r", + "pm.test(\"Имя автора совпадает с именем созданного пользователя\", function () {\r", + " pm.expect(jsonData.author.name).to.equal(expectedUserName);\r", + "});\r", + "\r", + "pm.test(\"ID события совпадает с ID созданного события\", function () {\r", + " pm.expect(jsonData.eventId).to.equal(expectedEventId);\r", + "});\r", + "\r", + "pm.test(\"Временная метка createdOn присутствует и имеет формат 'yyyy-MM-dd HH:mm:ss'\", function () {\r", + " pm.expect(jsonData.createdOn).to.be.a('string');\r", + " pm.expect(jsonData.createdOn).to.match(/^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}$/);\r", + "});\r", + "\r", + "pm.test(\"Флаг isEdited изначально false\", function () {\r", + " pm.expect(jsonData.isEdited).to.be.false;\r", + "});\r", + "\r", + "pm.test(\"Очистка переменных окружения после теста\", function () {\r", + " pm.environment.unset(\"createdUserId\");\r", + " pm.environment.unset(\"createdUserName\");\r", + " pm.environment.unset(\"createdEventId\");\r", + " pm.environment.unset(\"commentText\");\r", + " pm.environment.unset(\"createdCommentId\");\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"text\": \"{{commentText}}\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/users/{{createdUserId}}/comments?eventId={{createdEventId}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "users", + "{{createdUserId}}", + "comments" + ], + "query": [ + { + "key": "eventId", + "value": "{{createdEventId}}" + } + ] + } + }, + "response": [] + }, + { + "name": "Добавление комментария – Неопубликованное событие – 409", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "const randomSuffix = Math.floor(Math.random() * 100000);\r", + "const baseUrl = pm.collectionVariables.get(\"baseUrl\");\r", + "\r", + "function getFormattedFutureDate(hoursToAdd) {\r", + " const date = new Date();\r", + " date.setHours(date.getHours() + hoursToAdd);\r", + " const year = date.getFullYear();\r", + " const month = String(date.getMonth() + 1).padStart(2, '0');\r", + " const day = String(date.getDate()).padStart(2, '0');\r", + " const hours = String(date.getHours()).padStart(2, '0');\r", + " const minutes = String(date.getMinutes()).padStart(2, '0');\r", + " const seconds = String(date.getSeconds()).padStart(2, '0');\r", + " return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;\r", + "}\r", + "\r", + "pm.sendRequest({\r", + " url: `${baseUrl}/admin/users`,\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: {\r", + " mode: 'raw',\r", + " raw: JSON.stringify({\r", + " email: `testuser_unpublished_${randomSuffix}@example.com`,\r", + " name: `Test Unpublished Event User ${randomSuffix}`\r", + " })\r", + " }\r", + "}, (err, response) => {\r", + " if (err) {\r", + " console.error(\"Pre-request: Не удалось создать пользователя:\", err);\r", + " throw new Error(\"Pre-request: Не удалось создать пользователя\");\r", + " }\r", + " if (response.code !== 201) {\r", + " console.error(\"Pre-request: Создание пользователя не удалось, статус \" + response.code + \":\", response.text());\r", + " throw new Error(`Pre-request: Создание пользователя не удалось, статус ${response.code}`);\r", + " }\r", + " const userData = response.json();\r", + " pm.environment.set(\"createdUserId\", userData.id);\r", + " console.log(\"Pre-request: Пользователь создан:\", userData.id);\r", + "\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/admin/categories`,\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: {\r", + " mode: 'raw',\r", + " raw: JSON.stringify({\r", + " name: `Test Category For Unpublished Comment ${randomSuffix}`\r", + " })\r", + " }\r", + " }, (errCat, responseCat) => {\r", + " if (errCat) {\r", + " console.error(\"Pre-request: Не удалось создать категорию:\", errCat);\r", + " throw new Error(\"Pre-request: Не удалось создать категорию\");\r", + " }\r", + " if (responseCat.code !== 201) {\r", + " console.error(\"Pre-request: Создание категории не удалось, статус \" + responseCat.code + \":\", responseCat.text());\r", + " throw new Error(`Pre-request: Создание категории не удалось, статус ${responseCat.code}`);\r", + " }\r", + " const categoryData = responseCat.json();\r", + " console.log(\"Pre-request: Категория создана:\", categoryData.id);\r", + "\r", + " const eventDateForCreation = getFormattedFutureDate(3);\r", + "\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/users/${userData.id}/events`,\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: {\r", + " mode: 'raw',\r", + " raw: JSON.stringify({\r", + " annotation: `Annotation for unpublished event ${randomSuffix}`,\r", + " category: categoryData.id,\r", + " description: `Detailed description for unpublished event ${randomSuffix}`,\r", + " eventDate: eventDateForCreation,\r", + " location: { lat: 55.752 + (randomSuffix / 1000000), lon: 37.613 + (randomSuffix / 1000000) },\r", + " paid: false,\r", + " participantLimit: 10,\r", + " requestModeration: true,\r", + " title: `Unpublished Event for Comment Test ${randomSuffix}`,\r", + " commentsEnabled: true\r", + " })\r", + " }\r", + " }, (errEvent, responseEvent) => {\r", + " if (errEvent) {\r", + " console.error(\"Pre-request: Не удалось создать событие:\", errEvent);\r", + " throw new Error(\"Pre-request: Не удалось создать событие\");\r", + " }\r", + " if (responseEvent.code !== 201) {\r", + " console.error(\"Pre-request: Создание события не удалось, статус \" + responseEvent.code + \":\", responseEvent.text());\r", + " throw new Error(`Pre-request: Создание события не удалось, статус ${responseEvent.code}`);\r", + " }\r", + " const eventData = responseEvent.json();\r", + " pm.environment.set(\"createdEventId\", eventData.id);\r", + " console.log(\"Pre-request: Событие (неопубликованное) создано:\", eventData.id, \"Состояние:\", eventData.state);\r", + "\r", + " pm.environment.set(\"commentText\", `This comment should fail ${randomSuffix}`);\r", + " console.log(\"Pre-request: Настройка для теста с неопубликованным событием завершена.\");\r", + " });\r", + " });\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Код статуса ответа 409 Conflict\", function () {\r", + " pm.response.to.have.status(409);\r", + "});\r", + "\r", + "pm.test(\"Ответ является валидным JSON (для ApiError)\", function () {\r", + " pm.response.to.be.json;\r", + "});\r", + "\r", + "const jsonData = pm.response.json();\r", + "\r", + "pm.test(\"Тело ответа содержит ожидаемые поля ошибки ApiError\", function () {\r", + " pm.expect(jsonData).to.be.an('object');\r", + " pm.expect(jsonData).to.have.all.keys('status', 'reason', 'message', 'timestamp');\r", + " pm.expect(jsonData.status).to.equal(\"CONFLICT\");\r", + "});\r", + "\r", + "pm.test(\"Сообщение об ошибке указывает на проблему с событием (например, не опубликовано)\", function () {\r", + " console.log(\"Сообщение об ошибке: \", jsonData.message);\r", + " pm.expect(jsonData.message).to.not.be.empty;\r", + "});\r", + "\r", + "pm.test(\"Timestamp ошибки присутствует и в правильном формате\", function () {\r", + " pm.expect(jsonData.timestamp).to.be.a('string');\r", + " pm.expect(jsonData.timestamp).to.match(/^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}$/);\r", + "});\r", + "\r", + "\r", + "pm.test(\"Очистка переменных окружения после теста\", function () {\r", + " pm.environment.unset(\"createdUserId\");\r", + " pm.environment.unset(\"createdEventId\");\r", + " pm.environment.unset(\"commentText\");\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"text\": \"{{commentText}}\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/users/{{createdUserId}}/comments?eventId={{createdEventId}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "users", + "{{createdUserId}}", + "comments" + ], + "query": [ + { + "key": "eventId", + "value": "{{createdEventId}}" + } + ] + } + }, + "response": [] + }, + { + "name": "Добавление комментария – Комментарии отключены – 409", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "const randomSuffix = Math.floor(Math.random() * 100000);\r", + "const baseUrl = pm.collectionVariables.get(\"baseUrl\");\r", + "\r", + "function getFormattedFutureDate(hoursToAdd) {\r", + " const date = new Date();\r", + " date.setHours(date.getHours() + hoursToAdd);\r", + " const year = date.getFullYear();\r", + " const month = String(date.getMonth() + 1).padStart(2, '0');\r", + " const day = String(date.getDate()).padStart(2, '0');\r", + " const hours = String(date.getHours()).padStart(2, '0');\r", + " const minutes = String(date.getMinutes()).padStart(2, '0');\r", + " const seconds = String(date.getSeconds()).padStart(2, '0');\r", + " return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;\r", + "}\r", + "\r", + "pm.sendRequest({\r", + " url: `${baseUrl}/admin/users`,\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: {\r", + " mode: 'raw',\r", + " raw: JSON.stringify({\r", + " email: `testuser_comments_disabled_${randomSuffix}@example.com`,\r", + " name: `Test Comments Disabled User ${randomSuffix}`\r", + " })\r", + " }\r", + "}, (err, response) => {\r", + " if (err) {\r", + " console.error(\"Pre-request: Не удалось создать пользователя:\", err);\r", + " throw new Error(\"Pre-request: Не удалось создать пользователя\");\r", + " }\r", + " if (response.code !== 201) {\r", + " console.error(\"Pre-request: Создание пользователя не удалось, статус \" + response.code + \":\", response.text());\r", + " throw new Error(`Pre-request: Создание пользователя не удалось, статус ${response.code}`);\r", + " }\r", + " const userData = response.json();\r", + " pm.environment.set(\"createdUserId\", userData.id);\r", + " console.log(\"Pre-request: Пользователь создан:\", userData.id);\r", + "\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/admin/categories`,\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: {\r", + " mode: 'raw',\r", + " raw: JSON.stringify({\r", + " name: `Test Category For Comments Disabled ${randomSuffix}`\r", + " })\r", + " }\r", + " }, (errCat, responseCat) => {\r", + " if (errCat) {\r", + " console.error(\"Pre-request: Не удалось создать категорию:\", errCat);\r", + " throw new Error(\"Pre-request: Не удалось создать категорию\");\r", + " }\r", + " if (responseCat.code !== 201) {\r", + " console.error(\"Pre-request: Создание категории не удалось, статус \" + responseCat.code + \":\", responseCat.text());\r", + " throw new Error(`Pre-request: Создание категории не удалось, статус ${responseCat.code}`);\r", + " }\r", + " const categoryData = responseCat.json();\r", + " console.log(\"Pre-request: Категория создана:\", categoryData.id);\r", + "\r", + " const eventDateForCreation = getFormattedFutureDate(3);\r", + "\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/users/${userData.id}/events`,\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: {\r", + " mode: 'raw',\r", + " raw: JSON.stringify({\r", + " annotation: `Annotation for event with comments disabled ${randomSuffix}`,\r", + " category: categoryData.id,\r", + " description: `Detailed description for event with comments disabled ${randomSuffix}`,\r", + " eventDate: eventDateForCreation,\r", + " location: { lat: 55.753 + (randomSuffix / 1000000), lon: 37.614 + (randomSuffix / 1000000) },\r", + " paid: false,\r", + " participantLimit: 10,\r", + " requestModeration: true,\r", + " title: `Event with Comments Disabled ${randomSuffix}`,\r", + " commentsEnabled: false\r", + " })\r", + " }\r", + " }, (errEvent, responseEvent) => {\r", + " if (errEvent) {\r", + " console.error(\"Pre-request: Не удалось создать событие:\", errEvent);\r", + " throw new Error(\"Pre-request: Не удалось создать событие\");\r", + " }\r", + " if (responseEvent.code !== 201) {\r", + " console.error(\"Pre-request: Создание события не удалось, статус \" + responseEvent.code + \":\", responseEvent.text());\r", + " throw new Error(`Pre-request: Создание события не удалось, статус ${responseEvent.code}`);\r", + " }\r", + " const eventData = responseEvent.json();\r", + " pm.environment.set(\"createdEventId\", eventData.id);\r", + " console.log(\"Pre-request: Событие (commentsEnabled:false) создано:\", eventData.id);\r", + "\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/admin/events/${eventData.id}`,\r", + " method: 'PATCH',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: {\r", + " mode: 'raw',\r", + " raw: JSON.stringify({\r", + " stateAction: \"PUBLISH_EVENT\"\r", + " })\r", + " }\r", + " }, (errPublish, responsePublish) => {\r", + " if (errPublish) {\r", + " console.error(\"Pre-request: Не удалось опубликовать событие:\", errPublish);\r", + " throw new Error(\"Pre-request: Не удалось опубликовать событие\");\r", + " }\r", + " if (responsePublish.code !== 200) {\r", + " console.error(\"Pre-request: Публикация события не удалась, статус \" + responsePublish.code + \":\", responsePublish.text());\r", + " let errorMessage = responsePublish.text();\r", + " try { const errorJson = JSON.parse(errorMessage); errorMessage = errorJson.message || errorMessage; } catch(e) {}\r", + " throw new Error(`Pre-request: Публикация события не удалась, статус ${responsePublish.code}: ${errorMessage}`);\r", + " }\r", + " console.log(\"Pre-request: Событие опубликовано:\", eventData.id);\r", + "\r", + " pm.environment.set(\"commentText\", `This comment should also fail ${randomSuffix}`);\r", + " console.log(\"Pre-request: Настройка для теста с отключенными комментариями завершена.\");\r", + " });\r", + " });\r", + " });\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Код статуса ответа 409 Conflict\", function () {\r", + " pm.response.to.have.status(409);\r", + "});\r", + "\r", + "pm.test(\"Ответ является валидным JSON (для ApiError)\", function () {\r", + " pm.response.to.be.json;\r", + "});\r", + "\r", + "const jsonData = pm.response.json();\r", + "\r", + "pm.test(\"Тело ответа содержит ожидаемые поля ошибки ApiError\", function () {\r", + " pm.expect(jsonData).to.be.an('object');\r", + " pm.expect(jsonData).to.have.all.keys('status', 'reason', 'message', 'timestamp');\r", + " pm.expect(jsonData.status).to.equal(\"CONFLICT\");\r", + "});\r", + "\r", + "pm.test(\"Поле 'reason' содержит общую причину ошибки\", function () {\r", + " pm.expect(jsonData.reason).to.not.be.empty;\r", + "});\r", + "\r", + "pm.test(\"Timestamp ошибки присутствует и в правильном формате\", function () {\r", + " pm.expect(jsonData.timestamp).to.be.a('string');\r", + " pm.expect(jsonData.timestamp).to.match(/^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}$/);\r", + "});\r", + "\r", + "pm.test(\"Очистка переменных окружения после теста\", function () {\r", + " pm.environment.unset(\"createdUserId\");\r", + " pm.environment.unset(\"createdEventId\");\r", + " pm.environment.unset(\"commentText\");\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"text\": \"{{commentText}}\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/users/{{createdUserId}}/comments?eventId={{createdEventId}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "users", + "{{createdUserId}}", + "comments" + ], + "query": [ + { + "key": "eventId", + "value": "{{createdEventId}}" + } + ] + } + }, + "response": [] + }, + { + "name": "Добавление комментария – Пустой текст – 400", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "const randomSuffix = Math.floor(Math.random() * 100000);\r", + "const baseUrl = pm.collectionVariables.get(\"baseUrl\");\r", + "\r", + "function getFormattedFutureDate(hoursToAdd) {\r", + " const date = new Date();\r", + " date.setHours(date.getHours() + hoursToAdd);\r", + " const year = date.getFullYear();\r", + " const month = String(date.getMonth() + 1).padStart(2, '0');\r", + " const day = String(date.getDate()).padStart(2, '0');\r", + " const hours = String(date.getHours()).padStart(2, '0');\r", + " const minutes = String(date.getMinutes()).padStart(2, '0');\r", + " const seconds = String(date.getSeconds()).padStart(2, '0');\r", + " return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;\r", + "}\r", + "\r", + "let createdUserIdInternal, createdEventIdInternal;\r", + "\r", + "pm.sendRequest({\r", + " url: `${baseUrl}/admin/users`, method: 'POST', header: {'Content-Type': 'application/json'},\r", + " body: { mode: 'raw', raw: JSON.stringify({\r", + " email: `testuser_empty_comm_text_${randomSuffix}@example.com`,\r", + " name: `TestUser EmptyCommText ${randomSuffix}`\r", + " })}\r", + "}, (errUser, responseUser) => {\r", + " if (errUser || responseUser.code !== 201) {\r", + " console.error(\"Pre-request: Не удалось создать пользователя:\", errUser || responseUser.text());\r", + " throw new Error(\"Pre-request: Не удалось создать пользователя.\");\r", + " }\r", + " const userData = responseUser.json();\r", + " createdUserIdInternal = userData.id;\r", + " pm.environment.set(\"createdUserId\", createdUserIdInternal);\r", + " console.log(\"Pre-request: Пользователь создан:\", createdUserIdInternal);\r", + "\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/admin/categories`,\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: { mode: 'raw', raw: JSON.stringify({ name: `Category EmptyCommText ${randomSuffix}` }) }\r", + " }, (errCat, responseCat) => {\r", + " if (errCat || responseCat.code !== 201) {\r", + " console.error(\"Pre-request: Не удалось создать категорию:\", errCat || responseCat.text());\r", + " throw new Error(\"Pre-request: Не удалось создать категорию.\");\r", + " }\r", + " const categoryData = responseCat.json();\r", + " console.log(\"Pre-request: Категория создана:\", categoryData.id);\r", + "\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/users/${createdUserIdInternal}/events`,\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: { mode: 'raw', raw: JSON.stringify({\r", + " title: `Event EmptyCommText ${randomSuffix}`,\r", + " annotation: `Annotation with sufficient length ${randomSuffix}`, category: categoryData.id, description: `Description with sufficient length ${randomSuffix}`,\r", + " eventDate: getFormattedFutureDate(3), location: { lat: 55.74, lon: 37.64 }, commentsEnabled: true\r", + " })}\r", + " }, (errEvent, responseEvent) => {\r", + " if (errEvent || responseEvent.code !== 201) {\r", + " console.error(\"Pre-request: Не удалось создать событие:\", errEvent || responseEvent.text());\r", + " throw new Error(\"Pre-request: Не удалось создать событие.\");\r", + " }\r", + " const eventData = responseEvent.json();\r", + " createdEventIdInternal = eventData.id;\r", + " pm.environment.set(\"createdEventId\", createdEventIdInternal);\r", + " console.log(\"Pre-request: Событие создано:\", createdEventIdInternal);\r", + "\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/admin/events/${createdEventIdInternal}`, method: 'PATCH',\r", + " header: {'Content-Type': 'application/json'}, body: { mode: 'raw', raw: JSON.stringify({ stateAction: \"PUBLISH_EVENT\" })}\r", + " }, (errPublish, responsePublish) => {\r", + " if (errPublish || responsePublish.code !== 200) {\r", + " console.error(\"Pre-request: Не удалось опубликовать событие:\", errPublish || responsePublish.text());\r", + " throw new Error(\"Pre-request: Не удалось опубликовать событие.\");\r", + " }\r", + " console.log(\"Pre-request: Событие опубликовано:\", createdEventIdInternal);\r", + " console.log(\"Pre-request: Настройка для POST комментария с пустым текстом завершена.\");\r", + " });\r", + " });\r", + " });\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Код статуса ответа 400 Bad Request\", function () {\r", + " pm.response.to.have.status(400);\r", + "});\r", + "\r", + "pm.test(\"Ответ является валидным JSON (для ApiError)\", function () {\r", + " pm.response.to.be.json;\r", + "});\r", + "\r", + "const jsonData = pm.response.json();\r", + "\r", + "pm.test(\"Тело ответа содержит ожидаемые поля ошибки ApiError (без 'errors')\", function () {\r", + " pm.expect(jsonData).to.be.an('object');\r", + " pm.expect(jsonData).to.have.all.keys('status', 'reason', 'message', 'timestamp', 'errors');\r", + " pm.expect(jsonData.status).to.equal(\"BAD_REQUEST\");\r", + "});\r", + "\r", + "pm.test(\"Сообщение об ошибке (message) не пустое и указывает на проблему с полем 'text'\", function () {\r", + " pm.expect(jsonData.message).to.not.be.empty;\r", + " console.log(\"Сообщение об ошибке: \", jsonData.message);\r", + "});\r", + "\r", + "pm.test(\"Поле 'reason' (причина ошибки) не пустое\", function () {\r", + " pm.expect(jsonData.reason).to.not.be.empty;\r", + " console.log(\"Причина ошибки: \", jsonData.reason);\r", + "});\r", + "\r", + "pm.test(\"Timestamp ошибки присутствует и в правильном формате\", function () {\r", + " pm.expect(jsonData.timestamp).to.be.a('string');\r", + " pm.expect(jsonData.timestamp).to.match(/^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}$/);\r", + "});\r", + "\r", + "pm.test(\"Очистка переменных окружения после теста\", function () {\r", + " pm.environment.unset(\"createdUserId\");\r", + " pm.environment.unset(\"createdEventId\");\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"text\": \"\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/users/{{createdUserId}}/comments?eventId={{createdEventId}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "users", + "{{createdUserId}}", + "comments" + ], + "query": [ + { + "key": "eventId", + "value": "{{createdEventId}}" + } + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Изменение комментария", + "item": [ + { + "name": "Изменение комментария – Успех – 200", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "const randomSuffix = Math.floor(Math.random() * 100000);\r", + "const baseUrl = pm.collectionVariables.get(\"baseUrl\");\r", + "const delayMilliseconds = 1000;\r", + "\r", + "function getFormattedFutureDate(hoursToAdd) {\r", + " const date = new Date();\r", + " date.setHours(date.getHours() + hoursToAdd);\r", + " const year = date.getFullYear();\r", + " const month = String(date.getMonth() + 1).padStart(2, '0');\r", + " const day = String(date.getDate()).padStart(2, '0');\r", + " const hours = String(date.getHours()).padStart(2, '0');\r", + " const minutes = String(date.getMinutes()).padStart(2, '0');\r", + " const seconds = String(date.getSeconds()).padStart(2, '0');\r", + " return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;\r", + "}\r", + "\r", + "pm.sendRequest({\r", + " url: `${baseUrl}/admin/users`,\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: {\r", + " mode: 'raw',\r", + " raw: JSON.stringify({\r", + " email: `testuser_comment_updater_delay_${randomSuffix}@example.com`,\r", + " name: `Test Comment Updater Delay ${randomSuffix}`\r", + " })\r", + " }\r", + "}, (errUser, responseUser) => {\r", + " if (errUser) {\r", + " console.error(\"Pre-request: Не удалось создать пользователя:\", errUser);\r", + " throw new Error(\"Pre-request: Не удалось создать пользователя\");\r", + " }\r", + " if (responseUser.code !== 201) {\r", + " console.error(\"Pre-request: Создание пользователя не удалось, статус \" + responseUser.code + \":\", responseUser.text());\r", + " throw new Error(`Pre-request: Создание пользователя не удалось, статус ${responseUser.code}`);\r", + " }\r", + " const userData = responseUser.json();\r", + " pm.environment.set(\"createdUserId\", userData.id);\r", + " pm.environment.set(\"createdUserName\", userData.name);\r", + " console.log(\"Pre-request: Пользователь создан:\", userData.id);\r", + "\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/admin/categories`,\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: {\r", + " mode: 'raw',\r", + " raw: JSON.stringify({\r", + " name: `Test Category For Update Comment Delay ${randomSuffix}`\r", + " })\r", + " }\r", + " }, (errCat, responseCat) => {\r", + " if (errCat) {\r", + " console.error(\"Pre-request: Не удалось создать категорию:\", errCat);\r", + " throw new Error(\"Pre-request: Не удалось создать категорию\");\r", + " }\r", + " if (responseCat.code !== 201) {\r", + " console.error(\"Pre-request: Создание категории не удалось, статус \" + responseCat.code + \":\", responseCat.text());\r", + " throw new Error(`Pre-request: Создание категории не удалось, статус ${responseCat.code}`);\r", + " }\r", + " const categoryData = responseCat.json();\r", + " console.log(\"Pre-request: Категория создана:\", categoryData.id);\r", + "\r", + " const eventDateForCreation = getFormattedFutureDate(3);\r", + "\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/users/${userData.id}/events`,\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: {\r", + " mode: 'raw',\r", + " raw: JSON.stringify({\r", + " annotation: `Annotation for event to update comment delay ${randomSuffix}`,\r", + " category: categoryData.id,\r", + " description: `Detailed description for event to update comment delay ${randomSuffix}`,\r", + " eventDate: eventDateForCreation,\r", + " location: { lat: 55.7541 + (randomSuffix / 1000000), lon: 37.6151 + (randomSuffix / 1000000) },\r", + " paid: false,\r", + " participantLimit: 10,\r", + " requestModeration: true,\r", + " title: `Event for Comment Update Delay ${randomSuffix}`,\r", + " commentsEnabled: true\r", + " })\r", + " }\r", + " }, (errEvent, responseEvent) => {\r", + " if (errEvent) {\r", + " console.error(\"Pre-request: Не удалось создать событие:\", errEvent);\r", + " throw new Error(\"Pre-request: Не удалось создать событие\");\r", + " }\r", + " if (responseEvent.code !== 201) {\r", + " console.error(\"Pre-request: Создание события не удалось, статус \" + responseEvent.code + \":\", responseEvent.text());\r", + " throw new Error(`Pre-request: Создание события не удалось, статус ${responseEvent.code}`);\r", + " }\r", + " const eventData = responseEvent.json();\r", + " pm.environment.set(\"createdEventId\", eventData.id);\r", + " console.log(\"Pre-request: Событие создано:\", eventData.id);\r", + "\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/admin/events/${eventData.id}`,\r", + " method: 'PATCH',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: {\r", + " mode: 'raw',\r", + " raw: JSON.stringify({ stateAction: \"PUBLISH_EVENT\" })\r", + " }\r", + " }, (errPublish, responsePublish) => {\r", + " if (errPublish) {\r", + " console.error(\"Pre-request: Не удалось опубликовать событие:\", errPublish);\r", + " throw new Error(\"Pre-request: Не удалось опубликовать событие\");\r", + " }\r", + " if (responsePublish.code !== 200) {\r", + " console.error(\"Pre-request: Публикация события не удалась, статус \" + responsePublish.code + \":\", responsePublish.text());\r", + " throw new Error(`Pre-request: Публикация события не удалась, статус ${responsePublish.code}`);\r", + " }\r", + " console.log(\"Pre-request: Событие опубликовано:\", eventData.id);\r", + "\r", + " const initialCommentText = `Initial comment to be updated (delay) ${randomSuffix}`;\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/users/${userData.id}/comments?eventId=${eventData.id}`,\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: {\r", + " mode: 'raw',\r", + " raw: JSON.stringify({ text: initialCommentText })\r", + " }\r", + " }, (errComment, responseComment) => {\r", + " if (errComment) {\r", + " console.error(\"Pre-request: Не удалось создать первоначальный комментарий:\", errComment);\r", + " throw new Error(\"Pre-request: Не удалось создать первоначальный комментарий\");\r", + " }\r", + " if (responseComment.code !== 201) {\r", + " console.error(\"Pre-request: Создание первоначального комментария не удалось, статус \" + responseComment.code + \":\", responseComment.text());\r", + " throw new Error(`Pre-request: Создание первоначального комментария не удалось, статус ${responseComment.code}`);\r", + " }\r", + " const commentData = responseComment.json();\r", + " pm.environment.set(\"createdCommentId\", commentData.id);\r", + " console.log(\"Pre-request: Первоначальный комментарий создан:\", commentData.id);\r", + "\r", + " console.log(`Pre-request: Ожидание ${delayMilliseconds}мс перед отправкой запроса на обновление...`);\r", + " setTimeout(function() {\r", + " pm.environment.set(\"updatedCommentText\", `This is the UPDATED comment text (delay) ${randomSuffix}`);\r", + " console.log(\"Pre-request: Настройка для успешного обновления комментария (с задержкой) завершена. Основной запрос будет отправлен сейчас.\");\r", + " }, delayMilliseconds);\r", + " });\r", + " });\r", + " });\r", + " });\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Код статуса ответа 200 OK\", function () {\r", + " pm.response.to.have.status(200);\r", + "});\r", + "\r", + "pm.test(\"Ответ является валидным JSON\", function () {\r", + " pm.response.to.be.json;\r", + "});\r", + "\r", + "const jsonData = pm.response.json();\r", + "const expectedUserId = parseInt(pm.environment.get(\"createdUserId\"));\r", + "const expectedUserName = pm.environment.get(\"createdUserName\");\r", + "const expectedEventId = parseInt(pm.environment.get(\"createdEventId\"));\r", + "const expectedUpdatedText = pm.environment.get(\"updatedCommentText\");\r", + "const expectedCommentId = parseInt(pm.environment.get(\"createdCommentId\"));\r", + "\r", + "pm.test(\"ID комментария в ответе совпадает с обновляемым ID\", function () {\r", + " pm.expect(jsonData.id).to.equal(expectedCommentId);\r", + "});\r", + "\r", + "pm.test(\"Текст комментария обновлен на новый\", function () {\r", + " pm.expect(jsonData.text).to.equal(expectedUpdatedText);\r", + "});\r", + "\r", + "pm.test(\"ID автора совпадает с ID создавшего пользователя\", function () {\r", + " pm.expect(jsonData.author).to.be.an('object');\r", + " pm.expect(jsonData.author.id).to.equal(expectedUserId);\r", + "});\r", + "\r", + "pm.test(\"Имя автора совпадает с именем создавшего пользователя\", function () {\r", + " pm.expect(jsonData.author.name).to.equal(expectedUserName);\r", + "});\r", + "\r", + "pm.test(\"ID события совпадает с ID события, к которому относится комментарий\", function () {\r", + " pm.expect(jsonData.eventId).to.equal(expectedEventId);\r", + "});\r", + "\r", + "pm.test(\"Поле 'createdOn' присутствует и имеет корректный формат\", function () {\r", + " pm.expect(jsonData.createdOn).to.be.a('string');\r", + " pm.expect(jsonData.createdOn).to.match(/^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}$/);\r", + "});\r", + "\r", + "pm.test(\"Поле 'updatedOn' содержит дату позднее 'createdOn'\", function () {\r", + " pm.expect(jsonData.updatedOn).to.be.a('string');\r", + " pm.expect(jsonData.updatedOn).to.not.be.null;\r", + " pm.expect(jsonData.updatedOn).to.match(/^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}$/);\r", + " const createdDate = new Date(jsonData.createdOn.replace(' ', 'T') + 'Z');\r", + " const updatedDate = new Date(jsonData.updatedOn.replace(' ', 'T') + 'Z');\r", + " pm.expect(updatedDate.getTime()).to.be.greaterThan(createdDate.getTime());\r", + "});\r", + "\r", + "pm.test(\"Флаг 'isEdited' установлен в true (после обновления)\", function () {\r", + " pm.expect(jsonData.isEdited).to.be.true;\r", + "});\r", + "\r", + "pm.test(\"Очистка переменных окружения после теста\", function () {\r", + " pm.environment.unset(\"createdUserId\");\r", + " pm.environment.unset(\"createdUserName\");\r", + " pm.environment.unset(\"createdEventId\");\r", + " pm.environment.unset(\"createdCommentId\");\r", + " pm.environment.unset(\"updatedCommentText\");\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "PATCH", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"text\": \"{{updatedCommentText}}\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/users/{{createdUserId}}/comments/{{createdCommentId}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "users", + "{{createdUserId}}", + "comments", + "{{createdCommentId}}" + ] + } + }, + "response": [] + }, + { + "name": "Изменение комментария – Несуществующий комментарий – 404", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "const randomSuffix = Math.floor(Math.random() * 100000);\r", + "const baseUrl = pm.collectionVariables.get(\"baseUrl\");\r", + "\r", + "pm.sendRequest({\r", + " url: `${baseUrl}/admin/users`,\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: {\r", + " mode: 'raw',\r", + " raw: JSON.stringify({\r", + " email: `testuser_nonexistent_comment_patch_${randomSuffix}@example.com`,\r", + " name: `Test NonExistent Comment User ${randomSuffix}`\r", + " })\r", + " }\r", + "}, (errUser, responseUser) => {\r", + " if (errUser) {\r", + " console.error(\"Pre-request: Не удалось создать пользователя:\", errUser);\r", + " throw new Error(\"Pre-request: Не удалось создать пользователя\");\r", + " }\r", + " if (responseUser.code !== 201) {\r", + " console.error(\"Pre-request: Создание пользователя не удалось, статус \" + responseUser.code + \":\", responseUser.text());\r", + " throw new Error(`Pre-request: Создание пользователя не удалось, статус ${responseUser.code}`);\r", + " }\r", + " const userData = responseUser.json();\r", + " pm.environment.set(\"createdUserId\", userData.id);\r", + " console.log(\"Pre-request: Пользователь создан:\", userData.id);\r", + "\r", + " const nonExistentCommentId = Math.floor(Math.random() * 1000000) + 9000000;\r", + " pm.environment.set(\"nonExistentCommentId\", nonExistentCommentId);\r", + " console.log(\"Pre-request: Установлен ID несуществующего комментария:\", nonExistentCommentId);\r", + "\r", + " pm.environment.set(\"updatedCommentText\", `Attempt to update non-existent comment ${randomSuffix}`);\r", + " console.log(\"Pre-request: Настройка для обновления несуществующего комментария завершена.\");\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Код статуса ответа 404 Not Found\", function () {\r", + " pm.response.to.have.status(404);\r", + "});\r", + "\r", + "pm.test(\"Ответ является валидным JSON (для ApiError)\", function () {\r", + " pm.response.to.be.json;\r", + "});\r", + "\r", + "const jsonData = pm.response.json();\r", + "const expectedNonExistentCommentId = pm.environment.get(\"nonExistentCommentId\");\r", + "\r", + "pm.test(\"Тело ответа содержит ожидаемые поля ошибки ApiError (без 'errors')\", function () {\r", + " pm.expect(jsonData).to.be.an('object');\r", + " pm.expect(jsonData).to.have.all.keys('status', 'reason', 'message', 'timestamp');\r", + " pm.expect(jsonData.status).to.equal(\"NOT_FOUND\");\r", + "});\r", + "\r", + "pm.test(\"Сообщение об ошибке (message) не пустое\", function () {\r", + " pm.expect(jsonData.message).to.not.be.empty;\r", + " console.log(\"Сообщение об ошибке: \", jsonData.message);\r", + "});\r", + "\r", + "pm.test(\"Поле 'reason' (причина ошибки) не пустое\", function () {\r", + " pm.expect(jsonData.reason).to.not.be.empty;\r", + " console.log(\"Причина ошибки: \", jsonData.reason);\r", + "});\r", + "\r", + "pm.test(\"Timestamp ошибки присутствует и в правильном формате\", function () {\r", + " pm.expect(jsonData.timestamp).to.be.a('string');\r", + " pm.expect(jsonData.timestamp).to.match(/^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}$/);\r", + "});\r", + "\r", + "pm.test(\"Очистка переменных окружения после теста\", function () {\r", + " pm.environment.unset(\"createdUserId\");\r", + " pm.environment.unset(\"nonExistentCommentId\");\r", + " pm.environment.unset(\"updatedCommentText\");\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "PATCH", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"text\": \"{{updatedCommentText}}\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/users/{{createdUserId}}/comments/{{nonExistentCommentId}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "users", + "{{createdUserId}}", + "comments", + "{{nonExistentCommentId}}" + ] + } + }, + "response": [] + }, + { + "name": "Изменение комментария – Чужой комментарий – 404", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "const randomSuffix = Math.floor(Math.random() * 100000);\r", + "const baseUrl = pm.collectionVariables.get(\"baseUrl\");\r", + "\r", + "function getFormattedFutureDate(hoursToAdd) {\r", + " const date = new Date();\r", + " date.setHours(date.getHours() + hoursToAdd);\r", + " const year = date.getFullYear();\r", + " const month = String(date.getMonth() + 1).padStart(2, '0');\r", + " const day = String(date.getDate()).padStart(2, '0');\r", + " const hours = String(date.getHours()).padStart(2, '0');\r", + " const minutes = String(date.getMinutes()).padStart(2, '0');\r", + " const seconds = String(date.getSeconds()).padStart(2, '0');\r", + " return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;\r", + "}\r", + "\r", + "let victimUserId, attackerUserId, eventIdForComment;\r", + "\r", + "pm.sendRequest({\r", + " url: `${baseUrl}/admin/users`,\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: {\r", + " mode: 'raw',\r", + " raw: JSON.stringify({\r", + " email: `victim_user_patch_${randomSuffix}@example.com`,\r", + " name: `Victim User Patch ${randomSuffix}`\r", + " })\r", + " }\r", + "}, (errVictim, responseVictim) => {\r", + " if (errVictim || responseVictim.code !== 201) {\r", + " console.error(\"Pre-request: Не удалось создать Victim User:\", errVictim || responseVictim.text());\r", + " throw new Error(\"Pre-request: Не удалось создать Victim User. Status: \" + (responseVictim ? responseVictim.code : \"N/A\"));\r", + " }\r", + " const victimUserData = responseVictim.json();\r", + " victimUserId = victimUserData.id;\r", + " console.log(\"Pre-request: Victim User создан:\", victimUserId);\r", + "\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/admin/users`,\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: {\r", + " mode: 'raw',\r", + " raw: JSON.stringify({\r", + " email: `attacker_user_patch_${randomSuffix}@example.com`,\r", + " name: `Attacker User Patch ${randomSuffix}`\r", + " })\r", + " }\r", + " }, (errAttacker, responseAttacker) => {\r", + " if (errAttacker || responseAttacker.code !== 201) {\r", + " console.error(\"Pre-request: Не удалось создать Attacker User:\", errAttacker || responseAttacker.text());\r", + " throw new Error(\"Pre-request: Не удалось создать Attacker User. Status: \" + (responseAttacker ? responseAttacker.code : \"N/A\"));\r", + " }\r", + " const attackerUserData = responseAttacker.json();\r", + " attackerUserId = attackerUserData.id;\r", + " pm.environment.set(\"attackerUserId\", attackerUserId);\r", + " console.log(\"Pre-request: Attacker User создан:\", attackerUserId);\r", + "\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/admin/categories`,\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: {\r", + " mode: 'raw',\r", + " raw: JSON.stringify({ name: `Category for Foreign Comment Patch ${randomSuffix}` })\r", + " }\r", + " }, (errCat, responseCat) => {\r", + " if (errCat || responseCat.code !== 201) {\r", + " console.error(\"Pre-request: Не удалось создать категорию:\", errCat || responseCat.text());\r", + " throw new Error(\"Pre-request: Не удалось создать категорию. Status: \" + (responseCat ? responseCat.code : \"N/A\"));\r", + " }\r", + " const categoryData = responseCat.json();\r", + " console.log(\"Pre-request: Категория создана:\", categoryData.id);\r", + "\r", + " const eventDateForCreation = getFormattedFutureDate(3);\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/users/${victimUserId}/events`,\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: {\r", + " mode: 'raw',\r", + " raw: JSON.stringify({\r", + " annotation: `Event for foreign comment patch test ${randomSuffix}`,\r", + " category: categoryData.id,\r", + " description: `Description for foreign comment patch test ${randomSuffix}`,\r", + " eventDate: eventDateForCreation,\r", + " location: { lat: 55.755 + (randomSuffix / 1000000), lon: 37.616 + (randomSuffix / 1000000) },\r", + " commentsEnabled: true,\r", + " title: `Event Foreign Patch ${randomSuffix}`\r", + " })\r", + " }\r", + " }, (errEvent, responseEvent) => {\r", + " if (errEvent || responseEvent.code !== 201) {\r", + " console.error(\"Pre-request: Не удалось создать событие:\", errEvent || responseEvent.text());\r", + " throw new Error(\"Pre-request: Не удалось создать событие. Status: \" + (responseEvent ? responseEvent.code : \"N/A\"));\r", + " }\r", + " const eventData = responseEvent.json();\r", + " eventIdForComment = eventData.id;\r", + " console.log(\"Pre-request: Событие создано:\", eventIdForComment);\r", + "\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/admin/events/${eventIdForComment}`,\r", + " method: 'PATCH',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: {\r", + " mode: 'raw',\r", + " raw: JSON.stringify({ stateAction: \"PUBLISH_EVENT\" })\r", + " }\r", + " }, (errPublish, responsePublish) => {\r", + " if (errPublish || responsePublish.code !== 200) {\r", + " console.error(\"Pre-request: Не удалось опубликовать событие:\", errPublish || responsePublish.text());\r", + " throw new Error(\"Pre-request: Не удалось опубликовать событие. Status: \" + (responsePublish ? responsePublish.code : \"N/A\"));\r", + " }\r", + " console.log(\"Pre-request: Событие опубликовано:\", eventIdForComment);\r", + "\r", + " const victimCommentText = `This is Victim User's comment ${randomSuffix}`;\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/users/${victimUserId}/comments?eventId=${eventIdForComment}`,\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: {\r", + " mode: 'raw',\r", + " raw: JSON.stringify({ text: victimCommentText })\r", + " }\r", + " }, (errComment, responseComment) => {\r", + " if (errComment || responseComment.code !== 201) {\r", + " console.error(\"Pre-request: Не удалось создать комментарий Victim User:\", errComment || responseComment.text());\r", + " throw new Error(\"Pre-request: Не удалось создать комментарий Victim User. Status: \" + (responseComment ? responseComment.code : \"N/A\"));\r", + " }\r", + " const victimCommentData = responseComment.json();\r", + " pm.environment.set(\"victimCommentId\", victimCommentData.id);\r", + " console.log(\"Pre-request: Комментарий Victim User создан:\", victimCommentData.id);\r", + "\r", + " pm.environment.set(\"updatedCommentText\", `Attacker's attempt to update ${randomSuffix}`);\r", + " console.log(\"Pre-request: Настройка для обновления чужого комментария завершена.\");\r", + " });\r", + " });\r", + " });\r", + " });\r", + " });\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Код статуса ответа 404 Not Found\", function () {\r", + " pm.response.to.have.status(404);\r", + "});\r", + "\r", + "pm.test(\"Ответ является валидным JSON (для ApiError)\", function () {\r", + " pm.response.to.be.json;\r", + "});\r", + "\r", + "const jsonData = pm.response.json();\r", + "const victimCommentId = pm.environment.get(\"victimCommentId\");\r", + "\r", + "pm.test(\"Тело ответа содержит ожидаемые поля ошибки ApiError (без 'errors')\", function () {\r", + " pm.expect(jsonData).to.be.an('object');\r", + " pm.expect(jsonData).to.have.all.keys('status', 'reason', 'message', 'timestamp');\r", + " pm.expect(jsonData.status).to.equal(\"NOT_FOUND\");\r", + "});\r", + "\r", + "pm.test(\"Сообщение об ошибке (message) не пустое\", function () {\r", + " pm.expect(jsonData.message).to.not.be.empty;\r", + " console.log(\"Сообщение об ошибке: \", jsonData.message);\r", + "});\r", + "\r", + "pm.test(\"Поле 'reason' (причина ошибки) не пустое\", function () {\r", + " pm.expect(jsonData.reason).to.not.be.empty;\r", + " console.log(\"Причина ошибки: \", jsonData.reason);\r", + "});\r", + "\r", + "pm.test(\"Timestamp ошибки присутствует и в правильном формате\", function () {\r", + " pm.expect(jsonData.timestamp).to.be.a('string');\r", + " pm.expect(jsonData.timestamp).to.match(/^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}$/);\r", + "});\r", + "\r", + "pm.test(\"Очистка переменных окружения после теста\", function () {\r", + " pm.environment.unset(\"attackerUserId\");\r", + " pm.environment.unset(\"victimCommentId\");\r", + " pm.environment.unset(\"updatedCommentText\");\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "PATCH", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"text\": \"{{updatedCommentText}}\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/users/{{attackerUserId}}/comments/{{victimCommentId}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "users", + "{{attackerUserId}}", + "comments", + "{{victimCommentId}}" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Получение собственных комментариев", + "item": [ + { + "name": "Получение собственных комментариев – Успех – 200", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "const randomSuffix = Math.floor(Math.random() * 100000);\r", + "const baseUrl = pm.collectionVariables.get(\"baseUrl\");\r", + "const numberOfCommentsToCreate = 5;\r", + "const fromParam = 0;\r", + "const sizeParam = 3;\r", + "\r", + "function getFormattedFutureDate(hoursToAdd) {\r", + " const date = new Date();\r", + " date.setHours(date.getHours() + hoursToAdd);\r", + " const year = date.getFullYear();\r", + " const month = String(date.getMonth() + 1).padStart(2, '0');\r", + " const day = String(date.getDate()).padStart(2, '0');\r", + " const hours = String(date.getHours()).padStart(2, '0');\r", + " const minutes = String(date.getMinutes()).padStart(2, '0');\r", + " const seconds = String(date.getSeconds()).padStart(2, '0');\r", + " return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;\r", + "}\r", + "\r", + "let createdUserIdInternal;\r", + "let createdEventIds = [];\r", + "let createdCommentDetails = [];\r", + "\r", + "pm.sendRequest({\r", + " url: `${baseUrl}/admin/users`,\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: {\r", + " mode: 'raw',\r", + " raw: JSON.stringify({\r", + " email: `testuser_get_comments_${randomSuffix}@example.com`,\r", + " name: `Test User Get Comments ${randomSuffix}`\r", + " })\r", + " }\r", + "}, (errUser, responseUser) => {\r", + " if (errUser || responseUser.code !== 201) {\r", + " console.error(\"Pre-request: Не удалось создать пользователя:\", errUser || responseUser.text());\r", + " throw new Error(\"Pre-request: Не удалось создать пользователя. Status: \" + (responseUser ? responseUser.code : \"N/A\"));\r", + " }\r", + " const userData = responseUser.json();\r", + " createdUserIdInternal = userData.id;\r", + " pm.environment.set(\"createdUserId\", createdUserIdInternal);\r", + " pm.environment.set(\"createdUserName\", userData.name);\r", + " console.log(\"Pre-request: Пользователь создан:\", createdUserIdInternal);\r", + "\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/admin/categories`,\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: {\r", + " mode: 'raw',\r", + " raw: JSON.stringify({ name: `Category for Get Comments ${randomSuffix}` })\r", + " }\r", + " }, (errCat, responseCat) => {\r", + " if (errCat || responseCat.code !== 201) {\r", + " console.error(\"Pre-request: Не удалось создать категорию:\", errCat || responseCat.text());\r", + " throw new Error(\"Pre-request: Не удалось создать категорию. Status: \" + (responseCat ? responseCat.code : \"N/A\"));\r", + " }\r", + " const categoryData = responseCat.json();\r", + " console.log(\"Pre-request: Категория создана:\", categoryData.id);\r", + "\r", + " let eventsCreatedCount = 0;\r", + " const numberOfEvents = 2;\r", + " for (let i = 0; i < numberOfEvents; i++) {\r", + " const eventDateForCreation = getFormattedFutureDate(3 + i);\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/users/${createdUserIdInternal}/events`,\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: {\r", + " mode: 'raw',\r", + " raw: JSON.stringify({\r", + " annotation: `Event ${i} for get comments test ${randomSuffix}`,\r", + " category: categoryData.id,\r", + " description: `Description for event ${i} ${randomSuffix}`,\r", + " eventDate: eventDateForCreation,\r", + " location: { lat: 55.756 + i * 0.001 + (randomSuffix / 1000000), lon: 37.617 + i * 0.001 + (randomSuffix / 1000000) },\r", + " commentsEnabled: true,\r", + " title: `Event ${i} Get Comments ${randomSuffix}`\r", + " })\r", + " }\r", + " }, (errEvent, responseEvent) => {\r", + " if (errEvent || responseEvent.code !== 201) {\r", + " console.error(`Pre-request: Не удалось создать событие ${i}:`, errEvent || responseEvent.text());\r", + " throw new Error(`Pre-request: Не удалось создать событие ${i}. Status: ` + (responseEvent ? responseEvent.code : \"N/A\"));\r", + " }\r", + " const eventData = responseEvent.json();\r", + " createdEventIds.push(eventData.id);\r", + " console.log(`Pre-request: Событие ${i} создано:`, eventData.id);\r", + "\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/admin/events/${eventData.id}`,\r", + " method: 'PATCH',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: { mode: 'raw', raw: JSON.stringify({ stateAction: \"PUBLISH_EVENT\" }) }\r", + " }, (errPublish, responsePublish) => {\r", + " if (errPublish || responsePublish.code !== 200) {\r", + " console.error(`Pre-request: Не удалось опубликовать событие ${eventData.id}:`, errPublish || responsePublish.text());\r", + " throw new Error(`Pre-request: Не удалось опубликовать событие ${eventData.id}.`);\r", + " }\r", + " console.log(`Pre-request: Событие ${eventData.id} опубликовано.`);\r", + " eventsCreatedCount++;\r", + " if (eventsCreatedCount === numberOfEvents) {\r", + " createComments();\r", + " }\r", + " });\r", + " });\r", + " }\r", + "\r", + " function createComments() {\r", + " let commentsCreatedCount = 0;\r", + " function createCommentRecursive(j) {\r", + " if (j >= numberOfCommentsToCreate) {\r", + " createdCommentDetails.sort((a, b) => {\r", + " const dateA = new Date(a.createdOn.replace(' ', 'T') + 'Z').getTime();\r", + " const dateB = new Date(b.createdOn.replace(' ', 'T') + 'Z').getTime();\r", + " if (dateB !== dateA) return dateB - dateA;\r", + " return b.id - a.id;\r", + " });\r", + " pm.environment.set(\"expectedCommentDetails\", JSON.stringify(createdCommentDetails));\r", + " pm.environment.set(\"fromParam\", String(fromParam));\r", + " pm.environment.set(\"sizeParam\", String(sizeParam));\r", + " console.log(\"Pre-request: Настройка для получения комментариев пользователя завершена.\");\r", + " return;\r", + " }\r", + "\r", + " const commentText = `User's comment ${j} on event ${j % numberOfEvents} ${randomSuffix}`;\r", + " const eventIdForThisComment = createdEventIds[j % numberOfEvents];\r", + "\r", + " setTimeout(() => {\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/users/${createdUserIdInternal}/comments?eventId=${eventIdForThisComment}`,\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: {\r", + " mode: 'raw',\r", + " raw: JSON.stringify({ text: commentText })\r", + " }\r", + " }, (errComment, responseComment) => {\r", + " if (errComment || responseComment.code !== 201) {\r", + " console.error(`Pre-request: Не удалось создать комментарий ${j}:`, errComment || responseComment.text());\r", + " throw new Error(`Pre-request: Не удалось создать комментарий ${j}.`);\r", + " }\r", + " const commentData = responseComment.json();\r", + " createdCommentDetails.push({\r", + " id: commentData.id,\r", + " text: commentData.text,\r", + " eventId: commentData.eventId,\r", + " createdOn: commentData.createdOn\r", + " });\r", + " console.log(`Pre-request: Комментарий ${j} создан:`, commentData.id, `в ${commentData.createdOn}`);\r", + " \r", + " commentsCreatedCount++;\r", + " createCommentRecursive(j + 1);\r", + " });\r", + " }, 50);\r", + " }\r", + " createCommentRecursive(0);\r", + " }\r", + " });\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Код статуса ответа 200 OK\", function () {\r", + " pm.response.to.have.status(200);\r", + "});\r", + "\r", + "pm.test(\"Ответ является валидным JSON и представляет собой массив\", function () {\r", + " pm.response.to.be.json;\r", + " const jsonData = pm.response.json();\r", + " pm.expect(jsonData).to.be.an('array');\r", + "});\r", + "\r", + "const jsonData = pm.response.json();\r", + "const expectedSize = parseInt(pm.environment.get(\"sizeParam\"));\r", + "const allExpectedComments = JSON.parse(pm.environment.get(\"expectedCommentDetails\"));\r", + "const expectedUserId = parseInt(pm.environment.get(\"createdUserId\"));\r", + "const expectedUserName = pm.environment.get(\"createdUserName\");\r", + "\r", + "const expectedReturnedCount = Math.min(expectedSize, allExpectedComments.length - parseInt(pm.environment.get(\"fromParam\")));\r", + "\r", + "\r", + "pm.test(`Возвращено корректное количество комментариев (ожидается ${expectedReturnedCount})`, function () {\r", + " pm.expect(jsonData.length).to.equal(expectedReturnedCount);\r", + "});\r", + "\r", + "if (jsonData.length > 0) {\r", + " pm.test(\"Структура каждого комментария в ответе корректна\", function () {\r", + " jsonData.forEach(function(comment, index) {\r", + " pm.expect(comment, `Комментарий #${index}`).to.have.all.keys(\r", + " 'id', 'text', 'author', 'eventId', 'createdOn', 'updatedOn', 'isEdited'\r", + " );\r", + " pm.expect(comment.id, `ID комментария #${index}`).to.be.a('number');\r", + " pm.expect(comment.text, `Текст комментария #${index}`).to.be.a('string');\r", + " pm.expect(comment.author, `Автор комментария #${index}`).to.be.an('object');\r", + " pm.expect(comment.author.id, `ID автора комментария #${index}`).to.equal(expectedUserId);\r", + " pm.expect(comment.author.name, `Имя автора комментария #${index}`).to.equal(expectedUserName);\r", + " pm.expect(comment.eventId, `ID события комментария #${index}`).to.be.a('number');\r", + " pm.expect(comment.createdOn, `Время создания комментария #${index}`).to.match(/^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}$/);\r", + " pm.expect(comment.isEdited, `Флаг isEdited комментария #${index}`).to.be.a('boolean');\r", + " });\r", + " });\r", + "\r", + " pm.test(\"Комментарии соответствуют ожидаемым (с учетом пагинации и сортировки)\", function() {\r", + " const from = parseInt(pm.environment.get(\"fromParam\"));\r", + " const size = parseInt(pm.environment.get(\"sizeParam\"));\r", + " const expectedSlice = allExpectedComments.slice(from, from + size);\r", + "\r", + " pm.expect(jsonData.length).to.equal(expectedSlice.length);\r", + "\r", + " jsonData.forEach((returnedComment, index) => {\r", + " const expectedComment = expectedSlice[index];\r", + " pm.expect(returnedComment.id, `ID комментария ${index}`).to.equal(expectedComment.id);\r", + " pm.expect(returnedComment.text, `Текст комментария ${index}`).to.equal(expectedComment.text);\r", + " pm.expect(returnedComment.eventId, `EventID комментария ${index}`).to.equal(expectedComment.eventId);\r", + " });\r", + " });\r", + "} else if (expectedReturnedCount > 0) {\r", + " pm.test(\"Ожидались комментарии, но получен пустой массив\", function() {\r", + " pm.expect.fail(\"Ожидались комментарии, но массив пуст.\");\r", + " });\r", + "}\r", + "\r", + "\r", + "pm.test(\"Очистка переменных окружения после теста\", function () {\r", + " pm.environment.unset(\"createdUserId\");\r", + " pm.environment.unset(\"createdUserName\");\r", + " pm.environment.unset(\"fromParam\");\r", + " pm.environment.unset(\"sizeParam\");\r", + " pm.environment.unset(\"expectedCommentDetails\");\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/users/{{createdUserId}}/comments?from={{fromParam}}&size={{sizeParam}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "users", + "{{createdUserId}}", + "comments" + ], + "query": [ + { + "key": "from", + "value": "{{fromParam}}" + }, + { + "key": "size", + "value": "{{sizeParam}}" + } + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Удаление комментария", + "item": [ + { + "name": "Удаление комментария – Успех – 204", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "const randomSuffix = Math.floor(Math.random() * 100000);\r", + "const baseUrl = pm.collectionVariables.get(\"baseUrl\");\r", + "\r", + "function getFormattedFutureDate(hoursToAdd) {\r", + " const date = new Date();\r", + " date.setHours(date.getHours() + hoursToAdd);\r", + " const year = date.getFullYear();\r", + " const month = String(date.getMonth() + 1).padStart(2, '0');\r", + " const day = String(date.getDate()).padStart(2, '0');\r", + " const hours = String(date.getHours()).padStart(2, '0');\r", + " const minutes = String(date.getMinutes()).padStart(2, '0');\r", + " const seconds = String(date.getSeconds()).padStart(2, '0');\r", + " return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;\r", + "}\r", + "\r", + "let createdUserIdInternal;\r", + "let eventIdForComment;\r", + "\r", + "pm.sendRequest({\r", + " url: `${baseUrl}/admin/users`,\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: {\r", + " mode: 'raw',\r", + " raw: JSON.stringify({\r", + " email: `testuser_delete_comment_${randomSuffix}@example.com`,\r", + " name: `Test User Delete Comment ${randomSuffix}`\r", + " })\r", + " }\r", + "}, (errUser, responseUser) => {\r", + " if (errUser || responseUser.code !== 201) {\r", + " console.error(\"Pre-request: Не удалось создать пользователя:\", errUser || responseUser.text());\r", + " throw new Error(\"Pre-request: Не удалось создать пользователя.\");\r", + " }\r", + " const userData = responseUser.json();\r", + " createdUserIdInternal = userData.id;\r", + " pm.environment.set(\"createdUserId\", createdUserIdInternal);\r", + " console.log(\"Pre-request: Пользователь создан:\", createdUserIdInternal);\r", + "\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/admin/categories`,\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: {\r", + " mode: 'raw',\r", + " raw: JSON.stringify({ name: `Category for Delete Comment ${randomSuffix}` })\r", + " }\r", + " }, (errCat, responseCat) => {\r", + " if (errCat || responseCat.code !== 201) {\r", + " console.error(\"Pre-request: Не удалось создать категорию:\", errCat || responseCat.text());\r", + " throw new Error(\"Pre-request: Не удалось создать категорию.\");\r", + " }\r", + " const categoryData = responseCat.json();\r", + " console.log(\"Pre-request: Категория создана:\", categoryData.id);\r", + "\r", + " const eventDateForCreation = getFormattedFutureDate(3);\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/users/${createdUserIdInternal}/events`,\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: {\r", + " mode: 'raw',\r", + " raw: JSON.stringify({\r", + " annotation: `Event for comment deletion test ${randomSuffix}`,\r", + " category: categoryData.id,\r", + " description: `Description for comment deletion test ${randomSuffix}`,\r", + " eventDate: eventDateForCreation,\r", + " location: { lat: 55.760 + (randomSuffix / 1000000), lon: 37.621 + (randomSuffix / 1000000) },\r", + " commentsEnabled: true,\r", + " title: `Event Delete Comment ${randomSuffix}`\r", + " })\r", + " }\r", + " }, (errEvent, responseEvent) => {\r", + " if (errEvent || responseEvent.code !== 201) {\r", + " console.error(\"Pre-request: Не удалось создать событие:\", errEvent || responseEvent.text());\r", + " throw new Error(\"Pre-request: Не удалось создать событие.\");\r", + " }\r", + " const eventData = responseEvent.json();\r", + " pm.environment.set(\"eventIdForCommentVerification\", eventData.id);\r", + " console.log(\"Pre-request: Событие создано:\", eventData.id);\r", + "\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/admin/events/${eventData.id}`,\r", + " method: 'PATCH',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: { mode: 'raw', raw: JSON.stringify({ stateAction: \"PUBLISH_EVENT\" }) }\r", + " }, (errPublish, responsePublish) => {\r", + " if (errPublish || responsePublish.code !== 200) {\r", + " console.error(\"Pre-request: Не удалось опубликовать событие:\", errPublish || responsePublish.text());\r", + " throw new Error(\"Pre-request: Не удалось опубликовать событие.\");\r", + " }\r", + " console.log(\"Pre-request: Событие опубликовано:\", eventIdForComment);\r", + "\r", + " const commentText = `Comment to be deleted by user ${randomSuffix}`;\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/users/${createdUserIdInternal}/comments?eventId=${eventData.id}`,\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: {\r", + " mode: 'raw',\r", + " raw: JSON.stringify({ text: commentText })\r", + " }\r", + " }, (errComment, responseComment) => {\r", + " if (errComment || responseComment.code !== 201) {\r", + " console.error(\"Pre-request: Не удалось создать комментарий:\", errComment || responseComment.text());\r", + " throw new Error(\"Pre-request: Не удалось создать комментарий.\");\r", + " }\r", + " const commentData = responseComment.json();\r", + " pm.environment.set(\"createdCommentId\", commentData.id);\r", + " console.log(\"Pre-request: Комментарий для удаления создан:\", commentData.id);\r", + " console.log(\"Pre-request: Настройка для DELETE /users/{userId}/comments/{commentId} завершена.\");\r", + " });\r", + " });\r", + " });\r", + " });\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Код статуса ответа 204 No Content\", function () {\r", + " pm.response.to.have.status(204);\r", + "});\r", + "\r", + "pm.test(\"Тело ответа пустое\", function () {\r", + " pm.response.to.not.have.body;\r", + "});\r", + "\r", + "const deletedCommentId = parseInt(pm.environment.get(\"createdCommentId\"));\r", + "const eventId = pm.environment.get(\"eventIdForCommentVerification\");\r", + "const baseUrl = pm.collectionVariables.get(\"baseUrl\");\r", + "\r", + "pm.test(\"Проверка: 'удаленный' комментарий не возвращается публичным API события\", function (done) {\r", + " if (!eventId) {\r", + " console.error(\"Verification Error: eventIdForCommentVerification не найден в окружении.\");\r", + " throw new Error(\"eventIdForCommentVerification не найден для верификации.\");\r", + " }\r", + " if (isNaN(deletedCommentId)) {\r", + " console.error(\"Verification Error: createdCommentId не является числом или не найден.\");\r", + " throw new Error(\"createdCommentId не валиден для верификации.\");\r", + " }\r", + "\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/events/${eventId}/comments`,\r", + " }, function (err, response) {\r", + " if (err) {\r", + " console.error(\"Ошибка при верификационном GET запросе:\", err);\r", + " pm.expect.fail(\"Ошибка при верификационном GET запросе: \" + err.message);\r", + " }\r", + "\r", + " pm.expect(response).to.have.status(200, \"Верификационный GET должен вернуть 200 ОК\");\r", + " const comments = response.json();\r", + " pm.expect(comments).to.be.an('array', \"Ответ от верификационного GET должен быть массивом\");\r", + "\r", + " let foundDeletedComment = false;\r", + " for (let i = 0; i < comments.length; i++) {\r", + " if (comments[i].id === deletedCommentId) {\r", + " foundDeletedComment = true;\r", + " break;\r", + " }\r", + " }\r", + " pm.expect(foundDeletedComment).to.be.false, `Комментарий с ID ${deletedCommentId} не должен быть найден после удаления`;\r", + " \r", + " done();\r", + " });\r", + "});\r", + "\r", + "pm.test(\"Очистка переменных окружения после теста\", function () {\r", + " pm.environment.unset(\"createdUserId\");\r", + " pm.environment.unset(\"createdCommentId\");\r", + " pm.environment.unset(\"eventIdForCommentVerification\");\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{baseUrl}}/users/{{createdUserId}}/comments/{{createdCommentId}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "users", + "{{createdUserId}}", + "comments", + "{{createdCommentId}}" + ] + } + }, + "response": [] + }, + { + "name": "Удаление комментария – Чужой комментарий – 404", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "const randomSuffix = Math.floor(Math.random() * 100000);\r", + "const baseUrl = pm.collectionVariables.get(\"baseUrl\");\r", + "\r", + "function getFormattedFutureDate(hoursToAdd) {\r", + " const date = new Date();\r", + " date.setHours(date.getHours() + hoursToAdd);\r", + " const year = date.getFullYear();\r", + " const month = String(date.getMonth() + 1).padStart(2, '0');\r", + " const day = String(date.getDate()).padStart(2, '0');\r", + " const hours = String(date.getHours()).padStart(2, '0');\r", + " const minutes = String(date.getMinutes()).padStart(2, '0');\r", + " const seconds = String(date.getSeconds()).padStart(2, '0');\r", + " return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;\r", + "}\r", + "\r", + "let victimUserId, attackerUserId, eventIdForComment;\r", + "\r", + "pm.sendRequest({\r", + " url: `${baseUrl}/admin/users`,\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: {\r", + " mode: 'raw',\r", + " raw: JSON.stringify({\r", + " email: `victim_user_delete_foreign_${randomSuffix}@example.com`,\r", + " name: `Victim User Delete Foreign ${randomSuffix}`\r", + " })\r", + " }\r", + "}, (errVictim, responseVictim) => {\r", + " if (errVictim || responseVictim.code !== 201) {\r", + " console.error(\"Pre-request: Не удалось создать Victim User:\", errVictim || responseVictim.text());\r", + " throw new Error(\"Pre-request: Не удалось создать Victim User.\");\r", + " }\r", + " const victimUserData = responseVictim.json();\r", + " victimUserId = victimUserData.id;\r", + " console.log(\"Pre-request: Victim User создан:\", victimUserId);\r", + "\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/admin/users`,\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: {\r", + " mode: 'raw',\r", + " raw: JSON.stringify({\r", + " email: `attacker_user_delete_foreign_${randomSuffix}@example.com`,\r", + " name: `Attacker User Delete Foreign ${randomSuffix}`\r", + " })\r", + " }\r", + " }, (errAttacker, responseAttacker) => {\r", + " if (errAttacker || responseAttacker.code !== 201) {\r", + " console.error(\"Pre-request: Не удалось создать Attacker User:\", errAttacker || responseAttacker.text());\r", + " throw new Error(\"Pre-request: Не удалось создать Attacker User.\");\r", + " }\r", + " const attackerUserData = responseAttacker.json();\r", + " attackerUserId = attackerUserData.id;\r", + " pm.environment.set(\"attackerUserId\", attackerUserId);\r", + " console.log(\"Pre-request: Attacker User создан:\", attackerUserId);\r", + "\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/admin/categories`,\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: {\r", + " mode: 'raw',\r", + " raw: JSON.stringify({ name: `Category for Foreign Comment Delete ${randomSuffix}` })\r", + " }\r", + " }, (errCat, responseCat) => {\r", + " if (errCat || responseCat.code !== 201) {\r", + " console.error(\"Pre-request: Не удалось создать категорию:\", errCat || responseCat.text());\r", + " throw new Error(\"Pre-request: Не удалось создать категорию.\");\r", + " }\r", + " const categoryData = responseCat.json();\r", + " console.log(\"Pre-request: Категория создана:\", categoryData.id);\r", + "\r", + " const eventDateForCreation = getFormattedFutureDate(3);\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/users/${victimUserId}/events`, // Событие создает Victim User\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: {\r", + " mode: 'raw',\r", + " raw: JSON.stringify({\r", + " annotation: `Event for foreign comment delete test ${randomSuffix}`,\r", + " category: categoryData.id,\r", + " description: `Description for foreign comment delete test ${randomSuffix}`,\r", + " eventDate: eventDateForCreation,\r", + " location: { lat: 55.7602 + (randomSuffix / 1000000), lon: 37.6212 + (randomSuffix / 1000000) },\r", + " commentsEnabled: true,\r", + " title: `Event Foreign Delete ${randomSuffix}`\r", + " })\r", + " }\r", + " }, (errEvent, responseEvent) => {\r", + " if (errEvent || responseEvent.code !== 201) {\r", + " console.error(\"Pre-request: Не удалось создать событие:\", errEvent || responseEvent.text());\r", + " throw new Error(\"Pre-request: Не удалось создать событие.\");\r", + " }\r", + " const eventData = responseEvent.json();\r", + " eventIdForComment = eventData.id;\r", + " console.log(\"Pre-request: Событие создано:\", eventIdForComment);\r", + "\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/admin/events/${eventIdForComment}`,\r", + " method: 'PATCH',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: { mode: 'raw', raw: JSON.stringify({ stateAction: \"PUBLISH_EVENT\" }) }\r", + " }, (errPublish, responsePublish) => {\r", + " if (errPublish || responsePublish.code !== 200) {\r", + " console.error(\"Pre-request: Не удалось опубликовать событие:\", errPublish || responsePublish.text());\r", + " throw new Error(\"Pre-request: Не удалось опубликовать событие.\");\r", + " }\r", + " console.log(\"Pre-request: Событие опубликовано:\", eventIdForComment);\r", + "\r", + " const victimCommentText = `This is Victim User's comment to be targeted ${randomSuffix}`;\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/users/${victimUserId}/comments?eventId=${eventIdForComment}`,\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: {\r", + " mode: 'raw',\r", + " raw: JSON.stringify({ text: victimCommentText })\r", + " }\r", + " }, (errComment, responseComment) => {\r", + " if (errComment || responseComment.code !== 201) {\r", + " console.error(\"Pre-request: Не удалось создать комментарий Victim User:\", errComment || responseComment.text());\r", + " throw new Error(\"Pre-request: Не удалось создать комментарий Victim User.\");\r", + " }\r", + " const victimCommentData = responseComment.json();\r", + " pm.environment.set(\"victimCommentId\", victimCommentData.id);\r", + " console.log(\"Pre-request: Комментарий Victim User создан:\", victimCommentData.id);\r", + " console.log(\"Pre-request: Настройка для удаления чужого комментария завершена.\");\r", + " });\r", + " });\r", + " });\r", + " });\r", + " });\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Код статуса ответа 404 Not Found\", function () {\r", + " pm.response.to.have.status(404);\r", + "});\r", + "\r", + "pm.test(\"Ответ является валидным JSON (для ApiError)\", function () {\r", + " pm.response.to.be.json;\r", + "});\r", + "\r", + "const jsonData = pm.response.json();\r", + "const victimCommentId = pm.environment.get(\"victimCommentId\");\r", + "\r", + "pm.test(\"Тело ответа содержит ожидаемые поля ошибки ApiError (без 'errors')\", function () {\r", + " pm.expect(jsonData).to.be.an('object');\r", + " pm.expect(jsonData).to.have.all.keys('status', 'reason', 'message', 'timestamp');\r", + " pm.expect(jsonData.status).to.equal(\"NOT_FOUND\");\r", + "});\r", + "\r", + "pm.test(\"Сообщение об ошибке (message) не пустое\", function () {\r", + " pm.expect(jsonData.message).to.not.be.empty;\r", + " console.log(\"Сообщение об ошибке: \", jsonData.message);\r", + "});\r", + "\r", + "pm.test(\"Поле 'reason' (причина ошибки) не пустое\", function () {\r", + " pm.expect(jsonData.reason).to.not.be.empty;\r", + " console.log(\"Причина ошибки: \", jsonData.reason);\r", + "});\r", + "\r", + "pm.test(\"Timestamp ошибки присутствует и в правильном формате\", function () {\r", + " pm.expect(jsonData.timestamp).to.be.a('string');\r", + " pm.expect(jsonData.timestamp).to.match(/^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}$/);\r", + "});\r", + "\r", + "pm.test(\"Очистка переменных окружения после теста\", function () {\r", + " pm.environment.unset(\"attackerUserId\");\r", + " pm.environment.unset(\"victimCommentId\");\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{baseUrl}}/users/{{attackerUserId}}/comments/{{victimCommentId}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "users", + "{{attackerUserId}}", + "comments", + "{{victimCommentId}}" + ] + } + }, + "response": [] + }, + { + "name": "Удаление комментария – Удалённый комментарий – 204", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "const randomSuffix = Math.floor(Math.random() * 100000);\r", + "const baseUrl = pm.collectionVariables.get(\"baseUrl\");\r", + "\r", + "function getFormattedFutureDate(hoursToAdd) {\r", + " const date = new Date();\r", + " date.setHours(date.getHours() + hoursToAdd);\r", + " const year = date.getFullYear();\r", + " const month = String(date.getMonth() + 1).padStart(2, '0');\r", + " const day = String(date.getDate()).padStart(2, '0');\r", + " const hours = String(date.getHours()).padStart(2, '0');\r", + " const minutes = String(date.getMinutes()).padStart(2, '0');\r", + " const seconds = String(date.getSeconds()).padStart(2, '0');\r", + " return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;\r", + "}\r", + "\r", + "let createdUserIdInternal;\r", + "let eventIdForComment;\r", + "let commentIdForReDelete;\r", + "\r", + "pm.sendRequest({\r", + " url: `${baseUrl}/admin/users`,\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: {\r", + " mode: 'raw',\r", + " raw: JSON.stringify({\r", + " email: `testuser_redelete_comment_${randomSuffix}@example.com`,\r", + " name: `Test User ReDelete Comment ${randomSuffix}`\r", + " })\r", + " }\r", + "}, (errUser, responseUser) => {\r", + " if (errUser || responseUser.code !== 201) {\r", + " console.error(\"Pre-request: Не удалось создать пользователя:\", errUser || responseUser.text());\r", + " throw new Error(\"Pre-request: Не удалось создать пользователя.\");\r", + " }\r", + " const userData = responseUser.json();\r", + " createdUserIdInternal = userData.id;\r", + " pm.environment.set(\"createdUserId\", createdUserIdInternal);\r", + " console.log(\"Pre-request: Пользователь создан:\", createdUserIdInternal);\r", + "\r", + " pm.sendRequest({ \r", + " url: `${baseUrl}/admin/categories`,\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: { mode: 'raw', raw: JSON.stringify({ name: `Category for ReDelete Comment ${randomSuffix}` }) }\r", + " }, (errCat, responseCat) => {\r", + " if (errCat || responseCat.code !== 201) {\r", + " console.error(\"Pre-request: Не удалось создать категорию:\", errCat || responseCat.text());\r", + " throw new Error(\"Pre-request: Не удалось создать категорию.\");\r", + " }\r", + " const categoryData = responseCat.json();\r", + " console.log(\"Pre-request: Категория создана:\", categoryData.id);\r", + "\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/users/${createdUserIdInternal}/events`,\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: {\r", + " mode: 'raw',\r", + " raw: JSON.stringify({\r", + " annotation: `Event for comment re-deletion test ${randomSuffix}`,\r", + " category: categoryData.id,\r", + " description: `Description for comment re-deletion test ${randomSuffix}`,\r", + " eventDate: getFormattedFutureDate(3),\r", + " location: { lat: 55.7603 + (randomSuffix / 1000000), lon: 37.6213 + (randomSuffix / 1000000) },\r", + " commentsEnabled: true,\r", + " title: `Event ReDelete Comment ${randomSuffix}`\r", + " })\r", + " }\r", + " }, (errEvent, responseEvent) => {\r", + " if (errEvent || responseEvent.code !== 201) {\r", + " console.error(\"Pre-request: Не удалось создать событие:\", errEvent || responseEvent.text());\r", + " throw new Error(\"Pre-request: Не удалось создать событие.\");\r", + " }\r", + " const eventData = responseEvent.json();\r", + " eventIdForComment = eventData.id;\r", + " console.log(\"Pre-request: Событие создано:\", eventIdForComment);\r", + "\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/admin/events/${eventIdForComment}`,\r", + " method: 'PATCH',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: { mode: 'raw', raw: JSON.stringify({ stateAction: \"PUBLISH_EVENT\" }) }\r", + " }, (errPublish, responsePublish) => {\r", + " if (errPublish || responsePublish.code !== 200) {\r", + " console.error(\"Pre-request: Не удалось опубликовать событие:\", errPublish || responsePublish.text());\r", + " throw new Error(\"Pre-request: Не удалось опубликовать событие.\");\r", + " }\r", + " console.log(\"Pre-request: Событие опубликовано:\", eventIdForComment);\r", + "\r", + " const commentText = `Comment to be re-deleted by user ${randomSuffix}`;\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/users/${createdUserIdInternal}/comments?eventId=${eventIdForComment}`,\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: { mode: 'raw', raw: JSON.stringify({ text: commentText }) }\r", + " }, (errComment, responseComment) => {\r", + " if (errComment || responseComment.code !== 201) {\r", + " console.error(\"Pre-request: Не удалось создать комментарий:\", errComment || responseComment.text());\r", + " throw new Error(\"Pre-request: Не удалось создать комментарий.\");\r", + " }\r", + " const commentData = responseComment.json();\r", + " commentIdForReDelete = commentData.id;\r", + " pm.environment.set(\"createdCommentId\", commentIdForReDelete);\r", + " console.log(\"Pre-request: Комментарий создан:\", commentIdForReDelete);\r", + "\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/users/${createdUserIdInternal}/comments/${commentIdForReDelete}`,\r", + " method: 'DELETE'\r", + " }, (errFirstDelete, responseFirstDelete) => {\r", + " if (errFirstDelete || responseFirstDelete.code !== 204) {\r", + " console.error(\"Pre-request: Первое 'мягкое' удаление не удалось:\", errFirstDelete || responseFirstDelete.text());\r", + " throw new Error(\"Pre-request: Первое 'мягкое' удаление не удалось. Status: \" + (responseFirstDelete ? responseFirstDelete.code : 'N/A'));\r", + " }\r", + " console.log(\"Pre-request: Комментарий успешно 'мягко' удален (первый раз).\");\r", + " console.log(\"Pre-request: Настройка для повторного DELETE завершена. Основной запрос будет отправлен сейчас.\");\r", + " });\r", + " });\r", + " });\r", + " });\r", + " });\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Код статуса ответа 204 No Content (при повторном удалении)\", function () {\r", + " pm.response.to.have.status(204);\r", + "});\r", + "\r", + "pm.test(\"Тело ответа пустое (при повторном удалении)\", function () {\r", + " pm.response.to.not.have.body;\r", + "});\r", + "\r", + "pm.test(\"Очистка переменных окружения после теста\", function () {\r", + " pm.environment.unset(\"createdUserId\");\r", + " pm.environment.unset(\"createdCommentId\");\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{baseUrl}}/users/{{createdUserId}}/comments/{{createdCommentId}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "users", + "{{createdUserId}}", + "comments", + "{{createdCommentId}}" + ] + } + }, + "response": [] + } + ] + } + ] + }, + { + "name": "API – Public", + "item": [ + { + "name": "Получение комментариев к событию", + "item": [ + { + "name": "Получение комментариев к событию – Успех – 200", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "const randomSuffix = Math.floor(Math.random() * 100000);\r", + "const baseUrl = pm.collectionVariables.get(\"baseUrl\");\r", + "const numberOfCommentsToCreate = 5;\r", + "const fromParam = 0;\r", + "const sizeParam = 3;\r", + "\r", + "function getFormattedFutureDate(hoursToAdd) {\r", + " const date = new Date();\r", + " date.setHours(date.getHours() + hoursToAdd);\r", + " const year = date.getFullYear();\r", + " const month = String(date.getMonth() + 1).padStart(2, '0');\r", + " const day = String(date.getDate()).padStart(2, '0');\r", + " const hours = String(date.getHours()).padStart(2, '0');\r", + " const minutes = String(date.getMinutes()).padStart(2, '0');\r", + " const seconds = String(date.getSeconds()).padStart(2, '0');\r", + " return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;\r", + "}\r", + "\r", + "let createdUserIdInternal;\r", + "let createdEventIdInternal;\r", + "let commentAuthorName;\r", + "let createdCommentDetails = [];\r", + "\r", + "pm.sendRequest({\r", + " url: `${baseUrl}/admin/users`,\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: {\r", + " mode: 'raw',\r", + " raw: JSON.stringify({\r", + " email: `testuser_event_comments_pub_${randomSuffix}@example.com`,\r", + " name: `Test User Event Comments Pub ${randomSuffix}`\r", + " })\r", + " }\r", + "}, (errUser, responseUser) => {\r", + " if (errUser || responseUser.code !== 201) {\r", + " console.error(\"Pre-request: Не удалось создать пользователя:\", errUser || responseUser.text());\r", + " throw new Error(\"Pre-request: Не удалось создать пользователя.\");\r", + " }\r", + " const userData = responseUser.json();\r", + " createdUserIdInternal = userData.id;\r", + " commentAuthorName = userData.name;\r", + " console.log(\"Pre-request: Пользователь (автор комментариев) создан:\", createdUserIdInternal);\r", + "\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/admin/categories`,\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: {\r", + " mode: 'raw',\r", + " raw: JSON.stringify({ name: `Category for Event Comments Pub ${randomSuffix}` })\r", + " }\r", + " }, (errCat, responseCat) => {\r", + " if (errCat || responseCat.code !== 201) {\r", + " console.error(\"Pre-request: Не удалось создать категорию:\", errCat || responseCat.text());\r", + " throw new Error(\"Pre-request: Не удалось создать категорию.\");\r", + " }\r", + " const categoryData = responseCat.json();\r", + " console.log(\"Pre-request: Категория создана:\", categoryData.id);\r", + "\r", + " const eventDateForCreation = getFormattedFutureDate(3);\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/users/${createdUserIdInternal}/events`,\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: {\r", + " mode: 'raw',\r", + " raw: JSON.stringify({\r", + " annotation: `Event for public comments test ${randomSuffix}`,\r", + " category: categoryData.id,\r", + " description: `Description for public comments test ${randomSuffix}`,\r", + " eventDate: eventDateForCreation,\r", + " location: { lat: 55.758 + (randomSuffix / 1000000), lon: 37.619 + (randomSuffix / 1000000) },\r", + " commentsEnabled: true,\r", + " title: `Event Public Comments ${randomSuffix}`\r", + " })\r", + " }\r", + " }, (errEvent, responseEvent) => {\r", + " if (errEvent || responseEvent.code !== 201) {\r", + " console.error(\"Pre-request: Не удалось создать событие:\", errEvent || responseEvent.text());\r", + " throw new Error(\"Pre-request: Не удалось создать событие.\");\r", + " }\r", + " const eventData = responseEvent.json();\r", + " createdEventIdInternal = eventData.id;\r", + " pm.environment.set(\"createdEventId\", createdEventIdInternal);\r", + " console.log(\"Pre-request: Событие создано:\", createdEventIdInternal);\r", + "\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/admin/events/${createdEventIdInternal}`,\r", + " method: 'PATCH',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: { mode: 'raw', raw: JSON.stringify({ stateAction: \"PUBLISH_EVENT\" }) }\r", + " }, (errPublish, responsePublish) => {\r", + " if (errPublish || responsePublish.code !== 200) {\r", + " console.error(\"Pre-request: Не удалось опубликовать событие:\", errPublish || responsePublish.text());\r", + " throw new Error(\"Pre-request: Не удалось опубликовать событие.\");\r", + " }\r", + " console.log(\"Pre-request: Событие опубликовано:\", createdEventIdInternal);\r", + "\r", + " const numberOfEvents = 1;\r", + " const createdEventIds = [createdEventIdInternal];\r", + " createComments();\r", + "\r", + " function createComments() {\r", + " let commentsCreatedCount = 0;\r", + " function createCommentRecursive(j) {\r", + " if (j >= numberOfCommentsToCreate) {\r", + " createdCommentDetails.sort((a, b) => {\r", + " const dateA = new Date(a.createdOn.replace(' ', 'T') + 'Z').getTime();\r", + " const dateB = new Date(b.createdOn.replace(' ', 'T') + 'Z').getTime();\r", + " if (dateB !== dateA) return dateB - dateA;\r", + " return b.id - a.id;\r", + " });\r", + " pm.environment.set(\"expectedCommentDetails\", JSON.stringify(createdCommentDetails));\r", + " pm.environment.set(\"fromParam\", String(fromParam));\r", + " pm.environment.set(\"sizeParam\", String(sizeParam));\r", + " console.log(\"Pre-request: Настройка для получения комментариев пользователя завершена.\");\r", + " return;\r", + " }\r", + "\r", + " const commentText = `User's comment ${j} on event ${j % numberOfEvents} ${randomSuffix}`;\r", + " const eventIdForThisComment = createdEventIds[j % numberOfEvents];\r", + "\r", + " setTimeout(() => {\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/users/${createdUserIdInternal}/comments?eventId=${eventIdForThisComment}`,\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: {\r", + " mode: 'raw',\r", + " raw: JSON.stringify({ text: commentText })\r", + " }\r", + " }, (errComment, responseComment) => {\r", + " if (errComment || responseComment.code !== 201) {\r", + " console.error(`Pre-request: Не удалось создать комментарий ${j}:`, errComment || responseComment.text());\r", + " throw new Error(`Pre-request: Не удалось создать комментарий ${j}.`);\r", + " }\r", + " const commentData = responseComment.json();\r", + " createdCommentDetails.push({\r", + " id: commentData.id,\r", + " text: commentData.text,\r", + " eventId: commentData.eventId,\r", + " createdOn: commentData.createdOn\r", + " });\r", + " console.log(`Pre-request: Комментарий ${j} создан:`, commentData.id, `в ${commentData.createdOn}`);\r", + " \r", + " commentsCreatedCount++;\r", + " createCommentRecursive(j + 1);\r", + " });\r", + " }, 50);\r", + " }\r", + " createCommentRecursive(0);\r", + " }\r", + " });\r", + " });\r", + " });\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Код статуса ответа 200 OK\", function () {\r", + " pm.response.to.have.status(200);\r", + "});\r", + "\r", + "pm.test(\"Ответ является валидным JSON и представляет собой массив\", function () {\r", + " pm.response.to.be.json;\r", + " const jsonData = pm.response.json();\r", + " pm.expect(jsonData).to.be.an('array');\r", + "});\r", + "\r", + "const jsonData = pm.response.json();\r", + "const expectedSize = parseInt(pm.environment.get(\"sizeParam\"));\r", + "const allExpectedComments = JSON.parse(pm.environment.get(\"expectedCommentDetails\"));\r", + "const expectedEventId = parseInt(pm.environment.get(\"createdEventId\"));\r", + "\r", + "const from = parseInt(pm.environment.get(\"fromParam\"));\r", + "const expectedReturnedCount = Math.min(expectedSize, allExpectedComments.length - from);\r", + "\r", + "pm.test(`Возвращено корректное количество комментариев (ожидается ${expectedReturnedCount})`, function () {\r", + " pm.expect(jsonData.length).to.equal(expectedReturnedCount);\r", + "});\r", + "\r", + "if (jsonData.length > 0) {\r", + " pm.test(\"Структура каждого комментария в ответе корректна\", function () {\r", + " jsonData.forEach(function(comment, index) {\r", + " pm.expect(comment, `Комментарий #${index}`).to.have.all.keys(\r", + " 'id', 'text', 'author', 'eventId', 'createdOn', 'updatedOn', 'isEdited'\r", + " );\r", + " pm.expect(comment.id, `ID комментария #${index}`).to.be.a('number');\r", + " pm.expect(comment.text, `Текст комментария #${index}`).to.be.a('string');\r", + " \r", + " pm.expect(comment.author, `Автор комментария #${index}`).to.be.an('object');\r", + " pm.expect(comment.author.id, `ID автора комментария #${index}`).to.be.a('number');\r", + " pm.expect(comment.author.name, `Имя автора комментария #${index}`).to.be.a('string');\r", + "\r", + " pm.expect(comment.eventId, `ID события комментария #${index}`).to.equal(expectedEventId);\r", + " pm.expect(comment.createdOn, `Время создания комментария #${index}`).to.match(/^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}$/);\r", + " pm.expect(comment.isEdited, `Флаг isEdited комментария #${index}`).to.be.a('boolean');\r", + "\r", + " if (comment.hasOwnProperty('isDeleted')) {\r", + " pm.expect(comment.isDeleted, `Флаг isDeleted комментария #${index} (если есть)`).to.be.false;\r", + " }\r", + " });\r", + " });\r", + "\r", + " pm.test(\"Комментарии соответствуют ожидаемым (с учетом пагинации и сортировки)\", function() {\r", + " const expectedSlice = allExpectedComments.slice(from, from + expectedSize);\r", + "\r", + " pm.expect(jsonData.length).to.equal(expectedSlice.length);\r", + "\r", + " jsonData.forEach((returnedComment, index) => {\r", + " const expectedComment = expectedSlice[index];\r", + " pm.expect(returnedComment.id, `ID комментария ${index}`).to.equal(expectedComment.id);\r", + " pm.expect(returnedComment.text, `Текст комментария ${index}`).to.equal(expectedComment.text);\r", + " pm.expect(returnedComment.eventId, `EventID комментария ${index}`).to.equal(expectedComment.eventId);\r", + " });\r", + " });\r", + "} else if (expectedReturnedCount > 0) {\r", + " pm.test(\"Ожидались комментарии, но получен пустой массив\", function() {\r", + " pm.expect.fail(\"Ожидались комментарии, но массив пуст.\");\r", + " });\r", + "}\r", + "\r", + "pm.test(\"Очистка переменных окружения после теста\", function () {\r", + " pm.environment.unset(\"createdEventId\");\r", + " pm.environment.unset(\"fromParam\");\r", + " pm.environment.unset(\"sizeParam\");\r", + " pm.environment.unset(\"expectedCommentDetails\");\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/events/{{createdEventId}}/comments?from={{fromParam}}&size={{sizeParam}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "events", + "{{createdEventId}}", + "comments" + ], + "query": [ + { + "key": "from", + "value": "{{fromParam}}" + }, + { + "key": "size", + "value": "{{sizeParam}}" + } + ] + } + }, + "response": [] + }, + { + "name": "Получение комментариев к событию – Событие не существует – 404", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "const baseUrl = pm.collectionVariables.get(\"baseUrl\");\r", + "\r", + "const nonExistentEventId = Math.floor(Math.random() * 1000000) + 9000000;\r", + "pm.environment.set(\"nonExistentEventId\", nonExistentEventId);\r", + "\r", + "console.log(\"Pre-request: Установлен ID несуществующего события:\", nonExistentEventId);\r", + "console.log(\"Pre-request: Настройка для GET комментариев несуществующего события завершена.\");" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Код статуса ответа 404 Not Found\", function () {\r", + " pm.response.to.have.status(404);\r", + "});\r", + "\r", + "pm.test(\"Ответ является валидным JSON (для ApiError)\", function () {\r", + " pm.response.to.be.json;\r", + "});\r", + "\r", + "const jsonData = pm.response.json();\r", + "const expectedNonExistentEventId = pm.environment.get(\"nonExistentEventId\");\r", + "\r", + "pm.test(\"Тело ответа содержит ожидаемые поля ошибки ApiError (без 'errors')\", function () {\r", + " pm.expect(jsonData).to.be.an('object');\r", + " pm.expect(jsonData).to.have.all.keys('status', 'reason', 'message', 'timestamp');\r", + " pm.expect(jsonData.status).to.equal(\"NOT_FOUND\");\r", + "});\r", + "\r", + "pm.test(\"Сообщение об ошибке (message) не пустое\", function () {\r", + " pm.expect(jsonData.message).to.not.be.empty;\r", + " console.log(\"Сообщение об ошибке: \", jsonData.message);\r", + "});\r", + "\r", + "pm.test(\"Поле 'reason' (причина ошибки) не пустое\", function () {\r", + " pm.expect(jsonData.reason).to.not.be.empty;\r", + " console.log(\"Причина ошибки: \", jsonData.reason);\r", + "});\r", + "\r", + "pm.test(\"Timestamp ошибки присутствует и в правильном формате\", function () {\r", + " pm.expect(jsonData.timestamp).to.be.a('string');\r", + " pm.expect(jsonData.timestamp).to.match(/^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}$/);\r", + "});\r", + "\r", + "pm.test(\"Очистка переменных окружения после теста\", function () {\r", + " pm.environment.unset(\"nonExistentEventId\");\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/events/{{nonExistentEventId}}/comments", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "events", + "{{nonExistentEventId}}", + "comments" + ] + } + }, + "response": [] + }, + { + "name": "Получение комментариев к событию – Событие не опубликовано – 404", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "const randomSuffix = Math.floor(Math.random() * 100000);\r", + "const baseUrl = pm.collectionVariables.get(\"baseUrl\");\r", + "\r", + "function getFormattedFutureDate(hoursToAdd) {\r", + " const date = new Date();\r", + " date.setHours(date.getHours() + hoursToAdd);\r", + " const year = date.getFullYear();\r", + " const month = String(date.getMonth() + 1).padStart(2, '0');\r", + " const day = String(date.getDate()).padStart(2, '0');\r", + " const hours = String(date.getHours()).padStart(2, '0');\r", + " const minutes = String(date.getMinutes()).padStart(2, '0');\r", + " const seconds = String(date.getSeconds()).padStart(2, '0');\r", + " return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;\r", + "}\r", + "\r", + "let eventCreatorUserId;\r", + "\r", + "pm.sendRequest({\r", + " url: `${baseUrl}/admin/users`,\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: {\r", + " mode: 'raw',\r", + " raw: JSON.stringify({\r", + " email: `testuser_unpublished_evt_comm_${randomSuffix}@example.com`,\r", + " name: `Test User Unpublished Event Comments ${randomSuffix}`\r", + " })\r", + " }\r", + "}, (errUser, responseUser) => {\r", + " if (errUser || responseUser.code !== 201) {\r", + " console.error(\"Pre-request: Не удалось создать пользователя:\", errUser || responseUser.text());\r", + " throw new Error(\"Pre-request: Не удалось создать пользователя.\");\r", + " }\r", + " const userData = responseUser.json();\r", + " eventCreatorUserId = userData.id;\r", + " console.log(\"Pre-request: Пользователь (создатель события) создан:\", eventCreatorUserId);\r", + "\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/admin/categories`,\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: {\r", + " mode: 'raw',\r", + " raw: JSON.stringify({ name: `Category for Unpublished Event Comments ${randomSuffix}` })\r", + " }\r", + " }, (errCat, responseCat) => {\r", + " if (errCat || responseCat.code !== 201) {\r", + " console.error(\"Pre-request: Не удалось создать категорию:\", errCat || responseCat.text());\r", + " throw new Error(\"Pre-request: Не удалось создать категорию.\");\r", + " }\r", + " const categoryData = responseCat.json();\r", + " console.log(\"Pre-request: Категория создана:\", categoryData.id);\r", + "\r", + " const eventDateForCreation = getFormattedFutureDate(3);\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/users/${eventCreatorUserId}/events`,\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: {\r", + " mode: 'raw',\r", + " raw: JSON.stringify({\r", + " annotation: `Unpublished event for public comments test ${randomSuffix}`,\r", + " category: categoryData.id,\r", + " description: `Description for unpublished public comments test ${randomSuffix}`,\r", + " eventDate: eventDateForCreation,\r", + " location: { lat: 55.759 + (randomSuffix / 1000000), lon: 37.620 + (randomSuffix / 1000000) },\r", + " commentsEnabled: true,\r", + " title: `Unpublished Event Public Comments ${randomSuffix}`\r", + " })\r", + " }\r", + " }, (errEvent, responseEvent) => {\r", + " if (errEvent || responseEvent.code !== 201) {\r", + " console.error(\"Pre-request: Не удалось создать событие:\", errEvent || responseEvent.text());\r", + " throw new Error(\"Pre-request: Не удалось создать событие.\");\r", + " }\r", + " const eventData = responseEvent.json();\r", + " pm.environment.set(\"unpublishedEventId\", eventData.id);\r", + " console.log(\"Pre-request: Событие (неопубликованное) создано:\", eventData.id, \"Состояние:\", eventData.state);\r", + " console.log(\"Pre-request: Настройка для GET комментариев неопубликованного события завершена.\");\r", + " });\r", + " });\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Код статуса ответа 404 Not Found\", function () {\r", + " pm.response.to.have.status(404);\r", + "});\r", + "\r", + "pm.test(\"Ответ является валидным JSON (для ApiError)\", function () {\r", + " pm.response.to.be.json;\r", + "});\r", + "\r", + "const jsonData = pm.response.json();\r", + "const unpublishedEventId = pm.environment.get(\"unpublishedEventId\");\r", + "\r", + "pm.test(\"Тело ответа содержит ожидаемые поля ошибки ApiError (без 'errors')\", function () {\r", + " pm.expect(jsonData).to.be.an('object');\r", + " pm.expect(jsonData).to.have.all.keys('status', 'reason', 'message', 'timestamp');\r", + " pm.expect(jsonData.status).to.equal(\"NOT_FOUND\");\r", + "});\r", + "\r", + "pm.test(\"Сообщение об ошибке (message) не пустое\", function () {\r", + " pm.expect(jsonData.message).to.not.be.empty;\r", + " console.log(\"Сообщение об ошибке: \", jsonData.message);\r", + "});\r", + "\r", + "pm.test(\"Поле 'reason' (причина ошибки) не пустое\", function () {\r", + " pm.expect(jsonData.reason).to.not.be.empty;\r", + " console.log(\"Причина ошибки: \", jsonData.reason);\r", + "});\r", + "\r", + "pm.test(\"Timestamp ошибки присутствует и в правильном формате\", function () {\r", + " pm.expect(jsonData.timestamp).to.be.a('string');\r", + " pm.expect(jsonData.timestamp).to.match(/^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}$/);\r", + "});\r", + "\r", + "pm.test(\"Очистка переменных окружения после теста\", function () {\r", + " pm.environment.unset(\"unpublishedEventId\");\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/events/{{unpublishedEventId}}/comments", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "events", + "{{unpublishedEventId}}", + "comments" + ] + } + }, + "response": [] + }, + { + "name": "Получение комментариев к событию – size=0 – 400", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "const randomSuffix = Math.floor(Math.random() * 100000);\r", + "const baseUrl = pm.collectionVariables.get(\"baseUrl\") || \"http://localhost:8080\";\r", + "\r", + "function getFormattedFutureDate(hoursToAdd) {\r", + " const date = new Date();\r", + " date.setHours(date.getHours() + hoursToAdd);\r", + " const year = date.getFullYear();\r", + " const month = String(date.getMonth() + 1).padStart(2, '0');\r", + " const day = String(date.getDate()).padStart(2, '0');\r", + " const hours = String(date.getHours()).padStart(2, '0');\r", + " const minutes = String(date.getMinutes()).padStart(2, '0');\r", + " const seconds = String(date.getSeconds()).padStart(2, '0');\r", + " return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;\r", + "}\r", + "\r", + "let eventCreatorUserId, createdEventIdInternal;\r", + "\r", + "pm.sendRequest({\r", + " url: `${baseUrl}/admin/users`, method: 'POST', header: {'Content-Type': 'application/json'},\r", + " body: { mode: 'raw', raw: JSON.stringify({\r", + " email: `testuser_evt_comm_size0_${randomSuffix}@example.com`,\r", + " name: `TestUser EvtCommSize0 ${randomSuffix}`\r", + " })}\r", + "}, (errUser, responseUser) => {\r", + " if (errUser || responseUser.code !== 201) {\r", + " console.error(\"Pre-request: Не удалось создать пользователя:\", errUser || responseUser.text());\r", + " throw new Error(\"Pre-request: Не удалось создать пользователя.\");\r", + " }\r", + " const userData = responseUser.json();\r", + " eventCreatorUserId = userData.id;\r", + " console.log(\"Pre-request: Пользователь создан:\", eventCreatorUserId);\r", + "\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/admin/categories`,\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: { mode: 'raw', raw: JSON.stringify({ name: `Category EvtCommSize0 ${randomSuffix}` }) }\r", + " }, (errCat, responseCat) => {\r", + " if (errCat || responseCat.code !== 201) {\r", + " console.error(\"Pre-request: Не удалось создать категорию:\", errCat || responseCat.text());\r", + " throw new Error(\"Pre-request: Не удалось создать категорию.\");\r", + " }\r", + " const categoryData = responseCat.json();\r", + " console.log(\"Pre-request: Категория создана:\", categoryData.id);\r", + "\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/users/${eventCreatorUserId}/events`,\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: { mode: 'raw', raw: JSON.stringify({\r", + " title: `Event EvtCommSize0 ${randomSuffix}`,\r", + " annotation: `Annotation with sufficient length ${randomSuffix}`, category: categoryData.id, description: `Description with sufficient length ${randomSuffix}`,\r", + " eventDate: getFormattedFutureDate(3), location: { lat: 55.75, lon: 37.65 }, commentsEnabled: true\r", + " })}\r", + " }, (errEvent, responseEvent) => {\r", + " if (errEvent || responseEvent.code !== 201) {\r", + " console.error(\"Pre-request: Не удалось создать событие:\", errEvent || responseEvent.text());\r", + " throw new Error(\"Pre-request: Не удалось создать событие.\");\r", + " }\r", + " const eventData = responseEvent.json();\r", + " createdEventIdInternal = eventData.id;\r", + " pm.environment.set(\"createdEventId\", createdEventIdInternal);\r", + " console.log(\"Pre-request: Событие создано:\", createdEventIdInternal);\r", + "\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/admin/events/${createdEventIdInternal}`, method: 'PATCH',\r", + " header: {'Content-Type': 'application/json'}, body: { mode: 'raw', raw: JSON.stringify({ stateAction: \"PUBLISH_EVENT\" })}\r", + " }, (errPublish, responsePublish) => {\r", + " if (errPublish || responsePublish.code !== 200) {\r", + " console.error(\"Pre-request: Не удалось опубликовать событие:\", errPublish || responsePublish.text());\r", + " throw new Error(\"Pre-request: Не удалось опубликовать событие.\");\r", + " }\r", + " console.log(\"Pre-request: Событие опубликовано:\", createdEventIdInternal);\r", + " console.log(\"Pre-request: Настройка для GET комментариев события с size=0 завершена.\");\r", + " });\r", + " });\r", + " });\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Код статуса ответа 400 Bad Request\", function () {\r", + " pm.response.to.have.status(400);\r", + "});\r", + "\r", + "pm.test(\"Ответ является валидным JSON (для ApiError)\", function () {\r", + " pm.response.to.be.json;\r", + "});\r", + "\r", + "const jsonData = pm.response.json();\r", + "\r", + "pm.test(\"Тело ответа содержит ожидаемые поля ошибки ApiError (без 'errors')\", function () {\r", + " pm.expect(jsonData).to.be.an('object');\r", + " pm.expect(jsonData).to.have.all.keys('status', 'reason', 'message', 'timestamp', 'errors');\r", + " pm.expect(jsonData.status).to.equal(\"BAD_REQUEST\");\r", + "});\r", + "\r", + "pm.test(\"Сообщение об ошибке (message) не пустое и указывает на проблему с параметром 'size'\", function () {\r", + " pm.expect(jsonData.message).to.not.be.empty;\r", + " console.log(\"Сообщение об ошибке: \", jsonData.message);\r", + "});\r", + "\r", + "pm.test(\"Поле 'reason' (причина ошибки) не пустое\", function () {\r", + " pm.expect(jsonData.reason).to.not.be.empty;\r", + " console.log(\"Причина ошибки: \", jsonData.reason);\r", + "});\r", + "\r", + "pm.test(\"Timestamp ошибки присутствует и в правильном формате\", function () {\r", + " pm.expect(jsonData.timestamp).to.be.a('string');\r", + " pm.expect(jsonData.timestamp).to.match(/^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}$/);\r", + "});\r", + "\r", + "pm.test(\"Очистка переменных окружения после теста\", function () {\r", + " pm.environment.unset(\"createdEventId\");\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/events/{{createdEventId}}/comments?from=0&size=0", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "events", + "{{createdEventId}}", + "comments" + ], + "query": [ + { + "key": "from", + "value": "0" + }, + { + "key": "size", + "value": "0" + } + ] + } + }, + "response": [] + } + ] + } + ] + }, + { + "name": "API – Admin", + "item": [ + { + "name": "Удаление комментария", + "item": [ + { + "name": "Удаление комментария (админ) – Успех – 204", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "const randomSuffix = Math.floor(Math.random() * 100000);\r", + "const baseUrl = pm.collectionVariables.get(\"baseUrl\");\r", + "\r", + "function getFormattedFutureDate(hoursToAdd) {\r", + " const date = new Date();\r", + " date.setHours(date.getHours() + hoursToAdd);\r", + " const year = date.getFullYear();\r", + " const month = String(date.getMonth() + 1).padStart(2, '0');\r", + " const day = String(date.getDate()).padStart(2, '0');\r", + " const hours = String(date.getHours()).padStart(2, '0');\r", + " const minutes = String(date.getMinutes()).padStart(2, '0');\r", + " const seconds = String(date.getSeconds()).padStart(2, '0');\r", + " return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;\r", + "}\r", + "\r", + "let commentAuthorUserId;\r", + "let eventIdForComment;\r", + "\r", + "pm.sendRequest({\r", + " url: `${baseUrl}/admin/users`,\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: {\r", + " mode: 'raw',\r", + " raw: JSON.stringify({\r", + " email: `testuser_admin_delete_comment_author_${randomSuffix}@example.com`,\r", + " name: `TestUser AdminDeleteCommentAuthor ${randomSuffix}`\r", + " })\r", + " }\r", + "}, (errUser, responseUser) => {\r", + " if (errUser || responseUser.code !== 201) {\r", + " console.error(\"Pre-request: Не удалось создать пользователя (автора комментария):\", errUser || responseUser.text());\r", + " throw new Error(\"Pre-request: Не удалось создать пользователя (автора комментария).\");\r", + " }\r", + " const userData = responseUser.json();\r", + " commentAuthorUserId = userData.id;\r", + " console.log(\"Pre-request: Пользователь (автор комментария) создан:\", commentAuthorUserId);\r", + "\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/admin/categories`,\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: { mode: 'raw', raw: JSON.stringify({ name: `Category for Admin Delete Comment ${randomSuffix}` }) }\r", + " }, (errCat, responseCat) => {\r", + " if (errCat || responseCat.code !== 201) {\r", + " console.error(\"Pre-request: Не удалось создать категорию:\", errCat || responseCat.text());\r", + " throw new Error(\"Pre-request: Не удалось создать категорию.\");\r", + " }\r", + " const categoryData = responseCat.json();\r", + " console.log(\"Pre-request: Категория создана:\", categoryData.id);\r", + "\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/users/${commentAuthorUserId}/events`,\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: {\r", + " mode: 'raw',\r", + " raw: JSON.stringify({\r", + " annotation: `Event for admin comment deletion ${randomSuffix}`,\r", + " category: categoryData.id,\r", + " description: `Description for admin comment deletion ${randomSuffix}`,\r", + " eventDate: getFormattedFutureDate(3),\r", + " location: { lat: 55.7604 + (randomSuffix / 1000000), lon: 37.6214 + (randomSuffix / 1000000) },\r", + " commentsEnabled: true,\r", + " title: `Event Admin Delete Comment ${randomSuffix}`\r", + " })\r", + " }\r", + " }, (errEvent, responseEvent) => {\r", + " if (errEvent || responseEvent.code !== 201) {\r", + " console.error(\"Pre-request: Не удалось создать событие:\", errEvent || responseEvent.text());\r", + " throw new Error(\"Pre-request: Не удалось создать событие.\");\r", + " }\r", + " const eventData = responseEvent.json();\r", + " eventIdForComment = eventData.id;\r", + " pm.environment.set(\"eventIdForAdminDeleteVerification\", eventIdForComment);\r", + " console.log(\"Pre-request: Событие создано:\", eventIdForComment);\r", + "\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/admin/events/${eventIdForComment}`,\r", + " method: 'PATCH',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: { mode: 'raw', raw: JSON.stringify({ stateAction: \"PUBLISH_EVENT\" }) }\r", + " }, (errPublish, responsePublish) => {\r", + " if (errPublish || responsePublish.code !== 200) {\r", + " console.error(\"Pre-request: Не удалось опубликовать событие:\", errPublish || responsePublish.text());\r", + " throw new Error(\"Pre-request: Не удалось опубликовать событие.\");\r", + " }\r", + " console.log(\"Pre-request: Событие опубликовано:\", eventIdForComment);\r", + "\r", + " const commentText = `Comment to be deleted by admin ${randomSuffix}`;\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/users/${commentAuthorUserId}/comments?eventId=${eventIdForComment}`,\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: { mode: 'raw', raw: JSON.stringify({ text: commentText }) }\r", + " }, (errComment, responseComment) => {\r", + " if (errComment || responseComment.code !== 201) {\r", + " console.error(\"Pre-request: Не удалось создать комментарий:\", errComment || responseComment.text());\r", + " throw new Error(\"Pre-request: Не удалось создать комментарий.\");\r", + " }\r", + " const commentData = responseComment.json();\r", + " pm.environment.set(\"commentIdToDeleteByAdmin\", commentData.id);\r", + " console.log(\"Pre-request: Комментарий для удаления администратором создан:\", commentData.id);\r", + " console.log(\"Pre-request: Настройка для ADMIN DELETE /admin/comments/{commentId} завершена.\");\r", + " });\r", + " });\r", + " });\r", + " });\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Код статуса ответа 204 No Content\", function () {\r", + " pm.response.to.have.status(204);\r", + "});\r", + "\r", + "pm.test(\"Тело ответа пустое\", function () {\r", + " pm.response.to.not.have.body;\r", + "});\r", + "\r", + "const deletedCommentIdByAdmin = parseInt(pm.environment.get(\"commentIdToDeleteByAdmin\"));\r", + "const eventIdForVerification = pm.environment.get(\"eventIdForAdminDeleteVerification\");\r", + "const baseUrl = pm.collectionVariables.get(\"baseUrl\");\r", + "\r", + "pm.test(\"Проверка (Public API): 'удаленный' админом комментарий не виден публично\", function (done) {\r", + " if (!eventIdForVerification || isNaN(deletedCommentIdByAdmin)) {\r", + " pm.expect.fail(\"Необходимые ID для верификации не найдены или некорректны.\");\r", + " }\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/events/${eventIdForVerification}/comments`,\r", + " method: 'GET'\r", + " }, function (err, response) {\r", + " if (err) {\r", + " console.error(\"Ошибка при верификационном GET (public):\", err);\r", + " pm.expect.fail(\"Ошибка при верификационном GET (public): \" + err.message);\r", + " }\r", + " pm.expect(response).to.have.status(200, \"Верификационный GET (public) должен вернуть 200 ОК\");\r", + " const comments = response.json();\r", + " let found = comments.some(comment => comment.id === deletedCommentIdByAdmin);\r", + " pm.expect(found).to.be.false, `Комментарий ID ${deletedCommentIdByAdmin} не должен быть найден публично после удаления админом`;\r", + " done();\r", + " });\r", + "});\r", + "\r", + "pm.test(\"Проверка (Admin API): 'удаленный' комментарий виден админу с isDeleted=true\", function (done) {\r", + " if (isNaN(deletedCommentIdByAdmin)) {\r", + " pm.expect.fail(\"ID удаленного комментария некорректен для админской проверки.\");\r", + " }\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/admin/comments?isDeleted=false`,\r", + " method: 'GET'\r", + " }, function (err, response) {\r", + " if (err) {\r", + " console.error(\"Ошибка при верификационном GET (admin, isDeleted=false):\", errDeleted);\r", + " pm.expect.fail(\"Ошибка при верификационном GET (admin, isDeleted=false): \" + errDeleted.message);\r", + " }\r", + " pm.expect(response.code).to.be.oneOf([200], \"Верификационный GET (admin, isDeleted=false) должен вернуть 200\");\r", + " const undeletedComments = response.json();\r", + " let foundAsUndeleted = false;\r", + " for (let i = 0; i < undeletedComments.length; i++) {\r", + " if (undeletedComments[i].id === deletedCommentIdByAdmin) {\r", + " foundAsUndeleted = true;\r", + " break;\r", + " }\r", + " }\r", + " pm.expect(foundAsUndeleted, `Комментарий ID ${deletedCommentIdByAdmin} не должен быть найден админом как неудаленный`).to.be.false;\r", + " \r", + " pm.sendRequest({\r", + " url: `${baseUrl}/admin/comments`,\r", + " method: 'GET'\r", + " }, function (errDeleted, responseDeleted) {\r", + " if (errDeleted) {\r", + " console.error(\"Ошибка при верификационном GET (admin):\", errDeleted);\r", + " pm.expect.fail(\"Ошибка при верификационном GET (admin): \" + errDeleted.message);\r", + " }\r", + " pm.expect(responseDeleted.code).to.be.oneOf([200], \"Верификационный GET (admin) должен вернуть 200\");\r", + " const adminComments = responseDeleted.json();\r", + " let foundAsDeleted = false;\r", + " let correctComment = null;\r", + " for (let i = 0; i < adminComments.length; i++) {\r", + " if (adminComments[i].id === deletedCommentIdByAdmin) {\r", + " foundAsDeleted = true;\r", + " correctComment = adminComments[i];\r", + " break;\r", + " }\r", + " }\r", + " pm.expect(foundAsDeleted, `Комментарий ID ${deletedCommentIdByAdmin} должен быть найден админом при поиске без филтьра`).to.be.true;\r", + " if (correctComment) {\r", + " pm.expect(correctComment.isDeleted, `У комментария ID ${deletedCommentIdByAdmin} флаг isDeleted должен быть true`).to.be.true;\r", + " }\r", + " done();\r", + " });\r", + " });\r", + "});\r", + "\r", + "\r", + "pm.test(\"Очистка переменных окружения после теста\", function () {\r", + " pm.environment.unset(\"commentIdToDeleteByAdmin\");\r", + " pm.environment.unset(\"eventIdForAdminDeleteVerification\");\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{baseUrl}}/admin/comments/{{commentIdToDeleteByAdmin}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "admin", + "comments", + "{{commentIdToDeleteByAdmin}}" + ] + } + }, + "response": [] + }, + { + "name": "Удаление комментария (админ) – Несуществующий комментарий – 404", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "const baseUrl = pm.collectionVariables.get(\"baseUrl\");\r", + "\r", + "const nonExistentCommentId = Math.floor(Math.random() * 1000000) + 9500000;\r", + "pm.environment.set(\"nonExistentCommentIdForAdmin\", nonExistentCommentId);\r", + "\r", + "console.log(\"Pre-request: Установлен ID несуществующего комментария для удаления админом:\", nonExistentCommentId);\r", + "console.log(\"Pre-request: Настройка для ADMIN DELETE несуществующего комментария завершена.\");" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Код статуса ответа 404 Not Found\", function () {\r", + " pm.response.to.have.status(404);\r", + "});\r", + "\r", + "pm.test(\"Ответ является валидным JSON (для ApiError)\", function () {\r", + " pm.response.to.be.json;\r", + "});\r", + "\r", + "const jsonData = pm.response.json();\r", + "const expectedNonExistentCommentId = pm.environment.get(\"nonExistentCommentIdForAdmin\");\r", + "\r", + "pm.test(\"Тело ответа содержит ожидаемые поля ошибки ApiError (без 'errors')\", function () {\r", + " pm.expect(jsonData).to.be.an('object');\r", + " pm.expect(jsonData).to.have.all.keys('status', 'reason', 'message', 'timestamp');\r", + " pm.expect(jsonData.status).to.equal(\"NOT_FOUND\");\r", + "});\r", + "\r", + "pm.test(\"Сообщение об ошибке (message) не пустое\", function () {\r", + " pm.expect(jsonData.message).to.not.be.empty;\r", + " console.log(\"Сообщение об ошибке: \", jsonData.message);\r", + "});\r", + "\r", + "pm.test(\"Поле 'reason' (причина ошибки) не пустое\", function () {\r", + " pm.expect(jsonData.reason).to.not.be.empty;\r", + " console.log(\"Причина ошибки: \", jsonData.reason);\r", + "});\r", + "\r", + "pm.test(\"Timestamp ошибки присутствует и в правильном формате\", function () {\r", + " pm.expect(jsonData.timestamp).to.be.a('string');\r", + " pm.expect(jsonData.timestamp).to.match(/^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}$/);\r", + "});\r", + "\r", + "pm.test(\"Очистка переменных окружения после теста\", function () {\r", + " pm.environment.unset(\"nonExistentCommentIdForAdmin\");\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{baseUrl}}/admin/comments/{{nonExistentCommentIdForAdmin}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "admin", + "comments", + "{{nonExistentCommentIdForAdmin}}" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Восстановление комментария", + "item": [ + { + "name": "Восстановление комментария (админ) – Успех – 200", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "const randomSuffix = Math.floor(Math.random() * 100000);\r", + "const baseUrl = pm.collectionVariables.get(\"baseUrl\");\r", + "\r", + "function getFormattedFutureDate(hoursToAdd) {\r", + " const date = new Date();\r", + " date.setHours(date.getHours() + hoursToAdd);\r", + " const year = date.getFullYear();\r", + " const month = String(date.getMonth() + 1).padStart(2, '0');\r", + " const day = String(date.getDate()).padStart(2, '0');\r", + " const hours = String(date.getHours()).padStart(2, '0');\r", + " const minutes = String(date.getMinutes()).padStart(2, '0');\r", + " const seconds = String(date.getSeconds()).padStart(2, '0');\r", + " return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;\r", + "}\r", + "\r", + "let commentAuthorUserId;\r", + "let eventIdForComment;\r", + "let commentIdToManage;\r", + "\r", + "pm.sendRequest({\r", + " url: `${baseUrl}/admin/users`,\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: {\r", + " mode: 'raw',\r", + " raw: JSON.stringify({\r", + " email: `testuser_admin_restore_author_${randomSuffix}@example.com`,\r", + " name: `TestUser AdminRestoreAuthor ${randomSuffix}`\r", + " })\r", + " }\r", + "}, (errUser, responseUser) => {\r", + " if (errUser || responseUser.code !== 201) {\r", + " console.error(\"Pre-request: Не удалось создать пользователя (автора комментария):\", errUser || responseUser.text());\r", + " throw new Error(\"Pre-request: Не удалось создать пользователя (автора комментария).\");\r", + " }\r", + " const userData = responseUser.json();\r", + " commentAuthorUserId = userData.id;\r", + " pm.environment.set(\"commentAuthorIdForVerification\", commentAuthorUserId);\r", + " pm.environment.set(\"commentAuthorNameForVerification\", userData.name);\r", + " console.log(\"Pre-request: Пользователь (автор комментария) создан:\", commentAuthorUserId);\r", + "\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/admin/categories`,\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: { mode: 'raw', raw: JSON.stringify({ name: `Category for Admin Restore ${randomSuffix}` }) }\r", + " }, (errCat, responseCat) => {\r", + " if (errCat || responseCat.code !== 201) {\r", + " console.error(\"Pre-request: Не удалось создать категорию:\", errCat || responseCat.text());\r", + " throw new Error(\"Pre-request: Не удалось создать категорию.\");\r", + " }\r", + " const categoryData = responseCat.json();\r", + " console.log(\"Pre-request: Категория создана:\", categoryData.id);\r", + "\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/users/${commentAuthorUserId}/events`,\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: {\r", + " mode: 'raw',\r", + " raw: JSON.stringify({\r", + " annotation: `Event for admin comment restoration ${randomSuffix}`,\r", + " category: categoryData.id,\r", + " description: `Description for admin comment restoration ${randomSuffix}`,\r", + " eventDate: getFormattedFutureDate(3),\r", + " location: { lat: 55.7605 + (randomSuffix / 1000000), lon: 37.6215 + (randomSuffix / 1000000) },\r", + " commentsEnabled: true,\r", + " title: `Event Admin Restore Comment ${randomSuffix}`\r", + " })\r", + " }\r", + " }, (errEvent, responseEvent) => {\r", + " if (errEvent || responseEvent.code !== 201) {\r", + " console.error(\"Pre-request: Не удалось создать событие:\", errEvent || responseEvent.text());\r", + " throw new Error(\"Pre-request: Не удалось создать событие.\");\r", + " }\r", + " const eventData = responseEvent.json();\r", + " eventIdForComment = eventData.id;\r", + " pm.environment.set(\"eventIdForAdminRestoreVerification\", eventIdForComment);\r", + " console.log(\"Pre-request: Событие создано:\", eventIdForComment);\r", + "\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/admin/events/${eventIdForComment}`,\r", + " method: 'PATCH',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: { mode: 'raw', raw: JSON.stringify({ stateAction: \"PUBLISH_EVENT\" }) }\r", + " }, (errPublish, responsePublish) => {\r", + " if (errPublish || responsePublish.code !== 200) {\r", + " console.error(\"Pre-request: Не удалось опубликовать событие:\", errPublish || responsePublish.text());\r", + " throw new Error(\"Pre-request: Не удалось опубликовать событие.\");\r", + " }\r", + " console.log(\"Pre-request: Событие опубликовано:\", eventIdForComment);\r", + "\r", + " const commentText = `Comment to be restored by admin ${randomSuffix}`;\r", + " pm.environment.set(\"originalCommentText\", commentText);\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/users/${commentAuthorUserId}/comments?eventId=${eventIdForComment}`,\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: { mode: 'raw', raw: JSON.stringify({ text: commentText }) }\r", + " }, (errComment, responseComment) => {\r", + " if (errComment || responseComment.code !== 201) {\r", + " console.error(\"Pre-request: Не удалось создать комментарий:\", errComment || responseComment.text());\r", + " throw new Error(\"Pre-request: Не удалось создать комментарий.\");\r", + " }\r", + " const commentData = responseComment.json();\r", + " commentIdToManage = commentData.id;\r", + " pm.environment.set(\"commentIdToRestoreByAdmin\", commentIdToManage);\r", + " console.log(\"Pre-request: Комментарий создан:\", commentIdToManage);\r", + "\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/admin/comments/${commentIdToManage}`,\r", + " method: 'DELETE'\r", + " }, (errFirstDelete, responseFirstDelete) => {\r", + " if (errFirstDelete || responseFirstDelete.code !== 204) {\r", + " console.error(\"Pre-request: Первоначальное 'мягкое' удаление админом не удалось:\", errFirstDelete || responseFirstDelete.text());\r", + " throw new Error(\"Pre-request: Первоначальное 'мягкое' удаление админом не удалось.\");\r", + " }\r", + " console.log(\"Pre-request: Комментарий успешно 'мягко' удален админом (перед восстановлением).\");\r", + " console.log(\"Pre-request: Настройка для ADMIN RESTORE завершена. Основной запрос будет отправлен сейчас.\");\r", + " });\r", + " });\r", + " });\r", + " });\r", + " });\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Код статуса ответа 200 OK\", function () {\r", + " pm.response.to.have.status(200);\r", + "});\r", + "\r", + "pm.test(\"Ответ является валидным JSON (CommentDto)\", function () {\r", + " pm.response.to.be.json;\r", + "});\r", + "\r", + "const restoredComment = pm.response.json();\r", + "const expectedCommentId = parseInt(pm.environment.get(\"commentIdToRestoreByAdmin\"));\r", + "const expectedEventId = parseInt(pm.environment.get(\"eventIdForAdminRestoreVerification\"));\r", + "const expectedAuthorId = parseInt(pm.environment.get(\"commentAuthorIdForVerification\"));\r", + "const expectedAuthorName = pm.environment.get(\"commentAuthorNameForVerification\");\r", + "const originalCommentText = pm.environment.get(\"originalCommentText\");\r", + "\r", + "pm.test(\"Структура восстановленного комментария корректна\", function () {\r", + " pm.expect(restoredComment).to.have.all.keys(\r", + " 'id', 'text', 'author', 'eventId', 'createdOn', 'updatedOn', 'isEdited', 'isDeleted'\r", + " );\r", + " pm.expect(restoredComment.id).to.equal(expectedCommentId);\r", + " pm.expect(restoredComment.text).to.equal(originalCommentText);\r", + " pm.expect(restoredComment.eventId).to.equal(expectedEventId);\r", + " \r", + " pm.expect(restoredComment.author).to.be.an('object');\r", + " pm.expect(restoredComment.author.id).to.equal(expectedAuthorId);\r", + " pm.expect(restoredComment.author.name).to.equal(expectedAuthorName);\r", + "});\r", + "\r", + "pm.test(\"У восстановленного комментария флаг 'isDeleted' равен false\", function () {\r", + " pm.expect(restoredComment.isDeleted).to.be.false;\r", + "});\r", + "\r", + "const baseUrl = pm.collectionVariables.get(\"baseUrl\");\r", + "\r", + "pm.test(\"Проверка (Public API): восстановленный комментарий виден публично\", function (done) {\r", + " if (!expectedEventId || isNaN(expectedCommentId)) {\r", + " pm.expect.fail(\"Необходимые ID для верификации не найдены или некорректны.\");\r", + " done(); return;\r", + " }\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/events/${expectedEventId}/comments`,\r", + " method: 'GET'\r", + " }, function (err, response) {\r", + " if (err) {\r", + " console.error(\"Ошибка при верификационном GET (public):\", err);\r", + " pm.expect.fail(\"Ошибка при верификационном GET (public): \" + err.message);\r", + " done(); return;\r", + " }\r", + " pm.expect(response).to.have.status(200, \"Верификационный GET (public) должен вернуть 200 ОК\");\r", + " const comments = response.json();\r", + " let foundRestoredComment = comments.some(comment => \r", + " comment.id === expectedCommentId && comment.text === originalCommentText\r", + " );\r", + " pm.expect(foundRestoredComment, `Комментарий ID ${expectedCommentId} должен быть найден публично после восстановления`).to.be.true;\r", + " done();\r", + " });\r", + "});\r", + "\r", + "pm.test(\"Очистка переменных окружения после теста\", function () {\r", + " pm.environment.unset(\"commentIdToRestoreByAdmin\");\r", + " pm.environment.unset(\"eventIdForAdminRestoreVerification\");\r", + " pm.environment.unset(\"commentAuthorIdForVerification\");\r", + " pm.environment.unset(\"commentAuthorNameForVerification\");\r", + " pm.environment.unset(\"originalCommentText\");\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "PATCH", + "header": [], + "url": { + "raw": "{{baseUrl}}/admin/comments/{{commentIdToRestoreByAdmin}}/restore", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "admin", + "comments", + "{{commentIdToRestoreByAdmin}}", + "restore" + ] + } + }, + "response": [] + }, + { + "name": "Восстановление комментария (админ) – Комментарий не удалён – 200", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "const randomSuffix = Math.floor(Math.random() * 100000);\r", + "const baseUrl = pm.collectionVariables.get(\"baseUrl\");\r", + "\r", + "function getFormattedFutureDate(hoursToAdd) {\r", + " const date = new Date();\r", + " date.setHours(date.getHours() + hoursToAdd);\r", + " const year = date.getFullYear();\r", + " const month = String(date.getMonth() + 1).padStart(2, '0');\r", + " const day = String(date.getDate()).padStart(2, '0');\r", + " const hours = String(date.getHours()).padStart(2, '0');\r", + " const minutes = String(date.getMinutes()).padStart(2, '0');\r", + " const seconds = String(date.getSeconds()).padStart(2, '0');\r", + " return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;\r", + "}\r", + "\r", + "let commentAuthorUserId;\r", + "let eventIdForComment;\r", + "\r", + "pm.sendRequest({\r", + " url: `${baseUrl}/admin/users`,\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: {\r", + " mode: 'raw',\r", + " raw: JSON.stringify({\r", + " email: `testuser_admin_restore_nondeleted_author_${randomSuffix}@example.com`,\r", + " name: `TestUser AdminRestoreNonDeletedAuthor ${randomSuffix}`\r", + " })\r", + " }\r", + "}, (errUser, responseUser) => {\r", + " if (errUser || responseUser.code !== 201) {\r", + " console.error(\"Pre-request: Не удалось создать пользователя (автора комментария):\", errUser || responseUser.text());\r", + " throw new Error(\"Pre-request: Не удалось создать пользователя (автора комментария).\");\r", + " }\r", + " const userData = responseUser.json();\r", + " commentAuthorUserId = userData.id;\r", + " pm.environment.set(\"commentAuthorIdForVerification\", commentAuthorUserId);\r", + " pm.environment.set(\"commentAuthorNameForVerification\", userData.name);\r", + " console.log(\"Pre-request: Пользователь (автор комментария) создан:\", commentAuthorUserId);\r", + "\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/admin/categories`,\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: { mode: 'raw', raw: JSON.stringify({ name: `Category for Admin Restore NonDeleted ${randomSuffix}` }) }\r", + " }, (errCat, responseCat) => {\r", + " if (errCat || responseCat.code !== 201) {\r", + " console.error(\"Pre-request: Не удалось создать категорию:\", errCat || responseCat.text());\r", + " throw new Error(\"Pre-request: Не удалось создать категорию.\");\r", + " }\r", + " const categoryData = responseCat.json();\r", + " console.log(\"Pre-request: Категория создана:\", categoryData.id);\r", + "\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/users/${commentAuthorUserId}/events`,\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: {\r", + " mode: 'raw',\r", + " raw: JSON.stringify({\r", + " annotation: `Event for admin comment non-deleted restoration ${randomSuffix}`,\r", + " category: categoryData.id,\r", + " description: `Description for admin comment non-deleted restoration ${randomSuffix}`,\r", + " eventDate: getFormattedFutureDate(3),\r", + " location: { lat: 55.7606 + (randomSuffix / 1000000), lon: 37.6216 + (randomSuffix / 1000000) },\r", + " commentsEnabled: true,\r", + " title: `Event Admin Restore NonDeleted Comment ${randomSuffix}`\r", + " })\r", + " }\r", + " }, (errEvent, responseEvent) => {\r", + " if (errEvent || responseEvent.code !== 201) {\r", + " console.error(\"Pre-request: Не удалось создать событие:\", errEvent || responseEvent.text());\r", + " throw new Error(\"Pre-request: Не удалось создать событие.\");\r", + " }\r", + " const eventData = responseEvent.json();\r", + " eventIdForComment = eventData.id;\r", + " pm.environment.set(\"eventIdForAdminRestoreVerification\", eventIdForComment);\r", + " console.log(\"Pre-request: Событие создано:\", eventIdForComment);\r", + "\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/admin/events/${eventIdForComment}`,\r", + " method: 'PATCH',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: { mode: 'raw', raw: JSON.stringify({ stateAction: \"PUBLISH_EVENT\" }) }\r", + " }, (errPublish, responsePublish) => {\r", + " if (errPublish || responsePublish.code !== 200) {\r", + " console.error(\"Pre-request: Не удалось опубликовать событие:\", errPublish || responsePublish.text());\r", + " throw new Error(\"Pre-request: Не удалось опубликовать событие.\");\r", + " }\r", + " console.log(\"Pre-request: Событие опубликовано:\", eventIdForComment);\r", + "\r", + " const commentText = `Non-deleted comment for admin restore test ${randomSuffix}`;\r", + " pm.environment.set(\"originalCommentText\", commentText);\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/users/${commentAuthorUserId}/comments?eventId=${eventIdForComment}`,\r", + " method: 'POST',\r", + " header: {'Content-Type': 'application/json'},\r", + " body: { mode: 'raw', raw: JSON.stringify({ text: commentText }) }\r", + " }, (errComment, responseComment) => {\r", + " if (errComment || responseComment.code !== 201) {\r", + " console.error(\"Pre-request: Не удалось создать комментарий:\", errComment || responseComment.text());\r", + " throw new Error(\"Pre-request: Не удалось создать комментарий.\");\r", + " }\r", + " const commentData = responseComment.json();\r", + " pm.environment.set(\"commentIdToRestoreByAdmin\", commentData.id);\r", + " console.log(\"Pre-request: Комментарий (не удаленный) создан:\", commentData.id);\r", + " console.log(\"Pre-request: Настройка для ADMIN RESTORE (неудаленного комментария) завершена.\");\r", + " });\r", + " });\r", + " });\r", + " });\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Код статуса ответа 200 OK (при 'восстановлении' не удаленного)\", function () {\r", + " pm.response.to.have.status(200);\r", + "});\r", + "\r", + "pm.test(\"Ответ является валидным JSON (CommentDto)\", function () {\r", + " pm.response.to.be.json;\r", + "});\r", + "\r", + "const restoredComment = pm.response.json();\r", + "const expectedCommentId = parseInt(pm.environment.get(\"commentIdToRestoreByAdmin\"));\r", + "const expectedEventId = parseInt(pm.environment.get(\"eventIdForAdminRestoreVerification\"));\r", + "const expectedAuthorId = parseInt(pm.environment.get(\"commentAuthorIdForVerification\"));\r", + "const expectedAuthorName = pm.environment.get(\"commentAuthorNameForVerification\");\r", + "const originalCommentText = pm.environment.get(\"originalCommentText\");\r", + "const initialCreatedOn = restoredComment.createdOn;\r", + "const initialUpdatedOn = restoredComment.updatedOn;\r", + "\r", + "pm.test(\"Структура комментария корректна и ID совпадает\", function () {\r", + " pm.expect(restoredComment).to.have.all.keys(\r", + " 'id', 'text', 'author', 'eventId', 'createdOn', 'updatedOn', 'isEdited', 'isDeleted'\r", + " );\r", + " pm.expect(restoredComment.id).to.equal(expectedCommentId);\r", + " pm.expect(restoredComment.text).to.equal(originalCommentText);\r", + " pm.expect(restoredComment.eventId).to.equal(expectedEventId);\r", + " \r", + " pm.expect(restoredComment.author).to.be.an('object');\r", + " pm.expect(restoredComment.author.id).to.equal(expectedAuthorId);\r", + " pm.expect(restoredComment.author.name).to.equal(expectedAuthorName);\r", + "});\r", + "\r", + "pm.test(\"У комментария флаг 'isDeleted' равен false (как и был)\", function () {\r", + " pm.expect(restoredComment.isDeleted).to.be.false;\r", + "});\r", + "\r", + "pm.test(\"Время обновления 'updatedOn' не изменилось или осталось null (если не было обновлений)\", function () {\r", + " pm.expect(restoredComment.updatedOn).to.equal(initialUpdatedOn);\r", + "});\r", + " pm.test(\"Флаг 'isEdited' не изменился\", function () {\r", + " pm.expect(restoredComment.isEdited).to.be.false;\r", + "});\r", + "\r", + "\r", + "const baseUrl = pm.collectionVariables.get(\"baseUrl\");\r", + "\r", + "pm.test(\"Проверка (Public API): 'восстановленный' (не удаленный) комментарий по-прежнему виден публично\", function (done) {\r", + " if (!expectedEventId || isNaN(expectedCommentId)) {\r", + " pm.expect.fail(\"Необходимые ID для верификации не найдены или некорректны.\");\r", + " done(); return;\r", + " }\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/events/${expectedEventId}/comments`,\r", + " method: 'GET'\r", + " }, function (err, response) {\r", + " if (err) {\r", + " console.error(\"Ошибка при верификационном GET (public):\", err);\r", + " pm.expect.fail(\"Ошибка при верификационном GET (public): \" + err.message);\r", + " done(); return;\r", + " }\r", + " pm.expect(response).to.have.status(200, \"Верификационный GET (public) должен вернуть 200 ОК\");\r", + " const comments = response.json();\r", + " let foundComment = comments.some(comment => \r", + " comment.id === expectedCommentId && comment.text === originalCommentText\r", + " );\r", + " pm.expect(foundComment, `Комментарий ID ${expectedCommentId} должен быть найден публично`).to.be.true;\r", + " done();\r", + " });\r", + "});\r", + "\r", + "pm.test(\"Очистка переменных окружения после теста\", function () {\r", + " pm.environment.unset(\"commentIdToRestoreByAdmin\");\r", + " pm.environment.unset(\"eventIdForAdminRestoreVerification\");\r", + " pm.environment.unset(\"commentAuthorIdForVerification\");\r", + " pm.environment.unset(\"commentAuthorNameForVerification\");\r", + " pm.environment.unset(\"originalCommentText\");\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "PATCH", + "header": [], + "url": { + "raw": "{{baseUrl}}/admin/comments/{{commentIdToRestoreByAdmin}}/restore", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "admin", + "comments", + "{{commentIdToRestoreByAdmin}}", + "restore" + ] + } + }, + "response": [] + }, + { + "name": "Восстановление комментария (админ) – Несуществующий комментарий – 404", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "const baseUrl = pm.collectionVariables.get(\"baseUrl\");\r", + "\r", + "const nonExistentCommentId = Math.floor(Math.random() * 1000000) + 9600000;\r", + "pm.environment.set(\"nonExistentCommentIdForAdminRestore\", nonExistentCommentId);\r", + "\r", + "console.log(\"Pre-request: Установлен ID несуществующего комментария для 'восстановления' админом:\", nonExistentCommentId);\r", + "console.log(\"Pre-request: Настройка для ADMIN RESTORE несуществующего комментария завершена.\");" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Код статуса ответа 404 Not Found\", function () {\r", + " pm.response.to.have.status(404);\r", + "});\r", + "\r", + "pm.test(\"Ответ является валидным JSON (для ApiError)\", function () {\r", + " pm.response.to.be.json;\r", + "});\r", + "\r", + "const jsonData = pm.response.json();\r", + "const expectedNonExistentCommentId = pm.environment.get(\"nonExistentCommentIdForAdminRestore\");\r", + "\r", + "pm.test(\"Тело ответа содержит ожидаемые поля ошибки ApiError (без 'errors')\", function () {\r", + " pm.expect(jsonData).to.be.an('object');\r", + " pm.expect(jsonData).to.have.all.keys('status', 'reason', 'message', 'timestamp');\r", + " pm.expect(jsonData.status).to.equal(\"NOT_FOUND\");\r", + "});\r", + "\r", + "pm.test(\"Сообщение об ошибке (message) не пустое\", function () {\r", + " pm.expect(jsonData.message).to.not.be.empty;\r", + " console.log(\"Сообщение об ошибке: \", jsonData.message);\r", + "});\r", + "\r", + "pm.test(\"Поле 'reason' (причина ошибки) не пустое\", function () {\r", + " pm.expect(jsonData.reason).to.not.be.empty;\r", + " console.log(\"Причина ошибки: \", jsonData.reason);\r", + "});\r", + "\r", + "pm.test(\"Timestamp ошибки присутствует и в правильном формате\", function () {\r", + " pm.expect(jsonData.timestamp).to.be.a('string');\r", + " pm.expect(jsonData.timestamp).to.match(/^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}$/);\r", + "});\r", + "\r", + "pm.test(\"Очистка переменных окружения после теста\", function () {\r", + " pm.environment.unset(\"nonExistentCommentIdForAdminRestore\");\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "PATCH", + "header": [], + "url": { + "raw": "{{baseUrl}}/admin/comments/{{nonExistentCommentIdForAdminRestore}}/restore", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "admin", + "comments", + "{{nonExistentCommentIdForAdminRestore}}", + "restore" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Получение комметариев", + "item": [ + { + "name": "Получение комментариев (админ) – Без фильтров – 200", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "const randomSuffix = Math.floor(Math.random() * 100000);\r", + "const baseUrl = pm.collectionVariables.get(\"baseUrl\");\r", + "const numberOfUsers = 2;\r", + "const numberOfEventsPerUser = 1;\r", + "const numberOfCommentsPerEvent = 2;\r", + "const commentCreationDelay = 25;\r", + "\r", + "let createdUsers = [];\r", + "let createdEvents = [];\r", + "let allCreatedComments = [];\r", + "\r", + "let usersToCreate = numberOfUsers;\r", + "let eventsToCreateTotal = numberOfUsers * numberOfEventsPerUser;\r", + "let commentsToCreateTotal = eventsToCreateTotal * numberOfCommentsPerEvent;\r", + "\r", + "let usersCreatedCount = 0;\r", + "let eventsCreatedCount = 0;\r", + "let commentsCreatedCount = 0;\r", + "\r", + "function getFormattedFutureDate(hoursToAdd) {\r", + " const date = new Date();\r", + " date.setHours(date.getHours() + hoursToAdd);\r", + " const year = date.getFullYear();\r", + " const month = String(date.getMonth() + 1).padStart(2, '0');\r", + " const day = String(date.getDate()).padStart(2, '0');\r", + " const hours = String(date.getHours()).padStart(2, '0');\r", + " const minutes = String(date.getMinutes()).padStart(2, '0');\r", + " const seconds = String(date.getSeconds()).padStart(2, '0');\r", + " return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;\r", + "}\r", + "\r", + "function checkCompletion() {\r", + " if (usersCreatedCount === usersToCreate && \r", + " eventsCreatedCount === eventsToCreateTotal &&\r", + " commentsCreatedCount === commentsToCreateTotal) {\r", + " \r", + " allCreatedComments.sort((a, b) => {\r", + " const dateA = new Date(a.createdOn.replace(' ', 'T') + 'Z').getTime();\r", + " const dateB = new Date(b.createdOn.replace(' ', 'T') + 'Z').getTime();\r", + " if (dateB !== dateA) return dateB - dateA;\r", + " return b.id - a.id;\r", + " });\r", + " pm.environment.set(\"allCreatedCommentsForAdminGet\", JSON.stringify(allCreatedComments));\r", + " console.log(\"AdminGet PreReq: All entities created and data set for test.\");\r", + " console.log(\"Created comments:\", JSON.stringify(allCreatedComments, null, 2));\r", + " }\r", + "}\r", + "\r", + "function createComment(user, event, commentIndexInEvent, totalCommentsForThisEvent, isActuallyDeleted) {\r", + " const commentText = `Comment ${commentIndexInEvent} by U${user.id} on E${event.id} ${isActuallyDeleted ? '(del)' : ''} ${randomSuffix}`;\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/users/${user.id}/comments?eventId=${event.id}`, method: 'POST',\r", + " header: {'Content-Type': 'application/json'}, body: {mode: 'raw', raw: JSON.stringify({text: commentText})}\r", + " }, (err, res) => {\r", + " if (err || res.code !== 201) {\r", + " console.error(\"AdminGet PreReq: Comment creation failed\", err || res.text());\r", + " throw new Error(\"AdminGet PreReq: Comment creation failed\");\r", + " }\r", + " const commentData = res.json();\r", + " let finalCommentData = {...commentData, author: {id: user.id, name: user.name}, isDeleted: false};\r", + "\r", + " if (isActuallyDeleted) {\r", + " pm.sendRequest({ url: `${baseUrl}/admin/comments/${commentData.id}`, method: 'DELETE'}, (errDel, resDel) => {\r", + " if (errDel || resDel.code !== 204) {\r", + " console.error(\"AdminGet PreReq: Admin comment soft delete failed\", errDel || resDel.text());\r", + " throw new Error(\"AdminGet PreReq: Admin comment soft delete failed\");\r", + " } else { finalCommentData.isDeleted = true; }\r", + " allCreatedComments.push(finalCommentData);\r", + " commentsCreatedCount++;\r", + " console.log(`AdminGet PreReq: Comment ${commentData.id} (createdOn: ${commentData.createdOn}, isDel:${finalCommentData.isDeleted}) created.`);\r", + " checkCompletion();\r", + " });\r", + " } else {\r", + " allCreatedComments.push(finalCommentData);\r", + " commentsCreatedCount++;\r", + " console.log(`AdminGet PreReq: Comment ${commentData.id} (createdOn: ${commentData.createdOn}, isDel:${finalCommentData.isDeleted}) created.`);\r", + " checkCompletion();\r", + " }\r", + " });\r", + "}\r", + "\r", + "pm.sendRequest({\r", + " url: `${baseUrl}/admin/categories`, method: 'POST', header: {'Content-Type': 'application/json'},\r", + " body: { mode: 'raw', raw: JSON.stringify({ name: `Category AdminGetComments ${randomSuffix}` })}\r", + "}, (errCat, resCat) => {\r", + " if (errCat || resCat.code !== 201) {\r", + " console.error(\"AdminGet PreReq: Category creation failed\", errCat || resCat.text());\r", + " throw new Error(\"AdminGet PreReq: Category creation failed\");\r", + " }\r", + " const category = resCat.json();\r", + "\r", + " for (let i = 0; i < numberOfUsers; i++) {\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/admin/users`, method: 'POST', header: {'Content-Type': 'application/json'},\r", + " body: { mode: 'raw', raw: JSON.stringify({\r", + " email: `testuser_adminget_${i}_${randomSuffix}@example.com`,\r", + " name: `TestUser AdminGet ${i} ${randomSuffix}`\r", + " })}\r", + " }, (errUser, resUser) => {\r", + " if (errUser || resUser.code !== 201) {\r", + " console.error(\"AdminGet PreReq: User creation failed\", errUser || resUser.text());\r", + " throw new Error(\"AdminGet PreReq: User creation failed\");\r", + " }\r", + " const user = resUser.json();\r", + " createdUsers.push(user);\r", + " usersCreatedCount++;\r", + "\r", + " for (let j = 0; j < numberOfEventsPerUser; j++) {\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/users/${user.id}/events`, method: 'POST', header: {'Content-Type': 'application/json'},\r", + " body: { mode: 'raw', raw: JSON.stringify({\r", + " title: `Event ${j} by U${user.id} AG ${randomSuffix}`,\r", + " annotation: `Annotation with sufficient length ${randomSuffix}`, category: category.id, description: `Description with sufficient length ${randomSuffix}`,\r", + " eventDate: getFormattedFutureDate(3 + j), location: { lat: 55.7 + (j/100), lon: 37.6 + (j/100) },\r", + " commentsEnabled: true\r", + " })}\r", + " }, (errEvt, resEvt) => {\r", + " if (errEvt || resEvt.code !== 201) {\r", + " console.error(\"AdminGet PreReq: Event creation failed\", errEvt || resEvt.text());\r", + " throw new Error(\"AdminGet PreReq: Event creation failed\");\r", + " }\r", + " const event = resEvt.json();\r", + " \r", + " pm.sendRequest({\r", + " url: `${baseUrl}/admin/events/${event.id}`, method: 'PATCH', header: {'Content-Type': 'application/json'},\r", + " body: { mode: 'raw', raw: JSON.stringify({ stateAction: \"PUBLISH_EVENT\" })}\r", + " }, (errPub, resPub) => {\r", + " if (errPub || resPub.code !== 200) {\r", + " console.error(\"AdminGet PreReq: Event publish failed\", errPub || resPub.text());\r", + " throw new Error(\"AdminGet PreReq: Event publish failed\");\r", + " }\r", + " createdEvents.push(event);\r", + " eventsCreatedCount++;\r", + " \r", + " for (let k = 0; k < numberOfCommentsPerEvent; k++) {\r", + " const shouldBeDeleted = (k % 2 === 0);\r", + " setTimeout(() => createComment(user, event, k, numberOfCommentsPerEvent, shouldBeDeleted), ((((i * numberOfEventsPerUser) + j) * numberOfCommentsPerEvent) + k) * commentCreationDelay);\r", + " }\r", + " checkCompletion();\r", + " });\r", + " });\r", + " }\r", + " checkCompletion();\r", + " });\r", + " }\r", + " checkCompletion();\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Код статуса ответа 200 OK\", function () {\r", + " pm.response.to.have.status(200);\r", + "});\r", + "\r", + "pm.test(\"Ответ является валидным JSON и представляет собой массив\", function () {\r", + " pm.response.to.be.json;\r", + " const jsonData = pm.response.json();\r", + " pm.expect(jsonData).to.be.an('array');\r", + "});\r", + "\r", + "const returnedComments = pm.response.json();\r", + "const allExpectedComments = JSON.parse(pm.environment.get(\"allCreatedCommentsForAdminGet\") || \"[]\");\r", + "\r", + "const defaultPageSize = 10;\r", + "const expectedCountOnDefaultPage = Math.min(allExpectedComments.length, defaultPageSize);\r", + "\r", + "pm.test(`Возвращено не более ${defaultPageSize} комментариев (пагинация по умолчанию)`, function () {\r", + " pm.expect(returnedComments.length).to.at.most(defaultPageSize);\r", + "});\r", + "\r", + "if (returnedComments.length > 0) {\r", + " pm.test(\"Структура каждого комментария в ответе корректна (включая isDeleted)\", function () {\r", + " returnedComments.forEach(function(comment, index) {\r", + " pm.expect(comment, `Комментарий #${index}`).to.have.all.keys(\r", + " 'id', 'text', 'author', 'eventId', 'createdOn', 'updatedOn', 'isEdited', 'isDeleted'\r", + " );\r", + " pm.expect(comment.id, `ID комментария #${index}`).to.be.a('number');\r", + " pm.expect(comment.text, `Текст комментария #${index}`).to.be.a('string');\r", + " \r", + " pm.expect(comment.author, `Автор комментария #${index}`).to.be.an('object');\r", + " pm.expect(comment.author.id, `ID автора комментария #${index}`).to.be.a('number');\r", + " pm.expect(comment.author.name, `Имя автора комментария #${index}`).to.be.a('string');\r", + "\r", + " pm.expect(comment.eventId, `ID события комментария #${index}`).to.be.a('number');\r", + " pm.expect(comment.createdOn, `Время создания комментария #${index}`).to.match(/^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}$/);\r", + " pm.expect(comment.isEdited, `Флаг isEdited комментария #${index}`).to.be.a('boolean');\r", + " pm.expect(comment.isDeleted, `Флаг isDeleted комментария #${index}`).to.be.a('boolean');\r", + " });\r", + " });\r", + "\r", + " pm.test(\"Полученные комментарии соответствуют ожидаемым (самые свежие, пагинация по умолчанию)\", function() {\r", + " for(let i=0; i < allExpectedComments.length; i++) {\r", + " const returned = returnedComments[i];\r", + " const expected = allExpectedComments[i];\r", + " pm.expect(returned.id, `ID комментария ${i}`).to.equal(expected.id);\r", + " pm.expect(returned.text, `Текст комментария ${i}`).to.equal(expected.text);\r", + " pm.expect(returned.author.id, `Author ID ${i}`).to.equal(expected.author.id);\r", + " pm.expect(returned.eventId, `Event ID ${i}`).to.equal(expected.eventId);\r", + " pm.expect(returned.isDeleted, `isDeleted ${i}`).to.equal(expected.isDeleted);\r", + " }\r", + " });\r", + "} else if (allExpectedComments.length > 0) {\r", + " pm.test(\"Ожидались комментарии, но получен пустой массив (без фильтров)\", function() {\r", + " pm.expect.fail(\"Созданные комментарии не были возвращены.\");\r", + " });\r", + "}\r", + "\r", + "pm.test(\"Очистка переменных окружения после теста\", function () {\r", + " pm.environment.unset(\"allCreatedCommentsForAdminGet\");\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/admin/comments", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "admin", + "comments" + ] + } + }, + "response": [] + }, + { + "name": "Получение комментариев (админ) – Конкретный пользователь, Удалённые – 200", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "const randomSuffix = Math.floor(Math.random() * 100000);\r", + "const baseUrl = pm.collectionVariables.get(\"baseUrl\");\r", + "\r", + "const numCommentsTargetUser = 7;\r", + "const numDeletedTargetUser = 5;\r", + "const numCommentsOtherUser = 3;\r", + "\r", + "let targetUserData, otherUserData;\r", + "let categoryDataGlobal;\r", + "let targetUserEvents = [];\r", + "let otherUserEvents = [];\r", + "let allCreatedCommentsForTest = [];\r", + "\r", + "function getFormattedFutureDate(hoursToAdd) {\r", + " const date = new Date();\r", + " date.setHours(date.getHours() + hoursToAdd);\r", + " const year = date.getFullYear();\r", + " const month = String(date.getMonth() + 1).padStart(2, '0');\r", + " const day = String(date.getDate()).padStart(2, '0');\r", + " const hours = String(date.getHours()).padStart(2, '0');\r", + " const minutes = String(date.getMinutes()).padStart(2, '0');\r", + " const seconds = String(date.getSeconds()).padStart(2, '0');\r", + " return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;\r", + "}\r", + "\r", + "function createUser(emailPrefix, namePrefix, callback) {\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/admin/users`, method: 'POST', header: {'Content-Type': 'application/json'},\r", + " body: { mode: 'raw', raw: JSON.stringify({\r", + " email: `${emailPrefix}_${randomSuffix}@example.com`,\r", + " name: `${namePrefix} ${randomSuffix}`\r", + " })}\r", + " }, (err, res) => {\r", + " if (err || res.code !== 201) { console.error(`PreReq: User creation failed for ${namePrefix}`, err || res.text()); throw new Error(\"User creation failed\"); }\r", + " const user = res.json();\r", + " console.log(`PreReq: User ${user.id} (${namePrefix}) created.`);\r", + " callback(user);\r", + " });\r", + "}\r", + "\r", + "function createCategory(callback) {\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/admin/categories`, method: 'POST', header: {'Content-Type': 'application/json'},\r", + " body: { mode: 'raw', raw: JSON.stringify({ name: `Category AdminGet DeletedByUser ${randomSuffix}` })}\r", + " }, (err, res) => {\r", + " if (err || res.code !== 201) { console.error(\"PreReq: Category creation failed\", err || res.text()); throw new Error(\"Category creation failed\"); }\r", + " categoryDataGlobal = res.json();\r", + " console.log(\"PreReq: Category created.\");\r", + " callback(categoryDataGlobal);\r", + " });\r", + "}\r", + "\r", + "function createEvent(user, category, eventIndex, callback) {\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/users/${user.id}/events`, method: 'POST', header: {'Content-Type': 'application/json'},\r", + " body: { mode: 'raw', raw: JSON.stringify({\r", + " title: `Event ${eventIndex} by User ${user.id} ADGDBU ${randomSuffix}`,\r", + " annotation: `Annotation with sufficient length ${randomSuffix}`, category: category.id, description: `Description with sufficient length ${randomSuffix}`,\r", + " eventDate: getFormattedFutureDate(3 + eventIndex), location: { lat: 55.71 + (eventIndex/100), lon: 37.61 + (eventIndex/100) },\r", + " commentsEnabled: true\r", + " })}\r", + " }, (err, res) => {\r", + " if (err || res.code !== 201) { console.error(`PreReq: Event creation failed for User ${user.id}`, err || res.text()); throw new Error(\"Event creation failed\"); }\r", + " const eventData = res.json();\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/admin/events/${eventData.id}`, method: 'PATCH', header: {'Content-Type': 'application/json'},\r", + " body: { mode: 'raw', raw: JSON.stringify({ stateAction: \"PUBLISH_EVENT\" })}\r", + " }, (errPub, resPub) => {\r", + " if (errPub || resPub.code !== 200) { console.error(\"PreReq: Event publish failed\", errPub || resPub.text()); throw new Error(\"Event publish failed\"); }\r", + " console.log(`PreReq: Event ${eventData.id} (User ${user.id}) created and published.`);\r", + " callback(eventData);\r", + " });\r", + " });\r", + "}\r", + "\r", + "function createCommentChain(user, events, numTotal, numToDelete, commentPrefix, finalCallback) {\r", + " let commentsCreated = 0;\r", + " let localComments = [];\r", + "\r", + " function addComment(index) {\r", + " if (index >= numTotal) {\r", + " finalCallback(localComments);\r", + " return;\r", + " }\r", + " const eventForComment = events[index % events.length];\r", + " const text = `${commentPrefix} ${index} on Evt ${eventForComment.id} ${randomSuffix}`;\r", + " const shouldDelete = index < numToDelete;\r", + "\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/users/${user.id}/comments?eventId=${eventForComment.id}`, method: 'POST',\r", + " header: {'Content-Type': 'application/json'}, body: {mode: 'raw', raw: JSON.stringify({text: text})}\r", + " }, (err, res) => {\r", + " if (err || res.code !== 201) { console.error(`PreReq: Comment creation failed for ${text}`, err || res.text()); throw new Error(\"Comment creation failed\"); }\r", + " let comment = res.json();\r", + " comment.author = { id: user.id, name: user.name };\r", + " comment.isDeleted = false;\r", + "\r", + " if (shouldDelete) {\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/admin/comments/${comment.id}`, method: 'DELETE'\r", + " }, (errDel, resDel) => {\r", + " if (errDel || resDel.code !== 204) { console.error(`PreReq: Soft delete failed for comment ${comment.id}`, errDel || resDel.text()); throw new Error(\"Soft delete failed\"); }\r", + " comment.isDeleted = true;\r", + " console.log(`PreReq: Comment ${comment.id} (User ${user.id}) created and soft-deleted.`);\r", + " localComments.push(comment);\r", + " allCreatedCommentsForTest.push(comment);\r", + " addComment(index + 1);\r", + " });\r", + " } else {\r", + " console.log(`PreReq: Comment ${comment.id} (User ${user.id}) created (not deleted).`);\r", + " localComments.push(comment);\r", + " allCreatedCommentsForTest.push(comment);\r", + " addComment(index + 1);\r", + " }\r", + " });\r", + " }\r", + " addComment(0);\r", + "}\r", + "\r", + "createCategory(category => {\r", + " createUser(\"target_user_adgdbu\", \"TargetUser ADGDBU\", targetUser => {\r", + " targetUserData = targetUser;\r", + " pm.environment.set(\"targetUserId\", targetUser.id);\r", + "\r", + " createUser(\"other_user_adgdbu\", \"OtherUser ADGDBU\", otherUser => {\r", + " otherUserData = otherUser;\r", + "\r", + " createEvent(targetUser, category, 0, eventT1 => {\r", + " targetUserEvents.push(eventT1);\r", + " createEvent(targetUser, category, 1, eventT2 => {\r", + " targetUserEvents.push(eventT2);\r", + " \r", + " createEvent(otherUser, category, 0, eventO1 => {\r", + " otherUserEvents.push(eventO1);\r", + "\r", + " createCommentChain(targetUser, targetUserEvents, numCommentsTargetUser, numDeletedTargetUser, \"TargetC\", targetUserComments => {\r", + " createCommentChain(otherUser, otherUserEvents, numCommentsOtherUser, 1, \"OtherC\", otherUserComments => {\r", + " \r", + " let expectedResult = allCreatedCommentsForTest.filter(c => \r", + " c.author.id === targetUserData.id && c.isDeleted === true\r", + " );\r", + " expectedResult.sort((a, b) => {\r", + " const dateA = new Date(a.createdOn.replace(' ', 'T') + 'Z').getTime();\r", + " const dateB = new Date(b.createdOn.replace(' ', 'T') + 'Z').getTime();\r", + " if (dateB !== dateA) return dateB - dateA;\r", + " return b.id - a.id;\r", + " });\r", + " \r", + " pm.environment.set(\"expectedDeletedCommentsForTargetUser\", JSON.stringify(expectedResult));\r", + " console.log(\"PreReq: All entities created. Expected target user's deleted comments count: \" + expectedResult.length);\r", + " console.log(\"PreReq: Setup complete for GET deleted comments by user.\");\r", + " });\r", + " });\r", + " });\r", + " });\r", + " });\r", + " });\r", + " });\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Код статуса ответа 200 OK\", function () {\r", + " pm.response.to.have.status(200);\r", + "});\r", + "\r", + "pm.test(\"Ответ является валидным JSON и представляет собой массив\", function () {\r", + " pm.response.to.be.json;\r", + " const jsonData = pm.response.json();\r", + " pm.expect(jsonData).to.be.an('array');\r", + "});\r", + "\r", + "const returnedComments = pm.response.json();\r", + "const targetUserId = parseInt(pm.environment.get(\"targetUserId\"));\r", + "const allExpectedForTargetUserAndDeleted = JSON.parse(pm.environment.get(\"expectedDeletedCommentsForTargetUser\") || \"[]\");\r", + "\r", + "const requestSize = 5;\r", + "const expectedCountOnPage = Math.min(allExpectedForTargetUserAndDeleted.length, requestSize);\r", + "\r", + "pm.test(`Возвращено ${expectedCountOnPage} комментариев (согласно size=${requestSize} и фильтрам)`, function () {\r", + " pm.expect(returnedComments.length).to.equal(expectedCountOnPage);\r", + "});\r", + "\r", + "if (returnedComments.length > 0) {\r", + " pm.test(\"Все возвращенные комментарии принадлежат targetUserId и имеют isDeleted=true\", function () {\r", + " returnedComments.forEach(function(comment, index) {\r", + " pm.expect(comment, `Комментарий #${index}`).to.have.all.keys(\r", + " 'id', 'text', 'author', 'eventId', 'createdOn', 'updatedOn', 'isEdited', 'isDeleted'\r", + " );\r", + " pm.expect(comment.author.id, `ID автора комментария #${index}`).to.equal(targetUserId);\r", + " pm.expect(comment.isDeleted, `Флаг isDeleted комментария #${index}`).to.be.true;\r", + " });\r", + " });\r", + "\r", + " pm.test(\"Содержимое и порядок комментариев соответствуют ожидаемым\", function() {\r", + " const expectedSlice = allExpectedForTargetUserAndDeleted.slice(0, requestSize);\r", + "\r", + " pm.expect(returnedComments.length).to.equal(expectedSlice.length, \"Длина полученного массива должна совпадать с ожидаемым срезом\");\r", + "\r", + " returnedComments.forEach((returned, i) => {\r", + " const expected = expectedSlice[i];\r", + " if (!expected) {\r", + " pm.expect.fail(`Ожидаемый комментарий с индексом ${i} отсутствует в expectedSlice`);\r", + " return;\r", + " }\r", + " pm.expect(returned.id, `ID комментария ${i}`).to.equal(expected.id);\r", + " pm.expect(returned.text, `Текст комментария ${i}`).to.equal(expected.text);\r", + " pm.expect(returned.author.id, `Author ID ${i}`).to.equal(expected.author.id);\r", + " pm.expect(returned.eventId, `Event ID ${i}`).to.equal(expected.eventId);\r", + " pm.expect(returned.isDeleted, `isDeleted ${i}`).to.be.true;\r", + " });\r", + " });\r", + "\r", + "} else if (expectedCountOnPage > 0) {\r", + " pm.test(\"Ожидались комментарии, но получен пустой массив (с фильтрами userId, isDeleted)\", function() {\r", + " pm.expect.fail(\"Созданные и удаленные комментарии целевого пользователя не были возвращены.\");\r", + " });\r", + "}\r", + "\r", + "pm.test(\"Очистка переменных окружения после теста\", function () {\r", + " pm.environment.unset(\"targetUserId\");\r", + " pm.environment.unset(\"expectedDeletedCommentsForTargetUser\");\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/admin/comments?userId={{targetUserId}}&isDeleted=true&from=0&size=5", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "admin", + "comments" + ], + "query": [ + { + "key": "userId", + "value": "{{targetUserId}}" + }, + { + "key": "isDeleted", + "value": "true" + }, + { + "key": "from", + "value": "0" + }, + { + "key": "size", + "value": "5" + } + ] + } + }, + "response": [] + }, + { + "name": "Получение комментариев (админ) – Конкретное событие, Не-удалённые, 2-я страница – 200", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "const randomSuffix = Math.floor(Math.random() * 100000);\r", + "const baseUrl = pm.collectionVariables.get(\"baseUrl\");\r", + "\r", + "const numNonDeletedForTargetEvent = 7;\r", + "const numDeletedForTargetEvent = 2;\r", + "const numCommentsForOtherEvent = 3;\r", + "\r", + "const fromParamForTest = 3;\r", + "const sizeParamForTest = 3;\r", + "\r", + "let mainTestUser, categoryDataGlobal;\r", + "let targetEventData, otherEventData;\r", + "let allCreatedCommentsForTest = [];\r", + "\r", + "function getFormattedFutureDate(hoursToAdd) {\r", + " const date = new Date();\r", + " date.setHours(date.getHours() + hoursToAdd);\r", + " const year = date.getFullYear();\r", + " const month = String(date.getMonth() + 1).padStart(2, '0');\r", + " const day = String(date.getDate()).padStart(2, '0');\r", + " const hours = String(date.getHours()).padStart(2, '0');\r", + " const minutes = String(date.getMinutes()).padStart(2, '0');\r", + " const seconds = String(date.getSeconds()).padStart(2, '0');\r", + " return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;\r", + "}\r", + "\r", + "function createUser(emailPrefix, namePrefix, callback) {\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/admin/users`, method: 'POST', header: {'Content-Type': 'application/json'},\r", + " body: { mode: 'raw', raw: JSON.stringify({\r", + " email: `${emailPrefix}_${randomSuffix}@example.com`,\r", + " name: `${namePrefix} ${randomSuffix}`\r", + " })}\r", + " }, (err, res) => {\r", + " if (err || res.code !== 201) { console.error(`PreReq: User creation failed for ${namePrefix}`, err || res.text()); throw new Error(\"User creation failed\"); }\r", + " const user = res.json();\r", + " console.log(`PreReq: User ${user.id} (${namePrefix}) created.`);\r", + " callback(user);\r", + " });\r", + "}\r", + "\r", + "function createCategory(callback) {\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/admin/categories`, method: 'POST', header: {'Content-Type': 'application/json'},\r", + " body: { mode: 'raw', raw: JSON.stringify({ name: `Category AdminGet EvtPage ${randomSuffix}` })}\r", + " }, (err, res) => {\r", + " if (err || res.code !== 201) { console.error(\"PreReq: Category creation failed\", err || res.text()); throw new Error(\"Category creation failed\"); }\r", + " categoryDataGlobal = res.json();\r", + " console.log(\"PreReq: Category created.\");\r", + " callback(categoryDataGlobal);\r", + " });\r", + "}\r", + "\r", + "function createEvent(user, category, eventNameSuffix, callback) {\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/users/${user.id}/events`, method: 'POST', header: {'Content-Type': 'application/json'},\r", + " body: { mode: 'raw', raw: JSON.stringify({\r", + " title: `Event ${eventNameSuffix} ADGEP ${randomSuffix}`,\r", + " annotation: `Annotation with sufficient length ${randomSuffix}`, category: category.id, description: `Description with sufficient length ${randomSuffix}`,\r", + " eventDate: getFormattedFutureDate(3 + parseInt(eventNameSuffix.slice(-1))), location: { lat: 55.72 + (parseInt(eventNameSuffix.slice(-1))/100), lon: 37.62 + (parseInt(eventNameSuffix.slice(-1))/100) },\r", + " commentsEnabled: true\r", + " })}\r", + " }, (err, res) => {\r", + " if (err || res.code !== 201) { console.error(`PreReq: Event creation failed for ${eventNameSuffix}`, err || res.text()); throw new Error(\"Event creation failed\"); }\r", + " const eventData = res.json();\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/admin/events/${eventData.id}`, method: 'PATCH', header: {'Content-Type': 'application/json'},\r", + " body: { mode: 'raw', raw: JSON.stringify({ stateAction: \"PUBLISH_EVENT\" })}\r", + " }, (errPub, resPub) => {\r", + " if (errPub || resPub.code !== 200) { console.error(\"PreReq: Event publish failed\", errPub || resPub.text()); throw new Error(\"Event publish failed\"); }\r", + " console.log(`PreReq: Event ${eventData.id} (${eventNameSuffix}) created and published.`);\r", + " callback(eventData);\r", + " });\r", + " });\r", + "}\r", + "\r", + "function createCommentChainForEvent(user, event, numTotal, numToDelete, commentPrefix, finalCallback) {\r", + " let commentsCreated = 0;\r", + " let localComments = [];\r", + "\r", + " function addComment(index) {\r", + " if (index >= numTotal) {\r", + " finalCallback(localComments);\r", + " return;\r", + " }\r", + " const text = `${commentPrefix} ${index} on Evt ${event.id} ${randomSuffix}`;\r", + " const shouldDelete = index < numToDelete;\r", + "\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/users/${user.id}/comments?eventId=${event.id}`, method: 'POST',\r", + " header: {'Content-Type': 'application/json'}, body: {mode: 'raw', raw: JSON.stringify({text: text})}\r", + " }, (err, res) => {\r", + " if (err || res.code !== 201) { console.error(`PreReq: Comment creation failed for ${text}`, err || res.text()); throw new Error(\"Comment creation failed\"); }\r", + " let comment = res.json();\r", + " comment.author = { id: user.id, name: user.name };\r", + " comment.isDeleted = false;\r", + "\r", + " if (shouldDelete) {\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/admin/comments/${comment.id}`, method: 'DELETE'\r", + " }, (errDel, resDel) => {\r", + " if (errDel || resDel.code !== 204) { console.error(`PreReq: Soft delete failed for comment ${comment.id}`, errDel || resDel.text()); throw new Error(\"Soft delete failed\"); }\r", + " comment.isDeleted = true;\r", + " console.log(`PreReq: Comment ${comment.id} (User ${user.id}, Evt ${event.id}) created and soft-deleted.`);\r", + " localComments.push(comment);\r", + " allCreatedCommentsForTest.push(comment);\r", + " addComment(index + 1);\r", + " });\r", + " } else {\r", + " console.log(`PreReq: Comment ${comment.id} (User ${user.id}, Evt ${event.id}) created (not deleted).`);\r", + " localComments.push(comment);\r", + " allCreatedCommentsForTest.push(comment);\r", + " addComment(index + 1);\r", + " }\r", + " });\r", + " }\r", + " addComment(0);\r", + "}\r", + "\r", + "createCategory(category => {\r", + " createUser(\"main_user_adgep\", \"MainUser ADGEP\", user => {\r", + " mainTestUser = user;\r", + "\r", + " createEvent(user, category, \"TargetEvent1\", targetEvent => {\r", + " targetEventData = targetEvent;\r", + " pm.environment.set(\"targetEventId\", targetEvent.id);\r", + "\r", + " createEvent(user, category, \"OtherEvent2\", otherEvent => {\r", + " otherEventData = otherEvent;\r", + "\r", + " createCommentChainForEvent(mainTestUser, targetEventData, \r", + " numNonDeletedForTargetEvent + numDeletedForTargetEvent, \r", + " numDeletedForTargetEvent, \r", + " \"TargetEvtC\", \r", + " targetEventComments => {\r", + " createCommentChainForEvent(mainTestUser, otherEventData, \r", + " numCommentsForOtherEvent, \r", + " 1,\r", + " \"OtherEvtC\", \r", + " otherEventComments => {\r", + " \r", + " let expectedResult = allCreatedCommentsForTest.filter(c => \r", + " c.eventId === targetEventData.id && c.isDeleted === false\r", + " );\r", + " \r", + " expectedResult.sort((a, b) => {\r", + " const dateA = new Date(a.createdOn.replace(' ', 'T') + 'Z').getTime();\r", + " const dateB = new Date(b.createdOn.replace(' ', 'T') + 'Z').getTime();\r", + " if (dateB !== dateA) return dateB - dateA;\r", + " return b.id - a.id;\r", + " });\r", + " \r", + " pm.environment.set(\"expectedNonDeletedForTargetEvent\", JSON.stringify(expectedResult));\r", + " pm.environment.set(\"fromParam\", fromParamForTest.toString());\r", + " pm.environment.set(\"sizeParam\", sizeParamForTest.toString());\r", + "\r", + " console.log(\"PreReq: All entities created. Expected non-deleted comments for target event count: \" + expectedResult.length);\r", + " console.log(\"PreReq: Setup complete for GET non-deleted comments by event, page 2.\");\r", + " });\r", + " });\r", + " });\r", + " });\r", + " });\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Код статуса ответа 200 OK\", function () {\r", + " pm.response.to.have.status(200);\r", + "});\r", + "\r", + "pm.test(\"Ответ является валидным JSON и представляет собой массив\", function () {\r", + " pm.response.to.be.json;\r", + " const jsonData = pm.response.json();\r", + " pm.expect(jsonData).to.be.an('array');\r", + "});\r", + "\r", + "const returnedComments = pm.response.json();\r", + "const targetEventId = parseInt(pm.environment.get(\"targetEventId\"));\r", + "const allExpectedNonDeletedForTarget = JSON.parse(pm.environment.get(\"expectedNonDeletedForTargetEvent\") || \"[]\");\r", + "\r", + "const from = parseInt(pm.environment.get(\"fromParam\"));\r", + "const size = parseInt(pm.environment.get(\"sizeParam\"));\r", + "\r", + "const expectedSlice = allExpectedNonDeletedForTarget.slice(from, from + size);\r", + "const expectedCountOnThisPage = expectedSlice.length;\r", + "\r", + "pm.test(`Возвращено ${expectedCountOnThisPage} комментариев (согласно eventId, isDeleted=false, from=${from}, size=${size})`, function () {\r", + " pm.expect(returnedComments.length).to.equal(expectedCountOnThisPage);\r", + "});\r", + "\r", + "if (returnedComments.length > 0) {\r", + " pm.test(\"Все возвращенные комментарии принадлежат targetEventId и имеют isDeleted=false\", function () {\r", + " returnedComments.forEach(function(comment, index) {\r", + " pm.expect(comment, `Комментарий #${index}`).to.have.all.keys(\r", + " 'id', 'text', 'author', 'eventId', 'createdOn', 'updatedOn', 'isEdited', 'isDeleted'\r", + " );\r", + " pm.expect(comment.eventId, `ID события комментария #${index}`).to.equal(targetEventId);\r", + " pm.expect(comment.isDeleted, `Флаг isDeleted комментария #${index}`).to.be.false;\r", + " });\r", + " });\r", + "\r", + " pm.test(\"Содержимое и порядок комментариев соответствуют ожидаемым для данной страницы\", function() {\r", + " pm.expect(returnedComments.length).to.equal(expectedSlice.length, \"Длина полученного массива должна совпадать с ожидаемым срезом для страницы\");\r", + "\r", + " returnedComments.forEach((returned, i) => {\r", + " const expected = expectedSlice[i];\r", + " if (!expected) {\r", + " pm.expect.fail(`Ожидаемый комментарий с индексом ${i} (в срезе) отсутствует в expectedSlice`);\r", + " return;\r", + " }\r", + " pm.expect(returned.id, `ID комментария ${i}`).to.equal(expected.id);\r", + " pm.expect(returned.text, `Текст комментария ${i}`).to.equal(expected.text);\r", + " pm.expect(returned.author.id, `Author ID ${i}`).to.equal(expected.author.id);\r", + " pm.expect(returned.eventId, `Event ID ${i}`).to.equal(expected.eventId);\r", + " pm.expect(returned.isDeleted, `isDeleted ${i}`).to.be.false;\r", + " });\r", + " });\r", + "\r", + "} else if (expectedCountOnThisPage > 0) {\r", + " pm.test(\"Ожидались комментарии, но получен пустой массив (с фильтрами eventId, isDeleted, pagination)\", function() {\r", + " pm.expect.fail(\"Ожидаемые комментарии для данной страницы не были возвращены.\");\r", + " });\r", + "}\r", + "\r", + "pm.test(\"Очистка переменных окружения после теста\", function () {\r", + " pm.environment.unset(\"targetEventId\");\r", + " pm.environment.unset(\"fromParam\");\r", + " pm.environment.unset(\"sizeParam\");\r", + " pm.environment.unset(\"expectedNonDeletedForTargetEvent\");\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/admin/comments?eventId={{targetEventId}}&isDeleted=false&from={{fromParam}}&size={{sizeParam}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "admin", + "comments" + ], + "query": [ + { + "key": "eventId", + "value": "{{targetEventId}}" + }, + { + "key": "isDeleted", + "value": "false" + }, + { + "key": "from", + "value": "{{fromParam}}" + }, + { + "key": "size", + "value": "{{sizeParam}}" + } + ] + } + }, + "response": [] + } + ] + } + ] + } + ], + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "packages": {}, + "exec": [ + "" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "packages": {}, + "exec": [ + "" + ] + } + } + ], + "variable": [ + { + "key": "baseUrl", + "value": "http://localhost:8080", + "type": "string" + } + ] +} \ No newline at end of file diff --git a/stats-service/pom.xml b/stats-service/pom.xml new file mode 100644 index 0000000..d0e2a8a --- /dev/null +++ b/stats-service/pom.xml @@ -0,0 +1,20 @@ + + + 4.0.0 + + ru.practicum + explore-with-me + 0.0.1-SNAPSHOT + + + stats-service + pom + + + stats-client + stats-dto + stats-server + + \ No newline at end of file diff --git a/stats-service/stats-client/pom.xml b/stats-service/stats-client/pom.xml new file mode 100644 index 0000000..aeda0c2 --- /dev/null +++ b/stats-service/stats-client/pom.xml @@ -0,0 +1,43 @@ + + + 4.0.0 + + ru.practicum + stats-service + 0.0.1-SNAPSHOT + ../pom.xml + + + stats-client + jar + + + + ru.practicum + stats-dto + ${project.version} + + + org.springframework.boot + spring-boot-starter-web + + + org.projectlombok + lombok + true + + + org.springframework.boot + spring-boot-starter-test + test + + + ru.practicum + ewm-common + ${project.version} + + + + diff --git a/stats-service/stats-client/src/main/java/ru/practicum/explorewithme/stats/client/StatsClient.java b/stats-service/stats-client/src/main/java/ru/practicum/explorewithme/stats/client/StatsClient.java new file mode 100644 index 0000000..99911b0 --- /dev/null +++ b/stats-service/stats-client/src/main/java/ru/practicum/explorewithme/stats/client/StatsClient.java @@ -0,0 +1,13 @@ +package ru.practicum.explorewithme.stats.client; + +import ru.practicum.explorewithme.stats.dto.EndpointHitDto; +import ru.practicum.explorewithme.stats.dto.ViewStatsDto; + +import java.time.LocalDateTime; +import java.util.List; + +public interface StatsClient { + void saveHit(EndpointHitDto endpointHitDto); + + List getStats(LocalDateTime start, LocalDateTime end, List uris, Boolean unique); +} diff --git a/stats-service/stats-client/src/main/java/ru/practicum/explorewithme/stats/client/StatsClientImpl.java b/stats-service/stats-client/src/main/java/ru/practicum/explorewithme/stats/client/StatsClientImpl.java new file mode 100644 index 0000000..69f749f --- /dev/null +++ b/stats-service/stats-client/src/main/java/ru/practicum/explorewithme/stats/client/StatsClientImpl.java @@ -0,0 +1,93 @@ +package ru.practicum.explorewithme.stats.client; + +import static ru.practicum.explorewithme.common.constants.DateTimeConstants.DATE_TIME_FORMATTER; + +import java.time.LocalDateTime; +import java.util.List; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; +import ru.practicum.explorewithme.stats.dto.EndpointHitDto; +import ru.practicum.explorewithme.stats.dto.ViewStatsDto; + +@Service +@Slf4j +public class StatsClientImpl implements StatsClient { + + private final RestClient restClient; + + @Autowired + public StatsClientImpl(@Value("${stats-server.url}") String statsServerUrl) { + this.restClient = RestClient.builder() + .baseUrl(statsServerUrl) + .defaultStatusHandler(HttpStatusCode::isError, (request, response) -> { + String errorMessage = "Ошибка при обращении к сервису статистики: " + + response.getStatusCode() + " " + response.getStatusText(); + log.error(errorMessage); + + // Обработка ошибок по типу + if (response.getStatusCode().is4xxClientError()) { + throw new RestClientException("Ошибка клиентского запроса: " + errorMessage); + } else if (response.getStatusCode().is5xxServerError()) { + throw new RestClientException("Ошибка сервера статистики: " + errorMessage); + } else { + throw new RestClientException(errorMessage); + } + }) + .build(); + } + + public StatsClientImpl(RestClient restClient) { + this.restClient = restClient; + } + + @Override + public void saveHit(EndpointHitDto endpointHitDto) { + log.debug("Отправка данных статистики: {}", endpointHitDto); + restClient.post() + .uri("/hit") + .contentType(MediaType.APPLICATION_JSON) + .body(endpointHitDto) + .retrieve() + .toBodilessEntity(); + log.debug("Статистика успешно сохранена"); + } + + @Override + public List getStats(LocalDateTime start, LocalDateTime end, List uris, Boolean unique) { + log.debug("Запрос статистики: start={}, end={}, uris={}, unique={}", start, end, uris, unique); + List stats = restClient.get() + .uri(uriBuilder -> { + uriBuilder.path("/stats") + .queryParam("start", start + .format(DATE_TIME_FORMATTER)) + .queryParam("end", end + .format(DATE_TIME_FORMATTER)); + + if (uris != null && !uris.isEmpty()) { + for (String uri : uris) { + uriBuilder.queryParam("uris", uri); + } + } + + if (unique != null) { + uriBuilder.queryParam("unique", unique); + } + + return uriBuilder.build(); + }) + .retrieve() + .body(new ParameterizedTypeReference<>() {}); + + log.debug("Получена статистика: {}", stats); + return stats; + } + + +} \ No newline at end of file diff --git a/stats-service/stats-client/src/main/java/ru/practicum/explorewithme/stats/client/config/StatsClientModuleConfiguration.java b/stats-service/stats-client/src/main/java/ru/practicum/explorewithme/stats/client/config/StatsClientModuleConfiguration.java new file mode 100644 index 0000000..3058b14 --- /dev/null +++ b/stats-service/stats-client/src/main/java/ru/practicum/explorewithme/stats/client/config/StatsClientModuleConfiguration.java @@ -0,0 +1,9 @@ +package ru.practicum.explorewithme.stats.client.config; + +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ComponentScan("ru.practicum.explorewithme.stats.client") +public class StatsClientModuleConfiguration { +} \ No newline at end of file diff --git a/stats-service/stats-client/src/main/resources/application-local.yaml b/stats-service/stats-client/src/main/resources/application-local.yaml new file mode 100644 index 0000000..c0c5be9 --- /dev/null +++ b/stats-service/stats-client/src/main/resources/application-local.yaml @@ -0,0 +1,2 @@ +stats-server: + url: http://localhost:9090 \ No newline at end of file diff --git a/stats-service/stats-client/src/main/resources/application.yaml b/stats-service/stats-client/src/main/resources/application.yaml new file mode 100644 index 0000000..5e2c53f --- /dev/null +++ b/stats-service/stats-client/src/main/resources/application.yaml @@ -0,0 +1,2 @@ +stats-server: + url: http://stats-server:9090 \ No newline at end of file diff --git a/stats-service/stats-client/src/test/java/ru/practicum/explorewithme/stats/client/StatsClientTest.java b/stats-service/stats-client/src/test/java/ru/practicum/explorewithme/stats/client/StatsClientTest.java new file mode 100644 index 0000000..75a70f2 --- /dev/null +++ b/stats-service/stats-client/src/test/java/ru/practicum/explorewithme/stats/client/StatsClientTest.java @@ -0,0 +1,445 @@ +package ru.practicum.explorewithme.stats.client; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.content; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.jsonPath; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withServerError; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collections; +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.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; +import ru.practicum.explorewithme.stats.dto.EndpointHitDto; +import ru.practicum.explorewithme.stats.dto.ViewStatsDto; + +@DisplayName("Тесты для StatsClientImpl") +class StatsClientTest { + + private RestTemplate restTemplate; + private MockRestServiceServer mockServer; + private StatsClientImpl statsClient; + private final String baseUrl = "http://stats-server:9090"; + + @BeforeEach + void setUp() { + restTemplate = new RestTemplate(); + mockServer = MockRestServiceServer.createServer(restTemplate); + + // Создаем RestClient на основе RestTemplate + RestClient restClient = RestClient.builder(restTemplate) + .baseUrl(baseUrl) + .build(); + + statsClient = new StatsClientImpl(restClient); + } + + @Nested + @DisplayName("Тесты метода saveHit") + class SaveHitTests { + @Test + @DisplayName("Успешное сохранение статистики") + void saveHit_successful() { + // Подготовка тестовых данных + LocalDateTime timestamp = LocalDateTime.now(); + EndpointHitDto hitDto = new EndpointHitDto( + "service", + "/test", + "192.168.0.1", + timestamp + ); + + // Настройка ожидания и ответа сервера + mockServer.expect(requestTo(baseUrl + "/hit")) + .andExpect(method(HttpMethod.POST)) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.app").value("service")) + .andExpect(jsonPath("$.uri").value("/test")) + .andExpect(jsonPath("$.ip").value("192.168.0.1")) + .andRespond(withStatus(HttpStatus.CREATED)); + + // Вызов тестируемого метода + assertDoesNotThrow( + () -> statsClient.saveHit(hitDto), + "Метод saveHit должен успешно выполниться без исключений" + ); + + // Проверка выполнения всех ожиданий + mockServer.verify(); + } + + @Test + @DisplayName("Обработка ошибки сервера при сохранении") + void saveHit_throwsExceptionWhenFails() { + // Подготовка тестовых данных + EndpointHitDto hitDto = new EndpointHitDto( + "service", + "/test", + "192.168.0.1", + LocalDateTime.now() + ); + + // Настройка ожидания и ответа сервера с ошибкой 500 + mockServer.expect(requestTo(baseUrl + "/hit")) + .andExpect(method(HttpMethod.POST)) + .andRespond(withServerError()); + + // Вызов тестируемого метода и проверка исключения + Exception exception = assertThrows( + RestClientException.class, + () -> statsClient.saveHit(hitDto), + "Должно быть выброшено исключение при ошибке сервера" + ); + + assertThat(exception.getMessage()) + .as("Сообщение об ошибке должно содержать информацию о проблеме") + .contains("500"); + + // Проверка выполнения всех ожиданий + mockServer.verify(); + } + + @Test + @DisplayName("Обработка клиентской ошибки (400 Bad Request)") + void saveHit_handlesBadRequest() { + // Подготовка тестовых данных с некорректными значениями + EndpointHitDto hitDto = new EndpointHitDto( + "", // Пустое значение приведет к ошибке валидации + "/test", + "192.168.0.1", + LocalDateTime.now() + ); + + // Настройка ожидания и ответа сервера с ошибкой 400 + mockServer.expect(requestTo(baseUrl + "/hit")) + .andExpect(method(HttpMethod.POST)) + .andRespond(withStatus(HttpStatus.BAD_REQUEST) + .contentType(MediaType.APPLICATION_JSON) + .body("{\"error\":\"Validation failed\"}")); + + // Вызов тестируемого метода и проверка исключения + Exception exception = assertThrows( + RestClientException.class, + () -> statsClient.saveHit(hitDto), + "Должно быть выброшено исключение при ошибке валидации" + ); + + assertThat(exception.getMessage()) + .as("Сообщение об ошибке должно содержать информацию о коде статуса") + .contains("400"); + + // Проверка выполнения всех ожиданий + mockServer.verify(); + } + + @Test + @DisplayName("Обработка null в качестве параметра") + void saveHit_handlesNullParameter() { + // Вызов метода с null-параметром + Exception exception = assertThrows( + NullPointerException.class, + () -> statsClient.saveHit(null), + "Должно быть выброшено исключение при null-параметре" + ); + + // Проверка, что мок-сервер не получил запроса + // Это означает, что исключение произошло до обращения к серверу + mockServer.verify(); + } + } + + @Nested + @DisplayName("Тесты метода getStats") + class GetStatsTests { + @Test + @DisplayName("Успешное получение статистики") + void getStats_successful() { + // Подготовка тестовых данных с использованием LocalDateTime + LocalDateTime start = LocalDateTime.of(2023, 1, 1, 0, 0, 0); + LocalDateTime end = LocalDateTime.of(2023, 12, 31, 23, 59, 59); + List uris = Arrays.asList("/event/1", "/event/2"); + Boolean unique = true; + + String expectedResponseJson = + "[{\"app\":\"app1\",\"uri\":\"/event/1\",\"hits\":10}," + + "{\"app\":\"app1\",\"uri\":\"/event/2\",\"hits\":5}]"; + + // Форматированные строки для URL + String formattedStart = "2023-01-01%2000:00:00"; + String formattedEnd = "2023-12-31%2023:59:59"; + + // Формирование URL для запроса + String url = baseUrl + "/stats" + + "?start=" + formattedStart + + "&end=" + formattedEnd + + "&uris=/event/1" + + "&uris=/event/2" + + "&unique=" + unique; + + // Настройка ожидания и ответа сервера + mockServer.expect(requestTo(url)) + .andExpect(method(HttpMethod.GET)) + .andRespond(withSuccess(expectedResponseJson, MediaType.APPLICATION_JSON)); + + // Вызов тестируемого метода + List result = statsClient.getStats(start, end, uris, unique); + + // Проверка результата + assertThat(result) + .as("Результат должен содержать 2 элемента") + .hasSize(2); + + assertThat(result.get(0).getApp()) + .as("Первый элемент должен иметь правильное значение app") + .isEqualTo("app1"); + + assertThat(result.get(0).getUri()) + .as("Первый элемент должен иметь правильное значение uri") + .isEqualTo("/event/1"); + + assertThat(result.get(0).getHits()) + .as("Первый элемент должен иметь правильное значение hits") + .isEqualTo(10L); + + assertThat(result.get(1).getUri()) + .as("Второй элемент должен иметь правильное значение uri") + .isEqualTo("/event/2"); + + assertThat(result.get(1).getHits()) + .as("Второй элемент должен иметь правильное значение hits") + .isEqualTo(5L); + + // Проверка выполнения всех ожиданий + mockServer.verify(); + } + + @Test + @DisplayName("Получение статистики с пустым списком URI") + void getStats_withEmptyUris() { + // Подготовка тестовых данных с использованием LocalDateTime + LocalDateTime start = LocalDateTime.of(2023, 1, 1, 0, 0, 0); + LocalDateTime end = LocalDateTime.of(2023, 12, 31, 23, 59, 59); + List uris = Collections.emptyList(); + Boolean unique = false; + + String expectedResponseJson = "[{\"app\":\"app1\",\"uri\":\"all\",\"hits\":15}]"; + + // Форматированные строки для URL + String formattedStart = "2023-01-01%2000:00:00"; + String formattedEnd = "2023-12-31%2023:59:59"; + + // Формирование URL для запроса + String url = baseUrl + "/stats" + + "?start=" + formattedStart + + "&end=" + formattedEnd + + "&unique=" + unique; + + // Настройка ожидания и ответа сервера + mockServer.expect(requestTo(url)) + .andExpect(method(HttpMethod.GET)) + .andRespond(withSuccess(expectedResponseJson, MediaType.APPLICATION_JSON)); + + // Вызов тестируемого метода + List result = statsClient.getStats(start, end, uris, unique); + + // Проверка результата + assertThat(result) + .as("Результат должен содержать 1 элемент") + .hasSize(1); + + assertThat(result.get(0).getApp()) + .as("Элемент должен иметь правильное значение app") + .isEqualTo("app1"); + + assertThat(result.get(0).getUri()) + .as("Элемент должен иметь правильное значение uri") + .isEqualTo("all"); + + assertThat(result.get(0).getHits()) + .as("Элемент должен иметь правильное значение hits") + .isEqualTo(15L); + + // Проверка выполнения всех ожиданий + mockServer.verify(); + } + + @Test + @DisplayName("Получение статистики с null вместо списка URI") + void getStats_withNullUris() { + // Подготовка тестовых данных с использованием LocalDateTime + LocalDateTime start = LocalDateTime.of(2023, 1, 1, 0, 0, 0); + LocalDateTime end = LocalDateTime.of(2023, 12, 31, 23, 59, 59); + List uris = null; + Boolean unique = false; + + String expectedResponseJson = "[{\"app\":\"app1\",\"uri\":\"all\",\"hits\":15}]"; + + // Форматированные строки для URL + String formattedStart = "2023-01-01%2000:00:00"; + String formattedEnd = "2023-12-31%2023:59:59"; + + // URL без параметров uris + String url = baseUrl + "/stats" + + "?start=" + formattedStart + + "&end=" + formattedEnd + + "&unique=" + unique; + + // Настройка ожидания и ответа сервера + mockServer.expect(requestTo(url)) + .andExpect(method(HttpMethod.GET)) + .andRespond(withSuccess(expectedResponseJson, MediaType.APPLICATION_JSON)); + + // Вызов тестируемого метода + List result = statsClient.getStats(start, end, uris, unique); + + // Проверка результата + assertThat(result) + .as("Результат должен содержать 1 элемент") + .hasSize(1); + + assertThat(result.get(0).getUri()) + .as("Элемент должен иметь правильное значение uri") + .isEqualTo("all"); + + // Проверка выполнения всех ожиданий + mockServer.verify(); + } + + @Test + @DisplayName("Получение статистики с null вместо флага unique") + void getStats_withNullUniqueFlag() { + // Подготовка тестовых данных с использованием LocalDateTime + LocalDateTime start = LocalDateTime.of(2023, 1, 1, 0, 0, 0); + LocalDateTime end = LocalDateTime.of(2023, 12, 31, 23, 59, 59); + List uris = Collections.singletonList("/event/1"); + Boolean unique = null; + + String expectedResponseJson = "[{\"app\":\"app1\",\"uri\":\"/event/1\",\"hits\":10}]"; + + // Форматированные строки для URL + String formattedStart = "2023-01-01%2000:00:00"; + String formattedEnd = "2023-12-31%2023:59:59"; + + // URL с null в качестве unique + String url = baseUrl + "/stats" + + "?start=" + formattedStart + + "&end=" + formattedEnd + + "&uris=/event/1"; + + // Настройка ожидания и ответа сервера + mockServer.expect(requestTo(url)) + .andExpect(method(HttpMethod.GET)) + .andRespond(withSuccess(expectedResponseJson, MediaType.APPLICATION_JSON)); + + // Вызов тестируемого метода + List result = statsClient.getStats(start, end, uris, unique); + + // Проверка результата + assertThat(result) + .as("Результат должен содержать 1 элемент") + .hasSize(1); + + assertThat(result.get(0).getUri()) + .as("Элемент должен иметь правильное значение uri") + .isEqualTo("/event/1"); + + // Проверка выполнения всех ожиданий + mockServer.verify(); + } + + @Test + @DisplayName("Обработка ошибки сервера при получении статистики") + void getStats_throwsExceptionWhenFails() { + // Подготовка тестовых данных с использованием LocalDateTime + LocalDateTime start = LocalDateTime.of(2023, 1, 1, 0, 0, 0); + LocalDateTime end = LocalDateTime.of(2023, 12, 31, 23, 59, 59); + List uris = Arrays.asList("/event/1", "/event/2"); + Boolean unique = true; + + // Форматированные строки для URL + String formattedStart = "2023-01-01%2000:00:00"; + String formattedEnd = "2023-12-31%2023:59:59"; + + // Формирование URL для запроса + String url = baseUrl + "/stats" + + "?start=" + formattedStart + + "&end=" + formattedEnd + + "&uris=/event/1" + + "&uris=/event/2" + + "&unique=" + unique; + + // Настройка ожидания и ответа сервера с ошибкой + mockServer.expect(requestTo(url)) + .andExpect(method(HttpMethod.GET)) + .andRespond(withServerError()); + + // Вызов тестируемого метода и проверка исключения + Exception exception = assertThrows( + RestClientException.class, + () -> statsClient.getStats(start, end, uris, unique), + "Должно быть выброшено исключение при ошибке сервера" + ); + + assertThat(exception.getMessage()) + .as("Сообщение об ошибке должно содержать информацию о проблеме") + .contains("500"); + + // Проверка выполнения всех ожиданий + mockServer.verify(); + } + + @Test + @DisplayName("Получение пустого массива в ответе") + void getStats_emptyResponse() { + // Подготовка тестовых данных с использованием LocalDateTime + LocalDateTime start = LocalDateTime.of(2023, 1, 1, 0, 0, 0); + LocalDateTime end = LocalDateTime.of(2023, 12, 31, 23, 59, 59); + List uris = Arrays.asList("/non-existent/1", "/non-existent/2"); + Boolean unique = true; + + // Форматированные строки для URL + String formattedStart = "2023-01-01%2000:00:00"; + String formattedEnd = "2023-12-31%2023:59:59"; + + // Формирование URL + String url = baseUrl + "/stats" + + "?start=" + formattedStart + + "&end=" + formattedEnd + + "&uris=/non-existent/1" + + "&uris=/non-existent/2" + + "&unique=" + unique; + + // Настройка ожидания и ответа сервера с пустым массивом + mockServer.expect(requestTo(url)) + .andExpect(method(HttpMethod.GET)) + .andRespond(withSuccess("[]", MediaType.APPLICATION_JSON)); + + // Вызов тестируемого метода + List result = statsClient.getStats(start, end, uris, unique); + + // Проверка, что результат - пустой список + assertThat(result) + .as("Результат должен быть пустым списком") + .isEmpty(); + + // Проверка выполнения всех ожиданий + mockServer.verify(); + } + } +} \ No newline at end of file diff --git a/stats-service/stats-dto/pom.xml b/stats-service/stats-dto/pom.xml new file mode 100644 index 0000000..cff19e8 --- /dev/null +++ b/stats-service/stats-dto/pom.xml @@ -0,0 +1,63 @@ + + + 4.0.0 + + ru.practicum + stats-service + 0.0.1-SNAPSHOT + ../pom.xml + + + stats-dto + jar + + + + jakarta.validation + jakarta.validation-api + + + org.projectlombok + lombok + + + com.fasterxml.jackson.core + jackson-annotations + + + com.fasterxml.jackson.core + jackson-databind + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + org.junit.jupiter + junit-jupiter + + + org.assertj + assertj-core + test + + + org.hibernate.validator + hibernate-validator + + + org.glassfish + jakarta.el + 4.0.2 + test + + + ru.practicum + ewm-common + ${project.version} + + + + \ No newline at end of file diff --git a/stats-service/stats-dto/src/main/java/ru/practicum/explorewithme/stats/dto/EndpointHitDto.java b/stats-service/stats-dto/src/main/java/ru/practicum/explorewithme/stats/dto/EndpointHitDto.java new file mode 100644 index 0000000..5930d52 --- /dev/null +++ b/stats-service/stats-dto/src/main/java/ru/practicum/explorewithme/stats/dto/EndpointHitDto.java @@ -0,0 +1,41 @@ +package ru.practicum.explorewithme.stats.dto; + +import static ru.practicum.explorewithme.common.constants.DateTimeConstants.DATE_TIME_FORMAT_PATTERN; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.PastOrPresent; +import jakarta.validation.constraints.Size; +import lombok.*; + +import jakarta.validation.constraints.NotNull; +import lombok.experimental.FieldDefaults; + +import java.time.LocalDateTime; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@Builder +@JsonIgnoreProperties(ignoreUnknown = true) +@FieldDefaults(level = AccessLevel.PRIVATE) +public class EndpointHitDto { + + @NotBlank(message = "Поле app не может быть пустым") + @Size(min = 1, max = 32, message = "Поле app должно быть от 1 до 32 символов") + String app; + + @NotBlank(message = "Поле uri не может быть пустым") + @Size(min = 1, max = 128, message = "Поле uri должно быть от 1 до 128 символов") + String uri; + + @NotBlank(message = "Поле ip не может быть пустым") + @Size(min = 7, max = 16, message = "Поле ip должно быть от 7 до 16 символов") + String ip; + + @NotNull(message = "Поле timestamp не может быть пустым") + @PastOrPresent(message = "Поле timestamp должно быть не позже текущей даты и времени") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = DATE_TIME_FORMAT_PATTERN) + LocalDateTime timestamp; +} \ No newline at end of file diff --git a/stats-service/stats-dto/src/main/java/ru/practicum/explorewithme/stats/dto/ViewStatsDto.java b/stats-service/stats-dto/src/main/java/ru/practicum/explorewithme/stats/dto/ViewStatsDto.java new file mode 100644 index 0000000..71c28e3 --- /dev/null +++ b/stats-service/stats-dto/src/main/java/ru/practicum/explorewithme/stats/dto/ViewStatsDto.java @@ -0,0 +1,15 @@ +package ru.practicum.explorewithme.stats.dto; + +import lombok.*; +import lombok.experimental.FieldDefaults; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@Builder +@FieldDefaults(level = AccessLevel.PRIVATE) +public class ViewStatsDto { + String app; + String uri; + Long hits; +} \ No newline at end of file diff --git a/stats-service/stats-dto/src/test/java/ru/practicum/explorewithme/stats/dto/EndpointHitDtoTest.java b/stats-service/stats-dto/src/test/java/ru/practicum/explorewithme/stats/dto/EndpointHitDtoTest.java new file mode 100644 index 0000000..8f0cd31 --- /dev/null +++ b/stats-service/stats-dto/src/test/java/ru/practicum/explorewithme/stats/dto/EndpointHitDtoTest.java @@ -0,0 +1,400 @@ +// CHECKSTYLE:OFF RegexpSinglelineJava +package ru.practicum.explorewithme.stats.dto; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; +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.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.Set; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static ru.practicum.explorewithme.common.constants.DateTimeConstants.DATE_TIME_FORMAT_PATTERN; + +@DisplayName("Тесты для EndpointHitDto") +class EndpointHitDtoTest { + private ObjectMapper objectMapper; + private Validator validator; + + @BeforeEach + void setUp() { + objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); + validator = factory.getValidator(); + } + + @Nested + @DisplayName("Сериализация и десериализация") + class SerializationTests { + @Test + @DisplayName("Корректная сериализация в JSON") + void testSerializationToJson() throws Exception { + // Подготовка тестовых данных + LocalDateTime timestamp = LocalDateTime.of(2024, 3, 15, 12, 30, 0); + EndpointHitDto dto = new EndpointHitDto( + "test-app", + "/test/path", + "192.168.1.1", + timestamp + ); + + // Сериализация в JSON + String json = objectMapper.writeValueAsString(dto); + + // Проверки с информативными сообщениями + assertThat(json) + .as("JSON должен содержать поле app с правильным значением") + .contains("\"app\":\"test-app\""); + + assertThat(json) + .as("JSON должен содержать поле uri с правильным значением") + .contains("\"uri\":\"/test/path\""); + + assertThat(json) + .as("JSON должен содержать поле ip с правильным значением") + .contains("\"ip\":\"192.168.1.1\""); + + assertThat(json) + .as("JSON должен содержать поле timestamp в формате " + DATE_TIME_FORMAT_PATTERN) + .contains("\"timestamp\":\"2024-03-15 12:30:00\""); + } + + @Test + @DisplayName("Корректная десериализация из JSON") + void testDeserializationFromJson() throws Exception { + // Подготовка JSON + String json = """ + { + "app": "test-app", + "uri": "/test/path", + "ip": "192.168.1.1", + "timestamp": "2024-03-15 12:30:00" + }"""; + + // Десериализация из JSON + EndpointHitDto dto = objectMapper.readValue(json, EndpointHitDto.class); + + // Проверки с информативными сообщениями + assertThat(dto.getApp()) + .as("Поле app должно быть правильно десериализовано") + .isEqualTo("test-app"); + + assertThat(dto.getUri()) + .as("Поле uri должно быть правильно десериализовано") + .isEqualTo("/test/path"); + + assertThat(dto.getIp()) + .as("Поле ip должно быть правильно десериализовано") + .isEqualTo("192.168.1.1"); + + assertThat(dto.getTimestamp()) + .as("Поле timestamp должно быть правильно десериализовано") + .isEqualTo(LocalDateTime.of(2024, 3, 15, 12, 30, 0)); + } + + @Test + @DisplayName("Ошибка при неверном формате даты") + void testInvalidTimestampFormat() { + // Подготовка JSON с неверным форматом даты + String json = """ + { + "app": "test-app", + "uri": "/test/path", + "ip": "192.168.1.1", + "timestamp": "2024-03-15T12:30:00" + }"""; + + // Проверка исключения при неверном формате + Exception exception = assertThrows( + Exception.class, + () -> objectMapper.readValue(json, EndpointHitDto.class), + "Должно быть выброшено исключение при неверном формате даты" + ); + + assertThat(exception.getMessage()) + .as("Сообщение об ошибке должно указывать на проблему с форматом даты") + .contains("timestamp"); + } + + @Test + @DisplayName("Десериализация с дополнительными полями") + void testDeserializationWithExtraFields() throws Exception { + // JSON с дополнительными полями + String json = """ + { + "app": "test-app", + "uri": "/test/path", + "ip": "192.168.1.1", + "timestamp": "2024-03-15 12:30:00", + "extraField": "extra value" + }"""; + + // Десериализация + EndpointHitDto dto = objectMapper.readValue(json, EndpointHitDto.class); + + // Проверки основных полей - должны быть правильно заполнены + assertThat(dto.getApp()).isEqualTo("test-app"); + assertThat(dto.getUri()).isEqualTo("/test/path"); + assertThat(dto.getIp()).isEqualTo("192.168.1.1"); + assertThat(dto.getTimestamp()).isEqualTo(LocalDateTime.of(2024, 3, 15, 12, 30, 0)); + } + + @Test + @DisplayName("Обработка null-значений") + void testNullValues() throws Exception { + // Создание объекта с null-значениями + EndpointHitDto dto = new EndpointHitDto(null, null, null, null); + + // Сериализация + String json = objectMapper.writeValueAsString(dto); + + // Десериализация + EndpointHitDto deserializedDto = objectMapper.readValue(json, EndpointHitDto.class); + + // Проверки + assertThat(deserializedDto.getApp()).isNull(); + assertThat(deserializedDto.getUri()).isNull(); + assertThat(deserializedDto.getIp()).isNull(); + assertThat(deserializedDto.getTimestamp()).isNull(); + } + } + + @Nested + @DisplayName("Валидация полей") + class ValidationTests { + @Test + @DisplayName("Валидация пустого app") + void testEmptyAppValidation() { + // Создаем DTO с нарушением валидационных ограничений + EndpointHitDto dto = new EndpointHitDto( + "", // пустое app (нарушает @NotBlank) + "/uri", + "192.168.1.1", + LocalDateTime.now() + ); + + // Проверяем, что валидация выявила ошибки + Set> violations = validator.validate(dto); + + assertThat(violations) + .as("Должно быть обнаружено нарушение валидации для поля app") + .isNotEmpty(); + + assertThat(violations) + .as("Должно быть сообщение об ошибке для поля app") + .anyMatch(v -> v.getPropertyPath().toString().equals("app")); + } + + @Test + @DisplayName("Валидация длинного uri (более 128 символов)") + void testUriTooLongValidation() { + // Создаем URI длиной более 128 символов + String longUri = "/".repeat(129); + + EndpointHitDto dto = new EndpointHitDto( + "app", + longUri, + "192.168.1.1", + LocalDateTime.now() + ); + + // Проверяем, что валидация выявила ошибки + Set> violations = validator.validate(dto); + + assertThat(violations) + .as("Должно быть обнаружено нарушение валидации для поля uri") + .isNotEmpty(); + + assertThat(violations) + .as("Должно быть сообщение об ошибке для поля uri") + .anyMatch(v -> v.getPropertyPath().toString().equals("uri")); + } + + @Test + @DisplayName("Валидация IP адреса (слишком короткий)") + void testIpTooShortValidation() { + // IP адрес короче 7 символов + EndpointHitDto dto = new EndpointHitDto( + "app", + "/uri", + "1.1.1", // слишком короткий IP + LocalDateTime.now() + ); + + // Проверяем, что валидация выявила ошибки + Set> violations = validator.validate(dto); + + assertThat(violations) + .as("Должно быть обнаружено нарушение валидации для поля ip") + .isNotEmpty(); + + assertThat(violations) + .as("Должно быть сообщение об ошибке для поля ip") + .anyMatch(v -> v.getPropertyPath().toString().equals("ip")); + } + + @Test + @DisplayName("Валидация даты в будущем") + void testFutureTimestampValidation() { + // Timestamp в будущем (не соответствует @PastOrPresent) + LocalDateTime futureTime = LocalDateTime.now().plus(1, ChronoUnit.DAYS); + + EndpointHitDto dto = new EndpointHitDto( + "app", + "/uri", + "192.168.1.1", + futureTime + ); + + // Проверяем, что валидация выявила ошибки + Set> violations = validator.validate(dto); + + assertThat(violations) + .as("Должно быть обнаружено нарушение валидации для поля timestamp") + .isNotEmpty(); + + assertThat(violations) + .as("Должно быть сообщение об ошибке для поля timestamp") + .anyMatch(v -> v.getPropertyPath().toString().equals("timestamp")); + } + + @ParameterizedTest + @MethodSource("invalidDtoProvider") + @DisplayName("Параметризованный тест для различных нарушений валидации") + void testValidationConstraints(String app, String uri, String ip, LocalDateTime timestamp, String expectedField) { + // Создаем DTO с указанными параметрами + EndpointHitDto dto = new EndpointHitDto(app, uri, ip, timestamp); + + // Проверяем валидацию + Set> violations = validator.validate(dto); + + assertThat(violations) + .as("Должна быть обнаружена ошибка валидации") + .isNotEmpty(); + + assertThat(violations) + .as("Должна быть ошибка для поля " + expectedField) + .anyMatch(v -> v.getPropertyPath().toString().equals(expectedField)); + } + + static Stream invalidDtoProvider() { + return Stream.of( + // app, uri, ip, timestamp, expectedViolationField + Arguments.of(null, "/uri", "192.168.1.1", LocalDateTime.now(), "app"), + Arguments.of("app", null, "192.168.1.1", LocalDateTime.now(), "uri"), + Arguments.of("app", "/uri", null, LocalDateTime.now(), "ip"), + Arguments.of("app", "/uri", "192.168.1.1", null, "timestamp"), + Arguments.of("app", "", "192.168.1.1", LocalDateTime.now(), "uri"), + Arguments.of("app", "/uri", "ip", LocalDateTime.now(), "ip") // слишком короткий IP + ); + } + } + + @Nested + @DisplayName("Граничные значения") + class BoundaryTests { + @Test + @DisplayName("Максимально допустимая длина app (32 символа)") + void testMaxAppLength() throws Exception { + // Создаем app длиной ровно 32 символа + String maxLengthApp = "a".repeat(32); + + EndpointHitDto dto = new EndpointHitDto( + maxLengthApp, + "/uri", + "192.168.1.1", + LocalDateTime.now() + ); + + // Валидация должна пройти успешно + Set> violations = validator.validate(dto); + + assertThat(violations) + .as("Не должно быть нарушений валидации для app длиной 32 символа") + .isEmpty(); + + // Проверяем сериализацию/десериализацию + String json = objectMapper.writeValueAsString(dto); + EndpointHitDto deserializedDto = objectMapper.readValue(json, EndpointHitDto.class); + + assertThat(deserializedDto.getApp()) + .as("App должен быть корректно сериализован и десериализован") + .isEqualTo(maxLengthApp); + } + + @Test + @DisplayName("Максимально допустимая длина uri (128 символов)") + void testMaxUriLength() throws Exception { + // Создаем uri длиной ровно 128 символов + String maxLengthUri = "/".repeat(128); + + EndpointHitDto dto = new EndpointHitDto( + "app", + maxLengthUri, + "192.168.1.1", + LocalDateTime.now() + ); + + // Валидация должна пройти успешно + Set> violations = validator.validate(dto); + + assertThat(violations) + .as("Не должно быть нарушений валидации для uri длиной 128 символов") + .isEmpty(); + + // Проверяем сериализацию/десериализацию + String json = objectMapper.writeValueAsString(dto); + EndpointHitDto deserializedDto = objectMapper.readValue(json, EndpointHitDto.class); + + assertThat(deserializedDto.getUri()) + .as("URI должен быть корректно сериализован и десериализован") + .isEqualTo(maxLengthUri); + } + + @Test + @DisplayName("Минимально допустимая длина ip (7 символов)") + void testMinIpLength() throws Exception { + // Создаем ip длиной ровно 7 символов + String minLengthIp = "1.1.1.1"; // ровно 7 символов + + EndpointHitDto dto = new EndpointHitDto( + "app", + "/uri", + minLengthIp, + LocalDateTime.now() + ); + + // Валидация должна пройти успешно + Set> violations = validator.validate(dto); + + assertThat(violations) + .as("Не должно быть нарушений валидации для ip длиной 7 символов") + .isEmpty(); + + // Проверяем сериализацию/десериализацию + String json = objectMapper.writeValueAsString(dto); + EndpointHitDto deserializedDto = objectMapper.readValue(json, EndpointHitDto.class); + + assertThat(deserializedDto.getIp()) + .as("IP должен быть корректно сериализован и десериализован") + .isEqualTo(minLengthIp); + } + } +} \ No newline at end of file diff --git a/stats-service/stats-dto/src/test/java/ru/practicum/explorewithme/stats/dto/ViewStatsDtoTest.java b/stats-service/stats-dto/src/test/java/ru/practicum/explorewithme/stats/dto/ViewStatsDtoTest.java new file mode 100644 index 0000000..8ccc8fc --- /dev/null +++ b/stats-service/stats-dto/src/test/java/ru/practicum/explorewithme/stats/dto/ViewStatsDtoTest.java @@ -0,0 +1,217 @@ +// CHECKSTYLE:OFF RegexpSinglelineJava +package ru.practicum.explorewithme.stats.dto; + +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 static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("Тесты для ViewStatsDto") +class ViewStatsDtoTest { + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + objectMapper = new ObjectMapper(); + } + + @Nested + @DisplayName("Сериализация и десериализация") + class SerializationTests { + @Test + @DisplayName("Корректная сериализация в JSON") + void testSerializationToJson() throws Exception { + // Подготовка тестовых данных + ViewStatsDto dto = new ViewStatsDto("test-service", "/events/1", 100L); + + // Сериализация в JSON + String json = objectMapper.writeValueAsString(dto); + + // Проверки с информативными сообщениями + assertThat(json) + .as("JSON должен содержать поле app с правильным значением") + .contains("\"app\":\"test-service\""); + + assertThat(json) + .as("JSON должен содержать поле uri с правильным значением") + .contains("\"uri\":\"/events/1\""); + + assertThat(json) + .as("JSON должен содержать поле hits с правильным значением") + .contains("\"hits\":100"); + } + + @Test + @DisplayName("Корректная десериализация из JSON") + void testDeserializationFromJson() throws Exception { + // Подготовка JSON + String json = """ + { + "app": "test-service", + "uri": "/events/1", + "hits": 100 + }"""; + + // Десериализация из JSON + ViewStatsDto dto = objectMapper.readValue(json, ViewStatsDto.class); + + // Проверки с информативными сообщениями + assertThat(dto.getApp()) + .as("Поле app должно быть правильно десериализовано") + .isEqualTo("test-service"); + + assertThat(dto.getUri()) + .as("Поле uri должно быть правильно десериализовано") + .isEqualTo("/events/1"); + + assertThat(dto.getHits()) + .as("Поле hits должно быть правильно десериализовано") + .isEqualTo(100L); + } + + @Test + @DisplayName("Десериализация с отсутствующими полями") + void testDeserializationWithMissingFields() throws Exception { + // JSON с отсутствующими полями + String json = """ + { + "app": "test-service" + }"""; + + ViewStatsDto dto = objectMapper.readValue(json, ViewStatsDto.class); + + // Проверки + assertThat(dto.getApp()) + .as("Поле app должно быть правильно десериализовано") + .isEqualTo("test-service"); + + assertThat(dto.getUri()) + .as("Поле uri должно быть null при отсутствии в JSON") + .isNull(); + + assertThat(dto.getHits()) + .as("Поле hits должно быть null при отсутствии в JSON") + .isNull(); + } + + @Test + @DisplayName("Обработка null-значений") + void testNullValues() throws Exception { + // Создание объекта с null-значениями + ViewStatsDto dto = new ViewStatsDto(null, null, null); + + // Сериализация + String json = objectMapper.writeValueAsString(dto); + + // Десериализация + ViewStatsDto deserializedDto = objectMapper.readValue(json, ViewStatsDto.class); + + // Проверки + assertThat(deserializedDto.getApp()).isNull(); + assertThat(deserializedDto.getUri()).isNull(); + assertThat(deserializedDto.getHits()).isNull(); + } + + @Test + @DisplayName("Ошибка при неверном типе данных (hits не число)") + void testInvalidHitsType() { + // JSON с неверным типом поля hits + String json = """ + { + "app": "test-service", + "uri": "/events/1", + "hits": "not-a-number" + }"""; + + // Проверка исключения при неверном типе + Exception exception = assertThrows( + Exception.class, + () -> objectMapper.readValue(json, ViewStatsDto.class), + "Должно быть выброшено исключение при неверном типе поля hits" + ); + + assertThat(exception.getMessage()) + .as("Сообщение об ошибке должно указывать на проблему с типом hits") + .contains("hits"); + } + } + + @Nested + @DisplayName("Тесты конструкторов и методов") + class ConstructorTests { + @Test + @DisplayName("Конструктор со всеми параметрами") + void testAllArgsConstructor() { + // Создание объекта через конструктор со всеми аргументами + ViewStatsDto dto = new ViewStatsDto("app-name", "/uri", 200L); + + // Проверки + assertThat(dto.getApp()).isEqualTo("app-name"); + assertThat(dto.getUri()).isEqualTo("/uri"); + assertThat(dto.getHits()).isEqualTo(200L); + } + + @Test + @DisplayName("Конструктор без аргументов") + void testNoArgsConstructor() { + // Создание объекта через конструктор без аргументов + ViewStatsDto dto = new ViewStatsDto(); + + // Проверки + assertThat(dto.getApp()).isNull(); + assertThat(dto.getUri()).isNull(); + assertThat(dto.getHits()).isNull(); + } + + @Test + @DisplayName("Сеттеры") + void testSetters() { + // Создание объекта через конструктор без аргументов + ViewStatsDto dto = new ViewStatsDto(); + + // Установка значений через сеттеры + dto.setApp("app-from-setter"); + dto.setUri("/uri-from-setter"); + dto.setHits(300L); + + // Проверки + assertThat(dto.getApp()).isEqualTo("app-from-setter"); + assertThat(dto.getUri()).isEqualTo("/uri-from-setter"); + assertThat(dto.getHits()).isEqualTo(300L); + } + + @Test + @DisplayName("Equals и HashCode") + void testEqualsAndHashCode() { + // Создание двух одинаковых объектов + ViewStatsDto dto1 = new ViewStatsDto("same-app", "/same-uri", 100L); + ViewStatsDto dto2 = new ViewStatsDto("same-app", "/same-uri", 100L); + + // Проверка equals + assertThat(dto1) + .as("Объекты с одинаковыми полями должны быть равны") + .isEqualTo(dto2); + + // Проверка hashCode + assertThat(dto1.hashCode()) + .as("Хеш-коды объектов с одинаковыми полями должны совпадать") + .isEqualTo(dto2.hashCode()); + + // Изменяем один из объектов + dto2.setHits(200L); + + // Проверка, что equals и hashCode различаются + assertThat(dto1) + .as("Объекты с разными полями не должны быть равны") + .isNotEqualTo(dto2); + + assertThat(dto1.hashCode()) + .as("Хеш-коды объектов с разными полями не должны совпадать") + .isNotEqualTo(dto2.hashCode()); + } + } +} \ No newline at end of file diff --git a/stats-service/stats-server/Dockerfile b/stats-service/stats-server/Dockerfile new file mode 100644 index 0000000..0ff1817 --- /dev/null +++ b/stats-service/stats-server/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/stats-service/stats-server/pom.xml b/stats-service/stats-server/pom.xml new file mode 100644 index 0000000..278694f --- /dev/null +++ b/stats-service/stats-server/pom.xml @@ -0,0 +1,93 @@ + + + 4.0.0 + + ru.practicum + stats-service + 0.0.1-SNAPSHOT + ../pom.xml + + + stats-server + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-test + + + org.springframework.boot + spring-boot-starter-validation + + + org.postgresql + postgresql + + + org.projectlombok + lombok + provided + + + ru.practicum + stats-dto + ${project.version} + + + org.testcontainers + postgresql + test + + + org.testcontainers + junit-jupiter + + + ru.practicum + ewm-common + 0.0.1-SNAPSHOT + compile + + + org.mockito + mockito-inline + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + ${lombok.version} + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + \ No newline at end of file diff --git a/stats-service/stats-server/src/main/java/ru/practicum/explorewithme/stats/server/StatsServerApplication.java b/stats-service/stats-server/src/main/java/ru/practicum/explorewithme/stats/server/StatsServerApplication.java new file mode 100644 index 0000000..2a59295 --- /dev/null +++ b/stats-service/stats-server/src/main/java/ru/practicum/explorewithme/stats/server/StatsServerApplication.java @@ -0,0 +1,13 @@ +package ru.practicum.explorewithme.stats.server; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class StatsServerApplication { + + public static void main(String[] args) { + SpringApplication.run(StatsServerApplication.class, args); + } + +} diff --git a/stats-service/stats-server/src/main/java/ru/practicum/explorewithme/stats/server/controller/StatsController.java b/stats-service/stats-server/src/main/java/ru/practicum/explorewithme/stats/server/controller/StatsController.java new file mode 100644 index 0000000..82f53cf --- /dev/null +++ b/stats-service/stats-server/src/main/java/ru/practicum/explorewithme/stats/server/controller/StatsController.java @@ -0,0 +1,74 @@ +package ru.practicum.explorewithme.stats.server.controller; + +import static ru.practicum.explorewithme.common.constants.DateTimeConstants.DATE_TIME_FORMAT_PATTERN; + +import jakarta.validation.Valid; +import java.time.LocalDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.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.stats.dto.EndpointHitDto; +import ru.practicum.explorewithme.stats.dto.ViewStatsDto; +import ru.practicum.explorewithme.stats.server.service.StatsService; + +@RestController +@RequestMapping +@RequiredArgsConstructor +@Slf4j +@SuppressWarnings("unused") +public class StatsController { + + private final StatsService statsService; + + /** + * Сохранение информации о том, что к эндпоинту был запрос + * + * @param endpointHitDto данные запроса + */ + @PostMapping("/hit") + @ResponseStatus(HttpStatus.CREATED) + public void saveHit(@Valid @RequestBody EndpointHitDto endpointHitDto) { + log.info("Controller: request to save new hit received."); + log.debug("Saving new hit: {}", endpointHitDto); + statsService.saveHit(endpointHitDto); + } + + /** + * Получение статистики по посещениям. + * + * @param start Дата и время начала диапазона (в формате "yyyy-MM-dd HH:mm:ss") + * @param end Дата и время конца диапазона (в формате "yyyy-MM-dd HH:mm:ss") + * @param uris Список uri для которых нужно выгрузить статистику (опционально) + * @param unique Нужно ли учитывать только уникальные посещения (опционально, default: false) + * @return Список ViewStatsDto со статистикой + */ + @GetMapping("/stats") + @ResponseStatus(HttpStatus.OK) + public List getStats( + @RequestParam(name = "start") + @DateTimeFormat(pattern = DATE_TIME_FORMAT_PATTERN) + LocalDateTime start, + + @RequestParam(name = "end") + @DateTimeFormat(pattern = DATE_TIME_FORMAT_PATTERN) + LocalDateTime end, + + @RequestParam(name = "uris", required = false) List uris, + @RequestParam(name = "unique", defaultValue = "false") Boolean unique) { + + log.info("Controller: request to retrieve stats received."); + log.debug("Request params: start={}, end={}, uris={}, unique={}", + start, end, uris, unique); + + return statsService.getStats(start, end, uris, unique); + } +} \ No newline at end of file diff --git a/stats-service/stats-server/src/main/java/ru/practicum/explorewithme/stats/server/error/GlobalExceptionHandler.java b/stats-service/stats-server/src/main/java/ru/practicum/explorewithme/stats/server/error/GlobalExceptionHandler.java new file mode 100644 index 0000000..ad37194 --- /dev/null +++ b/stats-service/stats-server/src/main/java/ru/practicum/explorewithme/stats/server/error/GlobalExceptionHandler.java @@ -0,0 +1,75 @@ +package ru.practicum.explorewithme.stats.server.error; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +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 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(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(); + } +} diff --git a/stats-service/stats-server/src/main/java/ru/practicum/explorewithme/stats/server/mapper/EndpointHitMapper.java b/stats-service/stats-server/src/main/java/ru/practicum/explorewithme/stats/server/mapper/EndpointHitMapper.java new file mode 100644 index 0000000..5e0b19f --- /dev/null +++ b/stats-service/stats-server/src/main/java/ru/practicum/explorewithme/stats/server/mapper/EndpointHitMapper.java @@ -0,0 +1,24 @@ +package ru.practicum.explorewithme.stats.server.mapper; + +import ru.practicum.explorewithme.stats.dto.EndpointHitDto; +import ru.practicum.explorewithme.stats.server.model.EndpointHit; + +public interface EndpointHitMapper { + + /** + * Преобразует EndpointHitDto в сущность EndpointHit. + * + * @param dto объект EndpointHitDto для преобразования. + * @return сущность EndpointHit, или null если dto равен null. + */ + EndpointHit toEndpointHit(EndpointHitDto dto); + + /** + * Преобразует сущность EndpointHit в EndpointHitDto. + * + * @param entity сущность EndpointHit для преобразования. + * @return объект EndpointHitDto, или null если entity равен null. + */ + EndpointHitDto toEndpointHitDto(EndpointHit entity); + +} \ No newline at end of file diff --git a/stats-service/stats-server/src/main/java/ru/practicum/explorewithme/stats/server/mapper/EndpointHitMapperImpl.java b/stats-service/stats-server/src/main/java/ru/practicum/explorewithme/stats/server/mapper/EndpointHitMapperImpl.java new file mode 100644 index 0000000..1bee762 --- /dev/null +++ b/stats-service/stats-server/src/main/java/ru/practicum/explorewithme/stats/server/mapper/EndpointHitMapperImpl.java @@ -0,0 +1,31 @@ +package ru.practicum.explorewithme.stats.server.mapper; + + +import org.springframework.stereotype.Component; +import ru.practicum.explorewithme.stats.dto.EndpointHitDto; +import ru.practicum.explorewithme.stats.server.model.EndpointHit; + +@Component +public class EndpointHitMapperImpl implements EndpointHitMapper { + + @Override + public EndpointHit toEndpointHit(EndpointHitDto dto) { + if (dto == null) { + return null; + } + + return EndpointHit.builder().app(dto.getApp()).uri(dto.getUri()).ip(dto.getIp()) + .timestamp(dto.getTimestamp()).build(); + } + + @Override + public EndpointHitDto toEndpointHitDto(EndpointHit entity) { + if (entity == null) { + return null; + } + + return EndpointHitDto.builder().app(entity.getApp()).uri(entity.getUri()).ip(entity.getIp()) + .timestamp(entity.getTimestamp()).build(); + + } +} \ No newline at end of file diff --git a/stats-service/stats-server/src/main/java/ru/practicum/explorewithme/stats/server/model/EndpointHit.java b/stats-service/stats-server/src/main/java/ru/practicum/explorewithme/stats/server/model/EndpointHit.java new file mode 100644 index 0000000..a12fa79 --- /dev/null +++ b/stats-service/stats-server/src/main/java/ru/practicum/explorewithme/stats/server/model/EndpointHit.java @@ -0,0 +1,59 @@ +package ru.practicum.explorewithme.stats.server.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import java.util.Objects; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@Entity +@Table(name = "endpoint_hits") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@ToString +@Builder +public class EndpointHit { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "app", nullable = false, length = 32) + private String app; + + @Column(name = "uri", nullable = false, length = 128) + private String uri; + + @Column(name = "ip", nullable = false, length = 16) + private String ip; + + @Column(name = "timestamp", nullable = false) + private LocalDateTime timestamp; + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + return id != null && id.equals(((EndpointHit) o).getId()); + } + + @Override + public int hashCode() { + return id != null ? Objects.hash(id) : getClass().hashCode(); + } +} \ No newline at end of file diff --git a/stats-service/stats-server/src/main/java/ru/practicum/explorewithme/stats/server/repository/StatsRepository.java b/stats-service/stats-server/src/main/java/ru/practicum/explorewithme/stats/server/repository/StatsRepository.java new file mode 100644 index 0000000..eece591 --- /dev/null +++ b/stats-service/stats-server/src/main/java/ru/practicum/explorewithme/stats/server/repository/StatsRepository.java @@ -0,0 +1,39 @@ +package ru.practicum.explorewithme.stats.server.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.stats.dto.ViewStatsDto; +import ru.practicum.explorewithme.stats.server.model.EndpointHit; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; + +@Repository +public interface StatsRepository extends JpaRepository { + + @Query("SELECT new ru.practicum.explorewithme.stats.dto.ViewStatsDto(eh.app, eh.uri, COUNT(eh.ip)) " + + "FROM EndpointHit eh " + + "WHERE eh.timestamp BETWEEN :start AND :end " + + "AND (:uris IS NULL OR eh.uri IN :uris) " + + "GROUP BY eh.app, eh.uri " + + "ORDER BY COUNT(eh.ip) DESC") + List findStats( + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end, + @Param("uris") Collection uris); + + @Query("SELECT new ru.practicum.explorewithme.stats.dto.ViewStatsDto(eh.app, eh.uri, COUNT(DISTINCT eh.ip)) " + + "FROM EndpointHit eh " + + "WHERE eh.timestamp BETWEEN :start AND :end " + + "AND (:uris IS NULL OR eh.uri IN :uris) " + + "GROUP BY eh.app, eh.uri " + + "ORDER BY COUNT(DISTINCT eh.ip) DESC") + List findUniqueStats( + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end, + @Param("uris") Collection uris); + +} \ No newline at end of file diff --git a/stats-service/stats-server/src/main/java/ru/practicum/explorewithme/stats/server/service/StatsService.java b/stats-service/stats-server/src/main/java/ru/practicum/explorewithme/stats/server/service/StatsService.java new file mode 100644 index 0000000..5aa78a1 --- /dev/null +++ b/stats-service/stats-server/src/main/java/ru/practicum/explorewithme/stats/server/service/StatsService.java @@ -0,0 +1,29 @@ +package ru.practicum.explorewithme.stats.server.service; + +import ru.practicum.explorewithme.stats.dto.EndpointHitDto; +import ru.practicum.explorewithme.stats.dto.ViewStatsDto; + +import java.time.LocalDateTime; +import java.util.List; + +public interface StatsService { + + /** + * Сохраняет информацию о запросе к эндпоинту. + * + * @param endpointHitDto DTO с информацией о запросе. + */ + void saveHit(EndpointHitDto endpointHitDto); + + /** + * Возвращает статистику по посещениям за указанный период. + * + * @param start дата и время начала диапазона для статистики. + * @param end дата и время конца диапазона для статистики. + * @param uris список URI, для которых нужна статистика (может быть null или пустым для всех URI). + * @param unique true, если нужны только уникальные по IP посещения, false иначе. + * @return список DTO {@link ViewStatsDto} со статистикой. + */ + List getStats(LocalDateTime start, LocalDateTime end, List uris, boolean unique); + +} \ No newline at end of file diff --git a/stats-service/stats-server/src/main/java/ru/practicum/explorewithme/stats/server/service/StatsServiceImpl.java b/stats-service/stats-server/src/main/java/ru/practicum/explorewithme/stats/server/service/StatsServiceImpl.java new file mode 100644 index 0000000..87693ad --- /dev/null +++ b/stats-service/stats-server/src/main/java/ru/practicum/explorewithme/stats/server/service/StatsServiceImpl.java @@ -0,0 +1,61 @@ +package ru.practicum.explorewithme.stats.server.service; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import ru.practicum.explorewithme.stats.dto.EndpointHitDto; +import ru.practicum.explorewithme.stats.dto.ViewStatsDto; +import ru.practicum.explorewithme.stats.server.mapper.EndpointHitMapper; +import ru.practicum.explorewithme.stats.server.model.EndpointHit; +import ru.practicum.explorewithme.stats.server.repository.StatsRepository; + +@Service +@RequiredArgsConstructor +@Slf4j +@SuppressWarnings("unused") +public class StatsServiceImpl implements StatsService { + + private final EndpointHitMapper endpointHitMapper; + private final StatsRepository statsRepository; + + @Override + @Transactional + public void saveHit(EndpointHitDto endpointHitDto) { + log.debug("Service: Attempting to save hit: {}", endpointHitDto); + if (endpointHitDto == null) { + log.warn("Service: Cannot save hit, input EndpointHitDto was null."); + throw new IllegalArgumentException("Input EndpointHitDto cannot be null."); + } + EndpointHit endpointHit = endpointHitMapper.toEndpointHit(endpointHitDto); + statsRepository.save(endpointHit); + log.info("Service: Hit saved successfully for app: {}, uri: {}", endpointHit.getApp(), endpointHit.getUri()); + } + + @Override + @Transactional(readOnly = true) + public List getStats(LocalDateTime start, LocalDateTime end, List urisFromController, boolean unique) { + log.debug("Service: Requesting stats with params: start={}, end={}, uris={}, unique={}", + start, end, urisFromController, unique); + + if (start != null && end != null && start.isAfter(end)) { + log.warn("Validation error in getStats: Start date {} is after end date {}", start, end); + throw new IllegalArgumentException("Error: Start date cannot be after end date."); + } + + // Пустой список URI явно конвертируется в null для обработки репозиторием + Collection urisForRepo = (urisFromController == null || urisFromController.isEmpty()) ? null : urisFromController; + + List stats; + if (unique) { + stats = statsRepository.findUniqueStats(start, end, urisForRepo); + } else { + stats = statsRepository.findStats(start, end, urisForRepo); + } + log.info("Service: Found {} stats entries.", stats.size()); + return stats; + } +} \ No newline at end of file diff --git a/stats-service/stats-server/src/main/resources/application-local.yaml b/stats-service/stats-server/src/main/resources/application-local.yaml new file mode 100644 index 0000000..6b0b01a --- /dev/null +++ b/stats-service/stats-server/src/main/resources/application-local.yaml @@ -0,0 +1,15 @@ +spring: + datasource: + url: jdbc:postgresql://localhost:6543/ewm_stats_db + username: stats_user + password: stats_password + jpa: + hibernate: + ddl-auto: update + show-sql: true + properties: + hibernate.format_sql: true + logging: + level: + org.hibernate.SQL: DEBUG + org.hibernate.type.descriptor.sql.BasicBinder: TRACE \ No newline at end of file diff --git a/stats-service/stats-server/src/main/resources/application.yaml b/stats-service/stats-server/src/main/resources/application.yaml new file mode 100644 index 0000000..c599416 --- /dev/null +++ b/stats-service/stats-server/src/main/resources/application.yaml @@ -0,0 +1,17 @@ +server: + port: 9090 + +spring: + application: + name: stats-server + 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/stats-service/stats-server/src/main/resources/schema.sql b/stats-service/stats-server/src/main/resources/schema.sql new file mode 100644 index 0000000..ead75e2 --- /dev/null +++ b/stats-service/stats-server/src/main/resources/schema.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS endpoint_hits ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + app VARCHAR(32) NOT NULL, + uri VARCHAR(128) NOT NULL, + ip VARCHAR(16) NOT NULL, + "timestamp" TIMESTAMP WITHOUT TIME ZONE NOT NULL, + CONSTRAINT pk_endpoint_hits PRIMARY KEY (id) +); \ No newline at end of file diff --git a/stats-service/stats-server/src/test/java/ru/practicum/explorewithme/stats/server/controller/StatsControllerTest.java b/stats-service/stats-server/src/test/java/ru/practicum/explorewithme/stats/server/controller/StatsControllerTest.java new file mode 100644 index 0000000..116bae5 --- /dev/null +++ b/stats-service/stats-server/src/test/java/ru/practicum/explorewithme/stats/server/controller/StatsControllerTest.java @@ -0,0 +1,167 @@ +package ru.practicum.explorewithme.stats.server.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import ru.practicum.explorewithme.stats.dto.EndpointHitDto; +import ru.practicum.explorewithme.stats.dto.ViewStatsDto; +import ru.practicum.explorewithme.stats.server.service.StatsService; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.List; + +@ExtendWith(MockitoExtension.class) +class StatsControllerTest { + + @InjectMocks + private StatsController statsController; + + @Mock + private StatsService statsService; + + private MockMvc mvc; + + private EndpointHitDto validHitDto; + private LocalDateTime now; + private ObjectMapper objectMapper; + private DateTimeFormatter dateTimeFormatter; + + @BeforeEach + void setUp() { + objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + mvc = MockMvcBuilders + .standaloneSetup(statsController) + .build(); + now = LocalDateTime.now().truncatedTo(ChronoUnit.SECONDS); + validHitDto = EndpointHitDto.builder() + .app("test-app") + .uri("/test-uri") + .ip("127.0.0.1") + .timestamp(now.minusHours(1)) + .build(); + dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + } + + @Test + void saveHit_whenDtoIsValid_shouldReturnCreated() throws Exception { + doNothing().when(statsService).saveHit(any(EndpointHitDto.class)); + + mvc.perform(post("/hit") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validHitDto))) + .andExpect(status().isCreated()); + + verify(statsService, times(1)).saveHit(any(EndpointHitDto.class)); + + } + + @Test + void saveHitShouldReturn400WhenAppIsBlank() throws Exception { + validHitDto.setApp(""); + + mvc.perform(post("/hit") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validHitDto))) + .andExpect(status().isBadRequest()); + + verify(statsService, never()).saveHit(any()); + } + + @Test + void saveHitShouldReturn400WhenUriIsBlank() throws Exception { + validHitDto.setUri(""); + + mvc.perform(post("/hit") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validHitDto))) + .andExpect(status().isBadRequest()); + + verify(statsService, never()).saveHit(any()); + } + + @Test + void saveHitShouldReturn400WhenIpIsBlank() throws Exception { + validHitDto.setIp(""); + + mvc.perform(post("/hit") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validHitDto))) + .andExpect(status().isBadRequest()); + + verify(statsService, never()).saveHit(any()); + } + + @Test + void saveHitShouldReturn400WhenTimestampIsNull() throws Exception { + validHitDto.setTimestamp(null); + + mvc.perform(post("/hit") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validHitDto))) + .andExpect(status().isBadRequest()); + + verify(statsService, never()).saveHit(any()); + } + + @Test + void getStats_whenParamsAreValid_shouldReturn200Ok() throws Exception { + LocalDateTime start = now.minusDays(1); + LocalDateTime end = now; + List uris = List.of("/test-uri"); + Boolean unique = false; + + when(statsService.getStats(eq(start), eq(end), eq(uris), eq(unique))) + .thenReturn(List.of(new ViewStatsDto("test-app", "/test-uri", 10L))); + + mvc.perform(get("/stats") + .param("start", start.format(dateTimeFormatter)) + .param("end", end.format(dateTimeFormatter)) + .param("uris", "/test-uri") + .param("unique", String.valueOf(unique))) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString( + List.of(new ViewStatsDto("test-app", "/test-uri", 10L))) + )); + + verify(statsService, times(1)).getStats(eq(start), eq(end), eq(uris), eq(unique)); + } + + @Test + void getStats_whenNoUris_shouldReturn200Ok() throws Exception { + LocalDateTime start = now.minusDays(1); + LocalDateTime end = now; + Boolean unique = false; + + List statsList = List.of(new ViewStatsDto("test-app", "/", 5L)); + when(statsService.getStats(eq(start), eq(end), isNull(), eq(unique))).thenReturn(statsList); + + mvc.perform(get("/stats") + .param("start", start.format(dateTimeFormatter)) + .param("end", end.format(dateTimeFormatter)) + .param("unique", String.valueOf(unique))) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(statsList))); + + verify(statsService, times(1)).getStats(eq(start), eq(end), isNull(), eq(unique)); + } +} \ No newline at end of file diff --git a/stats-service/stats-server/src/test/java/ru/practicum/explorewithme/stats/server/controller/StatsServerIntegrationTest.java b/stats-service/stats-server/src/test/java/ru/practicum/explorewithme/stats/server/controller/StatsServerIntegrationTest.java new file mode 100644 index 0000000..6c83b20 --- /dev/null +++ b/stats-service/stats-server/src/test/java/ru/practicum/explorewithme/stats/server/controller/StatsServerIntegrationTest.java @@ -0,0 +1,158 @@ +package ru.practicum.explorewithme.stats.server.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static ru.practicum.explorewithme.common.constants.DateTimeConstants.DATE_TIME_FORMATTER; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +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 ru.practicum.explorewithme.stats.dto.EndpointHitDto; +import ru.practicum.explorewithme.stats.dto.ViewStatsDto; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Testcontainers +public class StatsServerIntegrationTest { + + @Container + private static final PostgreSQLContainer postgresqlContainer = new PostgreSQLContainer<>("postgres:16.1") + .withDatabaseName("testdb") + .withUsername("testuser") + .withPassword("testpass"); + + @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"); + } + + @Autowired + private TestRestTemplate restTemplate; + + private static final DateTimeFormatter FORMATTER = DATE_TIME_FORMATTER; + private final LocalDateTime now = LocalDateTime.now(); + + @Test + void shouldRetrieveUniqueStats_whenUniqueFlagIsTrue() { + EndpointHitDto hit1 = EndpointHitDto.builder() + .app("app1") + .uri("/event/1") + .ip("192.168.0.1") + .timestamp(now.minusHours(1)) + .build(); + + EndpointHitDto hit2 = EndpointHitDto.builder() + .app("app1") + .uri("/event/1") + .ip("192.168.0.1") // Повторный IP + .timestamp(now.minusMinutes(30)) + .build(); + + ResponseEntity response1 = restTemplate.postForEntity("/hit", hit1, Void.class); + ResponseEntity response2 = restTemplate.postForEntity("/hit", hit2, Void.class); + + assertThat(response1.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(response2.getStatusCode()).isEqualTo(HttpStatus.CREATED); + + String start = now.minusHours(2).format(FORMATTER); + String end = now.plusHours(1).format(FORMATTER); + String uris = "/event/1"; + String url = "/stats?start={start}&end={end}&uris={uris}&unique=true"; + + ResponseEntity statsResponse = restTemplate.getForEntity(url, ViewStatsDto[].class, start, end, uris); + + assertThat(statsResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + ViewStatsDto[] stats = statsResponse.getBody(); + assertThat(stats).isNotNull(); + assertThat(stats).hasSize(1); + + ViewStatsDto statsEvent1 = stats[0]; + assertThat(statsEvent1.getApp()).isEqualTo("app1"); + assertThat(statsEvent1.getUri()).isEqualTo("/event/1"); + assertThat(statsEvent1.getHits()).isEqualTo(1L); + } + + @Test + void shouldReturnEmptyStats_whenTimeRangeHasNoHits() { + String start = now.minusHours(10).format(FORMATTER); + String end = now.minusHours(8).format(FORMATTER); + String url = "/stats?start={start}&end={end}"; + + ResponseEntity statsResponse = restTemplate.getForEntity( + url, ViewStatsDto[].class, start, end); + + assertThat(statsResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + ViewStatsDto[] stats = statsResponse.getBody(); + assertThat(stats).isNotNull(); + assertThat(stats).isEmpty(); + } + + @Test + void shouldReturnStatsForAllUris_whenUrisParameterIsNotProvided() { + EndpointHitDto hit1 = EndpointHitDto.builder() + .app("app1") + .uri("/event/1") + .ip("192.168.0.1") + .timestamp(now.minusHours(1)) + .build(); + + EndpointHitDto hit2 = EndpointHitDto.builder() + .app("app2") + .uri("/event/2") + .ip("192.168.0.2") + .timestamp(now.minusMinutes(30)) + .build(); + + restTemplate.postForEntity("/hit", hit1, Void.class); + restTemplate.postForEntity("/hit", hit2, Void.class); + + String start = now.minusHours(2).format(FORMATTER); + String end = now.plusHours(1).format(FORMATTER); + String url = "/stats?start={start}&end={end}&unique=false"; + + ResponseEntity statsResponse = restTemplate.getForEntity( + url, ViewStatsDto[].class, start, end); + + assertThat(statsResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + ViewStatsDto[] stats = statsResponse.getBody(); + assertThat(stats).isNotNull(); + assertThat(stats).hasSize(2); + } + + @Test + void shouldReturnBadRequest_whenStartIsAfterEnd() { + String start = now.plusHours(1).format(FORMATTER); + String end = now.minusHours(1).format(FORMATTER); + String url = "/stats?start={start}&end={end}"; + + ResponseEntity response = restTemplate.getForEntity(url, String.class, start, end); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody()).contains("Start date cannot be after end date"); + } + + @Test + void shouldReturnBadRequest_whenHitDtoIsInvalid() { + EndpointHitDto invalidHit = EndpointHitDto.builder() + .uri("/event/1") + .ip("192.168.0.1") + .timestamp(now.minusHours(1)) + .build(); + + ResponseEntity response = restTemplate.postForEntity("/hit", invalidHit, String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody()).contains("Validation error"); + } +} \ No newline at end of file diff --git a/stats-service/stats-server/src/test/java/ru/practicum/explorewithme/stats/server/repository/StatsRepositoryTest.java b/stats-service/stats-server/src/test/java/ru/practicum/explorewithme/stats/server/repository/StatsRepositoryTest.java new file mode 100644 index 0000000..43266ed --- /dev/null +++ b/stats-service/stats-server/src/test/java/ru/practicum/explorewithme/stats/server/repository/StatsRepositoryTest.java @@ -0,0 +1,180 @@ +package ru.practicum.explorewithme.stats.server.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +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.stats.dto.ViewStatsDto; +import ru.practicum.explorewithme.stats.server.model.EndpointHit; + +@DataJpaTest +@Testcontainers +@DisplayName("Stats Repository DataJpa Tests") +public class StatsRepositoryTest { + + // Настройка контейнера с тестовой БД + @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"); + } + + @Autowired + private StatsRepository statsRepository; // Тестируемый репозиторий + + @Autowired + private TestEntityManager entityManager; + + // Тестовые данные для EndpointHit + private EndpointHit hit1, hit2, hit3, hit4, hit5; + private final LocalDateTime now = LocalDateTime.now(); + + @BeforeEach + void setUp() { + hit1 = new EndpointHit(null, "app1", "/uri1", "192.168.0.1", now.minusHours(1)); + hit2 = new EndpointHit(null, "app1", "/uri1", "192.168.0.2", now.minusMinutes(30)); + hit3 = new EndpointHit(null, "app1", "/uri1", "192.168.0.1", now.minusMinutes(10)); // Повторный IP для /uri1 + hit4 = new EndpointHit(null, "app2", "/uri2", "192.168.0.3", now.minusHours(2)); + hit5 = new EndpointHit(null, "app1", "/uri3", "192.168.0.1", now.minusMinutes(5)); // Другой URI, но IP как у hit1 + + statsRepository.saveAll(List.of(hit1, hit2, hit3, hit4, hit5)); + } + + @AfterEach + void tearDown() { + statsRepository.deleteAll(); + } + + @Nested + @DisplayName("findStats (статистика по общему количеству хитов)") + class FindStatsTest { + + @Test + @DisplayName("Должен вернуть корректное общее количество хитов для указанных URI в заданном временном диапазоне") + void findStats_whenUrisProvided_shouldReturnCorrectStats() { + LocalDateTime start = now.minusHours(3); + LocalDateTime end = now.plusHours(1); + List uris = List.of("/uri1", "/uri3"); + + List result = statsRepository.findStats(start, end, uris); + + assertThat(result).hasSize(2); // Ожидаем статистику для двух URI: /uri1 и /uri3 + + // Проверка статистики для /uri1 + ViewStatsDto statsUri1 = result.stream().filter(s -> s.getUri().equals("/uri1")).findFirst().orElse(null); + assertThat(statsUri1).isNotNull(); + assertThat(statsUri1.getApp()).isEqualTo("app1"); + assertThat(statsUri1.getHits()).isEqualTo(3L); // hit1, hit2, hit3 для /uri1 + + // Проверка статистики для /uri3 + ViewStatsDto statsUri3 = result.stream().filter(s -> s.getUri().equals("/uri3")).findFirst().orElse(null); + assertThat(statsUri3).isNotNull(); + assertThat(statsUri3.getApp()).isEqualTo("app1"); + assertThat(statsUri3.getHits()).isEqualTo(1L); // hit5 для /uri3 + } + + @Test + @DisplayName("Должен вернуть корректное общее количество хитов для всех URI в заданном временном диапазоне, если URI не указаны") + void findStats_whenUrisNotProvided_shouldReturnStatsForAllUris() { + LocalDateTime start = now.minusHours(3); + LocalDateTime end = now.plusHours(1); + + // На уровне сервиса пустой список URI должен быть явно преобразован в null + List result = statsRepository.findStats(start, end, null); // URI не указаны + + assertThat(result).hasSize(3); // Ожидаем статистику для трех уникальных URI: /uri1, /uri2, /uri3 + + // Результат отсортирован по app, затем по URI, затем по количеству хитов (здесь /uri1 первый) + assertThat(result.getFirst().getUri()).isEqualTo("/uri1"); // /uri1 имеет 3 хита + assertThat(result.getFirst().getHits()).isEqualTo(3L); + + // Проверка наличия и корректности данных для /uri2 и /uri3 + boolean foundUri2 = result.stream().anyMatch(s -> s.getUri().equals("/uri2") && s.getHits() == 1L); // hit4 для /uri2 + boolean foundUri3 = result.stream().anyMatch(s -> s.getUri().equals("/uri3") && s.getHits() == 1L); // hit5 для /uri3 + assertThat(foundUri2).isTrue(); + assertThat(foundUri3).isTrue(); + } + + @Test + @DisplayName("Должен вернуть пустой список, если временной диапазон не содержит хитов") + void findStats_whenTimeRangeExcludesData_shouldReturnEmptyList() { + // Задаем временной диапазон, который гарантированно не содержит тестовых данных + LocalDateTime start = now.plusHours(1); + LocalDateTime end = now.plusHours(2); + + List result = statsRepository.findStats(start, end, null); + assertThat(result).isEmpty(); + } + } + + @Nested + @DisplayName("findUniqueStats (статистика по уникальным IP)") + class FindUniqueStatsTest { + + @Test + @DisplayName("Должен вернуть корректное количество уникальных хитов (по IP) для указанных URI в заданном временном диапазоне") + void findUniqueStats_whenUrisProvided_shouldReturnCorrectUniqueStats() { + LocalDateTime start = now.minusHours(3); + LocalDateTime end = now.plusHours(1); + List uris = List.of("/uri1"); // Только для /uri1 + + List result = statsRepository.findUniqueStats(start, end, uris); + + assertThat(result).hasSize(1); // Ожидаем статистику только для /uri1 + ViewStatsDto statsUri1 = result.getFirst(); + assertThat(statsUri1.getApp()).isEqualTo("app1"); + assertThat(statsUri1.getUri()).isEqualTo("/uri1"); + assertThat(statsUri1.getHits()).isEqualTo(2L); // Уникальные IP для /uri1: 192.168.0.1, 192.168.0.2 + } + + @Test + @DisplayName("Должен вернуть корректное количество уникальных хитов (по IP) для всех URI в заданном временном диапазоне, если URI не указаны") + void findUniqueStats_whenUrisNotProvided_shouldReturnUniqueStatsForAllUris() { + LocalDateTime start = now.minusHours(3); + LocalDateTime end = now.plusHours(1); + + // На уровне сервиса пустой список URI должен быть явно преобразован в null + List result = statsRepository.findUniqueStats(start, end, null); // URI не указаны + + assertThat(result).hasSize(3); // Ожидаем статистику для трех уникальных URI + + // Проверка статистики для /uri1 + ViewStatsDto statsUri1 = result.stream().filter(s -> s.getUri().equals("/uri1")).findFirst().orElse(null); + assertThat(statsUri1).isNotNull(); + assertThat(statsUri1.getApp()).isEqualTo("app1"); + assertThat(statsUri1.getHits()).isEqualTo(2L); // Уникальные IP для /uri1: 192.168.0.1, 192.168.0.2 + + // Проверка статистики для /uri2 + ViewStatsDto statsUri2 = result.stream().filter(s -> s.getUri().equals("/uri2")).findFirst().orElse(null); + assertThat(statsUri2).isNotNull(); + assertThat(statsUri2.getApp()).isEqualTo("app2"); + assertThat(statsUri2.getHits()).isEqualTo(1L); // Уникальный IP для /uri2: 192.168.0.3 + + // Проверка статистики для /uri3 + ViewStatsDto statsUri3 = result.stream().filter(s -> s.getUri().equals("/uri3")).findFirst().orElse(null); + assertThat(statsUri3).isNotNull(); + assertThat(statsUri3.getApp()).isEqualTo("app1"); + assertThat(statsUri3.getHits()).isEqualTo(1L); // Уникальный IP для /uri3: 192.168.0.1 + } + } +} \ No newline at end of file diff --git a/stats-service/stats-server/src/test/java/ru/practicum/explorewithme/stats/server/service/StatsServiceImplTest.java b/stats-service/stats-server/src/test/java/ru/practicum/explorewithme/stats/server/service/StatsServiceImplTest.java new file mode 100644 index 0000000..909905a --- /dev/null +++ b/stats-service/stats-server/src/test/java/ru/practicum/explorewithme/stats/server/service/StatsServiceImplTest.java @@ -0,0 +1,224 @@ +package ru.practicum.explorewithme.stats.server.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.LocalDateTime; +import java.util.Collections; +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.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 ru.practicum.explorewithme.stats.dto.EndpointHitDto; +import ru.practicum.explorewithme.stats.dto.ViewStatsDto; +import ru.practicum.explorewithme.stats.server.mapper.EndpointHitMapper; +import ru.practicum.explorewithme.stats.server.model.EndpointHit; +import ru.practicum.explorewithme.stats.server.repository.StatsRepository; + +@ExtendWith(MockitoExtension.class) +@DisplayName("Тесты реализации сервиса статистики") +class StatsServiceImplTest { + + @Mock // Мок репозитория статистики + private StatsRepository statsRepository; + + @Mock // Мок маппера EndpointHit + private EndpointHitMapper endpointHitMapper; + + @InjectMocks // Тестируемый сервис статистики + private StatsServiceImpl statsService; + + @Captor // Для захвата аргумента при вызове save + private ArgumentCaptor endpointHitArgumentCaptor; + + // Тестовые данные и вспомогательные переменные + private EndpointHitDto validHitDto; + private EndpointHit mappedEndpointHit; + private LocalDateTime now; + + @BeforeEach + void setUp() { + now = LocalDateTime.now(); + validHitDto = EndpointHitDto.builder() + .app("test-app") + .uri("/test-uri") + .ip("127.0.0.1") + .timestamp(now.minusHours(1)) + .build(); + + mappedEndpointHit = EndpointHit.builder() + .app(validHitDto.getApp()) + .uri(validHitDto.getUri()) + .ip(validHitDto.getIp()) + .timestamp(validHitDto.getTimestamp()) + .build(); + } + + @Nested + @DisplayName("Тесты метода saveHit") + class SaveHitTests { + @Test + @DisplayName("Должен успешно сохранить хит при получении валидного DTO") + void saveHit_whenDtoIsValid_shouldMapAndSave() { + when(endpointHitMapper.toEndpointHit(validHitDto)).thenReturn(mappedEndpointHit); + + statsService.saveHit(validHitDto); + + verify(endpointHitMapper, times(1)).toEndpointHit(validHitDto); + verify(statsRepository, times(1)).save(endpointHitArgumentCaptor.capture()); + + EndpointHit capturedHit = endpointHitArgumentCaptor.getValue(); + assertThat(capturedHit.getApp()).isEqualTo(validHitDto.getApp()); + assertThat(capturedHit.getUri()).isEqualTo(validHitDto.getUri()); + assertThat(capturedHit.getIp()).isEqualTo(validHitDto.getIp()); + assertThat(capturedHit.getTimestamp()).isEqualTo(validHitDto.getTimestamp()); + } + + @Test + @DisplayName("Должен выбросить исключение, если DTO равен null") + void saveHit_whenDtoIsNull_shouldThrowIllegalArgumentException() { + assertThatThrownBy(() -> statsService.saveHit(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Input EndpointHitDto cannot be null"); + + verify(endpointHitMapper, never()).toEndpointHit(any()); + verify(statsRepository, never()).save(any()); + } + + @Test + @DisplayName("Должен выбросить исключение, если маппер вернул null для валидного DTO") + void saveHit_whenMapperReturnsNull_shouldThrowIllegalStateExceptionOrHandle() { + EndpointHitDto nonNullDto = validHitDto; + when(endpointHitMapper.toEndpointHit(nonNullDto)).thenReturn(null); + // Имитируем, что репозиторий выбросит исключение при попытке сохранить null + doThrow(new IllegalArgumentException("Entity must not be null")).when(statsRepository).save(null); + + assertThatThrownBy(() -> statsService.saveHit(nonNullDto)) + .isInstanceOf(IllegalArgumentException.class) // Исключение выброшено репозиторием + .hasMessageContaining("Entity must not be null"); + + verify(endpointHitMapper, times(1)).toEndpointHit(nonNullDto); + verify(statsRepository, times(1)).save(null); // Проверяем, что была попытка сохранить null + } + } + + @Nested + @DisplayName("Тесты метода getStats") + class GetStatsTests { + private LocalDateTime start; + private LocalDateTime end; + private List expectedStatsList; + + // Вспомогательные данные для getStats + @BeforeEach + void getStatsSetup() { + start = now.minusDays(1); + end = now; + expectedStatsList = List.of( + ViewStatsDto.builder().app("app1").uri("/uri1").hits(10L).build(), + ViewStatsDto.builder().app("app2").uri("/uri2").hits(5L).build() + ); + } + + @Test + @DisplayName("Должен вызвать findStats, когда unique=false и uris=null") + void getStats_whenUniqueFalseAndUrisNull_shouldCallFindStats() { + when(statsRepository.findStats(start, end, null)).thenReturn(expectedStatsList); + + List actualStats = statsService.getStats(start, end, null, false); + + assertThat(actualStats).isEqualTo(expectedStatsList); + verify(statsRepository, times(1)).findStats(start, end, null); + verify(statsRepository, never()).findUniqueStats(any(), any(), any()); + } + + @Test + @DisplayName("Должен вызвать findStats с uris=null, когда unique=false и uris пустой список") + void getStats_whenUniqueFalseAndUrisEmpty_shouldCallFindStatsWithNullUris() { + when(statsRepository.findStats(start, end, null)).thenReturn(expectedStatsList); + + List actualStats = statsService.getStats(start, end, Collections.emptyList(), false); + + assertThat(actualStats).isEqualTo(expectedStatsList); + verify(statsRepository, times(1)).findStats(start, end, null); // Сервис преобразует пустой список в null + verify(statsRepository, never()).findUniqueStats(any(), any(), any()); + } + + @Test + @DisplayName("Должен вызвать findStats с указанными uris, когда unique=false") + void getStats_whenUniqueFalseAndUrisProvided_shouldCallFindStatsWithUris() { + List uris = List.of("/uri1", "/uri2"); + when(statsRepository.findStats(start, end, uris)).thenReturn(expectedStatsList); + + List actualStats = statsService.getStats(start, end, uris, false); + + assertThat(actualStats).isEqualTo(expectedStatsList); + verify(statsRepository, times(1)).findStats(start, end, uris); + verify(statsRepository, never()).findUniqueStats(any(), any(), any()); + } + + @Test + @DisplayName("Должен вызвать findUniqueStats, когда unique=true и uris=null") + void getStats_whenUniqueTrueAndUrisNull_shouldCallFindUniqueStats() { + when(statsRepository.findUniqueStats(start, end, null)).thenReturn(expectedStatsList); + + List actualStats = statsService.getStats(start, end, null, true); + + assertThat(actualStats).isEqualTo(expectedStatsList); + verify(statsRepository, times(1)).findUniqueStats(start, end, null); + verify(statsRepository, never()).findStats(any(), any(), any()); + } + + @Test + @DisplayName("Должен вызвать findUniqueStats с uris=null, когда unique=true и uris пустой список") + void getStats_whenUniqueTrueAndUrisEmpty_shouldCallFindUniqueStatsWithNullUris() { + when(statsRepository.findUniqueStats(start, end, null)).thenReturn(expectedStatsList); + + List actualStats = statsService.getStats(start, end, Collections.emptyList(), true); + + assertThat(actualStats).isEqualTo(expectedStatsList); + verify(statsRepository, times(1)).findUniqueStats(start, end, null); // Сервис преобразует пустой список в null + verify(statsRepository, never()).findStats(any(), any(), any()); + } + + @Test + @DisplayName("Должен вызвать findUniqueStats с указанными uris, когда unique=true") + void getStats_whenUniqueTrueAndUrisProvided_shouldCallFindUniqueStatsWithUris() { + List uris = List.of("/uri1", "/uri2"); + when(statsRepository.findUniqueStats(start, end, uris)).thenReturn(expectedStatsList); + + List actualStats = statsService.getStats(start, end, uris, true); + + assertThat(actualStats).isEqualTo(expectedStatsList); + verify(statsRepository, times(1)).findUniqueStats(start, end, uris); + verify(statsRepository, never()).findStats(any(), any(), any()); + } + + @Test + @DisplayName("Должен выбросить IllegalArgumentException, если дата начала после даты окончания") + void getStats_whenStartIsAfterEnd_shouldReturnEmptyList() { + LocalDateTime laterStart = now; + LocalDateTime earlierEnd = now.minusDays(1); + + assertThatThrownBy(() -> statsService.getStats(laterStart, earlierEnd, null, false)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Error: Start date cannot be after end date."); + + verify(statsRepository, never()).findStats(any(), any(), any()); + verify(statsRepository, never()).findUniqueStats(any(), any(), any()); + } + } +} \ No newline at end of file