From 9aa1a88f93eae25ac3f02cb66c321393ad2d11b6 Mon Sep 17 00:00:00 2001 From: impatient0 Date: Mon, 5 May 2025 18:52:21 +0300 Subject: [PATCH 01/73] Initial commit --- .github/workflows/api-tests.yml | 8 + .github/workflows/dashboard-template.hbs | 1672 +++++++++++++ .github/workflows/wait-for-it.sh | 183 ++ .gitignore | 22 + checkstyle.xml | 257 ++ docker-compose.yml | 14 + ewm-main-service-spec.json | 2836 ++++++++++++++++++++++ ewm-stats-service-spec.json | 170 ++ lombok.config | 4 + pom.xml | 187 ++ suppressions.xml | 7 + 11 files changed, 5360 insertions(+) create mode 100644 .github/workflows/api-tests.yml create mode 100644 .github/workflows/dashboard-template.hbs create mode 100644 .github/workflows/wait-for-it.sh create mode 100644 .gitignore create mode 100644 checkstyle.xml create mode 100644 docker-compose.yml create mode 100644 ewm-main-service-spec.json create mode 100644 ewm-stats-service-spec.json create mode 100644 lombok.config create mode 100644 pom.xml create mode 100644 suppressions.xml diff --git a/.github/workflows/api-tests.yml b/.github/workflows/api-tests.yml new file mode 100644 index 0000000..96bb991 --- /dev/null +++ b/.github/workflows/api-tests.yml @@ -0,0 +1,8 @@ +name: Explore With Me API Tests + +on: + pull_request: + +jobs: + build: + uses: yandex-praktikum/java-explore-with-me-plus/.github/workflows/api-tests.yml@ci \ No newline at end of file diff --git a/.github/workflows/dashboard-template.hbs b/.github/workflows/dashboard-template.hbs new file mode 100644 index 0000000..56aa163 --- /dev/null +++ b/.github/workflows/dashboard-template.hbs @@ -0,0 +1,1672 @@ + + + + + {{browserTitle}} + + + + + + + + + +
+
+ + + +
+ {{#with summary}} +
+
+ + {{/with}} +
+
+
+
+

{{title}}

+
{{timestamp}}
+{{#with summary}} +
+
+
+
+
+ +
+
Итого итераций
+

{{stats.iterations.total}}

+
+
+
+
+
+
+
+ +
+
Всего проверенных утверждений
+

{{totalTests stats.assertions.total skippedTests.length}}

+
+
+
+
+
+
+
+ +
+
Всего проваленных тестов
+

{{failures.length}}

+
+
+
+
+
+
+
+ +
+
Всего пропущено тестов
+

{{#gt skippedTests.length 0}}{{skippedTests.length}}{{else}}0{{/gt}}

+
+
+
+
+
+
+
+
+
+
+
+
File Information
+ Коллекция: {{collection.name}}
+ {{/with}} + {{#if folders}} Указанные папки: {{folders}}
{{/if}} + {{#with summary}} + {{#if environment.name}} Окружение: {{environment.name}}
{{/if}} +
+
+
+
+ {{#if @root.showGlobalData}} + {{#if globals.values.members.length}} +
+
+
+
+
+ +
+
+
+ +
+ + + + {{#each globals.values.members}} + {{#isNotIn key @root.skipGlobalVars}} + + + + + {{/isNotIn}} + {{/each}} + +
Название переменнойЗначение переменной
{{key}}{{value}}
+
+
+
+
+
+
+
+ {{/if}} + {{/if}} + {{#if @root.showEnvironmentData}} + {{#if environment.values.members.length}} +
+
+
+
+
+ +
+
+
+ +
+ + + + {{#each environment.values.members}} + {{#isNotIn key @root.skipEnvironmentVars}} + + + + + {{/isNotIn}} + {{/each}} + +
Название переменнойЗначение переменной
{{key}}{{value}}
+
+
+
+
+
+
+
+ {{/if}} + {{/if}} + {{#if collection.description}} +
+
+
+
+
Описание Коллекции
+
+ {{collection.description}} +
+
+
+
+
+ {{/if}} +
+
+
+
+
Временные рамки и данные
+ Общая длительность выполнения: {{duration}}
+ Всего данных получено: {{responseTotal}}
+ Среднее время отклика: {{responseAverage}}
+
+
+
+
+ {{/with}} +
+
+
+ + + + + + + + + + {{#with summary.stats}} + + + + + + + + + + + + + + + + {{/with}} + {{#with summary}} + + + + + + + + + + + {{/with}} + +
Элемент сводных данныхВсегоПровалено
Requests{{requests.total}}{{requests.failed}}
Prerequest Scripts{{prerequestScripts.total}}{{prerequestScripts.failed}}
Test Scripts{{testScripts.total}}{{testScripts.failed}}
Assertions{{totalTests stats.assertions.total skippedTests.length}}{{stats.assertions.failed}}
Skipped Tests{{#gt skippedTests.length 0}}{{skippedTests.length}}{{else}}0{{/gt}}-
+
+
+
+
+
+
+
+
+
+
+ + + {{#if summary.failures.length}} +
+ +
+
+
+ + {{#with summary}} +
+

Showing {{failures.length}} {{#gt failures.length 1}}Failures{{else}}Failure{{/gt}}

+
+ {{/with}} + {{#each summary.failures}} +
+
+
+ +
+
+
Failed Test: {{error.test}}
+
+
Assertion Error Message
+
+
{{error.message}}
+
+
+
+
+
+
+ {{/each}} + {{else}} +
+

There are no failed tests



+
+ {{/if}} +
+ +
+ + + {{#if summary.skippedTests.length}} +
+ +
+
+
+ + {{#with summary}} +
+

Showing {{skippedTests.length}} Skipped {{#gt skippedTests.length 1}}Tests{{else}}Test{{/gt}}

+
+ {{/with}} + {{#each summary.skippedTests}} +
+
+
+ +
+
+
Request Name: {{item.name}}
+
+
+
+
+
+ {{/each}} + {{else}} +
+

There are no skipped tests



+
+ {{/if}} +
+
+ + + +
+ {{#if summary.failures.length}} + + {{/if}} + + +
+ +
+ {{#with summary}} +
{{stats.iterations.total}} {{#gt stats.iterations.total 1}}Iterations{{else}}Iteration{{/gt}} available to view
+ {{#gt stats.iterations.total 18}}{{/gt}} + {{/with}} + +
+
+
+{{#each aggregations}} + {{#isNotIn parent.name @root.skipFolders}} + {{#if parent.name }} + +
+ {{> aggregations}} +
+ {{else}} + {{> aggregations}} + {{/if}} + {{/isNotIn}} +{{/each}} +
+
+
+
+
+
+ +{{#*inline "aggregations"}} +{{#isNotIn parent.name @root.skipFolders}} +{{#if @root.showFolderDescription}} +{{#if parent.description.content}} + +
+
+
+
+
+
Описание папки
+
+ {{parent.description.content}} +
+
+
+
+
+
+{{/if}} +{{/if}} +{{#each executions}} +{{#isNotIn item.name @root.skipRequests}} +
+
+
+
+
+ + {{#if cumulativeTests.skipped}} + {{cumulativeTests.skipped}} Пропущено {{#gt cumulativeTests.skipped 1}}Тестов{{else}}Тест{{/gt}} + {{/if}} +
+
+
+ {{#with request}} + {{#if description.content}} +
+
+
+
+
+
Описание запроса
+
+ {{description.content}} +
+
+
+
+
+
+ {{/if}} + {{/with}} +
+
+
+
+
+
Информация о запросе
+ HTTP-метод запроса: {{request.method}}
+ URL запроса: {{request.url}}
+
+
+
+
+
Информация об ответе
+ Код статуса ответа: {{response.code}} - {{response.status}}
+ Среднее время на запрос: {{mean.time}}
+ Средний размер одного запроса: {{mean.size}}
+
+
Процент прохождения тестов
+
+ {{#if assertions.length}} +
+
+
{{#gte cumulativeTests.passed 1}}{{percent cumulativeTests.passed cumulativeTests.failed}} %{{else}}0 %{{/gte}}
+
+
+ {{else}} +
+
+
Для данного запроса нет тестов
+
+
+ {{/if}} +
+
+
+
+
+
+ {{#with request}} + {{#unless @root.omitHeaders}} + {{#unless @root.skipSensitiveData}} +
+
+
+
+
+
Заголовки запроса
+ {{#if headers}} +
+ + + + {{#each headers.members}} + {{#isNotIn key @root.skipHeaders}} + + + + + {{/isNotIn}} + {{/each}} + +
Название заголовкаЗначение заголовка
{{key}}{{value}}
+
+ {{/if}} +
+
+
+
+
+ {{/unless}} + {{/unless}} + {{/with}} + {{#unless @root.skipSensitiveData}} + {{#unless @root.omitRequestBodies}} + {{#isNotIn item.name @root.hideRequestBody}} + {{#with request}} + {{#if body.raw}} +
+
+
+
+
+
Тело запроса
+
+
{{body.raw}}
+
+ +
+
+
+
+
+ {{/if}} + {{/with}} + {{/isNotIn}} + {{/unless}} + {{/unless}} + {{#unless @root.skipSensitiveData}} + {{#unless @root.omitRequestBodies}} + {{#isNotIn item.name @root.hideRequestBody}} + {{#with request}} + {{#if body.formdata.members}} +
+
+
+
+
+
Тело Запроса
+
+
{{formdata body.formdata.members}}
+
+ +
+
+
+
+
+ {{/if}} + {{/with}} + {{/isNotIn}} + {{/unless}} + {{/unless}} + {{#unless @root.skipSensitiveData}} + {{#unless @root.omitRequestBodies}} + {{#isNotIn item.name @root.hideRequestBody}} + {{#with request}} + {{#if body.urlencoded.members}} +
+
+
+
+
+
Тело Запроса
+
+
{{formdata body.urlencoded.members}}
+
+ +
+
+
+
+
+ {{/if}} + {{/with}} + {{/isNotIn}} + {{/unless}} + {{/unless}} + {{#unless @root.skipSensitiveData}} + {{#unless @root.omitRequestBodies}} + {{#isNotIn item.name @root.hideRequestBody}} + {{#with request}} + {{#if body.graphql}} +
+
+
+
+
+
Тело Запроса
+
+
{{body.graphql.query}}
+
+ + {{#if body.graphql.variables }} +
Graphql Variables
+
+
{{body.graphql.variables}}
+
+ {{/if}} +
+
+
+
+
+ {{/if}} + {{/with}} + {{/isNotIn}} + {{/unless}} + {{/unless}} + {{#unless @root.omitHeaders}} + {{#unless @root.skipSensitiveData}} +
+
+
+
+
+
Заголовки Ответа
+ {{#if response.header}} +
+ + + + {{#each response.header}} + {{#isNotIn key @root.skipHeaders}} + + + + + {{/isNotIn}} + {{/each}} + +
Название заголовкаЗначение заголовка
{{key}}{{value}}
+
+ {{/if}} +
+
+
+
+
+ {{/unless}} + {{/unless}} + {{#unless @root.skipSensitiveData}} + {{#unless @root.omitResponseBodies}} + {{#isNotIn item.name @root.hideResponseBody}} +
+
+
+
+
+
Тело Ответа
+ {{#if response.body}} +
+
{{response.body}}
+
+ + {{else}} +
У ответа на этот запрос нет тела
+ {{/if}} +
+
+
+
+
+ {{/isNotIn}} + {{/unless}} + {{/unless}} + {{#if consoleLogs.length}} +
+
+
+
+
+
Логи консоли
+
+
+ + + + {{#each consoleLogs}} + + + + {{/each}} + +
Залогированные сообщения
{{#each messages}}{{this}}{{/each}}
+
+
+
+
+
+
+
+ {{/if}} +
+
+
+
Информация о тесте
+ {{#if assertions.length}} +
+ + + + {{#each assertions}} + + + + + + + {{/each}} + + + + + + + + + +
НазваниеПройденоПроваленоПропущено
{{this.name}}{{this.passed}}{{this.failed}}{{this.skipped}}
Всего{{cumulativeTests.passed}}{{cumulativeTests.failed}}{{cumulativeTests.skipped}}
+
+
+
+
+
+
+
{{#lte cumulativeTests.failed 1}}Проваленный тест{{else}}Проваленные тесты{{/lte}}
+
+ + + + {{#each assertions}} + {{#isTheSame testFailure.test this.name}} + + + + + {{/isTheSame}} + {{/each}} + +
Название тестаОшибка проверки
{{testFailure.test}}
{{testFailure.message}}
+
+
+
+
+
+
+ {{else}} +
No Tests for this request
+ {{/if}} +
+
+
+
+
+
+
+
+
+{{/isNotIn}} +{{/each}} +{{/isNotIn}} +{{/inline}} + + + + + + +{{#eq noSyntaxHighlighting false}} + + +{{/eq}} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.github/workflows/wait-for-it.sh b/.github/workflows/wait-for-it.sh new file mode 100644 index 0000000..df97b8f --- /dev/null +++ b/.github/workflows/wait-for-it.sh @@ -0,0 +1,183 @@ +#!/usr/bin/env bash +# Use this script to test if a given TCP host/port are available + +WAITFORIT_cmdname=${0##*/} + +echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi } + +usage() +{ + cat << USAGE >&2 +Usage: + $WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args] + -h HOST | --host=HOST Host or IP under test + -p PORT | --port=PORT TCP port under test + Alternatively, you specify the host and port as host:port + -s | --strict Only execute subcommand if the test succeeds + -q | --quiet Don't output any status messages + -t TIMEOUT | --timeout=TIMEOUT + Timeout in seconds, zero for no timeout + -- COMMAND ARGS Execute command with args after the test finishes +USAGE + exit 1 +} + +wait_for() +{ + if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then + echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" + else + echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout" + fi + WAITFORIT_start_ts=$(date +%s) + while : + do + if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then + nc -z $WAITFORIT_HOST $WAITFORIT_PORT + WAITFORIT_result=$? + else +# (echo -n > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1 + (curl --fail --silent http://$WAITFORIT_HOST:$WAITFORIT_PORT/actuator/health | grep UP) >/dev/null 2>&1 + WAITFORIT_result=$? + fi + if [[ $WAITFORIT_result -eq 0 ]]; then + WAITFORIT_end_ts=$(date +%s) + echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds" + break + fi + sleep 1 + done + return $WAITFORIT_result +} + +wait_for_wrapper() +{ + # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692 + if [[ $WAITFORIT_QUIET -eq 1 ]]; then + timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & + else + timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & + fi + WAITFORIT_PID=$! + trap "kill -INT -$WAITFORIT_PID" INT + wait $WAITFORIT_PID + WAITFORIT_RESULT=$? + if [[ $WAITFORIT_RESULT -ne 0 ]]; then + echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" + fi + return $WAITFORIT_RESULT +} + +# process arguments +while [[ $# -gt 0 ]] +do + case "$1" in + *:* ) + WAITFORIT_hostport=(${1//:/ }) + WAITFORIT_HOST=${WAITFORIT_hostport[0]} + WAITFORIT_PORT=${WAITFORIT_hostport[1]} + shift 1 + ;; + --child) + WAITFORIT_CHILD=1 + shift 1 + ;; + -q | --quiet) + WAITFORIT_QUIET=1 + shift 1 + ;; + -s | --strict) + WAITFORIT_STRICT=1 + shift 1 + ;; + -h) + WAITFORIT_HOST="$2" + if [[ $WAITFORIT_HOST == "" ]]; then break; fi + shift 2 + ;; + --host=*) + WAITFORIT_HOST="${1#*=}" + shift 1 + ;; + -p) + WAITFORIT_PORT="$2" + if [[ $WAITFORIT_PORT == "" ]]; then break; fi + shift 2 + ;; + --port=*) + WAITFORIT_PORT="${1#*=}" + shift 1 + ;; + -t) + WAITFORIT_TIMEOUT="$2" + if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi + shift 2 + ;; + --timeout=*) + WAITFORIT_TIMEOUT="${1#*=}" + shift 1 + ;; + --) + shift + WAITFORIT_CLI=("$@") + break + ;; + --help) + usage + ;; + *) + echoerr "Unknown argument: $1" + usage + ;; + esac +done + +if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then + echoerr "Error: you need to provide a host and port to test." + usage +fi + +WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15} +WAITFORIT_STRICT=${WAITFORIT_STRICT:-0} +WAITFORIT_CHILD=${WAITFORIT_CHILD:-0} +WAITFORIT_QUIET=${WAITFORIT_QUIET:-0} + +# Check to see if timeout is from busybox? +WAITFORIT_TIMEOUT_PATH=$(type -p timeout) +WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH) + +WAITFORIT_BUSYTIMEFLAG="" +if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then + WAITFORIT_ISBUSY=1 + # Check if busybox timeout uses -t flag + # (recent Alpine versions don't support -t anymore) + if timeout &>/dev/stdout | grep -q -e '-t '; then + WAITFORIT_BUSYTIMEFLAG="-t" + fi +else + WAITFORIT_ISBUSY=0 +fi + +if [[ $WAITFORIT_CHILD -gt 0 ]]; then + wait_for + WAITFORIT_RESULT=$? + exit $WAITFORIT_RESULT +else + if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then + wait_for_wrapper + WAITFORIT_RESULT=$? + else + wait_for + WAITFORIT_RESULT=$? + fi +fi + +if [[ $WAITFORIT_CLI != "" ]]; then + if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then + echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess" + exit $WAITFORIT_RESULT + fi + exec "${WAITFORIT_CLI[@]}" +else + exit $WAITFORIT_RESULT +fi \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..64703cb --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +*.class +*.log +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar +hs_err_pid* +replay_pid* +out/ +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr diff --git a/checkstyle.xml b/checkstyle.xml new file mode 100644 index 0000000..41b0212 --- /dev/null +++ b/checkstyle.xml @@ -0,0 +1,257 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..be96142 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +services: + stats-server: + ports: + - "9090:9090" + + stats-db: + image: postgres:16.1 + + ewm-service: + ports: + - "8080:8080" + + ewm-db: + image: postgres:16.1 diff --git a/ewm-main-service-spec.json b/ewm-main-service-spec.json new file mode 100644 index 0000000..f28d141 --- /dev/null +++ b/ewm-main-service-spec.json @@ -0,0 +1,2836 @@ +{ + "openapi": "3.0.1", + "info": { + "description": "Documentation \"Explore With Me\" API v1.0", + "title": "\"Explore With Me\" API сервер", + "version": "1.0" + }, + "servers": [ + { + "description": "Generated server url", + "url": "http://localhost:8080" + } + ], + "tags": [ + { + "description": "Публичный API для работы с подборками событий", + "name": "Public: Подборки событий" + }, + { + "description": "API для работы с категориями", + "name": "Admin: Категории" + }, + { + "description": "Закрытый API для работы с событиями", + "name": "Private: События" + }, + { + "description": "Публичный API для работы с категориями", + "name": "Public: Категории" + }, + { + "description": "API для работы с событиями", + "name": "Admin: События" + }, + { + "description": "Публичный API для работы с событиями", + "name": "Public: События" + }, + { + "description": "Закрытый API для работы с запросами текущего пользователя на участие в событиях", + "name": "Private: Запросы на участие" + }, + { + "description": "API для работы с пользователями", + "name": "Admin: Пользователи" + }, + { + "description": "API для работы с подборками событий", + "name": "Admin: Подборки событий" + } + ], + "paths": { + "/admin/categories": { + "post": { + "description": "Обратите внимание: имя категории должно быть уникальным", + "operationId": "addCategory", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewCategoryDto" + } + } + }, + "description": "данные добавляемой категории", + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CategoryDto" + } + } + }, + "description": "Категория добавлена" + }, + "400": { + "content": { + "application/json": { + "example": { + "status": "BAD_REQUEST", + "reason": "Incorrectly made request.", + "message": "Field: name. Error: must not be blank. Value: null", + "timestamp": "2022-09-07 09:10:50" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Запрос составлен некорректно" + }, + "409": { + "content": { + "application/json": { + "example": { + "status": "CONFLICT", + "reason": "Integrity constraint has been violated.", + "message": "could not execute statement; SQL [n/a]; constraint [uq_category_name]; nested exception is org.hibernate.exception.ConstraintViolationException: could not execute statement", + "timestamp": "2022-09-07 09:10:50" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Нарушение целостности данных" + } + }, + "summary": "Добавление новой категории", + "tags": [ + "Admin: Категории" + ] + } + }, + "/admin/categories/{catId}": { + "delete": { + "description": "Обратите внимание: с категорией не должно быть связано ни одного события.", + "operationId": "deleteCategory", + "parameters": [ + { + "description": "id категории", + "in": "path", + "name": "catId", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "204": { + "description": "Категория удалена" + }, + "404": { + "content": { + "application/json": { + "example": { + "status": "NOT_FOUND", + "reason": "The required object was not found.", + "message": "Category with id=27 was not found", + "timestamp": "2022-09-07 09:10:50" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Категория не найдена или недоступна" + }, + "409": { + "content": { + "application/json": { + "example": { + "status": "CONFLICT", + "reason": "For the requested operation the conditions are not met.", + "message": "The category is not empty", + "timestamp": "2023-01-21 16:56:19" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Существуют события, связанные с категорией" + } + }, + "summary": "Удаление категории", + "tags": [ + "Admin: Категории" + ] + }, + "patch": { + "description": "Обратите внимание: имя категории должно быть уникальным", + "operationId": "updateCategory", + "parameters": [ + { + "description": "id категории", + "in": "path", + "name": "catId", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CategoryDto" + } + } + }, + "description": "Данные категории для изменения", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CategoryDto" + } + } + }, + "description": "Данные категории изменены" + }, + "404": { + "content": { + "application/json": { + "example": { + "status": "NOT_FOUND", + "reason": "The required object was not found.", + "message": "Category with id=27 was not found", + "timestamp": "2022-09-07 09:10:50" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Категория не найдена или недоступна" + }, + "409": { + "content": { + "application/json": { + "example": { + "status": "CONFLICT", + "reason": "Integrity constraint has been violated.", + "message": "could not execute statement; SQL [n/a]; constraint [uq_category_name]; nested exception is org.hibernate.exception.ConstraintViolationException: could not execute statement", + "timestamp": "2022-09-07 09:10:50" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Нарушение целостности данных" + } + }, + "summary": "Изменение категории", + "tags": [ + "Admin: Категории" + ] + } + }, + "/admin/compilations": { + "post": { + "operationId": "saveCompilation", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewCompilationDto" + } + } + }, + "description": "данные новой подборки", + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CompilationDto" + } + } + }, + "description": "Подборка добавлена" + }, + "400": { + "content": { + "application/json": { + "example": { + "status": "BAD_REQUEST", + "reason": "Incorrectly made request.", + "message": "Field: title. Error: must not be blank. Value: null", + "timestamp": "2022-09-07 09:10:50" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Запрос составлен некорректно" + }, + "409": { + "content": { + "application/json": { + "example": { + "status": "CONFLICT", + "reason": "Integrity constraint has been violated.", + "message": "could not execute statement; SQL [n/a]; constraint [uq_compilation_name]; nested exception is org.hibernate.exception.ConstraintViolationException: could not execute statement", + "timestamp": "2022-09-07 09:10:50" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Нарушение целостности данных" + } + }, + "summary": "Добавление новой подборки (подборка может не содержать событий)", + "tags": [ + "Admin: Подборки событий" + ] + } + }, + "/admin/compilations/{compId}": { + "delete": { + "operationId": "deleteCompilation", + "parameters": [ + { + "description": "id подборки", + "in": "path", + "name": "compId", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "204": { + "description": "Подборка удалена" + }, + "404": { + "content": { + "application/json": { + "example": { + "status": "NOT_FOUND", + "reason": "The required object was not found.", + "message": "Compilation with id=11 was not found", + "timestamp": "2022-09-07 09:10:50" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Подборка не найдена или недоступна" + } + }, + "summary": "Удаление подборки", + "tags": [ + "Admin: Подборки событий" + ] + }, + "patch": { + "operationId": "updateCompilation", + "parameters": [ + { + "description": "id подборки", + "in": "path", + "name": "compId", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateCompilationRequest" + } + } + }, + "description": "данные для обновления подборки", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CompilationDto" + } + } + }, + "description": "Подборка обновлена" + }, + "404": { + "content": { + "application/json": { + "example": { + "status": "NOT_FOUND", + "reason": "The required object was not found.", + "message": "Category with id=27 was not found", + "timestamp": "2022-09-07 09:10:50" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Подборка не найдена или недоступна" + } + }, + "summary": "Обновить информацию о подборке", + "tags": [ + "Admin: Подборки событий" + ] + } + }, + "/admin/events": { + "get": { + "description": "Эндпоинт возвращает полную информацию обо всех событиях подходящих под переданные условия\n\nВ случае, если по заданным фильтрам не найдено ни одного события, возвращает пустой список", + "operationId": "getEvents_2", + "parameters": [ + { + "description": "список id пользователей, чьи события нужно найти", + "in": "query", + "name": "users", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int64" + } + } + }, + { + "description": "список состояний в которых находятся искомые события", + "in": "query", + "name": "states", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "description": "список id категорий в которых будет вестись поиск", + "in": "query", + "name": "categories", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int64" + } + } + }, + { + "description": "дата и время не раньше которых должно произойти событие", + "in": "query", + "name": "rangeStart", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "дата и время не позже которых должно произойти событие", + "in": "query", + "name": "rangeEnd", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "количество событий, которые нужно пропустить для формирования текущего набора", + "in": "query", + "name": "from", + "required": false, + "schema": { + "minimum": 0, + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "description": "количество событий в наборе", + "in": "query", + "name": "size", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 10 + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EventFullDto" + } + } + } + }, + "description": "События найдены" + }, + "400": { + "content": { + "application/json": { + "example": { + "status": "BAD_REQUEST", + "reason": "Incorrectly made request.", + "message": "Failed to convert value of type java.lang.String to required type int; nested exception is java.lang.NumberFormatException: For input string: ad", + "timestamp": "2022-09-07 09:10:50" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Запрос составлен некорректно" + } + }, + "summary": "Поиск событий", + "tags": [ + "Admin: События" + ] + } + }, + "/admin/events/{eventId}": { + "patch": { + "description": "Редактирование данных любого события администратором. Валидация данных не требуется.\nОбратите внимание:\n - дата начала изменяемого события должна быть не ранее чем за час от даты публикации. (Ожидается код ошибки 409)\n- событие можно публиковать, только если оно в состоянии ожидания публикации (Ожидается код ошибки 409)\n- событие можно отклонить, только если оно еще не опубликовано (Ожидается код ошибки 409)", + "operationId": "updateEvent_1", + "parameters": [ + { + "description": "id события", + "in": "path", + "name": "eventId", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateEventAdminRequest" + } + } + }, + "description": "Данные для изменения информации о событии", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EventFullDto" + } + } + }, + "description": "Событие отредактировано" + }, + "404": { + "content": { + "application/json": { + "example": { + "status": "NOT_FOUND", + "reason": "The required object was not found.", + "message": "Event with id=2 was not found", + "timestamp": "2022-09-07 09:10:50" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Событие не найдено или недоступно" + }, + "409": { + "content": { + "application/json": { + "example": { + "status": "FORBIDDEN", + "reason": "For the requested operation the conditions are not met.", + "message": "Cannot publish the event because it's not in the right state: PUBLISHED", + "timestamp": "2022-09-07 09:10:50" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Событие не удовлетворяет правилам редактирования" + } + }, + "summary": "Редактирование данных события и его статуса (отклонение/публикация).", + "tags": [ + "Admin: События" + ] + } + }, + "/admin/users": { + "get": { + "description": "Возвращает информацию обо всех пользователях (учитываются параметры ограничения выборки), либо о конкретных (учитываются указанные идентификаторы)\n\nВ случае, если по заданным фильтрам не найдено ни одного пользователя, возвращает пустой список", + "operationId": "getUsers", + "parameters": [ + { + "description": "id пользователей", + "in": "query", + "name": "ids", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int64" + } + } + }, + { + "description": "количество элементов, которые нужно пропустить для формирования текущего набора", + "in": "query", + "name": "from", + "required": false, + "schema": { + "minimum": 0, + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "description": "количество элементов в наборе", + "in": "query", + "name": "size", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 10 + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserDto" + } + } + } + }, + "description": "Пользователи найдены" + }, + "400": { + "content": { + "application/json": { + "example": { + "status": "BAD_REQUEST", + "reason": "Incorrectly made request.", + "message": "Failed to convert value of type java.lang.String to required type int; nested exception is java.lang.NumberFormatException: For input string: ad", + "timestamp": "2022-09-07 09:10:50" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Запрос составлен некорректно" + } + }, + "summary": "Получение информации о пользователях", + "tags": [ + "Admin: Пользователи" + ] + }, + "post": { + "operationId": "registerUser", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewUserRequest" + } + } + }, + "description": "Данные добавляемого пользователя", + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserDto" + } + } + }, + "description": "Пользователь зарегистрирован" + }, + "400": { + "content": { + "application/json": { + "example": { + "status": "BAD_REQUEST", + "reason": "Incorrectly made request.", + "message": "Field: name. Error: must not be blank. Value: null", + "timestamp": "2022-09-07 09:10:50" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Запрос составлен некорректно" + }, + "409": { + "content": { + "application/json": { + "example": { + "status": "CONFLICT", + "reason": "Integrity constraint has been violated.", + "message": "could not execute statement; SQL [n/a]; constraint [uq_email]; nested exception is org.hibernate.exception.ConstraintViolationException: could not execute statement", + "timestamp": "2022-09-07 09:10:50" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Нарушение целостности данных" + } + }, + "summary": "Добавление нового пользователя", + "tags": [ + "Admin: Пользователи" + ] + } + }, + "/admin/users/{userId}": { + "delete": { + "operationId": "delete", + "parameters": [ + { + "description": "id пользователя", + "in": "path", + "name": "userId", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "204": { + "description": "Пользователь удален" + }, + "404": { + "content": { + "application/json": { + "example": { + "status": "NOT_FOUND", + "reason": "The required object was not found.", + "message": "User with id=555 was not found", + "timestamp": "2022-09-07 09:10:50" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Пользователь не найден или недоступен" + } + }, + "summary": "Удаление пользователя", + "tags": [ + "Admin: Пользователи" + ] + } + }, + "/categories": { + "get": { + "description": "В случае, если по заданным фильтрам не найдено ни одной категории, возвращает пустой список", + "operationId": "getCategories", + "parameters": [ + { + "description": "количество категорий, которые нужно пропустить для формирования текущего набора", + "in": "query", + "name": "from", + "required": false, + "schema": { + "minimum": 0, + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "description": "количество категорий в наборе", + "in": "query", + "name": "size", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 10 + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CategoryDto" + } + } + } + }, + "description": "Категории найдены" + }, + "400": { + "content": { + "application/json": { + "example": { + "status": "BAD_REQUEST", + "reason": "Incorrectly made request.", + "message": "Failed to convert value of type java.lang.String to required type int; nested exception is java.lang.NumberFormatException: For input string: ad", + "timestamp": "2022-09-07 09:10:50" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Запрос составлен некорректно" + } + }, + "summary": "Получение категорий", + "tags": [ + "Public: Категории" + ] + } + }, + "/categories/{catId}": { + "get": { + "description": "В случае, если категории с заданным id не найдено, возвращает статус код 404", + "operationId": "getCategory", + "parameters": [ + { + "description": "id категории", + "in": "path", + "name": "catId", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CategoryDto" + } + } + }, + "description": "Категория найдена" + }, + "400": { + "content": { + "application/json": { + "example": { + "status": "BAD_REQUEST", + "reason": "Incorrectly made request.", + "message": "Failed to convert value of type java.lang.String to required type long; nested exception is java.lang.NumberFormatException: For input string: ad", + "timestamp": "2022-09-07 09:10:50" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Запрос составлен некорректно" + }, + "404": { + "content": { + "application/json": { + "example": { + "status": "NOT_FOUND", + "reason": "The required object was not found.", + "message": "Category with id=19 was not found", + "timestamp": "2022-09-07 09:10:50" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Категория не найдена или недоступна" + } + }, + "summary": "Получение информации о категории по её идентификатору", + "tags": [ + "Public: Категории" + ] + } + }, + "/compilations": { + "get": { + "description": "В случае, если по заданным фильтрам не найдено ни одной подборки, возвращает пустой список", + "operationId": "getCompilations", + "parameters": [ + { + "description": "искать только закрепленные/не закрепленные подборки", + "in": "query", + "name": "pinned", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "description": "количество элементов, которые нужно пропустить для формирования текущего набора", + "in": "query", + "name": "from", + "required": false, + "schema": { + "minimum": 0, + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "description": "количество элементов в наборе", + "in": "query", + "name": "size", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 10 + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CompilationDto" + } + } + } + }, + "description": "Найдены подборки событий" + }, + "400": { + "content": { + "application/json": { + "example": { + "status": "BAD_REQUEST", + "reason": "Incorrectly made request.", + "message": "Failed to convert value of type java.lang.String to required type int; nested exception is java.lang.NumberFormatException: For input string: ad", + "timestamp": "2022-09-07 09:10:50" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Запрос составлен некорректно" + } + }, + "summary": "Получение подборок событий", + "tags": [ + "Public: Подборки событий" + ] + } + }, + "/compilations/{compId}": { + "get": { + "description": "В случае, если подборки с заданным id не найдено, возвращает статус код 404", + "operationId": "getCompilation", + "parameters": [ + { + "description": "id подборки", + "in": "path", + "name": "compId", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CompilationDto" + } + } + }, + "description": "Подборка событий найдена" + }, + "400": { + "content": { + "application/json": { + "example": { + "status": "BAD_REQUEST", + "reason": "Incorrectly made request.", + "message": "Failed to convert value of type java.lang.String to required type long; nested exception is java.lang.NumberFormatException: For input string: ad", + "timestamp": "2022-09-07 09:10:50" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Запрос составлен некорректно" + }, + "404": { + "content": { + "application/json": { + "example": { + "status": "NOT_FOUND", + "reason": "The required object was not found.", + "message": "Compilation with id=84 was not found", + "timestamp": "2022-09-07 09:10:50" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Подборка не найдена или недоступна" + } + }, + "summary": "Получение подборки событий по его id", + "tags": [ + "Public: Подборки событий" + ] + } + }, + "/events": { + "get": { + "description": "Обратите внимание: \n- это публичный эндпоинт, соответственно в выдаче должны быть только опубликованные события\n- текстовый поиск (по аннотации и подробному описанию) должен быть без учета регистра букв\n- если в запросе не указан диапазон дат [rangeStart-rangeEnd], то нужно выгружать события, которые произойдут позже текущей даты и времени\n- информация о каждом событии должна включать в себя количество просмотров и количество уже одобренных заявок на участие\n- информацию о том, что по этому эндпоинту был осуществлен и обработан запрос, нужно сохранить в сервисе статистики\n\nВ случае, если по заданным фильтрам не найдено ни одного события, возвращает пустой список", + "operationId": "getEvents_1", + "parameters": [ + { + "description": "текст для поиска в содержимом аннотации и подробном описании события", + "in": "query", + "name": "text", + "required": false, + "schema": { + "maxLength": 7000, + "minLength": 1, + "type": "string" + } + }, + { + "description": "список идентификаторов категорий в которых будет вестись поиск", + "in": "query", + "name": "categories", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int64" + } + } + }, + { + "description": "поиск только платных/бесплатных событий", + "in": "query", + "name": "paid", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "description": "дата и время не раньше которых должно произойти событие", + "in": "query", + "name": "rangeStart", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "дата и время не позже которых должно произойти событие", + "in": "query", + "name": "rangeEnd", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "только события у которых не исчерпан лимит запросов на участие", + "in": "query", + "name": "onlyAvailable", + "required": false, + "schema": { + "type": "boolean", + "default": false + } + }, + { + "description": "Вариант сортировки: по дате события или по количеству просмотров", + "in": "query", + "name": "sort", + "required": false, + "schema": { + "type": "string", + "enum": [ + "EVENT_DATE", + "VIEWS" + ] + } + }, + { + "description": "количество событий, которые нужно пропустить для формирования текущего набора", + "in": "query", + "name": "from", + "required": false, + "schema": { + "minimum": 0, + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "description": "количество событий в наборе", + "in": "query", + "name": "size", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 10 + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EventShortDto" + } + } + } + }, + "description": "События найдены" + }, + "400": { + "content": { + "application/json": { + "example": { + "status": "BAD_REQUEST", + "reason": "Incorrectly made request.", + "message": "Event must be published", + "timestamp": "2022-09-07 09:10:50" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Запрос составлен некорректно" + } + }, + "summary": "Получение событий с возможностью фильтрации", + "tags": [ + "Public: События" + ] + } + }, + "/events/{id}": { + "get": { + "description": "Обратите внимание:\n- событие должно быть опубликовано\n- информация о событии должна включать в себя количество просмотров и количество подтвержденных запросов\n- информацию о том, что по этому эндпоинту был осуществлен и обработан запрос, нужно сохранить в сервисе статистики\n\nВ случае, если события с заданным id не найдено, возвращает статус код 404", + "operationId": "getEvent_1", + "parameters": [ + { + "description": "id события", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EventFullDto" + } + } + }, + "description": "Событие найдено" + }, + "400": { + "content": { + "application/json": { + "example": { + "status": "BAD_REQUEST", + "reason": "Incorrectly made request.", + "message": "Failed to convert value of type java.lang.String to required type int; nested exception is java.lang.NumberFormatException: For input string: ad", + "timestamp": "2022-09-07 09:10:50" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Запрос составлен некорректно" + }, + "404": { + "content": { + "application/json": { + "example": { + "status": "NOT_FOUND", + "reason": "The required object was not found.", + "message": "Event with id=13 was not found", + "timestamp": "2022-09-07 09:10:50" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Событие не найдено или недоступно" + } + }, + "summary": "Получение подробной информации об опубликованном событии по его идентификатору", + "tags": [ + "Public: События" + ] + } + }, + "/users/{userId}/events": { + "get": { + "description": "В случае, если по заданным фильтрам не найдено ни одного события, возвращает пустой список", + "operationId": "getEvents", + "parameters": [ + { + "description": "id текущего пользователя", + "in": "path", + "name": "userId", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "description": "количество элементов, которые нужно пропустить для формирования текущего набора", + "in": "query", + "name": "from", + "required": false, + "schema": { + "minimum": 0, + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "description": "количество элементов в наборе", + "in": "query", + "name": "size", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 10 + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EventShortDto" + } + } + } + }, + "description": "События найдены" + }, + "400": { + "content": { + "application/json": { + "example": { + "status": "BAD_REQUEST", + "reason": "Incorrectly made request.", + "message": "Failed to convert value of type java.lang.String to required type int; nested exception is java.lang.NumberFormatException: For input string: ad", + "timestamp": "2022-09-07 09:10:50" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Запрос составлен некорректно" + } + }, + "summary": "Получение событий, добавленных текущим пользователем", + "tags": [ + "Private: События" + ] + }, + "post": { + "description": "Обратите внимание: дата и время на которые намечено событие не может быть раньше, чем через два часа от текущего момента", + "operationId": "addEvent", + "parameters": [ + { + "description": "id текущего пользователя", + "in": "path", + "name": "userId", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewEventDto" + } + } + }, + "description": "данные добавляемого события", + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EventFullDto" + } + } + }, + "description": "Событие добавлено" + }, + "400": { + "content": { + "application/json": { + "example": { + "status": "BAD_REQUEST", + "reason": "Incorrectly made request.", + "message": "Field: category. Error: must not be blank. Value: null", + "timestamp": "2022-09-07 09:10:50" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Запрос составлен некорректно" + }, + "409": { + "content": { + "application/json": { + "example": { + "status": "FORBIDDEN", + "reason": "For the requested operation the conditions are not met.", + "message": "Field: eventDate. Error: должно содержать дату, которая еще не наступила. Value: 2020-12-31T15:10:05", + "timestamp": "2022-09-07 09:10:50" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Событие не удовлетворяет правилам создания" + } + }, + "summary": "Добавление нового события", + "tags": [ + "Private: События" + ] + } + }, + "/users/{userId}/events/{eventId}": { + "get": { + "description": "В случае, если события с заданным id не найдено, возвращает статус код 404", + "operationId": "getEvent", + "parameters": [ + { + "description": "id текущего пользователя", + "in": "path", + "name": "userId", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "description": "id события", + "in": "path", + "name": "eventId", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EventFullDto" + } + } + }, + "description": "Событие найдено" + }, + "400": { + "content": { + "application/json": { + "example": { + "status": "BAD_REQUEST", + "reason": "Incorrectly made request.", + "message": "Failed to convert value of type java.lang.String to required type long; nested exception is java.lang.NumberFormatException: For input string: ad", + "timestamp": "2022-09-07 09:10:50" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Запрос составлен некорректно" + }, + "404": { + "content": { + "application/json": { + "example": { + "status": "NOT_FOUND", + "reason": "The required object was not found.", + "message": "Event with id=13 was not found", + "timestamp": "2022-09-07 09:10:50" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Событие не найдено или недоступно" + } + }, + "summary": "Получение полной информации о событии добавленном текущим пользователем", + "tags": [ + "Private: События" + ] + }, + "patch": { + "description": "Обратите внимание:\n- изменить можно только отмененные события или события в состоянии ожидания модерации (Ожидается код ошибки 409)\n- дата и время на которые намечено событие не может быть раньше, чем через два часа от текущего момента (Ожидается код ошибки 409)\n", + "operationId": "updateEvent", + "parameters": [ + { + "description": "id текущего пользователя", + "in": "path", + "name": "userId", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "description": "id редактируемого события", + "in": "path", + "name": "eventId", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateEventUserRequest" + } + } + }, + "description": "Новые данные события", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EventFullDto" + } + } + }, + "description": "Событие обновлено" + }, + "400": { + "content": { + "application/json": { + "example": { + "status": "BAD_REQUEST", + "reason": "Incorrectly made request.", + "message": "Event must not be published", + "timestamp": "2022-09-07 09:10:50" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Запрос составлен некорректно" + }, + "404": { + "content": { + "application/json": { + "example": { + "status": "NOT_FOUND", + "reason": "The required object was not found.", + "message": "Event with id=283 was not found", + "timestamp": "2022-09-07 09:10:50" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Событие не найдено или недоступно" + }, + "409": { + "content": { + "application/json": { + "example": { + "status": "FORBIDDEN", + "reason": "For the requested operation the conditions are not met.", + "message": "Only pending or canceled events can be changed", + "timestamp": "2022-09-07 09:10:50" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Событие не удовлетворяет правилам редактирования" + } + }, + "summary": "Изменение события добавленного текущим пользователем", + "tags": [ + "Private: События" + ] + } + }, + "/users/{userId}/events/{eventId}/requests": { + "get": { + "description": "В случае, если по заданным фильтрам не найдено ни одной заявки, возвращает пустой список", + "operationId": "getEventParticipants", + "parameters": [ + { + "description": "id текущего пользователя", + "in": "path", + "name": "userId", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "description": "id события", + "in": "path", + "name": "eventId", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ParticipationRequestDto" + } + } + } + }, + "description": "Найдены запросы на участие" + }, + "400": { + "content": { + "application/json": { + "example": { + "status": "BAD_REQUEST", + "reason": "Incorrectly made request.", + "message": "Failed to convert value of type java.lang.String to required type int; nested exception is java.lang.NumberFormatException: For input string: ad", + "timestamp": "2022-09-07 09:10:50" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Запрос составлен некорректно" + } + }, + "summary": "Получение информации о запросах на участие в событии текущего пользователя", + "tags": [ + "Private: События" + ] + }, + "patch": { + "description": "Обратите внимание:\n- если для события лимит заявок равен 0 или отключена пре-модерация заявок, то подтверждение заявок не требуется\n- нельзя подтвердить заявку, если уже достигнут лимит по заявкам на данное событие (Ожидается код ошибки 409)\n- статус можно изменить только у заявок, находящихся в состоянии ожидания (Ожидается код ошибки 409)\n- если при подтверждении данной заявки, лимит заявок для события исчерпан, то все неподтверждённые заявки необходимо отклонить", + "operationId": "changeRequestStatus", + "parameters": [ + { + "description": "id текущего пользователя", + "in": "path", + "name": "userId", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "description": "id события текущего пользователя", + "in": "path", + "name": "eventId", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EventRequestStatusUpdateRequest" + } + } + }, + "description": "Новый статус для заявок на участие в событии текущего пользователя", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EventRequestStatusUpdateResult" + } + } + }, + "description": "Статус заявок изменён" + }, + "400": { + "content": { + "application/json": { + "example": { + "status": "BAD_REQUEST", + "reason": "Incorrectly made request.", + "message": "Request must have status PENDING", + "timestamp": "2022-09-07 09:10:50" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Запрос составлен некорректно" + }, + "404": { + "content": { + "application/json": { + "example": { + "status": "NOT_FOUND", + "reason": "The required object was not found.", + "message": "Event with id=321 was not found", + "timestamp": "2022-09-07 09:10:50" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Событие не найдено или недоступно" + }, + "409": { + "content": { + "application/json": { + "example": { + "status": "CONFLICT", + "reason": "For the requested operation the conditions are not met.", + "message": "The participant limit has been reached", + "timestamp": "2022-09-07 09:10:50" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Достигнут лимит одобренных заявок" + } + }, + "summary": "Изменение статуса (подтверждена, отменена) заявок на участие в событии текущего пользователя", + "tags": [ + "Private: События" + ] + } + }, + "/users/{userId}/requests": { + "get": { + "description": "В случае, если по заданным фильтрам не найдено ни одной заявки, возвращает пустой список", + "operationId": "getUserRequests", + "parameters": [ + { + "description": "id текущего пользователя", + "in": "path", + "name": "userId", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ParticipationRequestDto" + } + } + } + }, + "description": "Найдены запросы на участие" + }, + "400": { + "content": { + "application/json": { + "example": { + "status": "BAD_REQUEST", + "reason": "Incorrectly made request.", + "message": "Failed to convert value of type java.lang.String to required type long; nested exception is java.lang.NumberFormatException: For input string: ad", + "timestamp": "2022-09-07 09:10:50" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Запрос составлен некорректно" + }, + "404": { + "content": { + "application/json": { + "example": { + "status": "NOT_FOUND", + "reason": "The required object was not found.", + "message": "User with id=11 was not found", + "timestamp": "2022-09-07 09:10:50" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Пользователь не найден" + } + }, + "summary": "Получение информации о заявках текущего пользователя на участие в чужих событиях", + "tags": [ + "Private: Запросы на участие" + ] + }, + "post": { + "description": "Обратите внимание:\n- нельзя добавить повторный запрос (Ожидается код ошибки 409)\n- инициатор события не может добавить запрос на участие в своём событии (Ожидается код ошибки 409)\n- нельзя участвовать в неопубликованном событии (Ожидается код ошибки 409)\n- если у события достигнут лимит запросов на участие - необходимо вернуть ошибку (Ожидается код ошибки 409)\n- если для события отключена пре-модерация запросов на участие, то запрос должен автоматически перейти в состояние подтвержденного", + "operationId": "addParticipationRequest", + "parameters": [ + { + "description": "id текущего пользователя", + "in": "path", + "name": "userId", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "description": "id события", + "in": "query", + "name": "eventId", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ParticipationRequestDto" + } + } + }, + "description": "Заявка создана" + }, + "400": { + "content": { + "application/json": { + "example": { + "status": "BAD_REQUEST", + "reason": "Incorrectly made request.", + "message": "Failed to convert value of type java.lang.String to required type long; nested exception is java.lang.NumberFormatException: For input string: ad", + "timestamp": "2022-09-07 09:10:50" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Запрос составлен некорректно" + }, + "404": { + "content": { + "application/json": { + "example": { + "status": "NOT_FOUND", + "reason": "The required object was not found.", + "message": "Event with id=13 was not found", + "timestamp": "2022-09-07 09:10:50" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Событие не найдено или недоступно" + }, + "409": { + "content": { + "application/json": { + "example": { + "status": "CONFLICT", + "reason": "Integrity constraint has been violated.", + "message": "could not execute statement; SQL [n/a]; constraint [uq_request]; nested exception is org.hibernate.exception.ConstraintViolationException: could not execute statement", + "timestamp": "2022-09-07 09:10:50" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Нарушение целостности данных" + } + }, + "summary": "Добавление запроса от текущего пользователя на участие в событии", + "tags": [ + "Private: Запросы на участие" + ] + } + }, + "/users/{userId}/requests/{requestId}/cancel": { + "patch": { + "operationId": "cancelRequest", + "parameters": [ + { + "description": "id текущего пользователя", + "in": "path", + "name": "userId", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "description": "id запроса на участие", + "in": "path", + "name": "requestId", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ParticipationRequestDto" + } + } + }, + "description": "Заявка отменена" + }, + "404": { + "content": { + "application/json": { + "example": { + "status": "NOT_FOUND", + "reason": "The required object was not found.", + "message": "Request with id=2727 was not found", + "timestamp": "2022-09-07 09:10:50" + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Запрос не найден или недоступен" + } + }, + "summary": "Отмена своего запроса на участие в событии", + "tags": [ + "Private: Запросы на участие" + ] + } + } + }, + "components": { + "schemas": { + "ApiError": { + "type": "object", + "properties": { + "errors": { + "type": "array", + "description": "Список стектрейсов или описания ошибок", + "example": [], + "items": { + "type": "string", + "description": "Список стектрейсов или описания ошибок", + "example": "[]" + } + }, + "message": { + "type": "string", + "description": "Сообщение об ошибке", + "example": "Only pending or canceled events can be changed" + }, + "reason": { + "type": "string", + "description": "Общее описание причины ошибки", + "example": "For the requested operation the conditions are not met." + }, + "status": { + "type": "string", + "description": "Код статуса HTTP-ответа", + "example": "FORBIDDEN", + "enum": [ + "100 CONTINUE", + "101 SWITCHING_PROTOCOLS", + "102 PROCESSING", + "103 CHECKPOINT", + "200 OK", + "201 CREATED", + "202 ACCEPTED", + "203 NON_AUTHORITATIVE_INFORMATION", + "204 NO_CONTENT", + "205 RESET_CONTENT", + "206 PARTIAL_CONTENT", + "207 MULTI_STATUS", + "208 ALREADY_REPORTED", + "226 IM_USED", + "300 MULTIPLE_CHOICES", + "301 MOVED_PERMANENTLY", + "302 FOUND", + "302 MOVED_TEMPORARILY", + "303 SEE_OTHER", + "304 NOT_MODIFIED", + "305 USE_PROXY", + "307 TEMPORARY_REDIRECT", + "308 PERMANENT_REDIRECT", + "400 BAD_REQUEST", + "401 UNAUTHORIZED", + "402 PAYMENT_REQUIRED", + "403 FORBIDDEN", + "404 NOT_FOUND", + "405 METHOD_NOT_ALLOWED", + "406 NOT_ACCEPTABLE", + "407 PROXY_AUTHENTICATION_REQUIRED", + "408 REQUEST_TIMEOUT", + "409 CONFLICT", + "410 GONE", + "411 LENGTH_REQUIRED", + "412 PRECONDITION_FAILED", + "413 PAYLOAD_TOO_LARGE", + "413 REQUEST_ENTITY_TOO_LARGE", + "414 URI_TOO_LONG", + "414 REQUEST_URI_TOO_LONG", + "415 UNSUPPORTED_MEDIA_TYPE", + "416 REQUESTED_RANGE_NOT_SATISFIABLE", + "417 EXPECTATION_FAILED", + "418 I_AM_A_TEAPOT", + "419 INSUFFICIENT_SPACE_ON_RESOURCE", + "420 METHOD_FAILURE", + "421 DESTINATION_LOCKED", + "422 UNPROCESSABLE_ENTITY", + "423 LOCKED", + "424 FAILED_DEPENDENCY", + "425 TOO_EARLY", + "426 UPGRADE_REQUIRED", + "428 PRECONDITION_REQUIRED", + "429 TOO_MANY_REQUESTS", + "431 REQUEST_HEADER_FIELDS_TOO_LARGE", + "451 UNAVAILABLE_FOR_LEGAL_REASONS", + "500 INTERNAL_SERVER_ERROR", + "501 NOT_IMPLEMENTED", + "502 BAD_GATEWAY", + "503 SERVICE_UNAVAILABLE", + "504 GATEWAY_TIMEOUT", + "505 HTTP_VERSION_NOT_SUPPORTED", + "506 VARIANT_ALSO_NEGOTIATES", + "507 INSUFFICIENT_STORAGE", + "508 LOOP_DETECTED", + "509 BANDWIDTH_LIMIT_EXCEEDED", + "510 NOT_EXTENDED", + "511 NETWORK_AUTHENTICATION_REQUIRED" + ] + }, + "timestamp": { + "type": "string", + "description": "Дата и время когда произошла ошибка (в формате \"yyyy-MM-dd HH:mm:ss\")", + "example": "2022-06-09 06:27:23" + } + }, + "description": "Сведения об ошибке" + }, + "CategoryDto": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Идентификатор категории", + "format": "int64", + "readOnly": true, + "example": 1 + }, + "name": { + "maxLength": 50, + "minLength": 1, + "type": "string", + "description": "Название категории", + "example": "Концерты" + } + }, + "description": "Категория" + }, + "CompilationDto": { + "required": [ + "id", + "pinned", + "title" + ], + "type": "object", + "properties": { + "events": { + "uniqueItems": true, + "type": "array", + "description": "Список событий входящих в подборку", + "example": [ + { + "annotation": "Эксклюзивность нашего шоу гарантирует привлечение максимальной зрительской аудитории", + "category": { + "id": 1, + "name": "Концерты" + }, + "confirmedRequests": 5, + "eventDate": "2024-03-10 14:30:00", + "id": 1, + "initiator": { + "id": 3, + "name": "Фёдоров Матвей" + }, + "paid": true, + "title": "Знаменитое шоу 'Летающая кукуруза'", + "views": 999 + }, + { + "annotation": "За почти три десятилетия группа 'Java Core' закрепились на сцене как группа, объединяющая поколения.", + "category": { + "id": 1, + "name": "Концерты" + }, + "confirmedRequests": 555, + "eventDate": "2025-09-13 21:00:00", + "id": 1, + "initiator": { + "id": 3, + "name": "Паша Петров" + }, + "paid": true, + "title": "Концерт рок-группы 'Java Core'", + "views": 991 + } + ], + "items": { + "$ref": "#/components/schemas/EventShortDto" + } + }, + "id": { + "type": "integer", + "description": "Идентификатор", + "format": "int64", + "example": 1 + }, + "pinned": { + "type": "boolean", + "description": "Закреплена ли подборка на главной странице сайта", + "example": true + }, + "title": { + "type": "string", + "description": "Заголовок подборки", + "example": "Летние концерты" + } + }, + "description": "Подборка событий" + }, + "EventFullDto": { + "required": [ + "annotation", + "category", + "eventDate", + "initiator", + "location", + "paid", + "title" + ], + "type": "object", + "properties": { + "annotation": { + "type": "string", + "description": "Краткое описание", + "example": "Эксклюзивность нашего шоу гарантирует привлечение максимальной зрительской аудитории" + }, + "category": { + "$ref": "#/components/schemas/CategoryDto" + }, + "confirmedRequests": { + "type": "integer", + "description": "Количество одобренных заявок на участие в данном событии", + "format": "int64", + "example": 5 + }, + "createdOn": { + "type": "string", + "description": "Дата и время создания события (в формате \"yyyy-MM-dd HH:mm:ss\")", + "example": "2022-09-06 11:00:23" + }, + "description": { + "type": "string", + "description": "Полное описание события", + "example": "Что получится, если соединить кукурузу и полёт? Создатели \"Шоу летающей кукурузы\" испытали эту идею на практике и воплотили в жизнь инновационный проект, предлагающий свежий взгляд на развлечения..." + }, + "eventDate": { + "type": "string", + "description": "Дата и время на которые намечено событие (в формате \"yyyy-MM-dd HH:mm:ss\")", + "example": "2024-12-31 15:10:05" + }, + "id": { + "type": "integer", + "description": "Идентификатор", + "format": "int64", + "example": 1 + }, + "initiator": { + "$ref": "#/components/schemas/UserShortDto" + }, + "location": { + "$ref": "#/components/schemas/Location" + }, + "paid": { + "type": "boolean", + "description": "Нужно ли оплачивать участие", + "example": true + }, + "participantLimit": { + "type": "integer", + "description": "Ограничение на количество участников. Значение 0 - означает отсутствие ограничения", + "format": "int32", + "example": 10, + "default": 0 + }, + "publishedOn": { + "type": "string", + "description": "Дата и время публикации события (в формате \"yyyy-MM-dd HH:mm:ss\")", + "example": "2022-09-06 15:10:05" + }, + "requestModeration": { + "type": "boolean", + "description": "Нужна ли пре-модерация заявок на участие", + "example": true, + "default": true + }, + "state": { + "type": "string", + "description": "Список состояний жизненного цикла события", + "example": "PUBLISHED", + "enum": [ + "PENDING", + "PUBLISHED", + "CANCELED" + ] + }, + "title": { + "type": "string", + "description": "Заголовок", + "example": "Знаменитое шоу 'Летающая кукуруза'" + }, + "views": { + "type": "integer", + "description": "Количество просмотрев события", + "format": "int64", + "example": 999 + } + } + }, + "EventRequestStatusUpdateRequest": { + "type": "object", + "properties": { + "requestIds": { + "type": "array", + "description": "Идентификаторы запросов на участие в событии текущего пользователя", + "example": [ + 1, + 2, + 3 + ], + "items": { + "type": "integer", + "description": "Идентификаторы запросов на участие в событии текущего пользователя", + "format": "int64" + } + }, + "status": { + "type": "string", + "description": "Новый статус запроса на участие в событии текущего пользователя", + "example": "CONFIRMED", + "enum": [ + "CONFIRMED", + "REJECTED" + ] + } + }, + "description": "Изменение статуса запроса на участие в событии текущего пользователя" + }, + "EventRequestStatusUpdateResult": { + "type": "object", + "properties": { + "confirmedRequests": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ParticipationRequestDto" + } + }, + "rejectedRequests": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ParticipationRequestDto" + } + } + }, + "description": "Результат подтверждения/отклонения заявок на участие в событии" + }, + "EventShortDto": { + "required": [ + "annotation", + "category", + "eventDate", + "initiator", + "paid", + "title" + ], + "type": "object", + "properties": { + "annotation": { + "type": "string", + "description": "Краткое описание", + "example": "Эксклюзивность нашего шоу гарантирует привлечение максимальной зрительской аудитории" + }, + "category": { + "$ref": "#/components/schemas/CategoryDto" + }, + "confirmedRequests": { + "type": "integer", + "description": "Количество одобренных заявок на участие в данном событии", + "format": "int64", + "example": 5 + }, + "eventDate": { + "type": "string", + "description": "Дата и время на которые намечено событие (в формате \"yyyy-MM-dd HH:mm:ss\")", + "example": "2024-12-31 15:10:05" + }, + "id": { + "type": "integer", + "description": "Идентификатор", + "format": "int64", + "example": 1 + }, + "initiator": { + "$ref": "#/components/schemas/UserShortDto" + }, + "paid": { + "type": "boolean", + "description": "Нужно ли оплачивать участие", + "example": true + }, + "title": { + "type": "string", + "description": "Заголовок", + "example": "Знаменитое шоу 'Летающая кукуруза'" + }, + "views": { + "type": "integer", + "description": "Количество просмотрев события", + "format": "int64", + "example": 999 + } + }, + "description": "Краткая информация о событии", + "example": [ + { + "annotation": "Эксклюзивность нашего шоу гарантирует привлечение максимальной зрительской аудитории", + "category": { + "id": 1, + "name": "Концерты" + }, + "confirmedRequests": 5, + "eventDate": "2024-03-10 14:30:00", + "id": 1, + "initiator": { + "id": 3, + "name": "Фёдоров Матвей" + }, + "paid": true, + "title": "Знаменитое шоу 'Летающая кукуруза'", + "views": 999 + }, + { + "annotation": "За почти три десятилетия группа 'Java Core' закрепились на сцене как группа, объединяющая поколения.", + "category": { + "id": 1, + "name": "Концерты" + }, + "confirmedRequests": 555, + "eventDate": "2025-09-13 21:00:00", + "id": 1, + "initiator": { + "id": 3, + "name": "Паша Петров" + }, + "paid": true, + "title": "Концерт рок-группы 'Java Core'", + "views": 991 + } + ] + }, + "Location": { + "type": "object", + "properties": { + "lat": { + "type": "number", + "description": "Широта", + "format": "float", + "example": 55.754167 + }, + "lon": { + "type": "number", + "description": "Долгота", + "format": "float", + "example": 37.62 + } + }, + "description": "Широта и долгота места проведения события" + }, + "NewCategoryDto": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "name": { + "maxLength": 50, + "minLength": 1, + "type": "string", + "description": "Название категории", + "example": "Концерты" + } + }, + "description": "Данные для добавления новой категории" + }, + "NewCompilationDto": { + "required": [ + "title" + ], + "type": "object", + "properties": { + "events": { + "uniqueItems": true, + "type": "array", + "description": "Список идентификаторов событий входящих в подборку", + "example": [ + 1, + 2, + 3 + ], + "items": { + "type": "integer", + "description": "Список идентификаторов событий входящих в подборку", + "format": "int64" + } + }, + "pinned": { + "type": "boolean", + "description": "Закреплена ли подборка на главной странице сайта", + "example": false, + "default": false + }, + "title": { + "maxLength": 50, + "minLength": 1, + "type": "string", + "description": "Заголовок подборки", + "example": "Летние концерты" + } + }, + "description": "Подборка событий" + }, + "NewEventDto": { + "required": [ + "annotation", + "category", + "description", + "eventDate", + "location", + "title" + ], + "type": "object", + "properties": { + "annotation": { + "maxLength": 2000, + "minLength": 20, + "type": "string", + "description": "Краткое описание события", + "example": "Сплав на байдарках похож на полет." + }, + "category": { + "type": "integer", + "description": "id категории к которой относится событие", + "format": "int64", + "example": 2 + }, + "description": { + "maxLength": 7000, + "minLength": 20, + "type": "string", + "description": "Полное описание события", + "example": "Сплав на байдарках похож на полет. На спокойной воде — это парение. На бурной, порожистой — выполнение фигур высшего пилотажа. И то, и другое дарят чувство обновления, феерические эмоции, яркие впечатления." + }, + "eventDate": { + "type": "string", + "description": "Дата и время на которые намечено событие. Дата и время указываются в формате \"yyyy-MM-dd HH:mm:ss\"", + "example": "2024-12-31 15:10:05" + }, + "location": { + "$ref": "#/components/schemas/Location" + }, + "paid": { + "type": "boolean", + "description": "Нужно ли оплачивать участие в событии", + "example": true, + "default": false + }, + "participantLimit": { + "type": "integer", + "description": "Ограничение на количество участников. Значение 0 - означает отсутствие ограничения", + "format": "int32", + "example": 10, + "default": 0 + }, + "requestModeration": { + "type": "boolean", + "description": "Нужна ли пре-модерация заявок на участие. Если true, то все заявки будут ожидать подтверждения инициатором события. Если false - то будут подтверждаться автоматически.", + "example": false, + "default": true + }, + "title": { + "maxLength": 120, + "minLength": 3, + "type": "string", + "description": "Заголовок события", + "example": "Сплав на байдарках" + } + }, + "description": "Новое событие" + }, + "NewUserRequest": { + "required": [ + "email", + "name" + ], + "type": "object", + "properties": { + "email": { + "maxLength": 254, + "minLength": 6, + "type": "string", + "description": "Почтовый адрес", + "example": "ivan.petrov@practicummail.ru" + }, + "name": { + "maxLength": 250, + "minLength": 2, + "type": "string", + "description": "Имя", + "example": "Иван Петров" + } + }, + "description": "Данные нового пользователя" + }, + "ParticipationRequestDto": { + "type": "object", + "properties": { + "created": { + "type": "string", + "description": "Дата и время создания заявки", + "example": "2022-09-06T21:10:05.432" + }, + "event": { + "type": "integer", + "description": "Идентификатор события", + "format": "int64", + "example": 1 + }, + "id": { + "type": "integer", + "description": "Идентификатор заявки", + "format": "int64", + "example": 3 + }, + "requester": { + "type": "integer", + "description": "Идентификатор пользователя, отправившего заявку", + "format": "int64", + "example": 2 + }, + "status": { + "type": "string", + "description": "Статус заявки", + "example": "PENDING" + } + }, + "description": "Заявка на участие в событии" + }, + "UpdateCompilationRequest": { + "type": "object", + "properties": { + "events": { + "uniqueItems": true, + "type": "array", + "description": "Список id событий подборки для полной замены текущего списка", + "items": { + "type": "integer", + "description": "Список id событий подборки для полной замены текущего списка", + "format": "int64" + } + }, + "pinned": { + "type": "boolean", + "description": "Закреплена ли подборка на главной странице сайта", + "example": true + }, + "title": { + "maxLength": 50, + "minLength": 1, + "type": "string", + "description": "Заголовок подборки", + "example": "Необычные фотозоны" + } + }, + "description": "Изменение информации о подборке событий. Если поле в запросе не указано (равно null) - значит изменение этих данных не треубется." + }, + "UpdateEventAdminRequest": { + "type": "object", + "properties": { + "annotation": { + "maxLength": 2000, + "minLength": 20, + "type": "string", + "description": "Новая аннотация", + "example": "Сап прогулки по рекам и каналам – это возможность увидеть Практикбург с другого ракурса" + }, + "category": { + "type": "integer", + "description": "Новая категория", + "format": "int64", + "example": 3 + }, + "description": { + "maxLength": 7000, + "minLength": 20, + "type": "string", + "description": "Новое описание", + "example": "От английского SUP - Stand Up Paddle — \"стоя на доске с веслом\", гавайская разновидность сёрфинга, в котором серфер, стоя на доске, катается на волнах и при этом гребет веслом, а не руками, как в классическом серфинге." + }, + "eventDate": { + "type": "string", + "description": "Новые дата и время на которые намечено событие. Дата и время указываются в формате \"yyyy-MM-dd HH:mm:ss\"", + "example": "2023-10-11 23:10:05" + }, + "location": { + "$ref": "#/components/schemas/Location" + }, + "paid": { + "type": "boolean", + "description": "Новое значение флага о платности мероприятия", + "example": true + }, + "participantLimit": { + "type": "integer", + "description": "Новый лимит пользователей", + "format": "int32", + "example": 7 + }, + "requestModeration": { + "type": "boolean", + "description": "Нужна ли пре-модерация заявок на участие", + "example": false + }, + "stateAction": { + "type": "string", + "description": "Новое состояние события", + "enum": [ + "PUBLISH_EVENT", + "REJECT_EVENT" + ] + }, + "title": { + "maxLength": 120, + "minLength": 3, + "type": "string", + "description": "Новый заголовок", + "example": "Сап прогулки по рекам и каналам" + } + }, + "description": "Данные для изменения информации о событии. Если поле в запросе не указано (равно null) - значит изменение этих данных не треубется." + }, + "UpdateEventUserRequest": { + "type": "object", + "properties": { + "annotation": { + "maxLength": 2000, + "minLength": 20, + "type": "string", + "description": "Новая аннотация", + "example": "Сап прогулки по рекам и каналам – это возможность увидеть Практикбург с другого ракурса" + }, + "category": { + "type": "integer", + "description": "Новая категория", + "format": "int64", + "example": 3 + }, + "description": { + "maxLength": 7000, + "minLength": 20, + "type": "string", + "description": "Новое описание", + "example": "От английского SUP - Stand Up Paddle — \"стоя на доске с веслом\", гавайская разновидность сёрфинга, в котором серфер, стоя на доске, катается на волнах и при этом гребет веслом, а не руками, как в классическом серфинге." + }, + "eventDate": { + "type": "string", + "description": "Новые дата и время на которые намечено событие. Дата и время указываются в формате \"yyyy-MM-dd HH:mm:ss\"", + "example": "2023-10-11 23:10:05" + }, + "location": { + "$ref": "#/components/schemas/Location" + }, + "paid": { + "type": "boolean", + "description": "Новое значение флага о платности мероприятия", + "example": true + }, + "participantLimit": { + "type": "integer", + "description": "Новый лимит пользователей", + "format": "int32", + "example": 7 + }, + "requestModeration": { + "type": "boolean", + "description": "Нужна ли пре-модерация заявок на участие", + "example": false + }, + "stateAction": { + "type": "string", + "description": "Изменение сотояния события", + "example": "CANCEL_REVIEW", + "enum": [ + "SEND_TO_REVIEW", + "CANCEL_REVIEW" + ] + }, + "title": { + "maxLength": 120, + "minLength": 3, + "type": "string", + "description": "Новый заголовок", + "example": "Сап прогулки по рекам и каналам" + } + }, + "description": "Данные для изменения информации о событии. Если поле в запросе не указано (равно null) - значит изменение этих данных не треубется." + }, + "UserDto": { + "required": [ + "email", + "name" + ], + "type": "object", + "properties": { + "email": { + "type": "string", + "description": "Почтовый адрес", + "example": "petrov.i@practicummail.ru" + }, + "id": { + "type": "integer", + "description": "Идентификатор", + "format": "int64", + "readOnly": true, + "example": 1 + }, + "name": { + "type": "string", + "description": "Имя", + "example": "Петров Иван" + } + }, + "description": "Пользователь" + }, + "UserShortDto": { + "required": [ + "id", + "name" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Идентификатор", + "format": "int64", + "example": 3 + }, + "name": { + "type": "string", + "description": "Имя", + "example": "Фёдоров Матвей" + } + }, + "description": "Пользователь (краткая информация)" + } + } + } +} diff --git a/ewm-stats-service-spec.json b/ewm-stats-service-spec.json new file mode 100644 index 0000000..436892a --- /dev/null +++ b/ewm-stats-service-spec.json @@ -0,0 +1,170 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "Stat service API", + "version": "v0" + }, + "servers": [ + { + "url": "http://localhost:9090", + "description": "Generated server url" + } + ], + "tags": [ + { + "name": "StatsController", + "description": "API для работы со статистикой посещений" + } + ], + "paths": { + "/hit": { + "post": { + "tags": [ + "StatsController" + ], + "summary": "Сохранение информации о том, что к эндпоинту был запрос", + "description": "Сохранение информации о том, что на uri конкретного сервиса был отправлен запрос пользователем. Название сервиса, uri и ip пользователя указаны в теле запроса.", + "operationId": "hit", + "requestBody": { + "description": "данные запроса", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EndpointHit" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Информация сохранена" + } + } + } + }, + "/stats": { + "get": { + "tags": [ + "StatsController" + ], + "summary": "Получение статистики по посещениям. Обратите внимание: значение даты и времени нужно закодировать (например используя java.net.URLEncoder.encode) ", + "operationId": "getStats", + "parameters": [ + { + "name": "start", + "in": "query", + "description": "Дата и время начала диапазона за который нужно выгрузить статистику (в формате \"yyyy-MM-dd HH:mm:ss\")", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "end", + "in": "query", + "description": "Дата и время конца диапазона за который нужно выгрузить статистику (в формате \"yyyy-MM-dd HH:mm:ss\")", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "uris", + "in": "query", + "description": "Список uri для которых нужно выгрузить статистику", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "unique", + "in": "query", + "description": "Нужно ли учитывать только уникальные посещения (только с уникальным ip)", + "required": false, + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "description": "Статистика собрана", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ViewStats" + } + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "EndpointHit": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Идентификатор записи", + "format": "int64", + "readOnly": true, + "example": 1 + }, + "app": { + "type": "string", + "description": "Идентификатор сервиса для которого записывается информация", + "example": "ewm-main-service" + }, + "uri": { + "type": "string", + "description": "URI для которого был осуществлен запрос", + "example": "/events/1" + }, + "ip": { + "type": "string", + "description": "IP-адрес пользователя, осуществившего запрос", + "example": "192.163.0.1" + }, + "timestamp": { + "type": "string", + "description": "Дата и время, когда был совершен запрос к эндпоинту (в формате \"yyyy-MM-dd HH:mm:ss\")", + "example": "2022-09-06 11:00:23" + } + } + }, + "ViewStats": { + "type": "object", + "properties": { + "app": { + "type": "string", + "description": "Название сервиса", + "example": "ewm-main-service" + }, + "uri": { + "type": "string", + "description": "URI сервиса", + "example": "/events/1" + }, + "hits": { + "type": "integer", + "description": "Количество просмотров", + "format": "int64", + "example": 6 + } + } + } + } + } +} diff --git a/lombok.config b/lombok.config new file mode 100644 index 0000000..b0056c1 --- /dev/null +++ b/lombok.config @@ -0,0 +1,4 @@ +config.stopBubbling = true +lombok.anyconstructor.addconstructorproperties = false +lombok.addLombokGeneratedAnnotation = true +lombok.addSuppressWarnings = false \ No newline at end of file diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..ae39839 --- /dev/null +++ b/pom.xml @@ -0,0 +1,187 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.0 + + + + Explore With Me + + ru.practicum + explore-with-me + 0.0.1-SNAPSHOT + pom + + + 21 + UTF-8 + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + test + + + + + org.apache.maven.plugins + maven-checkstyle-plugin + 3.1.2 + + checkstyle.xml + true + true + true + + + + + check + + compile + + + + + com.puppycrawl.tools + checkstyle + 10.3 + + + + + com.github.spotbugs + spotbugs-maven-plugin + 4.8.5.0 + + Max + High + + + + + check + + + + + + org.jacoco + jacoco-maven-plugin + 0.8.12 + + file + + + + jacoco-initialize + + prepare-agent + + + + jacoco-check + + check + + + + + BUNDLE + + + INSTRUCTION + COVEREDRATIO + 0.01 + + + LINE + COVEREDRATIO + 0.2 + + + BRANCH + COVEREDRATIO + 0.2 + + + COMPLEXITY + COVEREDRATIO + 0.2 + + + METHOD + COVEREDRATIO + 0.2 + + + CLASS + MISSEDCOUNT + 1 + + + + + + + + jacoco-site + install + + report + + + + + + + + + + check + + + + org.apache.maven.plugins + maven-checkstyle-plugin + + + com.github.spotbugs + spotbugs-maven-plugin + + + + + + + com.github.spotbugs + spotbugs-maven-plugin + + + + + + coverage + + + + org.jacoco + jacoco-maven-plugin + + + + + + + diff --git a/suppressions.xml b/suppressions.xml new file mode 100644 index 0000000..b2c6822 --- /dev/null +++ b/suppressions.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file From 65394bb78d2e150ca8a6c1646e3f31ad2f7bd488 Mon Sep 17 00:00:00 2001 From: Pepe Ronin Date: Wed, 7 May 2025 02:13:14 +0300 Subject: [PATCH 02/73] initial repo setup --- compose.yaml | 71 +++++++++++ docker-compose.yml | 14 --- main-service/Dockerfile | 5 + main-service/pom.xml | 39 ++++++ .../main/MainServiceApplication.java | 13 ++ .../src/main/resources/application-local.yaml | 5 + .../src/main/resources/application.yaml | 14 +++ pom.xml | 91 +++++++++++++- stats-service/pom.xml | 20 ++++ stats-service/stats-client/pom.xml | 27 +++++ .../stats/client/StatsClient.java | 7 ++ stats-service/stats-dto/pom.xml | 112 ++++++++++++++++++ .../stats/dto/EndpointHitDto.java | 7 ++ .../explorewithme/stats/dto/ViewStatsDto.java | 7 ++ stats-service/stats-server/Dockerfile | 5 + stats-service/stats-server/pom.xml | 53 +++++++++ .../stats/server/StatsServerApplication.java | 13 ++ .../stats/server/model/EndpointHit.java | 7 ++ .../src/main/resources/application-local.yaml | 5 + .../src/main/resources/application.yaml | 14 +++ 20 files changed, 511 insertions(+), 18 deletions(-) create mode 100644 compose.yaml delete mode 100644 docker-compose.yml create mode 100644 main-service/Dockerfile create mode 100644 main-service/pom.xml create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/MainServiceApplication.java create mode 100644 main-service/src/main/resources/application-local.yaml create mode 100644 main-service/src/main/resources/application.yaml create mode 100644 stats-service/pom.xml create mode 100644 stats-service/stats-client/pom.xml create mode 100644 stats-service/stats-client/src/main/java/ru/practicum/explorewithme/stats/client/StatsClient.java create mode 100644 stats-service/stats-dto/pom.xml create mode 100644 stats-service/stats-dto/src/main/java/ru/practicum/explorewithme/stats/dto/EndpointHitDto.java create mode 100644 stats-service/stats-dto/src/main/java/ru/practicum/explorewithme/stats/dto/ViewStatsDto.java create mode 100644 stats-service/stats-server/Dockerfile create mode 100644 stats-service/stats-server/pom.xml create mode 100644 stats-service/stats-server/src/main/java/ru/practicum/exporewithme/stats/server/StatsServerApplication.java create mode 100644 stats-service/stats-server/src/main/java/ru/practicum/exporewithme/stats/server/model/EndpointHit.java create mode 100644 stats-service/stats-server/src/main/resources/application-local.yaml create mode 100644 stats-service/stats-server/src/main/resources/application.yaml diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..ab7aeff --- /dev/null +++ b/compose.yaml @@ -0,0 +1,71 @@ +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 + 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 +# 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/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index be96142..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,14 +0,0 @@ -services: - stats-server: - ports: - - "9090:9090" - - stats-db: - image: postgres:16.1 - - ewm-service: - ports: - - "8080:8080" - - ewm-db: - image: postgres:16.1 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..a5da993 --- /dev/null +++ b/main-service/pom.xml @@ -0,0 +1,39 @@ + + + 4.0.0 + + ru.practicum + explore-with-me + 0.0.1-SNAPSHOT + + + main-service + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-actuator + + + ru.practicum + stats-client + ${project.version} + + + + + + + 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..02fff01 --- /dev/null +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/MainServiceApplication.java @@ -0,0 +1,13 @@ +package ru.practicum.explorewithme.main; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class MainServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(MainServiceApplication.class, args); + } + +} 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..7ed0129 --- /dev/null +++ b/main-service/src/main/resources/application-local.yaml @@ -0,0 +1,5 @@ +spring: + datasource: + url: jdbc:postgresql://localhost:5432/ewm_main_db + username: emw_user + password: emw_password \ 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..d6fd5bd --- /dev/null +++ b/main-service/src/main/resources/application.yaml @@ -0,0 +1,14 @@ +server: + port: 8080 + +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 \ No newline at end of file diff --git a/pom.xml b/pom.xml index ae39839..d2f631f 100644 --- a/pom.xml +++ b/pom.xml @@ -6,22 +6,105 @@ org.springframework.boot spring-boot-starter-parent - 3.3.0 + 3.4.5 - Explore With Me - - ru.practicum + ru.practicum explore-with-me 0.0.1-SNAPSHOT pom 21 + 3.4.5 + 4.12.0 + 1.21.0 + 5.14.2 + ${java.version} + ${java.version} UTF-8 + Explore With Me + + stats-service + main-service + + + + + + 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 + + + javax.annotation + javax.annotation-api + 1.3.2 + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + 2.19.0 + + + javax.validation + validation-api + 2.0.1.Final + + + org.jetbrains + annotations + 24.0.1 + compile + + + com.google.code.findbugs + jsr305 + 3.0.2 + + + + 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..186673e --- /dev/null +++ b/stats-service/stats-client/pom.xml @@ -0,0 +1,27 @@ + + + 4.0.0 + + ru.practicum + stats-service + 0.0.1-SNAPSHOT + ../pom.xml + + + stats-client + + + + ru.practicum + stats-dto + ${project.version} + + + org.springframework + spring-web + + + + \ No newline at end of file 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..9b2631e --- /dev/null +++ b/stats-service/stats-client/src/main/java/ru/practicum/explorewithme/stats/client/StatsClient.java @@ -0,0 +1,7 @@ +package ru.practicum.explorewithme.stats.client; + +public class StatsClient { + + // TODO: stats client + +} diff --git a/stats-service/stats-dto/pom.xml b/stats-service/stats-dto/pom.xml new file mode 100644 index 0000000..97a493a --- /dev/null +++ b/stats-service/stats-dto/pom.xml @@ -0,0 +1,112 @@ + + + 4.0.0 + + ru.practicum + stats-service + 0.0.1-SNAPSHOT + ../pom.xml + + + stats-dto + + + + org.projectlombok + lombok + provided + + + com.fasterxml.jackson.core + jackson-annotations + + + javax.annotation + javax.annotation-api + + + com.fasterxml.jackson.core + jackson-databind + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + javax.validation + validation-api + + + org.jetbrains + annotations + + + com.google.code.findbugs + jsr305 + + + + + + + org.openapitools + openapi-generator-maven-plugin + 7.13.0 + + + + generate + + + ${project.basedir}/../../ewm-stats-service-spec.json + java + ru.practicum.explorewithme.stats.dto + ru.practicum.explorewithme.stats.dto.api + ${project.build.directory}/generated-sources/openapi + + src/gen/java/main + java8 + jackson + true + false + true + false + true + native + true + false + false + false + false + false + false + + + + + + + org.codehaus.mojo + build-helper-maven-plugin + 3.6.0 + + + add-source + generate-sources + + add-source + + + + ${project.build.directory}/generated-sources/openapi/src/gen/java/main + + + + + + + + + \ 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..60d9080 --- /dev/null +++ b/stats-service/stats-dto/src/main/java/ru/practicum/explorewithme/stats/dto/EndpointHitDto.java @@ -0,0 +1,7 @@ +package ru.practicum.explorewithme.stats.dto; + +public class EndpointHitDto { + + // TODO: endpoint hit DTO + +} 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..408a57f --- /dev/null +++ b/stats-service/stats-dto/src/main/java/ru/practicum/explorewithme/stats/dto/ViewStatsDto.java @@ -0,0 +1,7 @@ +package ru.practicum.explorewithme.stats.dto; + +public class ViewStatsDto { + + // TODO: view stats DTO + +} 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..60737d0 --- /dev/null +++ b/stats-service/stats-server/pom.xml @@ -0,0 +1,53 @@ + + + 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.postgresql + postgresql + + + org.projectlombok + lombok + provided + + + ru.practicum + stats-dto + ${project.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/exporewithme/stats/server/StatsServerApplication.java b/stats-service/stats-server/src/main/java/ru/practicum/exporewithme/stats/server/StatsServerApplication.java new file mode 100644 index 0000000..2415cfe --- /dev/null +++ b/stats-service/stats-server/src/main/java/ru/practicum/exporewithme/stats/server/StatsServerApplication.java @@ -0,0 +1,13 @@ +package ru.practicum.exporewithme.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/exporewithme/stats/server/model/EndpointHit.java b/stats-service/stats-server/src/main/java/ru/practicum/exporewithme/stats/server/model/EndpointHit.java new file mode 100644 index 0000000..11d6853 --- /dev/null +++ b/stats-service/stats-server/src/main/java/ru/practicum/exporewithme/stats/server/model/EndpointHit.java @@ -0,0 +1,7 @@ +package ru.practicum.exporewithme.stats.server.model; + +public class EndpointHit { + + // TODO: endpoint hit model + +} 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..b5f69f8 --- /dev/null +++ b/stats-service/stats-server/src/main/resources/application-local.yaml @@ -0,0 +1,5 @@ +spring: + datasource: + url: jdbc:postgresql://localhost:6543/ewm_stats_db + username: stats_user + password: stats_password \ 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..7335f40 --- /dev/null +++ b/stats-service/stats-server/src/main/resources/application.yaml @@ -0,0 +1,14 @@ +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 \ No newline at end of file From 1f9d0a5f3f21038f534bc2e18fe31ad080c4cc9b Mon Sep 17 00:00:00 2001 From: Sergey Filippovskikh <116564864+SergikF@users.noreply.github.com> Date: Wed, 7 May 2025 19:11:11 +0300 Subject: [PATCH 03/73] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BB=D0=B0=D1=81=D1=81=D0=BE?= =?UTF-8?q?=D0=B2=20DTO=20=D0=9E=D0=B7=D0=BD=D0=B0=D0=BA=D0=BE=D0=BC=D0=B8?= =?UTF-8?q?=D1=82=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20=D1=84=D0=B0=D0=B9?= =?UTF-8?q?=D0=BB=20=D1=81=D1=82=D1=80=D1=83=D0=BA=D1=82=D1=83=D1=80=D1=8B?= =?UTF-8?q?=20=D0=B1=D0=B0=D0=B7=D1=8B=20schema.sql?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- stats-service/stats-dto/pom.xml | 99 +++---------------- .../stats/dto/EndpointHitDto.java | 28 +++++- .../explorewithme/stats/dto/ViewStatsDto.java | 11 ++- .../src/main/resources/schema.sql | 10 ++ 4 files changed, 57 insertions(+), 91 deletions(-) create mode 100644 stats-service/stats-server/src/main/resources/schema.sql diff --git a/stats-service/stats-dto/pom.xml b/stats-service/stats-dto/pom.xml index 97a493a..92ad4b0 100644 --- a/stats-service/stats-dto/pom.xml +++ b/stats-service/stats-dto/pom.xml @@ -11,102 +11,31 @@ stats-dto + jar + + + UTF-8 + - org.projectlombok - lombok - provided + org.springframework.boot + spring-boot-starter-actuator - com.fasterxml.jackson.core - jackson-annotations + jakarta.validation + jakarta.validation-api + 3.1.0 - javax.annotation - javax.annotation-api + org.projectlombok + lombok + provided com.fasterxml.jackson.core - jackson-databind - - - com.fasterxml.jackson.datatype - jackson-datatype-jsr310 - - - javax.validation - validation-api - - - org.jetbrains - annotations - - - com.google.code.findbugs - jsr305 + jackson-annotations - - - - org.openapitools - openapi-generator-maven-plugin - 7.13.0 - - - - generate - - - ${project.basedir}/../../ewm-stats-service-spec.json - java - ru.practicum.explorewithme.stats.dto - ru.practicum.explorewithme.stats.dto.api - ${project.build.directory}/generated-sources/openapi - - src/gen/java/main - java8 - jackson - true - false - true - false - true - native - true - false - false - false - false - false - false - - - - - - - org.codehaus.mojo - build-helper-maven-plugin - 3.6.0 - - - add-source - generate-sources - - add-source - - - - ${project.build.directory}/generated-sources/openapi/src/gen/java/main - - - - - - - - \ 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 index 60d9080..1019e5b 100644 --- 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 @@ -1,7 +1,31 @@ package ru.practicum.explorewithme.stats.dto; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import jakarta.validation.constraints.NotNull; +import java.time.LocalDateTime; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor public class EndpointHitDto { + private Long id; + + @NotNull(message = "Поле app не может быть пустым") + private String app; + + @NotNull(message = "Поле uri не может быть пустым") + private String uri; - // TODO: endpoint hit DTO + @NotNull(message = "Поле ip не может быть пустым") + private String ip; -} + @NotNull(message = "Поле timestamp не может быть пустым") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss") + private 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 index 408a57f..22c6de9 100644 --- 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 @@ -1,7 +1,10 @@ package ru.practicum.explorewithme.stats.dto; -public class ViewStatsDto { - - // TODO: view stats DTO +import lombok.Data; -} +@Data +public class ViewStatsDto { + private String app; + private String uri; + private Long hits; +} \ 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..f69c200 --- /dev/null +++ b/stats-service/stats-server/src/main/resources/schema.sql @@ -0,0 +1,10 @@ +DROP TABLE IF EXISTS hits; + +CREATE TABLE IF NOT EXISTS hits +( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + app VARCHAR(32) NOT NULL, + uri VARCHAR(128) NOT NULL, + ip VARCHAR(16) NOT NULL, + timestamp TIMESTAMP WITHOUT TIME ZONE NOT NULL +); \ No newline at end of file From 5d9d419f2ebd2274cb3b7817e1895ab903db20a9 Mon Sep 17 00:00:00 2001 From: Sergey Filippovskikh <116564864+SergikF@users.noreply.github.com> Date: Wed, 7 May 2025 19:22:27 +0300 Subject: [PATCH 04/73] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BB=D0=B0=D1=81=D1=81=D0=BE?= =?UTF-8?q?=D0=B2=20DTO=20=D0=9E=D0=B7=D0=BD=D0=B0=D0=BA=D0=BE=D0=BC=D0=B8?= =?UTF-8?q?=D1=82=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20=D1=84=D0=B0=D0=B9?= =?UTF-8?q?=D0=BB=20=D1=81=D1=82=D1=80=D1=83=D0=BA=D1=82=D1=83=D1=80=D1=8B?= =?UTF-8?q?=20=D0=B1=D0=B0=D0=B7=D1=8B=20schema.sql?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stats-server/src/main/resources/schema.sql | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 stats-service/stats-server/src/main/resources/schema.sql diff --git a/stats-service/stats-server/src/main/resources/schema.sql b/stats-service/stats-server/src/main/resources/schema.sql deleted file mode 100644 index f69c200..0000000 --- a/stats-service/stats-server/src/main/resources/schema.sql +++ /dev/null @@ -1,10 +0,0 @@ -DROP TABLE IF EXISTS hits; - -CREATE TABLE IF NOT EXISTS hits -( - id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, - app VARCHAR(32) NOT NULL, - uri VARCHAR(128) NOT NULL, - ip VARCHAR(16) NOT NULL, - timestamp TIMESTAMP WITHOUT TIME ZONE NOT NULL -); \ No newline at end of file From 28caf741e637ac295f3fdd535e354d18cb09a68e Mon Sep 17 00:00:00 2001 From: Sergey Filippovskikh <116564864+SergikF@users.noreply.github.com> Date: Wed, 7 May 2025 19:31:38 +0300 Subject: [PATCH 05/73] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BB=D0=B0=D1=81=D1=81=D0=BE?= =?UTF-8?q?=D0=B2=20DTO=20=D0=9E=D0=B7=D0=BD=D0=B0=D0=BA=D0=BE=D0=BC=D0=B8?= =?UTF-8?q?=D1=82=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20=D1=84=D0=B0=D0=B9?= =?UTF-8?q?=D0=BB=20=D1=81=D1=82=D1=80=D1=83=D0=BA=D1=82=D1=83=D1=80=D1=8B?= =?UTF-8?q?=20=D0=B1=D0=B0=D0=B7=D1=8B=20schema.sql?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stats-server/src/main/resources/schema.sql | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 stats-service/stats-server/src/main/resources/schema.sql 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..0dd1fef --- /dev/null +++ b/stats-service/stats-server/src/main/resources/schema.sql @@ -0,0 +1,10 @@ +DROP TABLE IF EXISTS hits; + +CREATE TABLE IF NOT EXISTS hits +( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + app VARCHAR(32) NOT NULL, + uri VARCHAR(128) NOT NULL, + ip VARCHAR(16) NOT NULL, + timestamp TIMESTAMP WITHOUT TIME ZONE NOT NULL +); \ No newline at end of file From f1f5e14c32b230e1f1077ff0053ce506ebb353d9 Mon Sep 17 00:00:00 2001 From: Sergey Filippovskikh <116564864+SergikF@users.noreply.github.com> Date: Wed, 7 May 2025 19:31:38 +0300 Subject: [PATCH 06/73] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BB=D0=B0=D1=81=D1=81=D0=BE?= =?UTF-8?q?=D0=B2=20DTO=20=D0=9E=D0=B7=D0=BD=D0=B0=D0=BA=D0=BE=D0=BC=D0=B8?= =?UTF-8?q?=D1=82=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20=D1=84=D0=B0=D0=B9?= =?UTF-8?q?=D0=BB=20=D1=81=D1=82=D1=80=D1=83=D0=BA=D1=82=D1=83=D1=80=D1=8B?= =?UTF-8?q?=20=D0=B1=D0=B0=D0=B7=D1=8B=20schema.sql?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stats-server/src/main/resources/schema.sql | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 stats-service/stats-server/src/main/resources/schema.sql 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..0dd1fef --- /dev/null +++ b/stats-service/stats-server/src/main/resources/schema.sql @@ -0,0 +1,10 @@ +DROP TABLE IF EXISTS hits; + +CREATE TABLE IF NOT EXISTS hits +( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + app VARCHAR(32) NOT NULL, + uri VARCHAR(128) NOT NULL, + ip VARCHAR(16) NOT NULL, + timestamp TIMESTAMP WITHOUT TIME ZONE NOT NULL +); \ No newline at end of file From ca538ffa97a625aabb0ef0a0eeec60cba11a3897 Mon Sep 17 00:00:00 2001 From: Sergey Filippovskikh <116564864+SergikF@users.noreply.github.com> Date: Wed, 7 May 2025 20:28:55 +0300 Subject: [PATCH 07/73] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BB=D0=B0=D1=81=D1=81=D0=BE?= =?UTF-8?q?=D0=B2=20DTO=20=D0=9E=D0=B7=D0=BD=D0=B0=D0=BA=D0=BE=D0=BC=D0=B8?= =?UTF-8?q?=D1=82=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20=D1=84=D0=B0=D0=B9?= =?UTF-8?q?=D0=BB=20=D1=81=D1=82=D1=80=D1=83=D0=BA=D1=82=D1=83=D1=80=D1=8B?= =?UTF-8?q?=20=D0=B1=D0=B0=D0=B7=D1=8B=20schema.sql?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ru/practicum/explorewithme/stats/dto/EndpointHitDto.java | 2 ++ 1 file changed, 2 insertions(+) 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 index 1019e5b..d351147 100644 --- 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 @@ -1,6 +1,7 @@ package ru.practicum.explorewithme.stats.dto; import com.fasterxml.jackson.annotation.JsonFormat; +import jakarta.validation.constraints.PastOrPresent; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @@ -26,6 +27,7 @@ public class EndpointHitDto { private String ip; @NotNull(message = "Поле timestamp не может быть пустым") + @PastOrPresent(message = "Поле timestamp должно быть не позже текущей даты и времени") @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime timestamp; } \ No newline at end of file From 1178cbb3b8c7e2ec3e3b940f041a6d51d0c5534b Mon Sep 17 00:00:00 2001 From: Sergey Filippovskikh <116564864+SergikF@users.noreply.github.com> Date: Wed, 7 May 2025 20:35:21 +0300 Subject: [PATCH 08/73] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BB=D0=B0=D1=81=D1=81=D0=BE?= =?UTF-8?q?=D0=B2=20DTO=20=D0=9E=D0=B7=D0=BD=D0=B0=D0=BA=D0=BE=D0=BC=D0=B8?= =?UTF-8?q?=D1=82=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20=D1=84=D0=B0=D0=B9?= =?UTF-8?q?=D0=BB=20=D1=81=D1=82=D1=80=D1=83=D0=BA=D1=82=D1=83=D1=80=D1=8B?= =?UTF-8?q?=20=D0=B1=D0=B0=D0=B7=D1=8B=20schema.sql?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../explorewithme/stats/dto/EndpointHitDto.java | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) 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 index d351147..34311ed 100644 --- 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 @@ -2,6 +2,7 @@ import com.fasterxml.jackson.annotation.JsonFormat; import jakarta.validation.constraints.PastOrPresent; +import jakarta.validation.constraints.Size; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @@ -17,13 +18,16 @@ public class EndpointHitDto { private Long id; - @NotNull(message = "Поле app не может быть пустым") + @NotNull(message = "Поле app должно быть указано") + @Size(min = 1, max = 32, message = "Поле app должно быть от 1 до 32 символов") private String app; - @NotNull(message = "Поле uri не может быть пустым") + @NotNull(message = "Поле uri должно быть указано") + @Size(min = 1, max = 128, message = "Поле uri должно быть от 1 до 128 символов") private String uri; - @NotNull(message = "Поле ip не может быть пустым") + @NotNull(message = "Поле ip должно быть указано") + @Size(min = 7, max = 16, message = "Поле ip должно быть от 7 до 16 символов") private String ip; @NotNull(message = "Поле timestamp не может быть пустым") From 780be5ec20c9dde3fb771ec4d625f37f60344a7d Mon Sep 17 00:00:00 2001 From: Sergey Filippovskikh <116564864+SergikF@users.noreply.github.com> Date: Wed, 7 May 2025 21:46:40 +0300 Subject: [PATCH 09/73] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20JSON=20=D1=82=D0=B5=D1=81=D1=82=D1=8B=20Dto.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- stats-service/stats-dto/pom.xml | 16 +++ .../stats/dto/EndpointHitDtoTest.java | 108 ++++++++++++++++++ .../stats/dto/ViewStatsDtoTest.java | 85 ++++++++++++++ 3 files changed, 209 insertions(+) create mode 100644 stats-service/stats-dto/src/test/java/ru/practicum/explorewithme/stats/dto/EndpointHitDtoTest.java create mode 100644 stats-service/stats-dto/src/test/java/ru/practicum/explorewithme/stats/dto/ViewStatsDtoTest.java diff --git a/stats-service/stats-dto/pom.xml b/stats-service/stats-dto/pom.xml index 92ad4b0..aba48b3 100644 --- a/stats-service/stats-dto/pom.xml +++ b/stats-service/stats-dto/pom.xml @@ -36,6 +36,22 @@ com.fasterxml.jackson.core jackson-annotations + + com.fasterxml.jackson.core + jackson-databind + 2.15.3 + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + 2.15.3 + + + org.junit.jupiter + junit-jupiter + 5.10.1 + test + \ 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..7fb64ff --- /dev/null +++ b/stats-service/stats-dto/src/test/java/ru/practicum/explorewithme/stats/dto/EndpointHitDtoTest.java @@ -0,0 +1,108 @@ +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 org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.*; + +class EndpointHitDtoTest { + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + } + + @Test + void testSerializationToJson() throws Exception { + // Подготовка тестовых данных + LocalDateTime timestamp = LocalDateTime.of(2024, 3, 15, 12, 30, 0); + EndpointHitDto dto = new EndpointHitDto( + 1L, + "test-app", + "/test/path", + "192.168.1.1", + timestamp + ); + + // Сериализация в JSON + String json = objectMapper.writeValueAsString(dto); + + // Проверки + assertTrue(json.contains("\"id\":1")); + assertTrue(json.contains("\"app\":\"test-app\"")); + assertTrue(json.contains("\"uri\":\"/test/path\"")); + assertTrue(json.contains("\"ip\":\"192.168.1.1\"")); + assertTrue(json.contains("\"timestamp\":\"2024-03-15 12:30:00\"")); + } + + @Test + void testDeserializationFromJson() throws Exception { + // Подготовка JSON + String json = """ + { + "id": 1, + "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); + + // Проверки + assertEquals(1L, dto.getId()); + assertEquals("test-app", dto.getApp()); + assertEquals("/test/path", dto.getUri()); + assertEquals("192.168.1.1", dto.getIp()); + assertEquals( + LocalDateTime.of(2024, 3, 15, 12, 30, 0), + dto.getTimestamp() + ); + } + + @Test + void testInvalidTimestampFormat() { + // Подготовка JSON с неверным форматом даты + String json = """ + { + "id": 1, + "app": "test-app", + "uri": "/test/path", + "ip": "192.168.1.1", + "timestamp": "2024-03-15T12:30:00" + }"""; + + // Проверка исключения при неверном формате + assertThrows(Exception.class, () -> + objectMapper.readValue(json, EndpointHitDto.class) + ); + } + + @Test + void testNullValues() throws Exception { + // Создание объекта с null-значениями + EndpointHitDto dto = new EndpointHitDto(null, null, null, null, null); + + // Сериализация + String json = objectMapper.writeValueAsString(dto); + + // Десериализация + EndpointHitDto deserializedDto = objectMapper.readValue(json, EndpointHitDto.class); + + // Проверки + assertNull(deserializedDto.getId()); + assertNull(deserializedDto.getApp()); + assertNull(deserializedDto.getUri()); + assertNull(deserializedDto.getIp()); + assertNull(deserializedDto.getTimestamp()); + } +} \ 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..bc1bef7 --- /dev/null +++ b/stats-service/stats-dto/src/test/java/ru/practicum/explorewithme/stats/dto/ViewStatsDtoTest.java @@ -0,0 +1,85 @@ +package ru.practicum.explorewithme.stats.dto; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class ViewStatsDtoTest { + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + objectMapper = new ObjectMapper(); + } + + @Test + void testSerializationToJson() throws Exception { + // Подготовка тестовых данных + ViewStatsDto dto = new ViewStatsDto(); + dto.setApp("test-service"); + dto.setUri("/events/1"); + dto.setHits(100L); + + // Сериализация в JSON + String json = objectMapper.writeValueAsString(dto); + + // Проверки + assertTrue(json.contains("\"app\":\"test-service\"")); + assertTrue(json.contains("\"uri\":\"/events/1\"")); + assertTrue(json.contains("\"hits\":100")); + } + + @Test + void testDeserializationFromJson() throws Exception { + // Подготовка JSON + String json = """ + { + "app": "test-service", + "uri": "/events/1", + "hits": 100 + }"""; + + // Десериализация из JSON + ViewStatsDto dto = objectMapper.readValue(json, ViewStatsDto.class); + + // Проверки + assertEquals("test-service", dto.getApp()); + assertEquals("/events/1", dto.getUri()); + assertEquals(100L, dto.getHits()); + } + + @Test + void testDeserializationWithMissingFields() throws Exception { + // JSON с отсутствующими полями + String json = """ + { + "app": "test-service" + }"""; + + ViewStatsDto dto = objectMapper.readValue(json, ViewStatsDto.class); + + // Проверки + assertEquals("test-service", dto.getApp()); + assertNull(dto.getUri()); + assertNull(dto.getHits()); + } + + @Test + void testNullValues() throws Exception { + // Создание объекта с null-значениями + ViewStatsDto dto = new ViewStatsDto(); + + // Сериализация + String json = objectMapper.writeValueAsString(dto); + + // Десериализация + ViewStatsDto deserializedDto = objectMapper.readValue(json, ViewStatsDto.class); + + // Проверки + assertNull(deserializedDto.getApp()); + assertNull(deserializedDto.getUri()); + assertNull(deserializedDto.getHits()); + } +} \ No newline at end of file From bea083253e73c287f742a034a15047222c54d9a3 Mon Sep 17 00:00:00 2001 From: Sergey Filippovskikh <116564864+SergikF@users.noreply.github.com> Date: Wed, 7 May 2025 22:05:40 +0300 Subject: [PATCH 10/73] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20JSON=20=D1=82=D0=B5=D1=81=D1=82=D1=8B=20Dto.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- stats-service/stats-dto/pom.xml | 8 -------- .../explorewithme/stats/dto/EndpointHitDto.java | 8 ++++---- .../stats-server/src/main/resources/schema.sql | 10 ---------- 3 files changed, 4 insertions(+), 22 deletions(-) delete mode 100644 stats-service/stats-server/src/main/resources/schema.sql diff --git a/stats-service/stats-dto/pom.xml b/stats-service/stats-dto/pom.xml index aba48b3..cb4a9e8 100644 --- a/stats-service/stats-dto/pom.xml +++ b/stats-service/stats-dto/pom.xml @@ -13,15 +13,7 @@ stats-dto jar - - UTF-8 - - - - org.springframework.boot - spring-boot-starter-actuator - jakarta.validation jakarta.validation-api 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 index 34311ed..ad6b494 100644 --- 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 @@ -1,6 +1,7 @@ package ru.practicum.explorewithme.stats.dto; import com.fasterxml.jackson.annotation.JsonFormat; +import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.PastOrPresent; import jakarta.validation.constraints.Size; import lombok.AllArgsConstructor; @@ -16,17 +17,16 @@ @AllArgsConstructor @NoArgsConstructor public class EndpointHitDto { - private Long id; - @NotNull(message = "Поле app должно быть указано") + @NotBlank(message = "Поле app не может быть пустым") @Size(min = 1, max = 32, message = "Поле app должно быть от 1 до 32 символов") private String app; - @NotNull(message = "Поле uri должно быть указано") + @NotBlank(message = "Поле uri не может быть пустым") @Size(min = 1, max = 128, message = "Поле uri должно быть от 1 до 128 символов") private String uri; - @NotNull(message = "Поле ip должно быть указано") + @NotBlank(message = "Поле ip не может быть пустым") @Size(min = 7, max = 16, message = "Поле ip должно быть от 7 до 16 символов") private String ip; diff --git a/stats-service/stats-server/src/main/resources/schema.sql b/stats-service/stats-server/src/main/resources/schema.sql deleted file mode 100644 index 0dd1fef..0000000 --- a/stats-service/stats-server/src/main/resources/schema.sql +++ /dev/null @@ -1,10 +0,0 @@ -DROP TABLE IF EXISTS hits; - -CREATE TABLE IF NOT EXISTS hits -( - id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, - app VARCHAR(32) NOT NULL, - uri VARCHAR(128) NOT NULL, - ip VARCHAR(16) NOT NULL, - timestamp TIMESTAMP WITHOUT TIME ZONE NOT NULL -); \ No newline at end of file From 302108afa8e79cab557a69c51f4241849bf4499f Mon Sep 17 00:00:00 2001 From: Sergey Filippovskikh <116564864+SergikF@users.noreply.github.com> Date: Thu, 8 May 2025 05:49:21 +0300 Subject: [PATCH 11/73] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5?= =?UTF-8?q?=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20id.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../explorewithme/stats/dto/EndpointHitDtoTest.java | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) 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 index 7fb64ff..912c50d 100644 --- 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 @@ -25,7 +25,6 @@ void testSerializationToJson() throws Exception { // Подготовка тестовых данных LocalDateTime timestamp = LocalDateTime.of(2024, 3, 15, 12, 30, 0); EndpointHitDto dto = new EndpointHitDto( - 1L, "test-app", "/test/path", "192.168.1.1", @@ -36,7 +35,6 @@ void testSerializationToJson() throws Exception { String json = objectMapper.writeValueAsString(dto); // Проверки - assertTrue(json.contains("\"id\":1")); assertTrue(json.contains("\"app\":\"test-app\"")); assertTrue(json.contains("\"uri\":\"/test/path\"")); assertTrue(json.contains("\"ip\":\"192.168.1.1\"")); @@ -48,7 +46,6 @@ void testDeserializationFromJson() throws Exception { // Подготовка JSON String json = """ { - "id": 1, "app": "test-app", "uri": "/test/path", "ip": "192.168.1.1", @@ -59,7 +56,6 @@ void testDeserializationFromJson() throws Exception { EndpointHitDto dto = objectMapper.readValue(json, EndpointHitDto.class); // Проверки - assertEquals(1L, dto.getId()); assertEquals("test-app", dto.getApp()); assertEquals("/test/path", dto.getUri()); assertEquals("192.168.1.1", dto.getIp()); @@ -90,7 +86,7 @@ void testInvalidTimestampFormat() { @Test void testNullValues() throws Exception { // Создание объекта с null-значениями - EndpointHitDto dto = new EndpointHitDto(null, null, null, null, null); + EndpointHitDto dto = new EndpointHitDto(null, null, null, null); // Сериализация String json = objectMapper.writeValueAsString(dto); @@ -99,7 +95,6 @@ void testNullValues() throws Exception { EndpointHitDto deserializedDto = objectMapper.readValue(json, EndpointHitDto.class); // Проверки - assertNull(deserializedDto.getId()); assertNull(deserializedDto.getApp()); assertNull(deserializedDto.getUri()); assertNull(deserializedDto.getIp()); From 24cc213cca1e3210ea8a086108d03e676e7c8f93 Mon Sep 17 00:00:00 2001 From: Pepe Ronin Date: Thu, 8 May 2025 20:53:46 +0300 Subject: [PATCH 12/73] add README.md --- README.md | 151 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..c3b65cb --- /dev/null +++ b/README.md @@ -0,0 +1,151 @@ +# Explore With Me (Исследуй со мной) + +Приложение-афиша, позволяющее пользователям делиться информацией об интересных событиях и находить компанию для участия в них. Проект разрабатывается в рамках обучения в Яндекс Практикуме. + +## Оглавление + +- [Технологии](#технологии) +- [Структура проекта](#структура-проекта) +- [Начало работы](#начало-работы) + - [Предварительные требования](#предварительные-требования) + - [Сборка проекта](#сборка-проекта) + - [Запуск с использованием Docker Compose](#запуск-с-использованием-docker-compose) + - [Локальный запуск для разработки (IntelliJ IDEA)](#локальный-запуск-для-разработки-intellij-idea) +- [Тестирование](#тестирование) +- [Дополнительная функциональность](#дополнительная-функциональность) +- [Планы по использованию OpenAPI Generator](#планы-по-использованию-openapi-generator) +- [Команда](#команда) + +## Технологии + +- Java 21 +- Spring Boot 3.4.5 +- Spring Data JPA +- Spring MVC +- PostgreSQL +- Maven +- Docker / Docker Compose +- Lombok +- JUnit 5, Mockito +- Testcontainers +- Checkstyle, Spotbugs, Jacoco (для контроля качества кода) + +## Структура проекта + +Проект является многомодульным Maven-проектом и состоит из следующих основных частей: + +- `explore-with-me` (корневой POM) + - `main-service`: Основной сервис приложения. + - `Dockerfile` + - `stats-service` (родительский POM для модулей статистики) + - `stats-dto`: Data Transfer Objects (DTO) для сервиса статистики. + - `stats-client`: HTTP-клиент для взаимодействия с сервисом статистики. + - `stats-server`: Сервис статистики (сбор и предоставление данных о запросах). + - `Dockerfile` + +## Начало работы + +### Предварительные требования + +Для работы с проектом вам понадобятся: + +- JDK 21 (или выше, совместимая с Java 21) +- Apache Maven 3.6+ +- Docker и Docker Compose +- IntelliJ IDEA (рекомендуется) или другая IDE с поддержкой Maven и Spring Boot. + +### Сборка проекта + +Для сборки всех модулей проекта выполните следующую команду в корневой директории: + +```bash +mvn clean install +``` +Эта команда также запустит статические анализаторы кода (Checkstyle, Spotbugs) и юнит-тесты. + +### Запуск с использованием Docker Compose + +Наиболее предпочтительный способ запуска всего приложения (или его частей) – использование Docker Compose. + +1. **Соберите проект:** + ```bash + mvn clean install + ``` +2. **Запустите сервисы:** + В корневой директории проекта выполните: + ```bash + docker-compose up --build -d + ``` + Эта команда соберет Docker-образы для `stats-server` и `main-service` (если раскомментирован в `docker-compose.yml`) и запустит их вместе с необходимыми базами данных PostgreSQL. + + - Сервис статистики (`stats-server`) будет доступен по адресу: `http://localhost:9090` + - Основной сервис (`main-service`) будет доступен по адресу: `http://localhost:8080` (когда будет реализован и раскомментирован) + +3. **Остановка сервисов:** + ```bash + docker-compose down + ``` + Для удаления volumes (данных БД): + ```bash + docker-compose down -v + ``` + +### Локальный запуск для разработки (IntelliJ IDEA) + +Для удобства разработки и отладки, особенно сервиса статистики (`stats-server`), предусмотрен профиль запуска `stat-local` в IntelliJ IDEA. + +1. **Настройка базы данных:** + Убедитесь, что у вас локально запущен экземпляр PostgreSQL, доступный по адресу, указанному в `stats-service/stats-server/src/main/resources/application-local.yml`. + Примерные параметры для `application-local.yml`: + ```yaml + spring: + datasource: + url: jdbc:postgresql://localhost:6543/ewm_stats_db # Убедитесь, что порт и имя БД соответствуют вашей локальной PG + username: stats_user # Ваш пользователь + password: stats_password # Ваш пароль + jpa: + hibernate: + ddl-auto: update # или create-drop для локальной разработки + ``` + *Примечание: Вам может потребоваться создать базу данных `ewm_stats_db` и пользователя `stats_user` вручную, если они еще не существуют.* + +2. **Запуск `StatsServerApplication`:** + - Откройте проект в IntelliJ IDEA. + - Найдите класс `StatsServerApplication.java` в модуле `stats-server`. + - В репозитории должна быть предустановленная Run Configuration "stat-local". Если нет, создайте новую конфигурацию Spring Boot: + - **Main class:** `ru.practicum.explorewithme.stats.server.StatsServerApplication` + - **VM options:** `-Dspring.profiles.active=local` (это активирует `application-local.yml`) + - **Working directory:** Установите корневую директорию модуля `stats-server`. + - Запустите эту конфигурацию. Сервис статистики должен запуститься и подключиться к вашей локальной базе данных. + +## Тестирование + +Для запуска всех тестов в проекте выполните: + +```bash +mvn test +``` +Проект использует JUnit 5, Mockito и Testcontainers для различных уровней тестирования (юнит-тесты, интеграционные тесты с БД). Отчеты о покрытии кода (Jacoco) генерируются в `target/site/jacoco/`. + +## Дополнительная функциональность + +В рамках проекта командой была выбрана следующая дополнительная функциональность для реализации после основной части: + +- **Основной выбор:** "Администрирование. Первый вариант" + - Возможность для администратора добавлять конкретные локации — города, театры, концертные залы и другие в виде координат (широта, долгота, радиус). + - Получение списка этих локаций. + - Возможность поиска событий в конкретной локации. + +- **Резервный вариант:** "Комментарии" + - Возможность оставлять комментарии к событиям и модерировать их. + +## Планы по использованию OpenAPI Generator + +Команда планировала исследовать `openapi-generator-maven-plugin` для автоматической генерации DTO и, возможно, интерфейсов контроллеров на основе OpenAPI спецификаций. Однако, в связи с необходимостью сосредоточиться на основной функциональности первого этапа и отсутствием у команды предварительного опыта работы с данным инструментом, активное внедрение и использование генератора **отложено на более поздний срок**. На текущем этапе DTO создаются вручную. + +## Команда + +- Иван Петровский (Team Lead) - [@impatient0](https://github.com/impatient0) +- Андрей Гагарский - [@Gagarskiy-Andrey](https://github.com/Gagarskiy-Andrey) +- Валерия Бутько - [@progingir](https://github.com/progingir) +- Сергей Филипповских - [@SergikF](https://github.com/SergikF) \ No newline at end of file From f636044475e9ba11387f828ad6d5f6c1b9a5df70 Mon Sep 17 00:00:00 2001 From: Pepe Ronin Date: Thu, 8 May 2025 20:54:12 +0300 Subject: [PATCH 13/73] save stats-local run profile as a project file --- .run/stats-local.run.xml | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .run/stats-local.run.xml diff --git a/.run/stats-local.run.xml b/.run/stats-local.run.xml new file mode 100644 index 0000000..a43d386 --- /dev/null +++ b/.run/stats-local.run.xml @@ -0,0 +1,12 @@ + + + + \ No newline at end of file From 7627c08056b55ec7596448175aab9f1329be5862 Mon Sep 17 00:00:00 2001 From: Pepe Ronin Date: Fri, 9 May 2025 14:25:40 +0300 Subject: [PATCH 14/73] correct typo in package name --- .run/stats-local.run.xml | 2 +- .../stats/server/StatsServerApplication.java | 2 +- .../stats/server/model/EndpointHit.java | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename stats-service/stats-server/src/main/java/ru/practicum/{exporewithme => explorewithme}/stats/server/StatsServerApplication.java (86%) rename stats-service/stats-server/src/main/java/ru/practicum/{exporewithme => explorewithme}/stats/server/model/EndpointHit.java (53%) diff --git a/.run/stats-local.run.xml b/.run/stats-local.run.xml index a43d386..e68e3da 100644 --- a/.run/stats-local.run.xml +++ b/.run/stats-local.run.xml @@ -2,7 +2,7 @@ + + 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 + diff --git a/stats-service/stats-dto/pom.xml b/stats-service/stats-dto/pom.xml index cb4a9e8..1de74d2 100644 --- a/stats-service/stats-dto/pom.xml +++ b/stats-service/stats-dto/pom.xml @@ -17,7 +17,6 @@ jakarta.validation jakarta.validation-api - 3.1.0 org.projectlombok @@ -31,18 +30,14 @@ com.fasterxml.jackson.core jackson-databind - 2.15.3 com.fasterxml.jackson.datatype jackson-datatype-jsr310 - 2.15.3 org.junit.jupiter junit-jupiter - 5.10.1 - test From 091a2210e3f1def3c62ee5a41da9d7ec27073f07 Mon Sep 17 00:00:00 2001 From: Pepe Ronin Date: Sat, 10 May 2025 00:38:06 +0300 Subject: [PATCH 19/73] implement StatsRepository --- .../server/repository/StatsRepository.java | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 stats-service/stats-server/src/main/java/ru/practicum/explorewithme/stats/server/repository/StatsRepository.java 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 From 4bb57c2c8418f6444fc68eac0282f856bf827856 Mon Sep 17 00:00:00 2001 From: Pepe Ronin Date: Sat, 10 May 2025 00:38:41 +0300 Subject: [PATCH 20/73] create StatsService interface and a stub implementation --- .../stats/server/service/StatsService.java | 29 +++++++++++++--- .../server/service/StatsServiceImpl.java | 33 +++++++++++++++++++ 2 files changed, 57 insertions(+), 5 deletions(-) create mode 100644 stats-service/stats-server/src/main/java/ru/practicum/explorewithme/stats/server/service/StatsServiceImpl.java 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 index 35f4940..14dfe90 100644 --- 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 @@ -1,10 +1,29 @@ package ru.practicum.explorewithme.stats.server.service; -import org.springframework.stereotype.Service; +import ru.practicum.explorewithme.stats.dto.EndpointHitDto; +import ru.practicum.explorewithme.stats.dto.ViewStatsDto; -@Service -public class StatsService { +import java.time.LocalDateTime; +import java.util.List; - // TODO: stats service +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..a903412 --- /dev/null +++ b/stats-service/stats-server/src/main/java/ru/practicum/explorewithme/stats/server/service/StatsServiceImpl.java @@ -0,0 +1,33 @@ +package ru.practicum.explorewithme.stats.server.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import ru.practicum.explorewithme.stats.dto.EndpointHitDto; +import ru.practicum.explorewithme.stats.dto.ViewStatsDto; + +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; +import ru.practicum.explorewithme.stats.server.repository.StatsRepository; + +@Service +@RequiredArgsConstructor +@Slf4j +@SuppressWarnings("unused") +public class StatsServiceImpl implements StatsService { + + private final StatsRepository statsRepository; + + @Override + public void saveHit(EndpointHitDto endpointHitDto) { + log.warn("STUB IMPLEMENTATION: StatsServiceImpl.saveHit called with DTO: {}", endpointHitDto); + } + + @Override + public List getStats(LocalDateTime start, LocalDateTime end, List uris, boolean unique) { + log.warn("STUB IMPLEMENTATION: StatsServiceImpl.getStats called with params: start={}, end={}, uris={}, unique={}", + start, end, uris, unique); + return Collections.emptyList(); + } +} \ No newline at end of file From 4a081e725baaa03c18c2cd1eeb59a82e53e236ae Mon Sep 17 00:00:00 2001 From: Pepe Ronin Date: Sat, 10 May 2025 00:39:16 +0300 Subject: [PATCH 21/73] add constructors to ViewStatsDto --- .../ru/practicum/explorewithme/stats/dto/ViewStatsDto.java | 4 ++++ 1 file changed, 4 insertions(+) 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 index 22c6de9..ce4059f 100644 --- 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 @@ -1,8 +1,12 @@ package ru.practicum.explorewithme.stats.dto; +import lombok.AllArgsConstructor; import lombok.Data; +import lombok.NoArgsConstructor; @Data +@AllArgsConstructor +@NoArgsConstructor public class ViewStatsDto { private String app; private String uri; From ff73d6a83e98e16aedea79005d5da9ad63b8de62 Mon Sep 17 00:00:00 2001 From: Pepe Ronin Date: Sat, 10 May 2025 00:39:57 +0300 Subject: [PATCH 22/73] change StatsController to rely on StatsService --- .../server/controller/StatsController.java | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) 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 index 067da51..39c1ef9 100644 --- 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 @@ -1,17 +1,21 @@ package ru.practicum.explorewithme.stats.server.controller; +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.*; +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 jakarta.validation.Valid; -import java.time.LocalDateTime; -import java.util.Collections; -import java.util.List; import ru.practicum.explorewithme.stats.server.service.StatsService; @RestController @@ -31,7 +35,9 @@ public class StatsController { @PostMapping("/hit") @ResponseStatus(HttpStatus.CREATED) public void saveHit(@Valid @RequestBody EndpointHitDto endpointHitDto) { - log.info("STUB: Received POST /hit request with DTO: {}", endpointHitDto); + log.info("Controller: request to save new hit received."); + log.debug("Saving new hit: {}", endpointHitDto); + statsService.saveHit(endpointHitDto); // Calls the stubbed service method } /** @@ -57,9 +63,10 @@ public List getStats( @RequestParam(name = "uris", required = false) List uris, @RequestParam(name = "unique", defaultValue = "false") Boolean unique) { - log.info("STUB: Received GET /stats request with params: start={}, end={}, uris={}, unique={}", + log.info("Controller: request to retrieve stats received."); + log.debug("Request params: start={}, end={}, uris={}, unique={}", start, end, uris, unique); - return Collections.emptyList(); + return statsService.getStats(start, end, uris, unique); } } \ No newline at end of file From 3312643ff66135cdbe42f120907df9a32622ff1f Mon Sep 17 00:00:00 2001 From: Pepe Ronin Date: Sat, 10 May 2025 00:49:21 +0300 Subject: [PATCH 23/73] implement tests for StatsRepository --- stats-service/stats-server/pom.xml | 13 ++ .../repository/StatsRepositoryTest.java | 178 ++++++++++++++++++ 2 files changed, 191 insertions(+) create mode 100644 stats-service/stats-server/src/test/java/ru/practicum/explorewithme/stats/server/repository/StatsRepositoryTest.java diff --git a/stats-service/stats-server/pom.xml b/stats-service/stats-server/pom.xml index 60737d0..2efefd8 100644 --- a/stats-service/stats-server/pom.xml +++ b/stats-service/stats-server/pom.xml @@ -25,6 +25,10 @@ org.springframework.boot spring-boot-starter-actuator + + org.springframework.boot + spring-boot-starter-test + org.postgresql postgresql @@ -39,6 +43,15 @@ stats-dto ${project.version} + + org.testcontainers + postgresql + test + + + org.testcontainers + junit-jupiter + 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..519f02d --- /dev/null +++ b/stats-service/stats-server/src/test/java/ru/practicum/explorewithme/stats/server/repository/StatsRepositoryTest.java @@ -0,0 +1,178 @@ +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); + + 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); + + 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 From 49e9cbb550b4d341f516862e94dc4989df145e9c Mon Sep 17 00:00:00 2001 From: Pepe Ronin Date: Sat, 10 May 2025 00:58:50 +0300 Subject: [PATCH 24/73] clean up --- .../explorewithme/stats/server/controller/StatsController.java | 2 +- .../explorewithme/stats/server/service/StatsService.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 index 39c1ef9..07e5c92 100644 --- 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 @@ -37,7 +37,7 @@ public class StatsController { 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); // Calls the stubbed service method + statsService.saveHit(endpointHitDto); } /** 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 index 14dfe90..f67e1ef 100644 --- 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 @@ -12,7 +12,7 @@ public interface StatsService { * Сохраняет информацию о запросе к эндпоинту. * * @param endpointHitDto DTO с информацией о запросе. - */ + **/ void saveHit(EndpointHitDto endpointHitDto); /** From 42b335cbbb2748d8d9e36144607f47b8a6390252 Mon Sep 17 00:00:00 2001 From: Pepe Ronin Date: Sat, 10 May 2025 16:55:30 +0300 Subject: [PATCH 25/73] added comments to tests for clarity --- .../stats/server/repository/StatsRepositoryTest.java | 2 ++ 1 file changed, 2 insertions(+) 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 index 519f02d..43266ed 100644 --- 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 @@ -99,6 +99,7 @@ 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 @@ -152,6 +153,7 @@ 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 From 9586222a8d4bf19fc1a79b389694c4d59691778c Mon Sep 17 00:00:00 2001 From: Pepe Ronin Date: Sat, 10 May 2025 16:55:54 +0300 Subject: [PATCH 26/73] annotate service methods as transactional --- .../explorewithme/stats/server/service/StatsServiceImpl.java | 3 +++ 1 file changed, 3 insertions(+) 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 index a903412..affa0de 100644 --- 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 @@ -3,6 +3,7 @@ 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; @@ -20,11 +21,13 @@ public class StatsServiceImpl implements StatsService { private final StatsRepository statsRepository; @Override + @Transactional public void saveHit(EndpointHitDto endpointHitDto) { log.warn("STUB IMPLEMENTATION: StatsServiceImpl.saveHit called with DTO: {}", endpointHitDto); } @Override + @Transactional(readOnly = true) public List getStats(LocalDateTime start, LocalDateTime end, List uris, boolean unique) { log.warn("STUB IMPLEMENTATION: StatsServiceImpl.getStats called with params: start={}, end={}, uris={}, unique={}", start, end, uris, unique); From cfcb455d9386a3dbf9dda72930281fe3900e621a Mon Sep 17 00:00:00 2001 From: Sergey Filippovskikh <116564864+SergikF@users.noreply.github.com> Date: Sat, 10 May 2025 21:17:41 +0300 Subject: [PATCH 27/73] =?UTF-8?q?StatsClient=20=D0=B1=D0=B0=D0=B7=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D1=8F=20=D0=BA=D0=BE=D0=BD=D1=81=D1=82=D1=80=D1=83?= =?UTF-8?q?=D0=BA=D1=86=D0=B8=D1=8F.=20(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * update local app config for server * create EndpointHit entity * manage dependency versions in root pom.xml * implement StatsRepository * create StatsService interface and a stub implementation * add constructors to ViewStatsDto * change StatsController to rely on StatsService * implement tests for StatsRepository * clean up * Создание структуры StatClient с запросами /hit и /stats * Небольшое изменение StatClient Попытка создать тесты для StatClient Внесены некоторые изменения временный в main-service для проверки модуля статистики. * Внесены правки по замечаниям. --------- Co-authored-by: Pepe Ronin --- .../main/MainServiceApplication.java | 1 - .../src/main/resources/application-local.yaml | 3 + .../src/main/resources/application.yaml | 3 + stats-service/stats-client/pom.xml | 55 ++- .../stats/client/StatsClient.java | 10 +- .../stats/client/StatsClientImpl.java | 91 ++++ .../src/main/resources/application-local.yaml | 2 + .../src/main/resources/application.yaml | 2 + .../stats/client/StatsClientTest.java | 442 ++++++++++++++++++ 9 files changed, 584 insertions(+), 25 deletions(-) create mode 100644 stats-service/stats-client/src/main/java/ru/practicum/explorewithme/stats/client/StatsClientImpl.java create mode 100644 stats-service/stats-client/src/main/resources/application-local.yaml create mode 100644 stats-service/stats-client/src/main/resources/application.yaml create mode 100644 stats-service/stats-client/src/test/java/ru/practicum/explorewithme/stats/client/StatsClientTest.java 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 index 02fff01..7ca3ccb 100644 --- a/main-service/src/main/java/ru/practicum/explorewithme/main/MainServiceApplication.java +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/MainServiceApplication.java @@ -5,7 +5,6 @@ @SpringBootApplication public class MainServiceApplication { - public static void main(String[] args) { SpringApplication.run(MainServiceApplication.class, args); } diff --git a/main-service/src/main/resources/application-local.yaml b/main-service/src/main/resources/application-local.yaml index 7ed0129..e6ae4d5 100644 --- a/main-service/src/main/resources/application-local.yaml +++ b/main-service/src/main/resources/application-local.yaml @@ -1,3 +1,6 @@ +stats-server: + url: http://localhost:9090 + spring: datasource: url: jdbc:postgresql://localhost:5432/ewm_main_db diff --git a/main-service/src/main/resources/application.yaml b/main-service/src/main/resources/application.yaml index d6fd5bd..e4140c0 100644 --- a/main-service/src/main/resources/application.yaml +++ b/main-service/src/main/resources/application.yaml @@ -1,6 +1,9 @@ server: port: 8080 +stats-server: + url: http://stats-server:9090 + spring: application: name: main-service diff --git a/stats-service/stats-client/pom.xml b/stats-service/stats-client/pom.xml index 186673e..b5147ae 100644 --- a/stats-service/stats-client/pom.xml +++ b/stats-service/stats-client/pom.xml @@ -1,27 +1,38 @@ - 4.0.0 - - ru.practicum - stats-service - 0.0.1-SNAPSHOT - ../pom.xml - + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> + 4.0.0 + + ru.practicum + stats-service + 0.0.1-SNAPSHOT + ../pom.xml + - stats-client + stats-client + jar - - - ru.practicum - stats-dto - ${project.version} - - - org.springframework - spring-web - - + + + 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 + + - \ No newline at end of file + 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 index 9b2631e..99911b0 100644 --- 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 @@ -1,7 +1,13 @@ package ru.practicum.explorewithme.stats.client; -public class StatsClient { +import ru.practicum.explorewithme.stats.dto.EndpointHitDto; +import ru.practicum.explorewithme.stats.dto.ViewStatsDto; - // TODO: stats client +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..cd3ef04 --- /dev/null +++ b/stats-service/stats-client/src/main/java/ru/practicum/explorewithme/stats/client/StatsClientImpl.java @@ -0,0 +1,91 @@ +package ru.practicum.explorewithme.stats.client; + +import lombok.extern.slf4j.Slf4j; +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; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; + +@Service +@Slf4j +public class StatsClientImpl implements StatsClient { + + private final RestClient restClient; + + 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(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))) + .queryParam("end", end + .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); + + 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/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..617dc39 --- /dev/null +++ b/stats-service/stats-client/src/test/java/ru/practicum/explorewithme/stats/client/StatsClientTest.java @@ -0,0 +1,442 @@ +package ru.practicum.explorewithme.stats.client; + +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; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.*; +import static org.springframework.test.web.client.response.MockRestResponseCreators.*; + +@DisplayName("Тесты для StatsClientImpl") +class StatsClientTest { + + private RestTemplate restTemplate; + private MockRestServiceServer mockServer; + private StatsClientImpl statsClient; + private final String baseUrl = "http://stats-server:9090"; + private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + @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 From 9db3760f78e584ac762ef82111bea2360c7b285a Mon Sep 17 00:00:00 2001 From: Sergey Filippovskikh <116564864+SergikF@users.noreply.github.com> Date: Sun, 11 May 2025 11:12:22 +0300 Subject: [PATCH 28/73] =?UTF-8?q?=D0=98=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=B2=20=D1=82=D0=B5=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D1=85=20=D0=B8=20=D0=B2=20=D0=BA=D0=BB=D0=B0=D1=81=D1=81=D0=B5?= =?UTF-8?q?=20EndpointHitDto=20(#27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- stats-service/stats-dto/pom.xml | 15 + .../stats/dto/EndpointHitDto.java | 2 + .../stats/dto/EndpointHitDtoTest.java | 439 +++++++++++++++--- .../stats/dto/ViewStatsDtoTest.java | 261 ++++++++--- 4 files changed, 580 insertions(+), 137 deletions(-) diff --git a/stats-service/stats-dto/pom.xml b/stats-service/stats-dto/pom.xml index 1de74d2..79e14b2 100644 --- a/stats-service/stats-dto/pom.xml +++ b/stats-service/stats-dto/pom.xml @@ -39,6 +39,21 @@ org.junit.jupiter junit-jupiter + + org.assertj + assertj-core + test + + + org.hibernate.validator + hibernate-validator + + + org.glassfish + jakarta.el + 4.0.2 + test + \ 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 index ad6b494..66ebbac 100644 --- 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 @@ -1,6 +1,7 @@ package ru.practicum.explorewithme.stats.dto; 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; @@ -16,6 +17,7 @@ @Setter @AllArgsConstructor @NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) public class EndpointHitDto { @NotBlank(message = "Поле app не может быть пустым") 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 index 912c50d..eadc347 100644 --- 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 @@ -3,101 +3,396 @@ 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.junit.jupiter.api.Assertions.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +@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); - } - @Test - 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); - - // Проверки - assertTrue(json.contains("\"app\":\"test-app\"")); - assertTrue(json.contains("\"uri\":\"/test/path\"")); - assertTrue(json.contains("\"ip\":\"192.168.1.1\"")); - assertTrue(json.contains("\"timestamp\":\"2024-03-15 12:30:00\"")); + ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); + validator = factory.getValidator(); } - @Test - 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); - - // Проверки - assertEquals("test-app", dto.getApp()); - assertEquals("/test/path", dto.getUri()); - assertEquals("192.168.1.1", dto.getIp()); - assertEquals( - LocalDateTime.of(2024, 3, 15, 12, 30, 0), - dto.getTimestamp() - ); + @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 в формате yyyy-MM-dd HH:mm:ss") + .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(); + } } - @Test - void testInvalidTimestampFormat() { - // Подготовка JSON с неверным форматом даты - String json = """ - { - "id": 1, - "app": "test-app", - "uri": "/test/path", - "ip": "192.168.1.1", - "timestamp": "2024-03-15T12:30:00" - }"""; - - // Проверка исключения при неверном формате - assertThrows(Exception.class, () -> - objectMapper.readValue(json, EndpointHitDto.class) - ); + @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 + ); + } } - @Test - void testNullValues() throws Exception { - // Создание объекта с null-значениями - EndpointHitDto dto = new EndpointHitDto(null, null, null, null); + @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); - // Сериализация - String json = objectMapper.writeValueAsString(dto); + assertThat(violations) + .as("Не должно быть нарушений валидации для ip длиной 7 символов") + .isEmpty(); - // Десериализация - EndpointHitDto deserializedDto = objectMapper.readValue(json, EndpointHitDto.class); + // Проверяем сериализацию/десериализацию + String json = objectMapper.writeValueAsString(dto); + EndpointHitDto deserializedDto = objectMapper.readValue(json, EndpointHitDto.class); - // Проверки - assertNull(deserializedDto.getApp()); - assertNull(deserializedDto.getUri()); - assertNull(deserializedDto.getIp()); - assertNull(deserializedDto.getTimestamp()); + 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 index bc1bef7..b710f6d 100644 --- 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 @@ -2,10 +2,14 @@ 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; @@ -14,72 +18,199 @@ void setUp() { objectMapper = new ObjectMapper(); } - @Test - void testSerializationToJson() throws Exception { - // Подготовка тестовых данных - ViewStatsDto dto = new ViewStatsDto(); - dto.setApp("test-service"); - dto.setUri("/events/1"); - dto.setHits(100L); - - // Сериализация в JSON - String json = objectMapper.writeValueAsString(dto); - - // Проверки - assertTrue(json.contains("\"app\":\"test-service\"")); - assertTrue(json.contains("\"uri\":\"/events/1\"")); - assertTrue(json.contains("\"hits\":100")); + @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"); + } } - @Test - void testDeserializationFromJson() throws Exception { - // Подготовка JSON - String json = """ - { - "app": "test-service", - "uri": "/events/1", - "hits": 100 - }"""; - - // Десериализация из JSON - ViewStatsDto dto = objectMapper.readValue(json, ViewStatsDto.class); - - // Проверки - assertEquals("test-service", dto.getApp()); - assertEquals("/events/1", dto.getUri()); - assertEquals(100L, dto.getHits()); - } - - @Test - void testDeserializationWithMissingFields() throws Exception { - // JSON с отсутствующими полями - String json = """ - { - "app": "test-service" - }"""; - - ViewStatsDto dto = objectMapper.readValue(json, ViewStatsDto.class); - - // Проверки - assertEquals("test-service", dto.getApp()); - assertNull(dto.getUri()); - assertNull(dto.getHits()); - } - - @Test - void testNullValues() throws Exception { - // Создание объекта с null-значениями - ViewStatsDto dto = new ViewStatsDto(); - - // Сериализация - String json = objectMapper.writeValueAsString(dto); - - // Десериализация - ViewStatsDto deserializedDto = objectMapper.readValue(json, ViewStatsDto.class); - - // Проверки - assertNull(deserializedDto.getApp()); - assertNull(deserializedDto.getUri()); - assertNull(deserializedDto.getHits()); + @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 From 80de0adfb750b4da6e2243422ee711e6d30144d2 Mon Sep 17 00:00:00 2001 From: impatient0 Date: Sun, 11 May 2025 12:24:05 +0300 Subject: [PATCH 29/73] =?UTF-8?q?Stats=20Server:=20=D0=A0=D0=B5=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B1=D0=B8=D0=B7?= =?UTF-8?q?=D0=BD=D0=B5=D1=81-=D0=BB=D0=BE=D0=B3=D0=B8=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=B8=20=D1=84=D0=B8=D0=BD=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8F=20Docker-=D0=BA=D0=BE=D0=BD=D1=84=D0=B8=D0=B3?= =?UTF-8?q?=D1=83=D1=80=D0=B0=D1=86=D0=B8=D0=B8=20(#28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add @Builder to EndpointHit entity and DTO * fully implement StatsService * create dedicated common package * create global exception handler and a dedicated error DTO * correctly attach Mockito Java agent * fix a bug with StatsServiceImpl not saving hits * add date correctness validation in service * create unit tests for StatsServiceImpl * add schema.sql and mount it for db containers * fix db service healthcheck(s) * fix missing validation dependency * uncomment ewm-service * fix checkstyle flagging text blocks * fix checkstyle flagging text blocks - attempt 2 --------- Co-authored-by: Pepe Ronin --- compose.yaml | 72 +++--- ewm-common/pom.xml | 31 +++ .../explorewithme/common/error/ApiError.java | 24 ++ .../src/main/resources/application.yaml | 5 +- main-service/src/main/resources/schema.sql | 0 pom.xml | 6 + stats-service/stats-dto/pom.xml | 1 - .../stats/dto/EndpointHitDto.java | 2 + .../explorewithme/stats/dto/ViewStatsDto.java | 2 + .../stats/dto/EndpointHitDtoTest.java | 1 + .../stats/dto/ViewStatsDtoTest.java | 1 + stats-service/stats-server/pom.xml | 10 + .../server/error/GlobalExceptionHandler.java | 75 ++++++ .../server/mapper/EndpointHitMapper.java | 24 ++ .../server/mapper/EndpointHitMapperImpl.java | 31 +++ .../stats/server/model/EndpointHit.java | 2 + .../stats/server/service/StatsService.java | 4 +- .../server/service/StatsServiceImpl.java | 43 +++- .../src/main/resources/application.yaml | 5 +- .../src/main/resources/schema.sql | 8 + .../server/service/StatsServiceImplTest.java | 224 ++++++++++++++++++ 21 files changed, 522 insertions(+), 49 deletions(-) create mode 100644 ewm-common/pom.xml create mode 100644 ewm-common/src/main/java/ru/practicum/explorewithme/common/error/ApiError.java create mode 100644 main-service/src/main/resources/schema.sql create mode 100644 stats-service/stats-server/src/main/java/ru/practicum/explorewithme/stats/server/error/GlobalExceptionHandler.java create mode 100644 stats-service/stats-server/src/main/java/ru/practicum/explorewithme/stats/server/mapper/EndpointHitMapper.java create mode 100644 stats-service/stats-server/src/main/java/ru/practicum/explorewithme/stats/server/mapper/EndpointHitMapperImpl.java create mode 100644 stats-service/stats-server/src/main/resources/schema.sql create mode 100644 stats-service/stats-server/src/test/java/ru/practicum/explorewithme/stats/server/service/StatsServiceImplTest.java diff --git a/compose.yaml b/compose.yaml index ab7aeff..a5406f4 100644 --- a/compose.yaml +++ b/compose.yaml @@ -24,48 +24,50 @@ services: 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" ] + 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 -# 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 + 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/error/ApiError.java b/ewm-common/src/main/java/ru/practicum/explorewithme/common/error/ApiError.java new file mode 100644 index 0000000..95cbc32 --- /dev/null +++ b/ewm-common/src/main/java/ru/practicum/explorewithme/common/error/ApiError.java @@ -0,0 +1,24 @@ +package ru.practicum.explorewithme.common.error; + +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 = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime timestamp; + + private List errors; +} \ No newline at end of file diff --git a/main-service/src/main/resources/application.yaml b/main-service/src/main/resources/application.yaml index e4140c0..3b1d899 100644 --- a/main-service/src/main/resources/application.yaml +++ b/main-service/src/main/resources/application.yaml @@ -14,4 +14,7 @@ spring: url: ${SPRING_DATASOURCE_URL} username: ${SPRING_DATASOURCE_USERNAME} password: ${SPRING_DATASOURCE_PASSWORD} - driver-class-name: org.postgresql.Driver \ No newline at end of file + 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..e69de29 diff --git a/pom.xml b/pom.xml index 4c6e322..78be5f6 100644 --- a/pom.xml +++ b/pom.xml @@ -30,6 +30,7 @@ stats-service main-service + ewm-common @@ -127,10 +128,15 @@ org.apache.maven.plugins maven-surefire-plugin + 3.5.3 test + + -javaagent:${settings.localRepository}/org/mockito/mockito-core/${mockito.version}/mockito-core-${mockito.version}.jar + -Xshare:off + diff --git a/stats-service/stats-dto/pom.xml b/stats-service/stats-dto/pom.xml index 79e14b2..929bcc9 100644 --- a/stats-service/stats-dto/pom.xml +++ b/stats-service/stats-dto/pom.xml @@ -21,7 +21,6 @@ org.projectlombok lombok - provided com.fasterxml.jackson.core 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 index 66ebbac..9ddf249 100644 --- 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 @@ -6,6 +6,7 @@ import jakarta.validation.constraints.PastOrPresent; import jakarta.validation.constraints.Size; import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; @@ -17,6 +18,7 @@ @Setter @AllArgsConstructor @NoArgsConstructor +@Builder @JsonIgnoreProperties(ignoreUnknown = true) public class EndpointHitDto { 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 index ce4059f..3451cab 100644 --- 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 @@ -1,12 +1,14 @@ package ru.practicum.explorewithme.stats.dto; import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @Data @AllArgsConstructor @NoArgsConstructor +@Builder public class ViewStatsDto { private String app; private String uri; 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 index eadc347..4718888 100644 --- 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 @@ -1,3 +1,4 @@ +// CHECKSTYLE:OFF RegexpSinglelineJava package ru.practicum.explorewithme.stats.dto; import com.fasterxml.jackson.databind.ObjectMapper; 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 index b710f6d..8ccc8fc 100644 --- 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 @@ -1,3 +1,4 @@ +// CHECKSTYLE:OFF RegexpSinglelineJava package ru.practicum.explorewithme.stats.dto; import com.fasterxml.jackson.databind.ObjectMapper; diff --git a/stats-service/stats-server/pom.xml b/stats-service/stats-server/pom.xml index 2efefd8..2652b36 100644 --- a/stats-service/stats-server/pom.xml +++ b/stats-service/stats-server/pom.xml @@ -29,6 +29,10 @@ org.springframework.boot spring-boot-starter-test + + org.springframework.boot + spring-boot-starter-validation + org.postgresql postgresql @@ -52,6 +56,12 @@ org.testcontainers junit-jupiter + + ru.practicum + ewm-common + 0.0.1-SNAPSHOT + compile + 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 index 94005b5..a12fa79 100644 --- 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 @@ -9,6 +9,7 @@ import java.time.LocalDateTime; import java.util.Objects; import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; @@ -21,6 +22,7 @@ @NoArgsConstructor @AllArgsConstructor @ToString +@Builder public class EndpointHit { @Id 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 index f67e1ef..5aa78a1 100644 --- 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 @@ -12,7 +12,7 @@ public interface StatsService { * Сохраняет информацию о запросе к эндпоинту. * * @param endpointHitDto DTO с информацией о запросе. - **/ + */ void saveHit(EndpointHitDto endpointHitDto); /** @@ -23,7 +23,7 @@ public interface StatsService { * @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 index affa0de..87693ad 100644 --- 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 @@ -1,15 +1,16 @@ 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 java.time.LocalDateTime; -import java.util.Collections; -import java.util.List; +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 @@ -18,19 +19,43 @@ @SuppressWarnings("unused") public class StatsServiceImpl implements StatsService { + private final EndpointHitMapper endpointHitMapper; private final StatsRepository statsRepository; @Override @Transactional public void saveHit(EndpointHitDto endpointHitDto) { - log.warn("STUB IMPLEMENTATION: StatsServiceImpl.saveHit called with DTO: {}", 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 uris, boolean unique) { - log.warn("STUB IMPLEMENTATION: StatsServiceImpl.getStats called with params: start={}, end={}, uris={}, unique={}", - start, end, uris, unique); - return Collections.emptyList(); + 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.yaml b/stats-service/stats-server/src/main/resources/application.yaml index 7335f40..c599416 100644 --- a/stats-service/stats-server/src/main/resources/application.yaml +++ b/stats-service/stats-server/src/main/resources/application.yaml @@ -11,4 +11,7 @@ spring: url: ${SPRING_DATASOURCE_URL} username: ${SPRING_DATASOURCE_USERNAME} password: ${SPRING_DATASOURCE_PASSWORD} - driver-class-name: org.postgresql.Driver \ No newline at end of file + 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/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 From 87774911e4eb998818377aef2d68edae5b3ca5c1 Mon Sep 17 00:00:00 2001 From: Pepe Ronin Date: Sun, 11 May 2025 19:57:14 +0300 Subject: [PATCH 30/73] attempt to fix agent attachment issues --- pom.xml | 10 ++++++---- stats-service/stats-server/pom.xml | 4 ++++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/pom.xml b/pom.xml index 78be5f6..6a39f53 100644 --- a/pom.xml +++ b/pom.xml @@ -119,6 +119,12 @@ jakarta.validation-api 3.1.0 + + org.mockito + mockito-inline + 5.2.0 + test + @@ -133,10 +139,6 @@ test - - -javaagent:${settings.localRepository}/org/mockito/mockito-core/${mockito.version}/mockito-core-${mockito.version}.jar - -Xshare:off - diff --git a/stats-service/stats-server/pom.xml b/stats-service/stats-server/pom.xml index 2652b36..29ad796 100644 --- a/stats-service/stats-server/pom.xml +++ b/stats-service/stats-server/pom.xml @@ -62,6 +62,10 @@ 0.0.1-SNAPSHOT compile + + org.mockito + mockito-inline + From 9e38e879221dcd5e3a481790db48d82ecdad9c1f Mon Sep 17 00:00:00 2001 From: GrimJak Date: Sun, 11 May 2025 20:39:49 +0300 Subject: [PATCH 31/73] StatsControllerTest --- .../controller/StatsControllerTest.java | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 stats-service/stats-server/src/test/java/ru/practicum/explorewithme/stats/server/controller/StatsControllerTest.java 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..55147b8 --- /dev/null +++ b/stats-service/stats-server/src/test/java/ru/practicum/explorewithme/stats/server/controller/StatsControllerTest.java @@ -0,0 +1,146 @@ +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.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; + + @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(); + validHitDto = EndpointHitDto.builder() + .app("test-app") + .uri("/test-uri") + .ip("127.0.0.1") + .timestamp(now.minusHours(1)) + .build(); + } + + @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.setIp(""); + + 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.ofPattern("yyyy-MM-dd HH:mm:ss"))) + .param("end", end.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))) + .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)); + } + + +} \ No newline at end of file From 1e3ea5131fc8197b096008124efdadc2bda86e38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9B=D0=B5=D1=80=D0=B0?= <148673675+progingir@users.noreply.github.com> Date: Mon, 12 May 2025 22:05:23 +0500 Subject: [PATCH 32/73] Merge pull request #30 from impatient0/stat_svc-service-tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit добавлены тесты --- .../StatsServerIntegrationTest.java | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 stats-service/stats-server/src/test/java/ru/practicum/explorewithme/stats/server/controller/StatsServerIntegrationTest.java 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..acf7119 --- /dev/null +++ b/stats-service/stats-server/src/test/java/ru/practicum/explorewithme/stats/server/controller/StatsServerIntegrationTest.java @@ -0,0 +1,164 @@ +package ru.practicum.explorewithme.stats.server.controller; + +import org.junit.jupiter.api.BeforeAll; +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; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +import static org.assertj.core.api.Assertions.assertThat; + +@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 = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + private final LocalDateTime now = LocalDateTime.now(); + + @BeforeAll + static void setUpContainer() { + postgresqlContainer.start(); + } + + @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 From a2f50b3b746c5af6fefac59e8777c553a6923edf Mon Sep 17 00:00:00 2001 From: Pepe Ronin Date: Mon, 12 May 2025 20:27:46 +0300 Subject: [PATCH 33/73] add centralized date time format management --- .../common/constants/DateTimeConstants.java | 23 ++++++++++++++ .../explorewithme/common/error/ApiError.java | 4 ++- main-service/pom.xml | 5 ++++ stats-service/stats-client/pom.xml | 5 ++++ .../stats/client/StatsClientImpl.java | 12 ++++---- .../stats/client/StatsClientTest.java | 30 +++++++++++-------- stats-service/stats-dto/pom.xml | 5 ++++ .../stats/dto/EndpointHitDto.java | 4 ++- .../stats/dto/EndpointHitDtoTest.java | 3 +- .../server/controller/StatsController.java | 6 ++-- .../StatsServerIntegrationTest.java | 3 +- 11 files changed, 76 insertions(+), 24 deletions(-) create mode 100644 ewm-common/src/main/java/ru/practicum/explorewithme/common/constants/DateTimeConstants.java 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 index 95cbc32..98f1692 100644 --- 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 @@ -1,5 +1,7 @@ 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; @@ -17,7 +19,7 @@ public class ApiError { private String reason; private String message; - @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = DATE_TIME_FORMAT_PATTERN) private LocalDateTime timestamp; private List errors; diff --git a/main-service/pom.xml b/main-service/pom.xml index a5da993..8fd5155 100644 --- a/main-service/pom.xml +++ b/main-service/pom.xml @@ -25,6 +25,11 @@ stats-client ${project.version} + + ru.practicum + ewm-common + ${project.version} + diff --git a/stats-service/stats-client/pom.xml b/stats-service/stats-client/pom.xml index b5147ae..aeda0c2 100644 --- a/stats-service/stats-client/pom.xml +++ b/stats-service/stats-client/pom.xml @@ -33,6 +33,11 @@ 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/StatsClientImpl.java b/stats-service/stats-client/src/main/java/ru/practicum/explorewithme/stats/client/StatsClientImpl.java index cd3ef04..02bd111 100644 --- 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 @@ -1,5 +1,9 @@ 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.Value; import org.springframework.core.ParameterizedTypeReference; @@ -11,10 +15,6 @@ import ru.practicum.explorewithme.stats.dto.EndpointHitDto; import ru.practicum.explorewithme.stats.dto.ViewStatsDto; -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; -import java.util.List; - @Service @Slf4j public class StatsClientImpl implements StatsClient { @@ -64,9 +64,9 @@ public List getStats(LocalDateTime start, LocalDateTime end, List< .uri(uriBuilder -> { uriBuilder.path("/stats") .queryParam("start", start - .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))) + .format(DATE_TIME_FORMATTER)) .queryParam("end", end - .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); + .format(DATE_TIME_FORMATTER)); if (uris != null && !uris.isEmpty()) { for (String uri : uris) { 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 index 617dc39..e1bd148 100644 --- 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 @@ -1,5 +1,22 @@ 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 static ru.practicum.explorewithme.common.constants.DateTimeConstants.DATE_TIME_FORMATTER; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +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; @@ -14,17 +31,6 @@ import ru.practicum.explorewithme.stats.dto.EndpointHitDto; import ru.practicum.explorewithme.stats.dto.ViewStatsDto; -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.*; -import static org.springframework.test.web.client.match.MockRestRequestMatchers.*; -import static org.springframework.test.web.client.response.MockRestResponseCreators.*; - @DisplayName("Тесты для StatsClientImpl") class StatsClientTest { @@ -32,7 +38,7 @@ class StatsClientTest { private MockRestServiceServer mockServer; private StatsClientImpl statsClient; private final String baseUrl = "http://stats-server:9090"; - private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + private static final DateTimeFormatter FORMATTER = DATE_TIME_FORMATTER; @BeforeEach void setUp() { diff --git a/stats-service/stats-dto/pom.xml b/stats-service/stats-dto/pom.xml index 929bcc9..cff19e8 100644 --- a/stats-service/stats-dto/pom.xml +++ b/stats-service/stats-dto/pom.xml @@ -53,6 +53,11 @@ 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 index 9ddf249..37243d0 100644 --- 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 @@ -1,5 +1,7 @@ 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; @@ -36,6 +38,6 @@ public class EndpointHitDto { @NotNull(message = "Поле timestamp не может быть пустым") @PastOrPresent(message = "Поле timestamp должно быть не позже текущей даты и времени") - @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = DATE_TIME_FORMAT_PATTERN) private LocalDateTime timestamp; } \ 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 index 4718888..8f0cd31 100644 --- 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 @@ -23,6 +23,7 @@ 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 { @@ -71,7 +72,7 @@ void testSerializationToJson() throws Exception { .contains("\"ip\":\"192.168.1.1\""); assertThat(json) - .as("JSON должен содержать поле timestamp в формате yyyy-MM-dd HH:mm:ss") + .as("JSON должен содержать поле timestamp в формате " + DATE_TIME_FORMAT_PATTERN) .contains("\"timestamp\":\"2024-03-15 12:30:00\""); } 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 index 07e5c92..82f53cf 100644 --- 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 @@ -1,5 +1,7 @@ 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; @@ -53,11 +55,11 @@ public void saveHit(@Valid @RequestBody EndpointHitDto endpointHitDto) { @ResponseStatus(HttpStatus.OK) public List getStats( @RequestParam(name = "start") - @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @DateTimeFormat(pattern = DATE_TIME_FORMAT_PATTERN) LocalDateTime start, @RequestParam(name = "end") - @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @DateTimeFormat(pattern = DATE_TIME_FORMAT_PATTERN) LocalDateTime end, @RequestParam(name = "uris", required = false) List uris, 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 index acf7119..ef02cfc 100644 --- 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 @@ -19,6 +19,7 @@ import java.time.format.DateTimeFormatter; import static org.assertj.core.api.Assertions.assertThat; +import static ru.practicum.explorewithme.common.constants.DateTimeConstants.DATE_TIME_FORMATTER; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @Testcontainers @@ -41,7 +42,7 @@ static void postgresqlProperties(DynamicPropertyRegistry registry) { @Autowired private TestRestTemplate restTemplate; - private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + private static final DateTimeFormatter FORMATTER = DATE_TIME_FORMATTER; private final LocalDateTime now = LocalDateTime.now(); @BeforeAll From c34735c82b244920f59af38608196130c201f71f Mon Sep 17 00:00:00 2001 From: Pepe Ronin Date: Mon, 12 May 2025 20:58:17 +0300 Subject: [PATCH 34/73] remove dead code --- .../stats/client/StatsClientTest.java | 3 --- .../controller/StatsServerIntegrationTest.java | 17 +++++------------ 2 files changed, 5 insertions(+), 15 deletions(-) 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 index e1bd148..75a70f2 100644 --- 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 @@ -10,10 +10,8 @@ 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 static ru.practicum.explorewithme.common.constants.DateTimeConstants.DATE_TIME_FORMATTER; import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -38,7 +36,6 @@ class StatsClientTest { private MockRestServiceServer mockServer; private StatsClientImpl statsClient; private final String baseUrl = "http://stats-server:9090"; - private static final DateTimeFormatter FORMATTER = DATE_TIME_FORMATTER; @BeforeEach void setUp() { 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 index ef02cfc..6c83b20 100644 --- 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 @@ -1,6 +1,10 @@ package ru.practicum.explorewithme.stats.server.controller; -import org.junit.jupiter.api.BeforeAll; +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; @@ -15,12 +19,6 @@ import ru.practicum.explorewithme.stats.dto.EndpointHitDto; import ru.practicum.explorewithme.stats.dto.ViewStatsDto; -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; - -import static org.assertj.core.api.Assertions.assertThat; -import static ru.practicum.explorewithme.common.constants.DateTimeConstants.DATE_TIME_FORMATTER; - @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @Testcontainers public class StatsServerIntegrationTest { @@ -45,11 +43,6 @@ static void postgresqlProperties(DynamicPropertyRegistry registry) { private static final DateTimeFormatter FORMATTER = DATE_TIME_FORMATTER; private final LocalDateTime now = LocalDateTime.now(); - @BeforeAll - static void setUpContainer() { - postgresqlContainer.start(); - } - @Test void shouldRetrieveUniqueStats_whenUniqueFlagIsTrue() { EndpointHitDto hit1 = EndpointHitDto.builder() From 4a7409f7a14433922f120916a14c36a91622285e Mon Sep 17 00:00:00 2001 From: GrimJak Date: Mon, 12 May 2025 23:14:27 +0300 Subject: [PATCH 35/73] StatsControllerTest --- .../controller/StatsControllerTest.java | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) 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 index 55147b8..5b73aa9 100644 --- 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 @@ -1,6 +1,7 @@ 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; @@ -24,6 +25,7 @@ import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; import java.util.List; @ExtendWith(MockitoExtension.class) @@ -49,7 +51,7 @@ void setUp() { mvc = MockMvcBuilders .standaloneSetup(statsController) .build(); - now = LocalDateTime.now(); + now = LocalDateTime.now().truncatedTo(ChronoUnit.SECONDS); validHitDto = EndpointHitDto.builder() .app("test-app") .uri("/test-uri") @@ -142,5 +144,22 @@ void getStats_whenParamsAreValid_shouldReturn200Ok() throws Exception { 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.ofPattern("yyyy-MM-dd HH:mm:ss"))) + .param("end", end.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))) + .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 From cbf44cae679074dbc1104700c4350f8fd7b04bcd Mon Sep 17 00:00:00 2001 From: Sergey Filippovskikh <116564864+SergikF@users.noreply.github.com> Date: Thu, 15 May 2025 09:35:34 +0300 Subject: [PATCH 36/73] =?UTF-8?q?=D0=A1=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D1=81=D1=83=D1=89=D0=BD=D0=BE=D1=81=D1=82=D0=B5?= =?UTF-8?q?=D0=B9=20User=20Category=20Location=20(#61)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Создание сущностей User, Category, Location, Compilation, Event, ParticipationRequest --- main-service/pom.xml | 14 +++ .../explorewithme/main/models/Category.java | 30 +++++ .../main/models/Compilation.java | 49 ++++++++ .../explorewithme/main/models/Event.java | 116 ++++++++++++++++++ .../explorewithme/main/models/EventState.java | 22 ++++ .../explorewithme/main/models/Location.java | 42 +++++++ .../main/models/ParticipationRequest.java | 52 ++++++++ .../main/models/RequestStatus.java | 27 ++++ .../explorewithme/main/models/User.java | 36 ++++++ 9 files changed, 388 insertions(+) create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/models/Category.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/models/Compilation.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/models/Event.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/models/EventState.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/models/Location.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/models/ParticipationRequest.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/models/RequestStatus.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/models/User.java diff --git a/main-service/pom.xml b/main-service/pom.xml index 8fd5155..1b8a534 100644 --- a/main-service/pom.xml +++ b/main-service/pom.xml @@ -10,6 +10,7 @@ main-service + jar @@ -30,6 +31,19 @@ ewm-common ${project.version} + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.postgresql + postgresql + + + org.projectlombok + lombok + provided + diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/models/Category.java b/main-service/src/main/java/ru/practicum/explorewithme/main/models/Category.java new file mode 100644 index 0000000..4799b6f --- /dev/null +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/models/Category.java @@ -0,0 +1,30 @@ +package ru.practicum.explorewithme.main.models; + +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 = 64, unique = true) + private String name; + +} diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/models/Compilation.java b/main-service/src/main/java/ru/practicum/explorewithme/main/models/Compilation.java new file mode 100644 index 0000000..109ea63 --- /dev/null +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/models/Compilation.java @@ -0,0 +1,49 @@ +package ru.practicum.explorewithme.main.models; + +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 = false; + + /** + * Название подборки. + */ + @Column(name = "title", nullable = false, unique = true, length = 128) + private String title; + + /** + * События, входящие в подборку. + */ + @ManyToMany(fetch = FetchType.LAZY) + @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/models/Event.java b/main-service/src/main/java/ru/practicum/explorewithme/main/models/Event.java new file mode 100644 index 0000000..284a4f6 --- /dev/null +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/models/Event.java @@ -0,0 +1,116 @@ +package ru.practicum.explorewithme.main.models; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.Set; + +@Entity +@Table(name = "events") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@ToString(exclude = {"category", "initiator", "location", "compilations"}) +@EqualsAndHashCode(of = {"id", "title", "annotation", "eventDate", "publishedOn"}) +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; + + /** + * Дата и время создания события + */ + @Column(name = "created_on", nullable = false) + private LocalDateTime createdOn = LocalDateTime.now(); + + /** + * Дата и время публикации события + */ + @Column(name = "published_on") + private LocalDateTime publishedOn; + + /** + * Флаг платного участия + */ + @Column(name = "paid", nullable = false) + private boolean paid = false; + + /** + * Лимит участников события (0 - без ограничений) + */ + @Column(name = "participant_limit", nullable = false) + private int participantLimit = 0; + + /** + * Требуется ли модерация заявок на участие + */ + @Column(name = "request_moderation", nullable = false) + private boolean requestModeration = true; + + /** + * Заголовок события + */ + @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; + + /** + * Местоположение события + */ + @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL) + @JoinColumn(name = "location_id", nullable = false) + private Location location; + + /** + * Текущее состояние события + */ + @Enumerated(EnumType.STRING) + @Column(name = "state", nullable = false, length = 20) + private EventState state; + + /** + * Список подборок, в которых присутствует событие (создано для корректной обратной выборки) + */ + @ManyToMany(mappedBy = "events") + private Set compilations = new HashSet<>(); + +} diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/models/EventState.java b/main-service/src/main/java/ru/practicum/explorewithme/main/models/EventState.java new file mode 100644 index 0000000..a01f4a4 --- /dev/null +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/models/EventState.java @@ -0,0 +1,22 @@ +package ru.practicum.explorewithme.main.models; + +/** + * Состояния жизненного цикла события + */ +public enum EventState { + /** + * Ожидает модерации + */ + PENDING, + + /** + * Опубликовано + */ + PUBLISHED, + + /** + * Отменено + */ + CANCELED +} + diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/models/Location.java b/main-service/src/main/java/ru/practicum/explorewithme/main/models/Location.java new file mode 100644 index 0000000..d118617 --- /dev/null +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/models/Location.java @@ -0,0 +1,42 @@ +package ru.practicum.explorewithme.main.models; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "locations") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@ToString +@EqualsAndHashCode(of = {"id", "title", "lat", "lon"}) +public class Location { + + /** + * Уникальный идентификатор локации. + */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** + * Уникальное название локации. + */ + @Column(name = "title", nullable = false, length = 128, unique = true) + private String title; + + /** + * Широта географической точки. + */ + @Column(name = "lat") + private Float lat; + + /** + * Долгота географической точки. + */ + @Column(name = "lon") + private Float lon; + +} diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/models/ParticipationRequest.java b/main-service/src/main/java/ru/practicum/explorewithme/main/models/ParticipationRequest.java new file mode 100644 index 0000000..ab26465 --- /dev/null +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/models/ParticipationRequest.java @@ -0,0 +1,52 @@ +package ru.practicum.explorewithme.main.models; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@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"}) +public class ParticipationRequest { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** + * Дата и время создания запроса + */ + @Column(name = "created", nullable = false) + private LocalDateTime created = LocalDateTime.now(); + + /** + * Пользователь, создавший запрос на участие + */ + @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/models/RequestStatus.java b/main-service/src/main/java/ru/practicum/explorewithme/main/models/RequestStatus.java new file mode 100644 index 0000000..9877ba8 --- /dev/null +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/models/RequestStatus.java @@ -0,0 +1,27 @@ +package ru.practicum.explorewithme.main.models; + +/** + * Статусы запросов на участие в событии + */ +public enum RequestStatus { + /** + * Ожидает подтверждения + */ + PENDING, + + /** + * Подтвержден + */ + CONFIRMED, + + /** + * Отклонен + */ + REJECTED, + + /** + * Отменен + */ + CANCELED +} + diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/models/User.java b/main-service/src/main/java/ru/practicum/explorewithme/main/models/User.java new file mode 100644 index 0000000..46fa914 --- /dev/null +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/models/User.java @@ -0,0 +1,36 @@ +package ru.practicum.explorewithme.main.models; + +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; + +} From 19bee9e5f82099819bec9b06b94c3622737fe431 Mon Sep 17 00:00:00 2001 From: Sergey Filippovskikh <116564864+SergikF@users.noreply.github.com> Date: Thu, 15 May 2025 23:59:55 +0300 Subject: [PATCH 37/73] =?UTF-8?q?=D0=98=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=B4=D0=BB=D1=8F=20=D1=80=D0=B0=D0=B1?= =?UTF-8?q?=D0=BE=D1=82=D1=8B=20=D1=81=20location=20(#62)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Создание сущностей User Category Location * Создание сущности Compilation * Создание сущности Event * Создание сущности Request * Внесение исправлений * Исправление замечаний * Изменения с location. * Изменения с location. --- .../explorewithme/main/models/Event.java | 5 ++-- .../explorewithme/main/models/Location.java | 23 ++++--------------- 2 files changed, 7 insertions(+), 21 deletions(-) diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/models/Event.java b/main-service/src/main/java/ru/practicum/explorewithme/main/models/Event.java index 284a4f6..132f952 100644 --- a/main-service/src/main/java/ru/practicum/explorewithme/main/models/Event.java +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/models/Event.java @@ -14,7 +14,7 @@ @NoArgsConstructor @AllArgsConstructor @Builder -@ToString(exclude = {"category", "initiator", "location", "compilations"}) +@ToString(exclude = {"category", "initiator", "compilations"}) @EqualsAndHashCode(of = {"id", "title", "annotation", "eventDate", "publishedOn"}) public class Event { @@ -96,8 +96,7 @@ public class Event { /** * Местоположение события */ - @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL) - @JoinColumn(name = "location_id", nullable = false) + @Embedded private Location location; /** diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/models/Location.java b/main-service/src/main/java/ru/practicum/explorewithme/main/models/Location.java index d118617..937b850 100644 --- a/main-service/src/main/java/ru/practicum/explorewithme/main/models/Location.java +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/models/Location.java @@ -3,40 +3,27 @@ import jakarta.persistence.*; import lombok.*; -@Entity -@Table(name = "locations") +@Embeddable @Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder @ToString -@EqualsAndHashCode(of = {"id", "title", "lat", "lon"}) +@EqualsAndHashCode public class Location { - /** - * Уникальный идентификатор локации. - */ - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - /** - * Уникальное название локации. - */ - @Column(name = "title", nullable = false, length = 128, unique = true) - private String title; - /** * Широта географической точки. */ - @Column(name = "lat") + @Column(name = "lat", nullable = false) private Float lat; /** * Долгота географической точки. */ - @Column(name = "lon") + @Column(name = "lon", nullable = false) private Float lon; } + From 49944849c67f1649bb5072b31742e23abaf14a96 Mon Sep 17 00:00:00 2001 From: impatient0 Date: Fri, 16 May 2025 22:55:24 +0300 Subject: [PATCH 38/73] =?UTF-8?q?CORE:=20=D0=91=D0=B0=D0=B7=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D1=8F=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B9=D0=BA?= =?UTF-8?q?=D0=B0=20main-service=20=D0=B8=20=D0=B3=D0=BB=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B1?= =?UTF-8?q?=D0=BE=D1=82=D1=87=D0=B8=D0=BA=20=D0=B8=D1=81=D0=BA=D0=BB=D1=8E?= =?UTF-8?q?=D1=87=D0=B5=D0=BD=D0=B8=D0=B9=20(#64)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add validation dependency * create global exception handler * add schema.sql for the main service * cleanup * update README.md * add local config for main service --------- Co-authored-by: Pepe Ronin --- .run/main-local.run.xml | 12 +++ README.md | 87 +++++++++++---- main-service/pom.xml | 4 + .../main/error/GlobalExceptionHandler.java | 102 ++++++++++++++++++ main-service/src/main/resources/schema.sql | 62 +++++++++++ 5 files changed, 248 insertions(+), 19 deletions(-) create mode 100644 .run/main-local.run.xml create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/error/GlobalExceptionHandler.java diff --git a/.run/main-local.run.xml b/.run/main-local.run.xml new file mode 100644 index 0000000..93a802a --- /dev/null +++ b/.run/main-local.run.xml @@ -0,0 +1,12 @@ + + + + \ No newline at end of file diff --git a/README.md b/README.md index c3b65cb..980a2bd 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ - [Сборка проекта](#сборка-проекта) - [Запуск с использованием Docker Compose](#запуск-с-использованием-docker-compose) - [Локальный запуск для разработки (IntelliJ IDEA)](#локальный-запуск-для-разработки-intellij-idea) + - [Локальный запуск Stats Service](#локальный-запуск-stats-service) + - [Локальный запуск Main Service](#локальный-запуск-main-service) - [Тестирование](#тестирование) - [Дополнительная функциональность](#дополнительная-функциональность) - [Планы по использованию OpenAPI Generator](#планы-по-использованию-openapi-generator) @@ -19,10 +21,10 @@ ## Технологии - Java 21 -- Spring Boot 3.4.5 +- Spring Boot 3.4.5 # Убедитесь, что версия актуальна (в вашем корневом pom.xml указана эта версия) - Spring Data JPA - Spring MVC -- PostgreSQL +- PostgreSQL 16.1 # Можно уточнить версию PostgreSQL - Maven - Docker / Docker Compose - Lombok @@ -35,12 +37,13 @@ Проект является многомодульным Maven-проектом и состоит из следующих основных частей: - `explore-with-me` (корневой POM) - - `main-service`: Основной сервис приложения. + - `ewm-common`: Общий модуль, содержащий классы, используемые как основным сервисом, так и сервисом статистики (например, `ApiError.java`). + - `main-service`: Основной сервис приложения. Отвечает за бизнес-логику, управление пользователями, событиями, категориями, подборками и запросами на участие. - `Dockerfile` - `stats-service` (родительский POM для модулей статистики) - `stats-dto`: Data Transfer Objects (DTO) для сервиса статистики. - - `stats-client`: HTTP-клиент для взаимодействия с сервисом статистики. - - `stats-server`: Сервис статистики (сбор и предоставление данных о запросах). + - `stats-client`: HTTP-клиент для взаимодействия с сервисом статистики (используется `main-service`). + - `stats-server`: Сервис статистики (сбор и предоставление данных о запросах к эндпоинтам). - `Dockerfile` ## Начало работы @@ -65,9 +68,9 @@ mvn clean install ### Запуск с использованием Docker Compose -Наиболее предпочтительный способ запуска всего приложения (или его частей) – использование Docker Compose. +Наиболее предпочтительный способ запуска всего приложения – использование Docker Compose. Это обеспечит запуск всех сервисов (`main-service`, `stats-server`) и их соответствующих баз данных PostgreSQL в изолированных контейнерах. -1. **Соберите проект:** +1. **Соберите проект (если не делали ранее):** ```bash mvn clean install ``` @@ -76,12 +79,19 @@ mvn clean install ```bash docker-compose up --build -d ``` - Эта команда соберет Docker-образы для `stats-server` и `main-service` (если раскомментирован в `docker-compose.yml`) и запустит их вместе с необходимыми базами данных PostgreSQL. - + Ключ `-d` запускает контейнеры в фоновом режиме. + Эта команда соберет Docker-образы для `stats-server` и `main-service` и запустит их вместе с необходимыми базами данных PostgreSQL. - Сервис статистики (`stats-server`) будет доступен по адресу: `http://localhost:9090` - - Основной сервис (`main-service`) будет доступен по адресу: `http://localhost:8080` (когда будет реализован и раскомментирован) + - Основной сервис (`main-service`) будет доступен по адресу: `http://localhost:8080` -3. **Остановка сервисов:** +3. **Просмотр логов (при запуске с `-d`):** + ```bash + docker-compose logs -f main-service + docker-compose logs -f stats-server + # или docker-compose logs -f для всех сервисов + ``` + +4. **Остановка сервисов:** ```bash docker-compose down ``` @@ -92,31 +102,70 @@ mvn clean install ### Локальный запуск для разработки (IntelliJ IDEA) -Для удобства разработки и отладки, особенно сервиса статистики (`stats-server`), предусмотрен профиль запуска `stat-local` в IntelliJ IDEA. +Для удобства разработки и отладки можно запускать сервисы локально из IntelliJ IDEA. + +#### Локальный запуск Stats Service -1. **Настройка базы данных:** +Предусмотрен профиль запуска `stat-local` в IntelliJ IDEA для `stats-server`. + +1. **Настройка базы данных для `stats-server`:** Убедитесь, что у вас локально запущен экземпляр PostgreSQL, доступный по адресу, указанному в `stats-service/stats-server/src/main/resources/application-local.yml`. Примерные параметры для `application-local.yml`: ```yaml spring: datasource: - url: jdbc:postgresql://localhost:6543/ewm_stats_db # Убедитесь, что порт и имя БД соответствуют вашей локальной PG + url: jdbc:postgresql://localhost:6543/ewm_stats_db # Убедитесь, что порт и имя БД соответствуют вашей локальной PG для stats-db username: stats_user # Ваш пользователь password: stats_password # Ваш пароль jpa: hibernate: ddl-auto: update # или create-drop для локальной разработки + # ... другие настройки, если нужны ... ``` *Примечание: Вам может потребоваться создать базу данных `ewm_stats_db` и пользователя `stats_user` вручную, если они еще не существуют.* 2. **Запуск `StatsServerApplication`:** - Откройте проект в IntelliJ IDEA. - Найдите класс `StatsServerApplication.java` в модуле `stats-server`. - - В репозитории должна быть предустановленная Run Configuration "stat-local". Если нет, создайте новую конфигурацию Spring Boot: + - В репозитории должна быть предустановленная Run Configuration "stat-local" (проверьте `.idea/runConfigurations/`). Если нет, создайте новую конфигурацию Spring Boot: - **Main class:** `ru.practicum.explorewithme.stats.server.StatsServerApplication` - - **VM options:** `-Dspring.profiles.active=local` (это активирует `application-local.yml`) - - **Working directory:** Установите корневую директорию модуля `stats-server`. - - Запустите эту конфигурацию. Сервис статистики должен запуститься и подключиться к вашей локальной базе данных. + - **VM options:** `-Dspring.profiles.active=local` (активирует `application-local.yml`) + - **Working directory:** Корневая директория модуля `stats-server`. + - Запустите эту конфигурацию. + +#### Локальный запуск Main Service + +Аналогично можно настроить локальный запуск для `main-service`. + +1. **Настройка базы данных для `main-service`:** + Убедитесь, что у вас локально запущен экземпляр PostgreSQL, доступный по адресу, указанному в `main-service/src/main/resources/application-local.yml`. + Создайте файл `application-local.yml` в `main-service/src/main/resources/` (если его еще нет) с примерным содержанием: + ```yaml + # URL сервиса статистики для локального запуска main-service, + # если stats-server тоже запущен локально на порту 9090 + stats-server: + url: http://localhost:9090 + + spring: + datasource: + url: jdbc:postgresql://localhost:5432/ewm_main_db # Убедитесь, что порт и имя БД соответствуют вашей локальной PG для ewm-db + username: ewm_user # Ваш пользователь + password: ewm_password # Ваш пароль + jpa: + hibernate: + ddl-auto: update # или create-drop для локальной разработки + # ... другие настройки, если нужны ... + ``` + *Примечание: Вам может потребоваться создать базу данных `ewm_main_db` и пользователя `ewm_user` вручную, если они еще не существуют.* + *Также убедитесь, что сервис статистики (`stats-server`) запущен (локально или в Docker), если `main-service` будет к нему обращаться.* + +2. **Запуск `MainServiceApplication`:** + - Найдите класс `MainServiceApplication.java` в модуле `main-service`. + - В репозитории должна быть предустановленная Run Configuration "main-local" (проверьте `.idea/runConfigurations/`). Если нет, создайте новую конфигурацию Spring Boot: + - **Main class:** `ru.practicum.explorewithme.main.MainServiceApplication` + - **VM options:** `-Dspring.profiles.active=local` (активирует `application-local.yml`) + - **Working directory:** Корневая директория модуля `main-service`. + - Запустите эту конфигурацию. ## Тестирование @@ -141,7 +190,7 @@ mvn test ## Планы по использованию OpenAPI Generator -Команда планировала исследовать `openapi-generator-maven-plugin` для автоматической генерации DTO и, возможно, интерфейсов контроллеров на основе OpenAPI спецификаций. Однако, в связи с необходимостью сосредоточиться на основной функциональности первого этапа и отсутствием у команды предварительного опыта работы с данным инструментом, активное внедрение и использование генератора **отложено на более поздний срок**. На текущем этапе DTO создаются вручную. +Команда планировала исследовать `openapi-generator-maven-plugin` для автоматической генерации DTO и, возможно, интерфейсов контроллеров на основе OpenAPI спецификаций. По результатам исследования ([ссылка на документ Леры или краткое резюме, если есть]) было принято решение на текущем этапе **отказаться от автоматической генерации DTO** в пользу ручного создания. Это связано с лучшим контролем над кодом, интеграцией с Lombok и Jackson, а также более точной настройкой валидации, что на данном этапе более эффективно для команды. Вопрос может быть пересмотрен в будущем при значительном увеличении количества DTO или частоты изменения API. ## Команда diff --git a/main-service/pom.xml b/main-service/pom.xml index 1b8a534..4473db9 100644 --- a/main-service/pom.xml +++ b/main-service/pom.xml @@ -44,6 +44,10 @@ lombok provided + + org.springframework.boot + spring-boot-starter-validation + 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..cd1c665 --- /dev/null +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/error/GlobalExceptionHandler.java @@ -0,0 +1,102 @@ +package ru.practicum.explorewithme.main.error; + +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 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(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/main-service/src/main/resources/schema.sql b/main-service/src/main/resources/schema.sql index e69de29..9ab7a5e 100644 --- a/main-service/src/main/resources/schema.sql +++ b/main-service/src/main/resources/schema.sql @@ -0,0 +1,62 @@ +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, + participant_limit INTEGER NOT NULL, + published_on TIMESTAMP WITHOUT TIME ZONE, + request_moderation BOOLEAN NOT NULL, + state VARCHAR(20) NOT NULL, + title VARCHAR(120) NOT NULL, + 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 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, + 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 From 011c0141c6daff86d75410d4863d1bad3cebaf6b Mon Sep 17 00:00:00 2001 From: GrimJak Date: Sat, 17 May 2025 13:41:05 +0300 Subject: [PATCH 39/73] StatsControllerTest --- .../stats/server/controller/StatsControllerTest.java | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) 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 index 5b73aa9..116bae5 100644 --- 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 @@ -42,6 +42,7 @@ class StatsControllerTest { private EndpointHitDto validHitDto; private LocalDateTime now; private ObjectMapper objectMapper; + private DateTimeFormatter dateTimeFormatter; @BeforeEach void setUp() { @@ -58,6 +59,7 @@ void setUp() { .ip("127.0.0.1") .timestamp(now.minusHours(1)) .build(); + dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); } @Test @@ -75,7 +77,7 @@ void saveHit_whenDtoIsValid_shouldReturnCreated() throws Exception { @Test void saveHitShouldReturn400WhenAppIsBlank() throws Exception { - validHitDto.setIp(""); + validHitDto.setApp(""); mvc.perform(post("/hit") .contentType(MediaType.APPLICATION_JSON) @@ -132,8 +134,8 @@ void getStats_whenParamsAreValid_shouldReturn200Ok() throws Exception { .thenReturn(List.of(new ViewStatsDto("test-app", "/test-uri", 10L))); mvc.perform(get("/stats") - .param("start", start.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))) - .param("end", end.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))) + .param("start", start.format(dateTimeFormatter)) + .param("end", end.format(dateTimeFormatter)) .param("uris", "/test-uri") .param("unique", String.valueOf(unique))) .andExpect(status().isOk()) @@ -154,8 +156,8 @@ void getStats_whenNoUris_shouldReturn200Ok() throws Exception { when(statsService.getStats(eq(start), eq(end), isNull(), eq(unique))).thenReturn(statsList); mvc.perform(get("/stats") - .param("start", start.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))) - .param("end", end.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))) + .param("start", start.format(dateTimeFormatter)) + .param("end", end.format(dateTimeFormatter)) .param("unique", String.valueOf(unique))) .andExpect(status().isOk()) .andExpect(content().json(objectMapper.writeValueAsString(statsList))); From 27ac31a89c1dc498420b8c89ec514416b04f3b80 Mon Sep 17 00:00:00 2001 From: impatient0 Date: Sat, 17 May 2025 20:54:56 +0300 Subject: [PATCH 40/73] =?UTF-8?q?Admin=20API:=20=D0=A0=D0=B5=D0=B0=D0=BB?= =?UTF-8?q?=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BF=D0=BE=D0=B8=D1=81?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B8=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D0=B8=20=D1=81=D0=BE=D0=B1=D1=8B=D1=82=D0=B8?= =?UTF-8?q?=D0=B9=20(#65)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * set up dependencies for QueryDSL * create a basis for EventRepository * stub UserShortDto and CategoryDto * move Maven compiler plugin config to root pom * create EventFullDto and EventMapper * initial EventService implementation * separate all mappers * create tests for event mapper * create tests for event service implementation * create admin event controller * expand GlobalExceptionHandler * create unit tests for AdminEventController * improve empty predicate handling in the service * create EventRepository tests * create an additional test case for EventServiceImpl * refactor for getEventsAdmin() to use param wrapper * rename "models" to "model" --------- Co-authored-by: Pepe Ronin --- main-service/pom.xml | 33 +++ .../admin/AdminEventController.java | 77 +++++ .../explorewithme/main/dto/CategoryDto.java | 17 ++ .../explorewithme/main/dto/EventFullDto.java | 34 +++ .../explorewithme/main/dto/UserShortDto.java | 17 ++ .../main/error/GlobalExceptionHandler.java | 61 ++++ .../main/mapper/CategoryMapper.java | 10 + .../main/mapper/EventMapper.java | 19 ++ .../explorewithme/main/mapper/UserMapper.java | 10 + .../main/{models => model}/Category.java | 2 +- .../main/{models => model}/Compilation.java | 2 +- .../main/{models => model}/Event.java | 2 +- .../main/{models => model}/EventState.java | 2 +- .../main/{models => model}/Location.java | 2 +- .../ParticipationRequest.java | 2 +- .../main/{models => model}/RequestStatus.java | 2 +- .../main/{models => model}/User.java | 2 +- .../main/repository/EventRepository.java | 9 + .../main/service/EventService.java | 13 + .../main/service/EventServiceImpl.java | 95 ++++++ .../params/AdminEventSearchParams.java | 19 ++ .../admin/AdminEventControllerTest.java | 181 ++++++++++++ .../main/mapper/EventMapperTest.java | 206 +++++++++++++ .../main/repository/EventRepositoryTest.java | 253 ++++++++++++++++ .../main/service/EventServiceImplTest.java | 279 ++++++++++++++++++ pom.xml | 88 +++++- stats-service/stats-server/pom.xml | 13 + 27 files changed, 1431 insertions(+), 19 deletions(-) create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/controller/admin/AdminEventController.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/dto/CategoryDto.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/dto/EventFullDto.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/dto/UserShortDto.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/mapper/CategoryMapper.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/mapper/EventMapper.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/mapper/UserMapper.java rename main-service/src/main/java/ru/practicum/explorewithme/main/{models => model}/Category.java (92%) rename main-service/src/main/java/ru/practicum/explorewithme/main/{models => model}/Compilation.java (96%) rename main-service/src/main/java/ru/practicum/explorewithme/main/{models => model}/Event.java (98%) rename main-service/src/main/java/ru/practicum/explorewithme/main/{models => model}/EventState.java (85%) rename main-service/src/main/java/ru/practicum/explorewithme/main/{models => model}/Location.java (90%) rename main-service/src/main/java/ru/practicum/explorewithme/main/{models => model}/ParticipationRequest.java (96%) rename main-service/src/main/java/ru/practicum/explorewithme/main/{models => model}/RequestStatus.java (88%) rename main-service/src/main/java/ru/practicum/explorewithme/main/{models => model}/User.java (94%) create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/repository/EventRepository.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/service/EventService.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/service/EventServiceImpl.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/service/params/AdminEventSearchParams.java create mode 100644 main-service/src/test/java/ru/practicum/explorewithme/main/controller/admin/AdminEventControllerTest.java create mode 100644 main-service/src/test/java/ru/practicum/explorewithme/main/mapper/EventMapperTest.java create mode 100644 main-service/src/test/java/ru/practicum/explorewithme/main/repository/EventRepositoryTest.java create mode 100644 main-service/src/test/java/ru/practicum/explorewithme/main/service/EventServiceImplTest.java diff --git a/main-service/pom.xml b/main-service/pom.xml index 4473db9..eca8a19 100644 --- a/main-service/pom.xml +++ b/main-service/pom.xml @@ -44,14 +44,47 @@ lombok provided + + com.querydsl + querydsl-apt + + + com.querydsl + querydsl-jpa + jakarta + org.springframework.boot spring-boot-starter-validation + + org.springframework.boot + spring-boot-starter-test + + + org.mapstruct + mapstruct + + + org.testcontainers + testcontainers + + + org.testcontainers + junit-jupiter + + + org.testcontainers + postgresql + + + org.apache.maven.plugins + maven-compiler-plugin + org.springframework.boot spring-boot-maven-plugin 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..5f85bc6 --- /dev/null +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/controller/admin/AdminEventController.java @@ -0,0 +1,77 @@ +package ru.practicum.explorewithme.main.controller.admin; + +import static ru.practicum.explorewithme.common.constants.DateTimeConstants.DATE_TIME_FORMAT_PATTERN; + +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.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; + } + + // TODO: Добавить PATCH /admin/events/{eventId} для модерации событий + // (Задача: ADMIN-EVENTS: Реализация модерации событий (публикация/отклонение)) + +} \ 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..eeb0247 --- /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.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +// TODO: полноценная реализация CategoryDto + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CategoryDto { + private Long id; + private String name; +} \ No newline at end of file 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..1e75cc9 --- /dev/null +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/EventFullDto.java @@ -0,0 +1,34 @@ +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.Builder; +import lombok.Data; +import ru.practicum.explorewithme.main.model.EventState; +import ru.practicum.explorewithme.main.model.Location; + +@Data +@Builder +public class EventFullDto { + private Long id; + private String annotation; + private CategoryDto category; + private Long confirmedRequests; + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = DATE_TIME_FORMAT_PATTERN) + private LocalDateTime createdOn; + private String description; + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = DATE_TIME_FORMAT_PATTERN) + private LocalDateTime eventDate; + private UserShortDto initiator; + private Location location; + private boolean paid; + private int participantLimit; + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = DATE_TIME_FORMAT_PATTERN) + private LocalDateTime publishedOn; + private boolean requestModeration; + private EventState state; + private String title; + private Long views; +} \ 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..a2d728a --- /dev/null +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/UserShortDto.java @@ -0,0 +1,17 @@ +package ru.practicum.explorewithme.main.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +// TODO: полноценная реализация UserShortDto + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UserShortDto { + private Long id; + private String name; +} \ 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 index cd1c665..2d9e525 100644 --- 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 @@ -1,5 +1,7 @@ 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; @@ -12,6 +14,7 @@ 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 @@ -87,6 +90,56 @@ public ApiError handleHttpMessageNotReadableException(final HttpMessageNotReadab .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(Throwable.class) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) public ApiError handleThrowable(final Throwable e) { @@ -98,5 +151,13 @@ public ApiError handleThrowable(final Throwable e) { .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..c323b4c --- /dev/null +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/mapper/CategoryMapper.java @@ -0,0 +1,10 @@ +package ru.practicum.explorewithme.main.mapper; + +import org.mapstruct.Mapper; +import ru.practicum.explorewithme.main.dto.CategoryDto; +import ru.practicum.explorewithme.main.model.Category; + +@Mapper(componentModel = "spring") +public interface CategoryMapper { + CategoryDto toDto(Category category); +} \ 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..2894939 --- /dev/null +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/mapper/EventMapper.java @@ -0,0 +1,19 @@ +package ru.practicum.explorewithme.main.mapper; // Пример пакета + +import java.util.List; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import ru.practicum.explorewithme.main.dto.EventFullDto; +import ru.practicum.explorewithme.main.model.Event; + +@Mapper(componentModel = "spring", uses = {CategoryMapper.class, UserMapper.class}) +public interface EventMapper { + + @Mapping(source = "category", target = "category") + @Mapping(source = "initiator", target = "initiator") + @Mapping(target = "confirmedRequests", expression = "java(0L)") // Временная заглушка + @Mapping(target = "views", expression = "java(0L)") // Временная заглушка + EventFullDto toEventFullDto(Event event); + + List toEventFullDtoList(List events); +} 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..35e7a7a --- /dev/null +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/mapper/UserMapper.java @@ -0,0 +1,10 @@ +package ru.practicum.explorewithme.main.mapper; + +import org.mapstruct.Mapper; +import ru.practicum.explorewithme.main.dto.UserShortDto; +import ru.practicum.explorewithme.main.model.User; + +@Mapper(componentModel = "spring") +public interface UserMapper { + UserShortDto toShortDto(User user); +} diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/models/Category.java b/main-service/src/main/java/ru/practicum/explorewithme/main/model/Category.java similarity index 92% rename from main-service/src/main/java/ru/practicum/explorewithme/main/models/Category.java rename to main-service/src/main/java/ru/practicum/explorewithme/main/model/Category.java index 4799b6f..c3390b2 100644 --- a/main-service/src/main/java/ru/practicum/explorewithme/main/models/Category.java +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/model/Category.java @@ -1,4 +1,4 @@ -package ru.practicum.explorewithme.main.models; +package ru.practicum.explorewithme.main.model; import jakarta.persistence.*; import lombok.*; diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/models/Compilation.java b/main-service/src/main/java/ru/practicum/explorewithme/main/model/Compilation.java similarity index 96% rename from main-service/src/main/java/ru/practicum/explorewithme/main/models/Compilation.java rename to main-service/src/main/java/ru/practicum/explorewithme/main/model/Compilation.java index 109ea63..4c1c015 100644 --- a/main-service/src/main/java/ru/practicum/explorewithme/main/models/Compilation.java +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/model/Compilation.java @@ -1,4 +1,4 @@ -package ru.practicum.explorewithme.main.models; +package ru.practicum.explorewithme.main.model; import jakarta.persistence.*; import lombok.*; diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/models/Event.java b/main-service/src/main/java/ru/practicum/explorewithme/main/model/Event.java similarity index 98% rename from main-service/src/main/java/ru/practicum/explorewithme/main/models/Event.java rename to main-service/src/main/java/ru/practicum/explorewithme/main/model/Event.java index 132f952..35a370c 100644 --- a/main-service/src/main/java/ru/practicum/explorewithme/main/models/Event.java +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/model/Event.java @@ -1,4 +1,4 @@ -package ru.practicum.explorewithme.main.models; +package ru.practicum.explorewithme.main.model; import jakarta.persistence.*; import lombok.*; diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/models/EventState.java b/main-service/src/main/java/ru/practicum/explorewithme/main/model/EventState.java similarity index 85% rename from main-service/src/main/java/ru/practicum/explorewithme/main/models/EventState.java rename to main-service/src/main/java/ru/practicum/explorewithme/main/model/EventState.java index a01f4a4..fe1a3c6 100644 --- a/main-service/src/main/java/ru/practicum/explorewithme/main/models/EventState.java +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/model/EventState.java @@ -1,4 +1,4 @@ -package ru.practicum.explorewithme.main.models; +package ru.practicum.explorewithme.main.model; /** * Состояния жизненного цикла события diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/models/Location.java b/main-service/src/main/java/ru/practicum/explorewithme/main/model/Location.java similarity index 90% rename from main-service/src/main/java/ru/practicum/explorewithme/main/models/Location.java rename to main-service/src/main/java/ru/practicum/explorewithme/main/model/Location.java index 937b850..2354000 100644 --- a/main-service/src/main/java/ru/practicum/explorewithme/main/models/Location.java +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/model/Location.java @@ -1,4 +1,4 @@ -package ru.practicum.explorewithme.main.models; +package ru.practicum.explorewithme.main.model; import jakarta.persistence.*; import lombok.*; diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/models/ParticipationRequest.java b/main-service/src/main/java/ru/practicum/explorewithme/main/model/ParticipationRequest.java similarity index 96% rename from main-service/src/main/java/ru/practicum/explorewithme/main/models/ParticipationRequest.java rename to main-service/src/main/java/ru/practicum/explorewithme/main/model/ParticipationRequest.java index ab26465..18c659e 100644 --- a/main-service/src/main/java/ru/practicum/explorewithme/main/models/ParticipationRequest.java +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/model/ParticipationRequest.java @@ -1,4 +1,4 @@ -package ru.practicum.explorewithme.main.models; +package ru.practicum.explorewithme.main.model; import jakarta.persistence.*; import lombok.*; diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/models/RequestStatus.java b/main-service/src/main/java/ru/practicum/explorewithme/main/model/RequestStatus.java similarity index 88% rename from main-service/src/main/java/ru/practicum/explorewithme/main/models/RequestStatus.java rename to main-service/src/main/java/ru/practicum/explorewithme/main/model/RequestStatus.java index 9877ba8..e8b05b1 100644 --- a/main-service/src/main/java/ru/practicum/explorewithme/main/models/RequestStatus.java +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/model/RequestStatus.java @@ -1,4 +1,4 @@ -package ru.practicum.explorewithme.main.models; +package ru.practicum.explorewithme.main.model; /** * Статусы запросов на участие в событии diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/models/User.java b/main-service/src/main/java/ru/practicum/explorewithme/main/model/User.java similarity index 94% rename from main-service/src/main/java/ru/practicum/explorewithme/main/models/User.java rename to main-service/src/main/java/ru/practicum/explorewithme/main/model/User.java index 46fa914..3165df3 100644 --- a/main-service/src/main/java/ru/practicum/explorewithme/main/models/User.java +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/model/User.java @@ -1,4 +1,4 @@ -package ru.practicum.explorewithme.main.models; +package ru.practicum.explorewithme.main.model; import jakarta.persistence.*; import lombok.*; 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..2f8ecdf --- /dev/null +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/repository/EventRepository.java @@ -0,0 +1,9 @@ +package ru.practicum.explorewithme.main.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.querydsl.QuerydslPredicateExecutor; +import ru.practicum.explorewithme.main.model.Event; + +public interface EventRepository extends JpaRepository, QuerydslPredicateExecutor { + +} \ 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..683b5f8 --- /dev/null +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/service/EventService.java @@ -0,0 +1,13 @@ +package ru.practicum.explorewithme.main.service; + +import java.util.List; +import ru.practicum.explorewithme.main.dto.EventFullDto; +import ru.practicum.explorewithme.main.service.params.AdminEventSearchParams; + +public interface EventService { + List getEventsAdmin( + AdminEventSearchParams params, + int from, + int size + ); +} \ 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..6d0601f --- /dev/null +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/service/EventServiceImpl.java @@ -0,0 +1,95 @@ +package ru.practicum.explorewithme.main.service; + +import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.types.Predicate; +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.mapper.EventMapper; +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.repository.EventRepository; +// import ru.practicum.explorewithme.main.exception.NotFoundException; + +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; +import ru.practicum.explorewithme.main.service.params.AdminEventSearchParams; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Slf4j +public class EventServiceImpl implements EventService { + + private final EventRepository eventRepository; + private final EventMapper eventMapper; + // private final UserRepository userRepository; + // private final CategoryRepository categoryRepository; + + @Override + 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)) { + throw new IllegalArgumentException("Admin search: rangeStart cannot be after rangeEnd."); + } + + QEvent qEvent = QEvent.event; + BooleanBuilder predicate = new BooleanBuilder(); + + if (users != null && !users.isEmpty()) { + // TODO: Возможно, стоит проверить, существуют ли такие пользователи, если это требуется по логике + predicate.and(qEvent.initiator.id.in(users)); + } + + if (states != null && !states.isEmpty()) { + predicate.and(qEvent.state.in(states)); + } + + if (categories != null && !categories.isEmpty()) { + // TODO: Возможно, стоит проверить, существуют ли такие категории + predicate.and(qEvent.category.id.in(categories)); + } + + if (rangeStart != null) { + predicate.and(qEvent.eventDate.goe(rangeStart)); // greater or equal + } + + if (rangeEnd != null) { + predicate.and(qEvent.eventDate.loe(rangeEnd)); // lower or equal + } + + Predicate finalPredicate = predicate.getValue(); + + 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()); + log.debug("Admin search found {} events on page {}/{}", result.size(), pageable.getPageNumber(), eventPage.getTotalPages()); + return result; + } + +} \ 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/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..b2fcba9 --- /dev/null +++ b/main-service/src/test/java/ru/practicum/explorewithme/main/controller/admin/AdminEventControllerTest.java @@ -0,0 +1,181 @@ +package ru.practicum.explorewithme.main.controller.admin; + +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.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.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.model.EventState; +import ru.practicum.explorewithme.main.service.EventService; +import ru.practicum.explorewithme.main.service.params.AdminEventSearchParams; + +@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; + + @Test + @DisplayName("Поиск событий администратором: должен вернуть 200 OK и пустой список, если " + + "событий не найдено") + void searchEventsAdmin_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 searchEventsAdmin_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 searchEventsAdmin_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 searchEventsAdmin_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 searchEventsAdmin_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 searchEventsAdmin_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 searchEventsAdmin_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); + } +} \ 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..c605514 --- /dev/null +++ b/main-service/src/test/java/ru/practicum/explorewithme/main/mapper/EventMapperTest.java @@ -0,0 +1,206 @@ +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 static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +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.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import ru.practicum.explorewithme.main.dto.CategoryDto; +import ru.practicum.explorewithme.main.dto.EventFullDto; +import ru.practicum.explorewithme.main.dto.UserShortDto; +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; + +@ExtendWith(MockitoExtension.class) +@DisplayName("Тесты для EventMapper") +class EventMapperTest { + + @Mock + private CategoryMapper categoryMapper; + + @Mock + private UserMapper userMapper; + + @InjectMocks + private EventMapperImpl 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") + .build(); + + CategoryDto categoryDto = CategoryDto.builder().id(categoryModel.getId()).name(categoryModel.getName()).build(); + when(categoryMapper.toDto(any(Category.class))).thenReturn(categoryDto); + + UserShortDto userShortDto = UserShortDto.builder().id(initiatorModel.getId()).name(initiatorModel.getName()).build(); + when(userMapper.toShortDto(any(User.class))).thenReturn(userShortDto); + + 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(0L, dto.getConfirmedRequests()); + assertEquals(0L, 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(); + + when(categoryMapper.toDto(null)).thenReturn(null); + when(userMapper.toShortDto(null)).thenReturn(null); + + 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); + + CategoryDto categoryDto = CategoryDto.builder().id(categoryModel.getId()).name(categoryModel.getName()).build(); + UserShortDto userShortDto = UserShortDto.builder().id(initiatorModel.getId()).name(initiatorModel.getName()).build(); + + // Настраиваем моки для зависимых мапперов. + when(categoryMapper.toDto(categoryModel)).thenReturn(categoryDto); + when(userMapper.toShortDto(initiatorModel)).thenReturn(userShortDto); + + 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()); + } + } +} \ 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/EventServiceImplTest.java b/main-service/src/test/java/ru/practicum/explorewithme/main/service/EventServiceImplTest.java new file mode 100644 index 0000000..9814b6b --- /dev/null +++ b/main-service/src/test/java/ru/practicum/explorewithme/main/service/EventServiceImplTest.java @@ -0,0 +1,279 @@ +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.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.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.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 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.mapper.EventMapper; +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.repository.EventRepository; +import ru.practicum.explorewithme.main.service.params.AdminEventSearchParams; + +@ExtendWith(MockitoExtension.class) +@DisplayName("Тесты для реализации EventService") +class EventServiceImplTest { + + @Mock + private EventRepository eventRepository; + + @Mock + private EventMapper eventMapper; + + @InjectMocks + private EventServiceImpl eventService; + + @Captor + private ArgumentCaptor predicateCaptor; + + private LocalDateTime now; + private LocalDateTime plusOneHour; + private LocalDateTime plusTwoHours; + private QEvent qEvent; + + @BeforeEach + void setUp() { + now = LocalDateTime.now(); + plusOneHour = now.plusHours(1); + plusTwoHours = now.plusHours(2); + qEvent = QEvent.event; + } + + @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); + } + } + + // ... TODO: Добавить тесты для других методов EventService, когда они появятся ... +} \ No newline at end of file diff --git a/pom.xml b/pom.xml index 6a39f53..d0ad82f 100644 --- a/pom.xml +++ b/pom.xml @@ -16,14 +16,16 @@ pom + 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} - UTF-8 Explore With Me @@ -77,21 +79,11 @@ 1.18.38 provided - - javax.annotation - javax.annotation-api - 1.3.2 - com.fasterxml.jackson.datatype jackson-datatype-jsr310 2.19.0 - - javax.validation - validation-api - 2.0.1.Final - org.jetbrains annotations @@ -125,12 +117,86 @@ 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 diff --git a/stats-service/stats-server/pom.xml b/stats-service/stats-server/pom.xml index 29ad796..278694f 100644 --- a/stats-service/stats-server/pom.xml +++ b/stats-service/stats-server/pom.xml @@ -70,6 +70,19 @@ + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + ${lombok.version} + + + + org.springframework.boot spring-boot-maven-plugin From 9de3a39fde78dd5d5b27c232befdfd46829e4855 Mon Sep 17 00:00:00 2001 From: Sergey Filippovskikh <116564864+SergikF@users.noreply.github.com> Date: Sun, 18 May 2025 14:17:45 +0300 Subject: [PATCH 41/73] =?UTF-8?q?=D0=A1=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=BD=D0=B5=D0=BE=D0=B1=D1=85=D0=BE=D0=B4=D0=B8?= =?UTF-8?q?=D0=BC=D0=BE=D0=B3=D0=BE=20=D0=B4=D0=BB=D1=8F=20=D0=B2=D0=B5?= =?UTF-8?q?=D1=82=D0=BA=D0=B8=20User=20(#66)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Создание необходимого для ветки User Dto классы, маперы, контроллер, сервис, репозиторий, тесты. --- .../controller/admin/AdminUserController.java | 62 ++++ .../main/dto/NewUserRequestDto.java | 25 ++ .../explorewithme/main/dto/UserDto.java | 16 + .../explorewithme/main/dto/UserShortDto.java | 2 - .../error/EntityAlreadyExistsException.java | 12 + .../main/error/EntityNotFoundException.java | 12 + .../main/error/GlobalExceptionHandler.java | 24 ++ .../explorewithme/main/mapper/UserMapper.java | 10 + .../explorewithme/main/model/Compilation.java | 2 + .../explorewithme/main/model/Event.java | 5 + .../main/model/ParticipationRequest.java | 1 + .../main/repository/UserRepository.java | 17 + .../main/service/UserService.java | 17 + .../main/service/UserServiceImpl.java | 74 ++++ .../params/GetListUsersParameters.java | 15 + .../src/main/resources/application-local.yaml | 4 +- .../admin/AdminUserControllerTest.java | 265 +++++++++++++++ .../main/mapper/UserMapperTest.java | 208 ++++++++++++ .../service/UserServiceIntegrationTest.java | 320 ++++++++++++++++++ 19 files changed, 1087 insertions(+), 4 deletions(-) create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/controller/admin/AdminUserController.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/dto/NewUserRequestDto.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/dto/UserDto.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/error/EntityAlreadyExistsException.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/error/EntityNotFoundException.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/repository/UserRepository.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/service/UserService.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/service/UserServiceImpl.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/service/params/GetListUsersParameters.java create mode 100644 main-service/src/test/java/ru/practicum/explorewithme/main/controller/admin/AdminUserControllerTest.java create mode 100644 main-service/src/test/java/ru/practicum/explorewithme/main/mapper/UserMapperTest.java create mode 100644 main-service/src/test/java/ru/practicum/explorewithme/main/service/UserServiceIntegrationTest.java 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/dto/NewUserRequestDto.java b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/NewUserRequestDto.java new file mode 100644 index 0000000..9033b3d --- /dev/null +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/NewUserRequestDto.java @@ -0,0 +1,25 @@ +package ru.practicum.explorewithme.main.dto; + +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class NewUserRequestDto { + @NotBlank(message = "Имя не может быть пустым") + @Size(min = 2, max = 250, message = "Имя должно быть от 2 до 250 символов") + private String name; + + @NotBlank(message = "Email не может быть пустым") + @Size(min = 6, max = 254, message = "Email должен быть от 6 до 254 символов") + @Email(message = "Некорректный формат email") + private String email; +} 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..3c9b76f --- /dev/null +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/UserDto.java @@ -0,0 +1,16 @@ +package ru.practicum.explorewithme.main.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UserDto { + private Long id; + private String name; + private 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 index a2d728a..fe63d57 100644 --- 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 @@ -5,8 +5,6 @@ import lombok.Data; import lombok.NoArgsConstructor; -// TODO: полноценная реализация UserShortDto - @Data @Builder @NoArgsConstructor 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/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 index 2d9e525..45f258e 100644 --- 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 @@ -140,6 +140,30 @@ public ApiError handleMethodArgumentTypeMismatchException(final MethodArgumentTy .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("Requested object already exists" + 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("Requested object not found" + e.getMessage()) + .timestamp(LocalDateTime.now()) + .build(); + } + @ExceptionHandler(Throwable.class) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) public ApiError handleThrowable(final Throwable e) { 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 index 35e7a7a..64242ef 100644 --- 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 @@ -1,10 +1,20 @@ 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); + } 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 index 4c1c015..0f76cc5 100644 --- 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 @@ -28,6 +28,7 @@ public class Compilation { * Флаг, закреплена ли подборка на главной странице. */ @Column(name = "pinned", nullable = false) + @Builder.Default private boolean pinned = false; /** @@ -45,5 +46,6 @@ public class Compilation { joinColumns = @JoinColumn(name = "compilation_id"), inverseJoinColumns = @JoinColumn(name = "event_id") ) + @Builder.Default 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 index 35a370c..6cd6cd6 100644 --- 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 @@ -47,6 +47,7 @@ public class Event { * Дата и время создания события */ @Column(name = "created_on", nullable = false) + @Builder.Default private LocalDateTime createdOn = LocalDateTime.now(); /** @@ -59,18 +60,21 @@ public class Event { * Флаг платного участия */ @Column(name = "paid", nullable = false) + @Builder.Default private boolean paid = false; /** * Лимит участников события (0 - без ограничений) */ @Column(name = "participant_limit", nullable = false) + @Builder.Default private int participantLimit = 0; /** * Требуется ли модерация заявок на участие */ @Column(name = "request_moderation", nullable = false) + @Builder.Default private boolean requestModeration = true; /** @@ -110,6 +114,7 @@ public class Event { * Список подборок, в которых присутствует событие (создано для корректной обратной выборки) */ @ManyToMany(mappedBy = "events") + @Builder.Default private Set compilations = new HashSet<>(); } 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 index 18c659e..57644ab 100644 --- 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 @@ -26,6 +26,7 @@ public class ParticipationRequest { * Дата и время создания запроса */ @Column(name = "created", nullable = false) + @Builder.Default private LocalDateTime created = LocalDateTime.now(); /** 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..76ce710 --- /dev/null +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/repository/UserRepository.java @@ -0,0 +1,17 @@ +package ru.practicum.explorewithme.main.repository; + +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); + + List findAllByIdIn(List ids, Pageable pageable); + +} 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..8b9053a --- /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.isPresent()) { + 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/GetListUsersParameters.java b/main-service/src/main/java/ru/practicum/explorewithme/main/service/params/GetListUsersParameters.java new file mode 100644 index 0000000..beb61f7 --- /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; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class GetListUsersParameters { + List ids; + int from; + int size; +} diff --git a/main-service/src/main/resources/application-local.yaml b/main-service/src/main/resources/application-local.yaml index e6ae4d5..961a77d 100644 --- a/main-service/src/main/resources/application-local.yaml +++ b/main-service/src/main/resources/application-local.yaml @@ -4,5 +4,5 @@ stats-server: spring: datasource: url: jdbc:postgresql://localhost:5432/ewm_main_db - username: emw_user - password: emw_password \ No newline at end of file + username: ewm_user + password: ewm_password \ 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..567be11 --- /dev/null +++ b/main-service/src/test/java/ru/practicum/explorewithme/main/controller/admin/AdminUserControllerTest.java @@ -0,0 +1,265 @@ +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(); + // Email и имя не заданы + + 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/mapper/UserMapperTest.java b/main-service/src/test/java/ru/practicum/explorewithme/main/mapper/UserMapperTest.java new file mode 100644 index 0000000..c89a2e6 --- /dev/null +++ b/main-service/src/test/java/ru/practicum/explorewithme/main/mapper/UserMapperTest.java @@ -0,0 +1,208 @@ + +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 { + + // Получаем фактическую реализацию маппера, сгенерированную MapStruct + 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()); + // Id не должен быть установлен маппером + assertThat(result.getId()).isNull(); + } + + @Test + @DisplayName("возвращать null при преобразовании null") + void toUser_ShouldReturnNullWhenNewUserRequestIsNull() { + // Действие + User result = userMapper.toUser(null); + + // Проверка + assertThat(result).isNull(); + } + } + + @Nested + @DisplayName("при сквозных тестах маппинга") + class IntegrationTests { + + @Test + @DisplayName("сохранять все поля при цепочке преобразований") + void mapper_ShouldPreserveAllFieldsInConversionChain() { + // Подготовка + NewUserRequestDto request = new NewUserRequestDto(); + request.setName("Тестовый пользователь"); + request.setEmail("test@example.com"); + + // Преобразование NewUserRequestDto -> User + User user = userMapper.toUser(request); + user.setId(1L); // устанавливаем id вручную, так как он не устанавливается маппером + + // Преобразование User -> UserDto + 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"); + + // Преобразование NewUserRequestDto -> User -> UserShortDto + 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()); + // Email не должен присутствовать в ShortDto + } + } + + @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/service/UserServiceIntegrationTest.java b/main-service/src/test/java/ru/practicum/explorewithme/main/service/UserServiceIntegrationTest.java new file mode 100644 index 0000000..b937481 --- /dev/null +++ b/main-service/src/test/java/ru/practicum/explorewithme/main/service/UserServiceIntegrationTest.java @@ -0,0 +1,320 @@ +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") + .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); + + // Проверка исключения при создании пользователя с тем же email + EntityAlreadyExistsException exception = assertThrows(EntityAlreadyExistsException.class, () -> { + userService.createUser(newUserRequestDto); + }); + + // Проверка сообщения об ошибке + assertTrue(exception.getMessage().contains(newUserRequestDto.getEmail())); + } + + @Test + @DisplayName("Транзакция откатывается при возникновении ошибки") + void transactionRollback_WhenExceptionOccurs() { + // Подготовка - создаем пользователя + UserDto user = userService.createUser(newUserRequestDto); + + // Действие - пытаемся создать пользователя с тем же email, что должно вызвать ошибку + try { + userService.createUser(newUserRequestDto); + } catch (EntityAlreadyExistsException ignored) { + // Ожидаемое исключение + } + + // Проверка - убеждаемся, что в базе только один пользователь + assertEquals(1, userRepository.count()); + } + } + + @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() { + // Подготовка - создаем 5 пользователей + 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()); + + // Запрашиваем первую страницу с размером 2 + GetListUsersParameters page1Params = new GetListUsersParameters(null, 0, 2); + List page1 = userService.getUsers(page1Params); + + // Запрашиваем вторую страницу с размером 2 + GetListUsersParameters page2Params = new GetListUsersParameters(null, 2, 2); + List page2 = userService.getUsers(page2Params); + + // Запрашиваем третью страницу с размером 2 + 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() { + // Создаем 100 пользователей + 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() { + // Подготовка - создаем 3 пользователей + 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 From 07ad710e62087daa103cf151781e2b4d1c81ab4c Mon Sep 17 00:00:00 2001 From: impatient0 Date: Sun, 18 May 2025 17:34:58 +0300 Subject: [PATCH 42/73] =?UTF-8?q?FIX:=20=D0=9A=D0=BE=D1=80=D1=80=D0=B5?= =?UTF-8?q?=D0=BA=D1=82=D0=BD=D0=B0=D1=8F=20=D0=BD=D0=B0=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=B9=D0=BA=D0=B0=20Spring-=D0=BA=D0=BE=D0=BD=D1=82?= =?UTF-8?q?=D0=B5=D0=BA=D1=81=D1=82=D0=B0=20=D0=B4=D0=BB=D1=8F=20=D1=8E?= =?UTF-8?q?=D0=BD=D0=B8=D1=82-=D1=82=D0=B5=D1=81=D1=82=D0=BE=D0=B2=20Event?= =?UTF-8?q?Mapper=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * create separate test config for mapper tests * refactor tests to pull mapper from context --------- Co-authored-by: Pepe Ronin --- main-service/pom.xml | 5 +++ .../resources/application-mapper_test.yaml | 12 ++++++ .../main/mapper/EventMapperTest.java | 37 ++++--------------- 3 files changed, 24 insertions(+), 30 deletions(-) create mode 100644 main-service/src/main/resources/application-mapper_test.yaml diff --git a/main-service/pom.xml b/main-service/pom.xml index eca8a19..14b3aff 100644 --- a/main-service/pom.xml +++ b/main-service/pom.xml @@ -77,6 +77,11 @@ org.testcontainers postgresql + + com.h2database + h2 + test + 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/test/java/ru/practicum/explorewithme/main/mapper/EventMapperTest.java b/main-service/src/test/java/ru/practicum/explorewithme/main/mapper/EventMapperTest.java index c605514..2e0cab7 100644 --- 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 @@ -4,8 +4,6 @@ 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 static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.when; import java.time.LocalDateTime; import java.util.Arrays; @@ -15,12 +13,11 @@ import org.junit.jupiter.api.Nested; 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 ru.practicum.explorewithme.main.dto.CategoryDto; +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.UserShortDto; import ru.practicum.explorewithme.main.model.Category; import ru.practicum.explorewithme.main.model.Event; import ru.practicum.explorewithme.main.model.EventState; @@ -29,16 +26,12 @@ @ExtendWith(MockitoExtension.class) @DisplayName("Тесты для EventMapper") +@ActiveProfiles("mapper_test") +@SpringBootTest class EventMapperTest { - @Mock - private CategoryMapper categoryMapper; - - @Mock - private UserMapper userMapper; - - @InjectMocks - private EventMapperImpl eventMapper; + @Autowired // Внедряем экземпляр, созданный Spring и MapStruct + private EventMapper eventMapper; @Nested @DisplayName("Метод toEventFullDto (маппинг одиночного события в EventFullDto)") @@ -68,12 +61,6 @@ void toEventFullDto_shouldMapAllFieldsCorrectly() { .title("Test Event Title") .build(); - CategoryDto categoryDto = CategoryDto.builder().id(categoryModel.getId()).name(categoryModel.getName()).build(); - when(categoryMapper.toDto(any(Category.class))).thenReturn(categoryDto); - - UserShortDto userShortDto = UserShortDto.builder().id(initiatorModel.getId()).name(initiatorModel.getName()).build(); - when(userMapper.toShortDto(any(User.class))).thenReturn(userShortDto); - EventFullDto dto = eventMapper.toEventFullDto(event); assertNotNull(dto); @@ -131,9 +118,6 @@ void toEventFullDto_shouldHandleNullNestedObjects() { .title("Test Event Title") .build(); - when(categoryMapper.toDto(null)).thenReturn(null); - when(userMapper.toShortDto(null)).thenReturn(null); - EventFullDto dto = eventMapper.toEventFullDto(event); assertNotNull(dto); @@ -159,13 +143,6 @@ void toEventFullDtoList_shouldMapListOfEvents() { 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); - CategoryDto categoryDto = CategoryDto.builder().id(categoryModel.getId()).name(categoryModel.getName()).build(); - UserShortDto userShortDto = UserShortDto.builder().id(initiatorModel.getId()).name(initiatorModel.getName()).build(); - - // Настраиваем моки для зависимых мапперов. - when(categoryMapper.toDto(categoryModel)).thenReturn(categoryDto); - when(userMapper.toShortDto(initiatorModel)).thenReturn(userShortDto); - List dtoList = eventMapper.toEventFullDtoList(events); assertNotNull(dtoList); From caeecf6ad29359055e9c44bbe2e2f5cf4015a57b Mon Sep 17 00:00:00 2001 From: impatient0 Date: Sun, 18 May 2025 17:41:40 +0300 Subject: [PATCH 43/73] =?UTF-8?q?CORE:=20=D0=A0=D0=B5=D1=84=D0=B0=D0=BA?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D0=B8=D0=BD=D0=B3=20=D1=81=D1=83=D1=89=D0=BD?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D0=B5=D0=B9=20(JPA=20Auditing,=20=D0=B7?= =?UTF-8?q?=D0=BD=D0=B0=D1=87=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D1=83=D0=BC=D0=BE=D0=BB=D1=87=D0=B0=D0=BD=D0=B8=D1=8E,=20Build?= =?UTF-8?q?er)=20(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add jpa auditing config * set creation dates through JPA auditing * use DB defaults instead of field defaults * add builder defaults for collection fields --------- Co-authored-by: Pepe Ronin --- .../main/config/JpaAuditingConfig.java | 12 ++++++++++++ .../explorewithme/main/model/Compilation.java | 5 ++--- .../explorewithme/main/model/Event.java | 16 ++++++++-------- .../main/model/ParticipationRequest.java | 7 +++++-- main-service/src/main/resources/schema.sql | 10 +++++----- 5 files changed, 32 insertions(+), 18 deletions(-) create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/config/JpaAuditingConfig.java 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..0dbf3a8 --- /dev/null +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/config/JpaAuditingConfig.java @@ -0,0 +1,12 @@ +package ru.practicum.explorewithme.main.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +@Profile("!test") +@SuppressWarnings("unused") +public class JpaAuditingConfig { +} \ 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 index 0f76cc5..cd963ea 100644 --- 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 @@ -28,8 +28,7 @@ public class Compilation { * Флаг, закреплена ли подборка на главной странице. */ @Column(name = "pinned", nullable = false) - @Builder.Default - private boolean pinned = false; + private boolean pinned; /** * Название подборки. @@ -41,11 +40,11 @@ public class Compilation { * События, входящие в подборку. */ @ManyToMany(fetch = FetchType.LAZY) + @Builder.Default @JoinTable( name = "compilation_events", joinColumns = @JoinColumn(name = "compilation_id"), inverseJoinColumns = @JoinColumn(name = "event_id") ) - @Builder.Default 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 index 6cd6cd6..0f7dbbb 100644 --- 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 @@ -6,6 +6,8 @@ import java.time.LocalDateTime; import java.util.HashSet; import java.util.Set; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; @Entity @Table(name = "events") @@ -16,6 +18,7 @@ @Builder @ToString(exclude = {"category", "initiator", "compilations"}) @EqualsAndHashCode(of = {"id", "title", "annotation", "eventDate", "publishedOn"}) +@EntityListeners(AuditingEntityListener.class) public class Event { /** @@ -46,9 +49,9 @@ public class Event { /** * Дата и время создания события */ + @CreatedDate @Column(name = "created_on", nullable = false) - @Builder.Default - private LocalDateTime createdOn = LocalDateTime.now(); + private LocalDateTime createdOn; /** * Дата и время публикации события @@ -60,22 +63,19 @@ public class Event { * Флаг платного участия */ @Column(name = "paid", nullable = false) - @Builder.Default - private boolean paid = false; + private boolean paid; /** * Лимит участников события (0 - без ограничений) */ @Column(name = "participant_limit", nullable = false) - @Builder.Default - private int participantLimit = 0; + private int participantLimit; /** * Требуется ли модерация заявок на участие */ @Column(name = "request_moderation", nullable = false) - @Builder.Default - private boolean requestModeration = true; + private boolean requestModeration; /** * Заголовок события 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 index 57644ab..2928989 100644 --- 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 @@ -4,6 +4,8 @@ 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 = { @@ -16,6 +18,7 @@ @Builder @ToString(exclude = {"requester", "event"}) @EqualsAndHashCode(of = {"id", "created"}) +@EntityListeners(AuditingEntityListener.class) public class ParticipationRequest { @Id @@ -25,9 +28,9 @@ public class ParticipationRequest { /** * Дата и время создания запроса */ + @CreatedDate @Column(name = "created", nullable = false) - @Builder.Default - private LocalDateTime created = LocalDateTime.now(); + private LocalDateTime created; /** * Пользователь, создавший запрос на участие diff --git a/main-service/src/main/resources/schema.sql b/main-service/src/main/resources/schema.sql index 9ab7a5e..047a6dc 100644 --- a/main-service/src/main/resources/schema.sql +++ b/main-service/src/main/resources/schema.sql @@ -26,10 +26,10 @@ CREATE TABLE IF NOT EXISTS events ( initiator_id BIGINT NOT NULL, lat REAL NOT NULL, lon REAL NOT NULL, - paid BOOLEAN NOT NULL, - participant_limit INTEGER NOT NULL, - published_on TIMESTAMP WITHOUT TIME ZONE, - request_moderation BOOLEAN NOT NULL, + paid BOOLEAN NOT NULL DEFAULT FALSE, + participant_limit INTEGER NOT NULL DEFAULT 0, + published_on TIMESTAMP WITHOUT TIME ZONE NOT NULL, + request_moderation BOOLEAN NOT NULL DEFAULT TRUE, state VARCHAR(20) NOT NULL, title VARCHAR(120) NOT NULL, CONSTRAINT fk_event_to_category FOREIGN KEY(category_id) REFERENCES categories(id), @@ -49,7 +49,7 @@ CREATE TABLE IF NOT EXISTS requests ( CREATE TABLE IF NOT EXISTS compilations ( id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, - pinned BOOLEAN NOT NULL, + pinned BOOLEAN NOT NULL DEFAULT FALSE, title VARCHAR(128) NOT NULL UNIQUE ); From 8e8d2bd28241fb1fcb820a3a0bf05a6624331f6f Mon Sep 17 00:00:00 2001 From: Sergey Filippovskikh <116564864+SergikF@users.noreply.github.com> Date: Tue, 20 May 2025 17:26:02 +0300 Subject: [PATCH 44/73] =?UTF-8?q?=D0=A1=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=BD=D0=B5=D0=BE=D0=B1=D1=85=D0=BE=D0=B4=D0=B8?= =?UTF-8?q?=D0=BC=D0=BE=D0=B3=D0=BE=20=D0=B4=D0=BB=D1=8F=20=D1=80=D0=B0?= =?UTF-8?q?=D0=B1=D0=BE=D1=82=D1=8B=20=D0=B2=D0=B5=D1=82=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=A1ategories=20(#69)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Создание необходимого для работы ветки Сategories Админской и публичной части. * Исправление замечаний ГитХаба * Исправление ревьювера и оформительские изменения кода тестов * Исправление ревьювера и оформительские изменения кода тестов * Исправление замечаний по стилю --- .../admin/AdminCategoryController.java | 51 ++ .../pub/PublicCategoryController.java | 45 ++ .../explorewithme/main/dto/CategoryDto.java | 5 +- .../main/dto/NewCategoryDto.java | 20 + .../main/error/EntityDeletedException.java | 12 + .../main/error/GlobalExceptionHandler.java | 12 + .../main/mapper/CategoryMapper.java | 7 + .../explorewithme/main/mapper/UserMapper.java | 2 + .../explorewithme/main/model/Category.java | 2 +- .../main/repository/CategoryRepository.java | 16 + .../main/repository/EventRepository.java | 2 + .../main/service/CategoryService.java | 19 + .../main/service/CategoryServiceImpl.java | 91 ++++ .../admin/AdminCategoryControllerTest.java | 283 +++++++++++ .../admin/AdminUserControllerTest.java | 1 - .../pub/PublicCategoryControllerTest.java | 187 ++++++++ .../main/mapper/CategoryMapperTest.java | 130 ++++++ .../main/mapper/UserMapperTest.java | 46 +- .../CategoryServiceIntegrationTest.java | 439 ++++++++++++++++++ .../service/UserServiceIntegrationTest.java | 64 +-- 20 files changed, 1344 insertions(+), 90 deletions(-) create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/controller/admin/AdminCategoryController.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/controller/pub/PublicCategoryController.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/dto/NewCategoryDto.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/error/EntityDeletedException.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/repository/CategoryRepository.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/service/CategoryService.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/service/CategoryServiceImpl.java create mode 100644 main-service/src/test/java/ru/practicum/explorewithme/main/controller/admin/AdminCategoryControllerTest.java create mode 100644 main-service/src/test/java/ru/practicum/explorewithme/main/controller/pub/PublicCategoryControllerTest.java create mode 100644 main-service/src/test/java/ru/practicum/explorewithme/main/mapper/CategoryMapperTest.java create mode 100644 main-service/src/test/java/ru/practicum/explorewithme/main/service/CategoryServiceIntegrationTest.java 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/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/dto/CategoryDto.java b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/CategoryDto.java index eeb0247..e5b3f86 100644 --- 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 @@ -5,13 +5,14 @@ import lombok.Data; import lombok.NoArgsConstructor; -// TODO: полноценная реализация CategoryDto - @Data @Builder @NoArgsConstructor @AllArgsConstructor public class CategoryDto { + private Long id; + private String name; + } \ 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..53f55a2 --- /dev/null +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/NewCategoryDto.java @@ -0,0 +1,20 @@ +package ru.practicum.explorewithme.main.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class NewCategoryDto { + + @NotBlank(message = "Название категории не может быть пустым") + @Size(min = 1, max = 50, message = "Название категории должно быть от 1 до 50 символов") + private String name; + +} 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/GlobalExceptionHandler.java b/main-service/src/main/java/ru/practicum/explorewithme/main/error/GlobalExceptionHandler.java index 45f258e..294ab36 100644 --- 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 @@ -164,6 +164,18 @@ public ApiError handleEntityNotFoundException(EntityNotFoundException e) { .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("Restriction of removal" + e.getMessage()) + .timestamp(LocalDateTime.now()) + .build(); + } + @ExceptionHandler(Throwable.class) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) public ApiError handleThrowable(final Throwable e) { 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 index c323b4c..b0ab12c 100644 --- 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 @@ -1,10 +1,17 @@ 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); + + @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/UserMapper.java b/main-service/src/main/java/ru/practicum/explorewithme/main/mapper/UserMapper.java index 64242ef..73cb19a 100644 --- 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 @@ -17,4 +17,6 @@ public interface UserMapper { @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 index c3390b2..c12e2a5 100644 --- 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 @@ -24,7 +24,7 @@ public class Category { /** * Уникальное наименование категории. */ - @Column(name = "name", nullable = false, length = 64, unique = true) + @Column(name = "name", nullable = false, length = 50, unique = true) private String name; } 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/EventRepository.java b/main-service/src/main/java/ru/practicum/explorewithme/main/repository/EventRepository.java index 2f8ecdf..3567f3a 100644 --- 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 @@ -6,4 +6,6 @@ public interface EventRepository extends JpaRepository, QuerydslPredicateExecutor { + boolean existsByCategoryId(Long categoryId); + } \ No newline at end of file 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..108c46e --- /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).isPresent()) { + 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/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/AdminUserControllerTest.java b/main-service/src/test/java/ru/practicum/explorewithme/main/controller/admin/AdminUserControllerTest.java index 567be11..65c1b6c 100644 --- 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 @@ -94,7 +94,6 @@ void createUser_WithExistingEmail_ReturnsConflict() throws Exception { @DisplayName("возвращать 400 при создании пользователя с невалидными данными") void createUser_WithInvalidData_ReturnsBadRequest() throws Exception { NewUserRequestDto invalidRequest = new NewUserRequestDto(); - // Email и имя не заданы mockMvc.perform(post("/admin/users") .contentType(MediaType.APPLICATION_JSON) 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/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/UserMapperTest.java b/main-service/src/test/java/ru/practicum/explorewithme/main/mapper/UserMapperTest.java index c89a2e6..7751474 100644 --- 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 @@ -15,7 +15,6 @@ @DisplayName("Маппер пользователей должен") class UserMapperTest { - // Получаем фактическую реализацию маппера, сгенерированную MapStruct private final UserMapper userMapper = Mappers.getMapper(UserMapper.class); @Nested @@ -25,16 +24,14 @@ 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()); @@ -43,10 +40,9 @@ void toShortDto_ShouldMapAllFields() { @Test @DisplayName("возвращать null при преобразовании null") void toShortDto_ShouldReturnNullWhenUserIsNull() { - // Действие + UserShortDto result = userMapper.toShortDto(null); - // Проверка assertThat(result).isNull(); } } @@ -58,16 +54,14 @@ 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()); @@ -77,10 +71,9 @@ void toUserDto_ShouldMapAllFields() { @Test @DisplayName("возвращать null при преобразовании null") void toUserDto_ShouldReturnNullWhenUserIsNull() { - // Действие + UserDto result = userMapper.toUserDto(null); - // Проверка assertThat(result).isNull(); } } @@ -92,29 +85,25 @@ 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()); - // Id не должен быть установлен маппером assertThat(result.getId()).isNull(); } @Test @DisplayName("возвращать null при преобразовании null") void toUser_ShouldReturnNullWhenNewUserRequestIsNull() { - // Действие - User result = userMapper.toUser(null); - // Проверка + User result = userMapper.toUser((NewUserRequestDto) null); + assertThat(result).isNull(); } } @@ -126,19 +115,16 @@ class IntegrationTests { @Test @DisplayName("сохранять все поля при цепочке преобразований") void mapper_ShouldPreserveAllFieldsInConversionChain() { - // Подготовка + NewUserRequestDto request = new NewUserRequestDto(); request.setName("Тестовый пользователь"); request.setEmail("test@example.com"); - // Преобразование NewUserRequestDto -> User User user = userMapper.toUser(request); - user.setId(1L); // устанавливаем id вручную, так как он не устанавливается маппером + user.setId(1L); - // Преобразование User -> UserDto UserDto userDto = userMapper.toUserDto(user); - // Проверка полного цикла преобразования assertThat(userDto.getId()).isEqualTo(user.getId()); assertThat(userDto.getName()).isEqualTo(request.getName()); assertThat(userDto.getEmail()).isEqualTo(request.getEmail()); @@ -147,20 +133,18 @@ void mapper_ShouldPreserveAllFieldsInConversionChain() { @Test @DisplayName("корректно преобразовывать в UserShortDto сохраняя нужные поля") void mapper_ShouldCorrectlyMapToShortDto() { - // Подготовка + NewUserRequestDto request = new NewUserRequestDto(); request.setName("Тестовый пользователь"); request.setEmail("test@example.com"); - // Преобразование NewUserRequestDto -> User -> UserShortDto 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()); - // Email не должен присутствовать в ShortDto + } } @@ -171,15 +155,13 @@ 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(); @@ -188,7 +170,7 @@ void mapper_ShouldHandleEmptyStrings() { @Test @DisplayName("корректно обрабатывать специальные символы") void mapper_ShouldHandleSpecialCharacters() { - // Подготовка + String specialName = "Имя с !@#$%^&*()_+"; String specialEmail = "special!@example.com"; @@ -196,11 +178,9 @@ void mapper_ShouldHandleSpecialCharacters() { 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); } 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/UserServiceIntegrationTest.java b/main-service/src/test/java/ru/practicum/explorewithme/main/service/UserServiceIntegrationTest.java index b937481..b62a26f 100644 --- 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 @@ -37,7 +37,7 @@ class UserServiceIntegrationTest { @Container - static PostgreSQLContainer postgresContainer = new PostgreSQLContainer<>("postgres:16") + static PostgreSQLContainer postgresContainer = new PostgreSQLContainer<>("postgres:16.1") .withDatabaseName("explorewithme_test") .withUsername("test") .withPassword("test"); @@ -78,16 +78,14 @@ 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()); @@ -97,34 +95,15 @@ void createUser_Success() { @Test @DisplayName("Исключение при создании пользователя с дублирующимся email") void createUser_DuplicateEmail_ThrowsException() { - // Подготовка + userService.createUser(newUserRequestDto); - // Проверка исключения при создании пользователя с тем же email EntityAlreadyExistsException exception = assertThrows(EntityAlreadyExistsException.class, () -> { userService.createUser(newUserRequestDto); }); - // Проверка сообщения об ошибке assertTrue(exception.getMessage().contains(newUserRequestDto.getEmail())); } - - @Test - @DisplayName("Транзакция откатывается при возникновении ошибки") - void transactionRollback_WhenExceptionOccurs() { - // Подготовка - создаем пользователя - UserDto user = userService.createUser(newUserRequestDto); - - // Действие - пытаемся создать пользователя с тем же email, что должно вызвать ошибку - try { - userService.createUser(newUserRequestDto); - } catch (EntityAlreadyExistsException ignored) { - // Ожидаемое исключение - } - - // Проверка - убеждаемся, что в базе только один пользователь - assertEquals(1, userRepository.count()); - } } @Nested @@ -134,31 +113,26 @@ 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())); } } @@ -170,20 +144,17 @@ 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())); @@ -192,17 +163,15 @@ void getUsers_WithoutIds_ReturnsAllUsers() { @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()); @@ -211,7 +180,7 @@ void getUsers_WithIds_ReturnsSpecificUsers() { @Test @DisplayName("Корректная работа пагинации при получении пользователей") void getUsers_Pagination_ReturnsCorrectPage() { - // Подготовка - создаем 5 пользователей + List createdUsers = IntStream.range(0, 5) .mapToObj(i -> { NewUserRequestDto request = new NewUserRequestDto(); @@ -221,24 +190,19 @@ void getUsers_Pagination_ReturnsCorrectPage() { }) .collect(Collectors.toList()); - // Запрашиваем первую страницу с размером 2 GetListUsersParameters page1Params = new GetListUsersParameters(null, 0, 2); List page1 = userService.getUsers(page1Params); - // Запрашиваем вторую страницу с размером 2 GetListUsersParameters page2Params = new GetListUsersParameters(null, 2, 2); List page2 = userService.getUsers(page2Params); - // Запрашиваем третью страницу с размером 2 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())); @@ -251,11 +215,10 @@ void getUsers_Pagination_ReturnsCorrectPage() { @Test @DisplayName("Получение пустого списка при отсутствии пользователей") void getUsers_EmptyRepository_ReturnsEmptyList() { - // Действие + GetListUsersParameters parameters = new GetListUsersParameters(null, 0, 10); List users = userService.getUsers(parameters); - // Проверка assertNotNull(users); assertTrue(users.isEmpty()); } @@ -268,7 +231,7 @@ class PerformanceTests { @Test @DisplayName("Эффективная работа с большим количеством данных") void getUsers_WithLargeDataset_PerformsEfficiently() { - // Создаем 100 пользователей + for (int i = 0; i < 100; i++) { NewUserRequestDto request = new NewUserRequestDto(); request.setName("User " + i); @@ -276,17 +239,14 @@ void getUsers_WithLargeDataset_PerformsEfficiently() { 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) + " мс"); } } @@ -298,7 +258,7 @@ class EdgeCaseTests { @Test @DisplayName("Корректная обработка запроса страницы за пределами допустимого диапазона") void getUsers_PageOutOfRange_ReturnsEmptyList() { - // Подготовка - создаем 3 пользователей + IntStream.range(0, 3) .forEach(i -> { NewUserRequestDto request = new NewUserRequestDto(); @@ -307,11 +267,9 @@ void getUsers_PageOutOfRange_ReturnsEmptyList() { userService.createUser(request); }); - // Запрашиваем страницу, которая находится за пределами доступных данных GetListUsersParameters outOfRangeParams = new GetListUsersParameters(null, 10, 5); List result = userService.getUsers(outOfRangeParams); - // Проверка assertNotNull(result); assertTrue(result.isEmpty()); } From 762e9e91c6db973115b8e829684eb051a55ac61a Mon Sep 17 00:00:00 2001 From: Gagarskiy-Andrey Date: Tue, 20 May 2025 18:37:20 +0300 Subject: [PATCH 45/73] =?UTF-8?q?PRIVATE-EVENTS:=20=D0=94=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=BE=D0=B3=D0=BE=20=D1=81=D0=BE=D0=B1=D1=8B=D1=82=D0=B8=D1=8F?= =?UTF-8?q?=20#46=20(#71)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * добавлен NewEventDto * добавлены EventFullDto / UserShortDto / CategoryDto * добавлен PrivateEventController.addEventPrivate * добавлен NewEventDto * добавлены EventFullDto / UserShortDto / CategoryDto * добавлен PrivateEventController.addEventPrivate * добавлен метод EventServiceImpl.addEventPrivate * добавлен метод EventServiceImpl.addEventPrivate * добавлен NewEventDto * добавлены EventFullDto / UserShortDto / CategoryDto * добавлен PrivateEventController.addEventPrivate * добавлен метод EventServiceImpl.addEventPrivate * fix incorrect constraint in DTO * add new exception for general business rule violations * change exception type to map to correct status * remove creation timestamp mapping to let JPA auditing handle it * add new tests for new EventServiceImpl method * checkstyle * create integration tests for EventService * fix after rebase * rename private_events to priv --------- Co-authored-by: Pepe Ronin --- .../main/config/JpaAuditingConfig.java | 2 - .../priv/PrivateEventController.java | 28 +++ .../explorewithme/main/dto/CategoryDto.java | 7 +- .../explorewithme/main/dto/EventFullDto.java | 7 + .../explorewithme/main/dto/NewEventDto.java | 56 +++++ .../explorewithme/main/dto/UserShortDto.java | 3 + .../error/BusinessRuleViolationException.java | 8 + .../main/error/GlobalExceptionHandler.java | 16 +- .../main/mapper/CategoryMapper.java | 7 + .../main/mapper/EventMapper.java | 9 + .../main/service/EventService.java | 5 +- .../main/service/EventServiceImpl.java | 36 ++- .../main/service/EventServiceImplTest.java | 186 +++++++++++++- .../service/EventServiceIntegrationTest.java | 233 ++++++++++++++++++ 14 files changed, 585 insertions(+), 18 deletions(-) create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/controller/priv/PrivateEventController.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/dto/NewEventDto.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/error/BusinessRuleViolationException.java create mode 100644 main-service/src/test/java/ru/practicum/explorewithme/main/service/EventServiceIntegrationTest.java 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 index 0dbf3a8..f6dbade 100644 --- 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 @@ -1,12 +1,10 @@ package ru.practicum.explorewithme.main.config; import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Profile; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @Configuration @EnableJpaAuditing -@Profile("!test") @SuppressWarnings("unused") public class JpaAuditingConfig { } \ No newline at end of file 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..39a87f9 --- /dev/null +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/controller/priv/PrivateEventController.java @@ -0,0 +1,28 @@ +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.NewEventDto; +import ru.practicum.explorewithme.main.service.EventService; + +@RestController +@RequestMapping("/users") +@RequiredArgsConstructor +@Validated +@Slf4j +public class PrivateEventController { + + private final EventService eventService; + + @PostMapping("/{userId}/events") + 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)); + } +} 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 index e5b3f86..976ba91 100644 --- 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 @@ -1,14 +1,13 @@ package ru.practicum.explorewithme.main.dto; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; +import lombok.*; +import lombok.experimental.FieldDefaults; @Data @Builder @NoArgsConstructor @AllArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE) public class CategoryDto { private Long id; 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 index 1e75cc9..cc3c3f6 100644 --- 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 @@ -4,13 +4,20 @@ 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 { private Long id; private String annotation; 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..99c7245 --- /dev/null +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/NewEventDto.java @@ -0,0 +1,56 @@ +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.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 = "Дата события должна быть в будущем") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = DATE_TIME_FORMAT_PATTERN) + LocalDateTime eventDate; + + @NotNull(message = "Поле location не может быть пустым") + Location location; + + @Builder.Default + Boolean paid = false; + + @Builder.Default + Long participantLimit = 0L; + + @Builder.Default + Boolean requestModeration = true; + + @NotBlank(message = "Поле title не может быть пустым") + @Size(min = 3, max = 120, message = "Поле title должно быть от 3 до 120 символов") + String title; +} 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 index fe63d57..50f4af7 100644 --- 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 @@ -1,14 +1,17 @@ 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 { private Long id; private String name; 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/GlobalExceptionHandler.java b/main-service/src/main/java/ru/practicum/explorewithme/main/error/GlobalExceptionHandler.java index 294ab36..710b079 100644 --- 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 @@ -147,7 +147,7 @@ public ApiError handleEntityAlreadyExistsException(EntityAlreadyExistsException return ApiError.builder() .status(HttpStatus.CONFLICT) .reason("Requested object already exists") - .message("Requested object already exists" + e.getMessage()) + .message(e.getMessage()) .timestamp(LocalDateTime.now()) .build(); } @@ -159,7 +159,19 @@ public ApiError handleEntityNotFoundException(EntityNotFoundException e) { return ApiError.builder() .status(HttpStatus.NOT_FOUND) .reason("Requested object not found") - .message("Requested object not found" + e.getMessage()) + .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("Business rule violation") + .message(e.getMessage()) .timestamp(LocalDateTime.now()) .build(); } 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 index b0ab12c..9fc7cf3 100644 --- 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 @@ -11,6 +11,13 @@ 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); 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 index 2894939..c153ec9 100644 --- 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 @@ -4,6 +4,7 @@ import org.mapstruct.Mapper; import org.mapstruct.Mapping; import ru.practicum.explorewithme.main.dto.EventFullDto; +import ru.practicum.explorewithme.main.dto.NewEventDto; import ru.practicum.explorewithme.main.model.Event; @Mapper(componentModel = "spring", uses = {CategoryMapper.class, UserMapper.class}) @@ -15,5 +16,13 @@ public interface EventMapper { @Mapping(target = "views", expression = "java(0L)") // Временная заглушка 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(source = "category", target = "category") + @Mapping(target = "state", expression = "java(ru.practicum.explorewithme.main.model.EventState.PENDING)") + Event toEvent(NewEventDto newEventDto); + List toEventFullDtoList(List events); } 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 index 683b5f8..c525b44 100644 --- 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 @@ -3,6 +3,7 @@ import java.util.List; import ru.practicum.explorewithme.main.dto.EventFullDto; import ru.practicum.explorewithme.main.service.params.AdminEventSearchParams; +import ru.practicum.explorewithme.main.dto.NewEventDto; public interface EventService { List getEventsAdmin( @@ -10,4 +11,6 @@ List getEventsAdmin( int from, int size ); -} \ No newline at end of file + + EventFullDto addEventPrivate(Long userId, NewEventDto newEventDto); +} 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 index 6d0601f..39d2f78 100644 --- 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 @@ -11,16 +11,20 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import ru.practicum.explorewithme.main.dto.EventFullDto; +import ru.practicum.explorewithme.main.dto.NewEventDto; +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.Event; -import ru.practicum.explorewithme.main.model.EventState; +import ru.practicum.explorewithme.main.model.*; import ru.practicum.explorewithme.main.model.QEvent; +import ru.practicum.explorewithme.main.repository.CategoryRepository; import ru.practicum.explorewithme.main.repository.EventRepository; -// import ru.practicum.explorewithme.main.exception.NotFoundException; import java.time.LocalDateTime; import java.util.Collections; import java.util.List; + +import ru.practicum.explorewithme.main.repository.UserRepository; import ru.practicum.explorewithme.main.service.params.AdminEventSearchParams; @Service @@ -31,8 +35,8 @@ public class EventServiceImpl implements EventService { private final EventRepository eventRepository; private final EventMapper eventMapper; - // private final UserRepository userRepository; - // private final CategoryRepository categoryRepository; + private final UserRepository userRepository; + private final CategoryRepository categoryRepository; @Override public List getEventsAdmin(AdminEventSearchParams params, @@ -92,4 +96,26 @@ public List getEventsAdmin(AdminEventSearchParams params, return result; } + @Transactional + @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)); + } } \ 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 index 9814b6b..f83b1e9 100644 --- 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 @@ -13,8 +13,10 @@ 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; @@ -30,11 +32,15 @@ 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.NewEventDto; +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.Event; -import ru.practicum.explorewithme.main.model.EventState; -import ru.practicum.explorewithme.main.model.QEvent; +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) @@ -47,23 +53,86 @@ class EventServiceImplTest { @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(); + 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 @@ -275,5 +344,114 @@ void getEventsAdmin_whenRangeStartIsAfterRangeEnd_shouldThrowIllegalArgumentExce } } + @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 категории должен быть корректно установлен маппером."); + } + } + // ... TODO: Добавить тесты для других методов EventService, когда они появятся ... } \ 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..5e430cf --- /dev/null +++ b/main-service/src/test/java/ru/practicum/explorewithme/main/service/EventServiceIntegrationTest.java @@ -0,0 +1,233 @@ +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.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.NewEventDto; +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.UserRepository; +import ru.practicum.explorewithme.main.service.params.AdminEventSearchParams; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@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 EventService eventService; + + @Autowired + private EventRepository eventRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private CategoryRepository categoryRepository; + + private User user1, user2; + private Category category1, category2; + private Location location1; + private LocalDateTime now; + + @BeforeEach + void setUp() { + eventRepository.deleteAll(); + categoryRepository.deleteAll(); + userRepository.deleteAll(); + + 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()); + + 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(); + } + + @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()); + } + } +} \ No newline at end of file From 1346683cb77c02032b7898448c8fe788b6b0a121 Mon Sep 17 00:00:00 2001 From: Sergey Filippovskikh <116564864+SergikF@users.noreply.github.com> Date: Thu, 22 May 2025 10:48:28 +0300 Subject: [PATCH 46/73] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20=D0=B1=D0=BB=D0=BE=D0=BA=D0=B8=20=D1=80?= =?UTF-8?q?=D0=B0=D0=B7=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=BA=D0=B8=20#50=20?= =?UTF-8?q?#51=20#52?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавлены блоки разработки: Добавление запроса на участие в событии (#50) Отмена своего запроса на участие (#52) Получение информации о своих запросах на участие (#51) --- .../priv/PrivateRequestController.java | 55 +++++++++++ .../main/dto/ParticipationRequestDto.java | 24 +++++ .../main/mapper/RequestMapper.java | 15 +++ .../main/repository/RequestRepository.java | 22 +++++ .../main/service/RequestService.java | 15 +++ .../main/service/RequestServiceImpl.java | 96 +++++++++++++++++++ 6 files changed, 227 insertions(+) create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/controller/priv/PrivateRequestController.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/dto/ParticipationRequestDto.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/mapper/RequestMapper.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/repository/RequestRepository.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/service/RequestService.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/service/RequestServiceImpl.java 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..504770f --- /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 requestEventId) { + log.info("Private: Received request to add user {} in event: {}", userId, requestEventId); + ParticipationRequestDto result = requestService.createRequest(userId, requestEventId); + 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/dto/ParticipationRequestDto.java b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/ParticipationRequestDto.java new file mode 100644 index 0000000..cd243f9 --- /dev/null +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/ParticipationRequestDto.java @@ -0,0 +1,24 @@ +package ru.practicum.explorewithme.main.dto; + +import lombok.*; +import ru.practicum.explorewithme.main.model.RequestStatus; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ParticipationRequestDto { + + private Long id; + + private LocalDateTime created; + + private Long requesterId; + + private Long eventId; + + private RequestStatus status; + +} 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/repository/RequestRepository.java b/main-service/src/main/java/ru/practicum/explorewithme/main/repository/RequestRepository.java new file mode 100644 index 0000000..eb27cd1 --- /dev/null +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/repository/RequestRepository.java @@ -0,0 +1,22 @@ +package ru.practicum.explorewithme.main.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +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); + +} \ 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..77a54fd --- /dev/null +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/service/RequestService.java @@ -0,0 +1,15 @@ +package ru.practicum.explorewithme.main.service; + +import ru.practicum.explorewithme.main.dto.ParticipationRequestDto; + +import java.util.List; + +public interface RequestService { + + ParticipationRequestDto createRequest(Long userId,Long requestEventId); + + List getRequests(Long userId); + + ParticipationRequestDto cancelRequest(Long userId, Long requestId); + +} 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..64c069b --- /dev/null +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/service/RequestServiceImpl.java @@ -0,0 +1,96 @@ +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.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 java.util.List; +import java.util.Comparator; + +@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 + public List getRequests(Long userId) { + userRepository.findById(userId) + .orElseThrow(() -> new EntityNotFoundException("User", "Id", userId)); + List result = requestRepository.findByRequester_Id(userId).stream() + .sorted(Comparator.comparing(ParticipationRequest::getCreated).reversed()) + .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()) { + newRequest.setStatus(RequestStatus.PENDING); + } else { + newRequest.setStatus(RequestStatus.CONFIRMED); + } + + return newRequest; + } +} From 4667e9f5d40fde4c567767412dbd0acb36ee8141 Mon Sep 17 00:00:00 2001 From: Sergey Filippovskikh <116564864+SergikF@users.noreply.github.com> Date: Thu, 22 May 2025 12:04:02 +0300 Subject: [PATCH 47/73] =?UTF-8?q?=D0=9F=D0=BE=D0=BB=D1=83=D1=87=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=B7=D0=B0=D0=BF=D1=80=D0=BE=D1=81=D0=BE?= =?UTF-8?q?=D0=B2=20=D0=BD=D0=B0=20=D1=83=D1=87=D0=B0=D1=81=D1=82=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=B2=20=D1=81=D0=BE=D0=B1=D1=8B=D1=82=D0=B8=D0=B8=20?= =?UTF-8?q?=D1=82=D0=B5=D0=BA=D1=83=D1=89=D0=B5=D0=B3=D0=BE=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/priv/PrivateEventController.java | 17 +++++++++++++++++ .../main/repository/EventRepository.java | 2 ++ .../main/repository/RequestRepository.java | 2 ++ .../main/service/RequestService.java | 3 +++ .../main/service/RequestServiceImpl.java | 13 ++++++++++++- 5 files changed, 36 insertions(+), 1 deletion(-) 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 index 39a87f9..d8d9dfa 100644 --- 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 @@ -1,6 +1,7 @@ package ru.practicum.explorewithme.main.controller.priv; import jakarta.validation.Valid; +import jakarta.validation.constraints.Positive; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; @@ -9,7 +10,11 @@ import org.springframework.web.bind.annotation.*; import ru.practicum.explorewithme.main.dto.EventFullDto; import ru.practicum.explorewithme.main.dto.NewEventDto; +import ru.practicum.explorewithme.main.dto.ParticipationRequestDto; import ru.practicum.explorewithme.main.service.EventService; +import ru.practicum.explorewithme.main.service.RequestService; + +import java.util.List; @RestController @RequestMapping("/users") @@ -19,10 +24,22 @@ public class PrivateEventController { private final EventService eventService; + private final RequestService requestService; @PostMapping("/{userId}/events") 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)); } + + @GetMapping("/{userId}/events/{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; + } } 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 index 3567f3a..d38a64e 100644 --- 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 @@ -8,4 +8,6 @@ public interface EventRepository extends JpaRepository, QuerydslPre boolean existsByCategoryId(Long categoryId); + boolean existsByIdAndInitiator_Id(Long id, Long initiatorId); + } \ 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 index eb27cd1..045aeb2 100644 --- 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 @@ -2,6 +2,7 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import ru.practicum.explorewithme.main.dto.ParticipationRequestDto; import ru.practicum.explorewithme.main.model.ParticipationRequest; import ru.practicum.explorewithme.main.model.RequestStatus; @@ -19,4 +20,5 @@ public interface RequestRepository extends JpaRepository findByIdAndRequester_Id(Long requestId, Long userId); + List findByEvent_Id(Long eventId); } \ 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 index 77a54fd..2144a01 100644 --- 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 @@ -1,5 +1,6 @@ package ru.practicum.explorewithme.main.service; +import jakarta.validation.constraints.Positive; import ru.practicum.explorewithme.main.dto.ParticipationRequestDto; import java.util.List; @@ -12,4 +13,6 @@ public interface RequestService { ParticipationRequestDto cancelRequest(Long userId, Long requestId); + List getEventRequests(@Positive Long userId, @Positive Long eventId); + } 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 index 64c069b..9be77fa 100644 --- 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 @@ -38,7 +38,7 @@ public ParticipationRequestDto createRequest(Long userId, Long requestEventId) { 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)); + new EntityNotFoundException("User with Id = " + userId + " and Request", "Id", userId)); result.setStatus(RequestStatus.CANCELED); requestRepository.save(result); return requestMapper.toRequestDto(result); @@ -55,6 +55,17 @@ public List getRequests(Long userId) { return result; } + @Override + @Transactional + public List getEventRequests(Long userId, Long eventId) { + if (!eventRepository.existsByIdAndInitiator_Id(eventId, userId)) + throw new EntityNotFoundException("Event with Id = " + eventId + " when initiator", "Id", userId); + List result = requestRepository.findByEvent_Id(eventId).stream() + .sorted(Comparator.comparing(ParticipationRequest::getCreated).reversed()) + .map(requestMapper::toRequestDto).toList(); + return result; + } + private ParticipationRequest checkRequest(Long userId, Long requestEventId) { User user = userRepository.findById(userId) From cafd20a94af6f0bfca8299f0c9f1dd5146c0f818 Mon Sep 17 00:00:00 2001 From: Sergey Filippovskikh <116564864+SergikF@users.noreply.github.com> Date: Thu, 22 May 2025 12:09:02 +0300 Subject: [PATCH 48/73] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BE?= =?UTF-8?q?=D0=BA=20=D1=81=D1=82=D0=B8=D0=BB=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../explorewithme/main/repository/RequestRepository.java | 1 - 1 file changed, 1 deletion(-) 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 index 045aeb2..df2b2ef 100644 --- 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 @@ -2,7 +2,6 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; -import ru.practicum.explorewithme.main.dto.ParticipationRequestDto; import ru.practicum.explorewithme.main.model.ParticipationRequest; import ru.practicum.explorewithme.main.model.RequestStatus; From f94df25a312dbc7dd5fa1b3239f67fb4fe9cae8e Mon Sep 17 00:00:00 2001 From: impatient0 Date: Thu, 22 May 2025 23:07:22 +0300 Subject: [PATCH 49/73] =?UTF-8?q?Event=20Management:=20=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D1=8C=D1=81?= =?UTF-8?q?=D0=BA=D0=B8=D0=B5=20=D0=BE=D0=BF=D0=B5=D1=80=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D0=B8=20=D0=B8=20=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?(#72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * create EventShortDto and update mapper * add new functionality to repository and service * change service logic to return empty for nonexistent user * create initial PrivateEventController implementation * create unit tests for PrivateEventController * implement endpoint for fetching user's own event * add PrivateEventController tests for new endpoint * cleanup * rename user (controller package) to priv * create UpdateEventUserRequestDto * add custom time constraint to NewEventDto * add event update method to service * create event update endpoint in controller (private) * remove custom validation as it maps to incorrect status * add "two hours later" validation to service * create controller unit tests * add new tests for EventServiceImplTest * add new tests for EventServiceIntegrationTest * create UpdateEventAdminRequestDto * implement admin event moderation method in service * create admin event moderation endpoint * add more tests to EventServiceImplTest * add more tests to EventServiceIntegrationTest * add more unit tests for admin event controller --------- Co-authored-by: Pepe Ronin --- .../admin/AdminEventController.java | 32 +- .../priv/PrivateEventController.java | 99 +++- .../explorewithme/main/dto/EventShortDto.java | 28 ++ .../explorewithme/main/dto/NewEventDto.java | 2 - .../main/dto/UpdateEventAdminRequestDto.java | 51 ++ .../main/dto/UpdateEventUserRequestDto.java | 51 ++ .../main/error/GlobalExceptionHandler.java | 4 +- .../main/mapper/EventMapper.java | 24 +- .../main/repository/EventRepository.java | 6 + .../main/service/EventService.java | 13 +- .../main/service/EventServiceImpl.java | 195 +++++++- .../admin/AdminEventControllerTest.java | 427 +++++++++++------ .../priv/PrivateEventControllerTest.java | 298 ++++++++++++ .../main/service/EventServiceImplTest.java | 434 ++++++++++++++++++ .../service/EventServiceIntegrationTest.java | 382 +++++++++++++++ 15 files changed, 1875 insertions(+), 171 deletions(-) create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/dto/EventShortDto.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/dto/UpdateEventAdminRequestDto.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/dto/UpdateEventUserRequestDto.java create mode 100644 main-service/src/test/java/ru/practicum/explorewithme/main/controller/priv/PrivateEventControllerTest.java 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 index 5f85bc6..86f79f5 100644 --- 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 @@ -2,6 +2,7 @@ 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; @@ -9,6 +10,7 @@ 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; @@ -71,7 +73,33 @@ public List searchEventsAdmin( return foundEvents; } - // TODO: Добавить PATCH /admin/events/{eventId} для модерации событий - // (Задача: ADMIN-EVENTS: Реализация модерации событий (публикация/отклонение)) + /** + * Редактирование данных события и его статуса (отклонение/публикация) администратором.
+ * Валидация данных не требуется (согласно старому ТЗ, но 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/priv/PrivateEventController.java b/main-service/src/main/java/ru/practicum/explorewithme/main/controller/priv/PrivateEventController.java index 39a87f9..3778708 100644 --- 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 @@ -8,11 +8,17 @@ 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.EventShortDto; import ru.practicum.explorewithme.main.dto.NewEventDto; +import ru.practicum.explorewithme.main.dto.UpdateEventUserRequestDto; import ru.practicum.explorewithme.main.service.EventService; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import java.util.List; + @RestController -@RequestMapping("/users") +@RequestMapping("/users/{userId}/events") @RequiredArgsConstructor @Validated @Slf4j @@ -20,9 +26,96 @@ public class PrivateEventController { private final EventService eventService; - @PostMapping("/{userId}/events") + /** + * Получение событий, добавленных текущим пользователем.
+ * В случае, если по заданным фильтрам не найдено ни одного события, возвращает пустой список. + * + * @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; + } + + // TODO: GET /users/{userId}/events/{eventId}/requests - Получение запросов на участие в событии текущего пользователя (-> List) + // (Задача: PRIVATE-EVENTS: Получение запросов на участие в событии текущего пользователя) + + // TODO: PATCH /users/{userId}/events/{eventId}/requests - Изменение статуса заявок (подтверждение/отклонение) (EventRequestStatusUpdateRequest -> EventRequestStatusUpdateResult) + // (Задача: PRIVATE-EVENTS: Изменение статуса заявок (подтверждение/отклонение)) +} \ 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..8af745b --- /dev/null +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/EventShortDto.java @@ -0,0 +1,28 @@ +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.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class EventShortDto { + + private Long id; + private String annotation; + private CategoryDto category; + private Long confirmedRequests; + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = DATE_TIME_FORMAT_PATTERN) + private LocalDateTime eventDate; + private UserShortDto initiator; + private boolean paid; + private String title; + private Long views; +} \ 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 index 99c7245..ab63ece 100644 --- 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 @@ -2,7 +2,6 @@ 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.Size; @@ -34,7 +33,6 @@ public class NewEventDto { String description; @NotNull(message = "Поле eventDate не может быть пустым") - @Future(message = "Дата события должна быть в будущем") @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = DATE_TIME_FORMAT_PATTERN) LocalDateTime eventDate; 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..092d05c --- /dev/null +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/UpdateEventAdminRequestDto.java @@ -0,0 +1,51 @@ +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.PositiveOrZero; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import ru.practicum.explorewithme.main.model.Location; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UpdateEventAdminRequestDto { + + @Size(min = 20, max = 2000, message = "Annotation length must be between 20 and 2000 characters") + private String annotation; + + private Long category; + + @Size(min = 20, max = 7000, message = "Description length must be between 20 and 7000 characters") + private String description; + + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = DATE_TIME_FORMAT_PATTERN) + private LocalDateTime eventDate; + + private Location location; + + private Boolean paid; + + @PositiveOrZero(message = "Participant limit must be positive or zero") + private Integer participantLimit; + + private Boolean requestModeration; + + private StateActionAdmin stateAction; + + @Size(min = 3, max = 120, message = "Title length must be between 3 and 120 characters") + private 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..4e82b89 --- /dev/null +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/UpdateEventUserRequestDto.java @@ -0,0 +1,51 @@ +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.PositiveOrZero; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import ru.practicum.explorewithme.main.model.Location; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UpdateEventUserRequestDto { + + @Size(min = 20, max = 2000, message = "Annotation length must be between 20 and 2000 characters") + private String annotation; + + private Long category; + + @Size(min = 20, max = 7000, message = "Description length must be between 20 and 7000 characters") + private String description; + + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = DATE_TIME_FORMAT_PATTERN) + private LocalDateTime eventDate; + + private Location location; + + private Boolean paid; + + @PositiveOrZero(message = "Participant limit must be positive or zero") + private Integer participantLimit; + + private Boolean requestModeration; + + private StateActionUser stateAction; + + @Size(min = 3, max = 120, message = "Title length must be between 3 and 120 characters") + private 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/error/GlobalExceptionHandler.java b/main-service/src/main/java/ru/practicum/explorewithme/main/error/GlobalExceptionHandler.java index 710b079..6d262ab 100644 --- 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 @@ -170,7 +170,7 @@ public ApiError handleBusinessRuleViolationException(BusinessRuleViolationExcept log.warn("Business rule violation: {}", e.getMessage()); return ApiError.builder() .status(HttpStatus.CONFLICT) - .reason("Business rule violation") + .reason("Conditions not met for requested operation") .message(e.getMessage()) .timestamp(LocalDateTime.now()) .build(); @@ -183,7 +183,7 @@ public ApiError handleEntityDeletedException(EntityDeletedException e) { return ApiError.builder() .status(HttpStatus.NOT_FOUND) .reason("Restriction of removal") - .message("Restriction of removal" + e.getMessage()) + .message(e.getMessage()) .timestamp(LocalDateTime.now()) .build(); } 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 index c153ec9..48abe9f 100644 --- 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 @@ -1,19 +1,23 @@ -package ru.practicum.explorewithme.main.mapper; // Пример пакета +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 { - @Mapping(source = "category", target = "category") - @Mapping(source = "initiator", target = "initiator") - @Mapping(target = "confirmedRequests", expression = "java(0L)") // Временная заглушка - @Mapping(target = "views", expression = "java(0L)") // Временная заглушка + @Mappings({ + @Mapping(source = "category", target = "category"), + @Mapping(source = "initiator", target = "initiator"), + @Mapping(target = "confirmedRequests", expression = "java(0L)"), // Заглушка + @Mapping(target = "views", expression = "java(0L)") // Заглушка + }) EventFullDto toEventFullDto(Event event); @Mapping(target = "id", ignore = true) @@ -25,4 +29,14 @@ public interface EventMapper { Event toEvent(NewEventDto newEventDto); List toEventFullDtoList(List events); + + @Mappings({ + @Mapping(source = "category", target = "category"), + @Mapping(source = "initiator", target = "initiator"), + @Mapping(target = "confirmedRequests", expression = "java(0L)"), // Заглушка + @Mapping(target = "views", expression = "java(0L)") // Заглушка + }) + EventShortDto toEventShortDto(Event event); + + List toEventShortDtoList(List events); } 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 index 3567f3a..49d2912 100644 --- 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 @@ -1,10 +1,16 @@ 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; public interface EventRepository extends JpaRepository, QuerydslPredicateExecutor { + Page findByInitiatorId(Long userId, Pageable pageable); + + Optional findByIdAndInitiatorId(Long eventId, Long userId); boolean existsByCategoryId(Long categoryId); 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 index c525b44..148d15b 100644 --- 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 @@ -2,6 +2,9 @@ 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; @@ -12,5 +15,13 @@ List getEventsAdmin( 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); +} \ 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 index 39d2f78..7dbdc55 100644 --- 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 @@ -1,7 +1,10 @@ package ru.practicum.explorewithme.main.service; import com.querydsl.core.BooleanBuilder; -import com.querydsl.core.types.Predicate; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.Collections; +import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; @@ -11,25 +14,26 @@ 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.*; +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.User; import ru.practicum.explorewithme.main.repository.CategoryRepository; import ru.practicum.explorewithme.main.repository.EventRepository; - -import java.time.LocalDateTime; -import java.util.Collections; -import java.util.List; - import ru.practicum.explorewithme.main.repository.UserRepository; import ru.practicum.explorewithme.main.service.params.AdminEventSearchParams; @Service @RequiredArgsConstructor -@Transactional(readOnly = true) +@Transactional @Slf4j public class EventServiceImpl implements EventService { @@ -38,7 +42,10 @@ public class EventServiceImpl implements EventService { private final UserRepository userRepository; private final CategoryRepository categoryRepository; + private static final long MIN_HOURS_BEFORE_PUBLICATION_FOR_ADMIN = 1; + @Override + @Transactional(readOnly = true) public List getEventsAdmin(AdminEventSearchParams params, int from, int size) { @@ -53,6 +60,7 @@ public List getEventsAdmin(AdminEventSearchParams params, 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."); } @@ -81,8 +89,6 @@ public List getEventsAdmin(AdminEventSearchParams params, predicate.and(qEvent.eventDate.loe(rangeEnd)); // lower or equal } - Predicate finalPredicate = predicate.getValue(); - Pageable pageable = PageRequest.of(from / size, size, Sort.by(Sort.Direction.ASC, "id")); Page eventPage = eventRepository.findAll(predicate, pageable); @@ -96,7 +102,174 @@ public List getEventsAdmin(AdminEventSearchParams params, return result; } - @Transactional + @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); + + 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) { 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 index b2fcba9..d5bdf48 100644 --- 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 @@ -1,25 +1,9 @@ package ru.practicum.explorewithme.main.controller.admin; -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.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.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; @@ -27,10 +11,33 @@ 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 { @@ -43,139 +50,269 @@ class AdminEventControllerTest { @MockitoBean private EventService eventService; - @Test - @DisplayName("Поиск событий администратором: должен вернуть 200 OK и пустой список, если " - + "событий не найдено") - void searchEventsAdmin_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)); - } + @Nested + @DisplayName("GET /admin/events: Поиск событий администратором") + class GetEventsByAdminTests { - @Test - @DisplayName("Поиск событий администратором: должен вернуть 200 OK и список событий, если они" - + " найдены") - void searchEventsAdmin_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("Должен вернуть 200 OK и пустой список, если событий не найдено") + void whenNoEventsFound_shouldReturnOkAndEmptyList() throws Exception { + when(eventService.getEventsAdmin(any(AdminEventSearchParams.class), anyInt(), + anyInt())).thenReturn(Collections.emptyList()); - @Test - @DisplayName("Поиск событий администратором: должен корректно передавать все параметры " - + "фильтрации в сервис") - void searchEventsAdmin_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)); - } + 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))); - @Test - @DisplayName("Поиск событий администратором: должен использовать значения по умолчанию для " - + "from и size, если они не переданы") - void searchEventsAdmin_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)); - } + 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 searchEventsAdmin_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("Должен вернуть 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))); - @Test - @DisplayName("Поиск событий администратором: должен вернуть 400 Bad Request при невалидном " - + "значении 'size'") - void searchEventsAdmin_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); + 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); + } } - @Test - @DisplayName("Поиск событий администратором: должен вернуть 400 Bad Request при некорректном " - + "формате rangeStart") - void searchEventsAdmin_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/priv/PrivateEventControllerTest.java b/main-service/src/test/java/ru/practicum/explorewithme/main/controller/priv/PrivateEventControllerTest.java new file mode 100644 index 0000000..4c104ef --- /dev/null +++ b/main-service/src/test/java/ru/practicum/explorewithme/main/controller/priv/PrivateEventControllerTest.java @@ -0,0 +1,298 @@ +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; + +@WebMvcTest(PrivateEventController.class) +@DisplayName("Тесты для PrivateEventController") +class PrivateEventControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; // Может пригодиться в дальнейших тестах + + @MockitoBean + private EventService eventService; + + 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/service/EventServiceImplTest.java b/main-service/src/test/java/ru/practicum/explorewithme/main/service/EventServiceImplTest.java index f83b1e9..a827b4f 100644 --- 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 @@ -3,10 +3,12 @@ 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; @@ -34,6 +36,8 @@ import org.springframework.data.domain.Sort; import ru.practicum.explorewithme.main.dto.EventFullDto; 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; @@ -453,5 +457,435 @@ void addEventPrivate_shouldSetInitiatorAndCategoryCorrectly() { } } + @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() { + // Arrange + 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()); + } + } + // ... TODO: Добавить тесты для других методов EventService, когда они появятся ... } \ 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 index 5e430cf..851ae70 100644 --- 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 @@ -1,5 +1,6 @@ package ru.practicum.explorewithme.main.service; +import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -13,7 +14,10 @@ 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.*; @@ -230,4 +234,382 @@ void getEventsAdmin_whenNoEventsMatchCriteria_thenReturnsEmptyList() { 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 = userRepository.save(User.builder().name("User Three").email("user3@events.com").build()); + 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)); + } + } } \ No newline at end of file From 8eeea90adcd42dae8a385f6a0a6d8876913cfdb7 Mon Sep 17 00:00:00 2001 From: Sergey Filippovskikh <116564864+SergikF@users.noreply.github.com> Date: Thu, 22 May 2025 23:46:18 +0300 Subject: [PATCH 50/73] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BE?= =?UTF-8?q?=D0=BA=20=D1=81=D1=82=D0=B8=D0=BB=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main-service/src/main/resources/schema.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main-service/src/main/resources/schema.sql b/main-service/src/main/resources/schema.sql index 047a6dc..ca97922 100644 --- a/main-service/src/main/resources/schema.sql +++ b/main-service/src/main/resources/schema.sql @@ -28,7 +28,7 @@ CREATE TABLE IF NOT EXISTS events ( lon REAL NOT NULL, paid BOOLEAN NOT NULL DEFAULT FALSE, participant_limit INTEGER NOT NULL DEFAULT 0, - published_on TIMESTAMP WITHOUT TIME ZONE NOT NULL, + published_on TIMESTAMP WITHOUT TIME ZONE, request_moderation BOOLEAN NOT NULL DEFAULT TRUE, state VARCHAR(20) NOT NULL, title VARCHAR(120) NOT NULL, From bbcb2ed3144d03963634fad3e4645c4248284537 Mon Sep 17 00:00:00 2001 From: Sergey Filippovskikh <116564864+SergikF@users.noreply.github.com> Date: Fri, 23 May 2025 00:24:18 +0300 Subject: [PATCH 51/73] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B7=D0=B0=D0=BC=D0=B5=D1=87?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../explorewithme/main/service/RequestServiceImpl.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 9be77fa..697c8a9 100644 --- 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 @@ -45,7 +45,7 @@ public ParticipationRequestDto cancelRequest(Long userId, Long requestId) { } @Override - @Transactional + @Transactional(readOnly = true) public List getRequests(Long userId) { userRepository.findById(userId) .orElseThrow(() -> new EntityNotFoundException("User", "Id", userId)); @@ -56,7 +56,7 @@ public List getRequests(Long userId) { } @Override - @Transactional + @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); From b45fbc8cac6d0af40299da35fc86da66b05f7c86 Mon Sep 17 00:00:00 2001 From: Sergey Filippovskikh <116564864+SergikF@users.noreply.github.com> Date: Fri, 23 May 2025 00:55:28 +0300 Subject: [PATCH 52/73] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B7=D0=B0=D0=BC=D0=B5=D1=87?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/controller/priv/PrivateEventControllerTest.java | 5 +++++ 1 file changed, 5 insertions(+) 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 index 4c104ef..ca11b26 100644 --- 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 @@ -33,6 +33,7 @@ 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") @@ -47,6 +48,10 @@ class PrivateEventControllerTest { @MockitoBean private EventService eventService; + @MockitoBean + private RequestService requestService; + + private final Long testUserId = 1L; private final DateTimeFormatter formatter = DATE_TIME_FORMATTER; From 1b80f3456731f385f4d4c573951eee7cad53edff Mon Sep 17 00:00:00 2001 From: Sergey Filippovskikh <116564864+SergikF@users.noreply.github.com> Date: Sat, 24 May 2025 15:59:35 +0300 Subject: [PATCH 53/73] =?UTF-8?q?PRIVATE-EVENTS:=20=D0=98=D0=B7=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=81=D1=82=D0=B0=D1=82?= =?UTF-8?q?=D1=83=D1=81=D0=B0=20=D0=B7=D0=B0=D1=8F=D0=B2=D0=BE=D0=BA=20(?= =?UTF-8?q?=D0=BF=D0=BE=D0=B4=D1=82=D0=B2=D0=B5=D1=80=D0=B6=D0=B4=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5/=D0=BE=D1=82=D0=BA=D0=BB=D0=BE=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5)=20#54=20(#75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * PRIVATE-EVENTS: Изменение статуса заявок (подтверждение/отклонение) #54 * Исправление ошибок стиля * Исправление ошибок кода * Исправление ошибок кода * Исправление ошибок кода * Исправление ошибок кода * Исправление ошибок кода выявленных при помощи постмана * изменение кода по замечаниям и не только Изменение способа отказа заявок в случае отсуствия свободных мест List updatePendingRequestsToRejected(Long eventId) * Изменена логика "свободных мест" * Исправление стиля * Изменение логики отказа при отсутствии свободных мест * Изменение логики отказа при отсутствии свободных мест * Изменение логики отказа при отсутствии свободных мест --- .../priv/PrivateEventController.java | 26 ++++- .../priv/PrivateRequestController.java | 6 +- .../EventRequestStatusUpdateRequestDto.java | 25 +++++ .../EventRequestStatusUpdateResultDto.java | 23 +++++ .../main/dto/ParticipationRequestDto.java | 7 ++ .../main/repository/RequestRepository.java | 14 +++ .../main/service/RequestService.java | 4 + .../main/service/RequestServiceImpl.java | 98 ++++++++++++++++--- ...EventRequestStatusUpdateRequestParams.java | 20 ++++ .../priv/PrivateEventControllerTest.java | 1 - 10 files changed, 205 insertions(+), 19 deletions(-) create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/dto/EventRequestStatusUpdateRequestDto.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/dto/EventRequestStatusUpdateResultDto.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/service/params/EventRequestStatusUpdateRequestParams.java 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 index e8b2d86..0d247ae 100644 --- 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 @@ -8,15 +8,20 @@ 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 @@ -129,6 +134,23 @@ public List getEventRequests( } - // TODO: PATCH /users/{userId}/events/{eventId}/requests - Изменение статуса заявок (подтверждение/отклонение) (EventRequestStatusUpdateRequest -> EventRequestStatusUpdateResult) - // (Задача: PRIVATE-EVENTS: Изменение статуса заявок (подтверждение/отклонение)) + @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 index 504770f..acf6494 100644 --- 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 @@ -25,9 +25,9 @@ public class PrivateRequestController { @ResponseStatus(HttpStatus.CREATED) public ParticipationRequestDto createRequest( @PathVariable @Positive Long userId, - @RequestParam @Positive Long requestEventId) { - log.info("Private: Received request to add user {} in event: {}", userId, requestEventId); - ParticipationRequestDto result = requestService.createRequest(userId, requestEventId); + @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; } 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..3806dda --- /dev/null +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/EventRequestStatusUpdateRequestDto.java @@ -0,0 +1,25 @@ +package ru.practicum.explorewithme.main.dto; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import ru.practicum.explorewithme.main.model.RequestStatus; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +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..4ad51f0 --- /dev/null +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/EventRequestStatusUpdateResultDto.java @@ -0,0 +1,23 @@ +package ru.practicum.explorewithme.main.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class EventRequestStatusUpdateResultDto { + + @Builder.Default + 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/ParticipationRequestDto.java b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/ParticipationRequestDto.java index cd243f9..f8947b4 100644 --- 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 @@ -1,10 +1,14 @@ package ru.practicum.explorewithme.main.dto; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonProperty; import lombok.*; 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 @@ -13,10 +17,13 @@ public class ParticipationRequestDto { private Long id; + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = DATE_TIME_FORMAT_PATTERN) private LocalDateTime created; + @JsonProperty("requester") private Long requesterId; + @JsonProperty("event") private Long eventId; private RequestStatus status; 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 index df2b2ef..d40816d 100644 --- 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 @@ -1,6 +1,9 @@ package ru.practicum.explorewithme.main.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import ru.practicum.explorewithme.main.model.ParticipationRequest; import ru.practicum.explorewithme.main.model.RequestStatus; @@ -19,5 +22,16 @@ public interface RequestRepository extends JpaRepository 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); } \ 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 index 2144a01..44aa356 100644 --- 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 @@ -1,7 +1,9 @@ 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; @@ -15,4 +17,6 @@ public interface RequestService { 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 index 697c8a9..4b558fc 100644 --- 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 @@ -1,8 +1,10 @@ package ru.practicum.explorewithme.main.service; +import jakarta.persistence.EntityManager; 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; @@ -11,9 +13,12 @@ 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 @@ -24,6 +29,7 @@ public class RequestServiceImpl implements RequestService { private final EventRepository eventRepository; private final UserRepository userRepository; + private final EntityManager entityManager; @Override @Transactional @@ -36,7 +42,7 @@ public ParticipationRequestDto createRequest(Long userId, Long requestEventId) { @Override @Transactional public ParticipationRequestDto cancelRequest(Long userId, Long requestId) { - ParticipationRequest result = requestRepository.findByIdAndRequester_Id(requestId,userId) + ParticipationRequest result = requestRepository.findByIdAndRequester_Id(requestId, userId) .orElseThrow(() -> new EntityNotFoundException("User with Id = " + userId + " and Request", "Id", userId)); result.setStatus(RequestStatus.CANCELED); @@ -66,42 +72,108 @@ public List getEventRequests(Long userId, Long eventId) return result; } - private ParticipationRequest checkRequest(Long userId, Long requestEventId) { + @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()) { - newRequest.setStatus(RequestStatus.PENDING); - } else { + 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/params/EventRequestStatusUpdateRequestParams.java b/main-service/src/main/java/ru/practicum/explorewithme/main/service/params/EventRequestStatusUpdateRequestParams.java new file mode 100644 index 0000000..a70dc72 --- /dev/null +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/service/params/EventRequestStatusUpdateRequestParams.java @@ -0,0 +1,20 @@ +package ru.practicum.explorewithme.main.service.params; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import ru.practicum.explorewithme.main.model.RequestStatus; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class EventRequestStatusUpdateRequestParams { + Long userId; + Long eventId; + List requestIds; + RequestStatus status; +} 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 index ca11b26..ff1be9b 100644 --- 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 @@ -51,7 +51,6 @@ class PrivateEventControllerTest { @MockitoBean private RequestService requestService; - private final Long testUserId = 1L; private final DateTimeFormatter formatter = DATE_TIME_FORMATTER; From bb994fd1250e05deb62b01b1a2084f2b0290b1fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9B=D0=B5=D1=80=D0=B0?= <148673675+progingir@users.noreply.github.com> Date: Mon, 26 May 2025 01:54:01 +0500 Subject: [PATCH 54/73] main-svc-compilations (#77) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * проба * реализовала get /complications and get/complications{compd} * стиль * правки * добавила аннотацию билдер * закомментила тест * стиль * добавила админ контроллер * добавила еще один контроллер * это кошмар * :) * откат изменений * ошибки 7, 8, 10, 11 * ошибка1 * ошибка2 * ошибка4 * ошибки56 * откат изменений * пробую исправить дату * откат изменений * правки на pom.xml * правки1 * правки2 * правки3 * удалила изменения в statService * разбираюсь с датой * test now passes with correct date format * reset "stats-service" to main branch version --------- Co-authored-by: Pepe Ronin --- main-service/pom.xml | 6 +- .../admin/AdminCompilationController.java | 51 ++++++ .../pub/PublicCompilationController.java | 45 +++++ .../controller/pub/PublicEventController.java | 75 +++++++++ .../main/dto/CompilationDto.java | 18 ++ .../explorewithme/main/dto/EventShortDto.java | 20 +-- .../main/dto/NewCompilationDto.java | 21 +++ .../explorewithme/main/dto/NewEventDto.java | 2 + .../main/dto/UpdateCompilationRequestDto.java | 17 ++ .../main/mapper/CompilationMapper.java | 24 +++ .../repository/CompilationRepository.java | 27 +++ .../main/service/CompilationService.java | 19 +++ .../main/service/CompilationServiceImpl.java | 155 ++++++++++++++++++ .../main/service/EventService.java | 5 + .../main/service/EventServiceImpl.java | 131 ++++++++++++--- .../params/PublicEventSearchParams.java | 19 +++ 16 files changed, 600 insertions(+), 35 deletions(-) create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/controller/admin/AdminCompilationController.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/controller/pub/PublicCompilationController.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/controller/pub/PublicEventController.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/dto/CompilationDto.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/dto/NewCompilationDto.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/dto/UpdateCompilationRequestDto.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/mapper/CompilationMapper.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/repository/CompilationRepository.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/service/CompilationService.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/service/CompilationServiceImpl.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/service/params/PublicEventSearchParams.java diff --git a/main-service/pom.xml b/main-service/pom.xml index 14b3aff..dfc3f8a 100644 --- a/main-service/pom.xml +++ b/main-service/pom.xml @@ -26,6 +26,11 @@ stats-client ${project.version} + + ru.practicum + stats-dto + ${project.version} + ru.practicum ewm-common @@ -96,5 +101,4 @@
- \ 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/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..a2c596d --- /dev/null +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/controller/pub/PublicEventController.java @@ -0,0 +1,75 @@ +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.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) + 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, ipAddress); + log.info("Public: Found {} events", events.size()); + return events; + } + + @GetMapping("/{eventId}") + @ResponseStatus(HttpStatus.OK) + 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, ipAddress); + 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/CompilationDto.java b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/CompilationDto.java new file mode 100644 index 0000000..5089342 --- /dev/null +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/CompilationDto.java @@ -0,0 +1,18 @@ +package ru.practicum.explorewithme.main.dto; + +import lombok.*; +import lombok.experimental.FieldDefaults; + +import java.util.Set; + +@Getter +@Setter +@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/EventShortDto.java b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/EventShortDto.java index 8af745b..0eb8c76 100644 --- 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 @@ -1,20 +1,18 @@ 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 lombok.*; + import java.time.LocalDateTime; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; -@Data -@Builder -@NoArgsConstructor +import static ru.practicum.explorewithme.common.constants.DateTimeConstants.DATE_TIME_FORMAT_PATTERN; + +@Getter +@Setter @AllArgsConstructor +@NoArgsConstructor +@Builder public class EventShortDto { - private Long id; private String annotation; private CategoryDto category; @@ -22,7 +20,7 @@ public class EventShortDto { @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = DATE_TIME_FORMAT_PATTERN) private LocalDateTime eventDate; private UserShortDto initiator; - private boolean paid; + private Boolean paid; private String title; private Long views; } \ 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..d958196 --- /dev/null +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/NewCompilationDto.java @@ -0,0 +1,21 @@ +package ru.practicum.explorewithme.main.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.*; + +import java.util.List; + +@Builder +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +public class NewCompilationDto { + @Builder.Default + private Boolean pinned = false; + @NotBlank(message = "Название подборки не может быть пустым") + @Size(max = 50, message = "Название подборки должно быть до 50 символов") + private String title; + private 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 index ab63ece..4130a07 100644 --- 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 @@ -4,6 +4,7 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; 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; @@ -43,6 +44,7 @@ public class NewEventDto { Boolean paid = false; @Builder.Default + @PositiveOrZero(message = "Participant limit must be positive or zero") Long participantLimit = 0L; @Builder.Default 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..fa24e75 --- /dev/null +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/UpdateCompilationRequestDto.java @@ -0,0 +1,17 @@ +package ru.practicum.explorewithme.main.dto; + +import jakarta.validation.constraints.Size; +import lombok.*; + +import java.util.List; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +public class UpdateCompilationRequestDto { + private Boolean pinned; + @Size(min = 1, max = 50, message = "Название подборки должно быть от 1 до 50 символов") + private String title; + private List events; +} \ 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/repository/CompilationRepository.java b/main-service/src/main/java/ru/practicum/explorewithme/main/repository/CompilationRepository.java new file mode 100644 index 0000000..da6c4b7 --- /dev/null +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/repository/CompilationRepository.java @@ -0,0 +1,27 @@ +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; + +import java.util.List; + +@Repository +public interface CompilationRepository extends JpaRepository { + + @EntityGraph(attributePaths = {"events", "events.category", "events.initiator"}) + List 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/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..e4fda55 --- /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) + : 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) + : 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 index 148d15b..2350b09 100644 --- 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 @@ -7,6 +7,7 @@ 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( @@ -24,4 +25,8 @@ List getEventsAdmin( EventFullDto updateEventByOwner(Long userId, Long eventId, UpdateEventUserRequestDto requestDto); EventFullDto moderateEventByAdmin(Long eventId, UpdateEventAdminRequestDto requestDto); + + List getEventsPublic(PublicEventSearchParams params, int from, int size, String ipAddress); + + EventFullDto getEventByIdPublic(Long eventId, String ipAddress); } \ 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 index 7dbdc55..ec83707 100644 --- 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 @@ -1,10 +1,12 @@ 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.List; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; @@ -30,6 +32,7 @@ import ru.practicum.explorewithme.main.repository.EventRepository; import ru.practicum.explorewithme.main.repository.UserRepository; import ru.practicum.explorewithme.main.service.params.AdminEventSearchParams; +import ru.practicum.explorewithme.main.service.params.PublicEventSearchParams; @Service @RequiredArgsConstructor @@ -46,10 +49,95 @@ public class EventServiceImpl implements EventService { @Override @Transactional(readOnly = true) - public List getEventsAdmin(AdminEventSearchParams params, - int from, - int size) { + public List getEventsPublic(PublicEventSearchParams params, int from, int size, String ipAddress) { + log.debug("Public search for events with params: {}", params); + + 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("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().contains(searchText) + .or(qEvent.description.lower().contains(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) { + predicate.and(qEvent.eventDate.goe(rangeStart)); + } else { + predicate.and(qEvent.eventDate.goe(LocalDateTime.now())); + } + + if (rangeEnd != null) { + predicate.and(qEvent.eventDate.loe(rangeEnd)); + } + + if (onlyAvailable) { + // Заглушка: проверка на доступность (participantLimit > confirmedRequests) + predicate.and(qEvent.participantLimit.eq(0)); + } + + Sort sortOption = sort != null && sort.equals("VIEWS") ? + Sort.by(Sort.Direction.DESC, "views") : + 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(); + } + + // TODO: Реализовать логику учета просмотров через ipAddress (StatsClient) + List result = eventMapper.toEventShortDtoList(eventPage.getContent()); + log.debug("Public search found {} events", result.size()); + return result; + } + + @Override + @Transactional(readOnly = true) + public EventFullDto getEventByIdPublic(Long eventId, String ipAddress) { + log.debug("Public: Fetching event id={}", eventId); + + Event event = eventRepository.findById(eventId) + .orElseThrow(() -> new EntityNotFoundException("Event with id=" + eventId + " not found.")); + + if (event.getState() != EventState.PUBLISHED) { + throw new EntityNotFoundException("Event with id=" + eventId + " is not published."); + } + + // TODO: Реализовать логику учета просмотров через ipAddress (StatsClient) + EventFullDto result = eventMapper.toEventFullDto(event); + log.debug("Public: Found event: {}", result); + return result; + } + @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(); @@ -57,7 +145,7 @@ public List getEventsAdmin(AdminEventSearchParams params, 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); + 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); @@ -68,7 +156,6 @@ public List getEventsAdmin(AdminEventSearchParams params, BooleanBuilder predicate = new BooleanBuilder(); if (users != null && !users.isEmpty()) { - // TODO: Возможно, стоит проверить, существуют ли такие пользователи, если это требуется по логике predicate.and(qEvent.initiator.id.in(users)); } @@ -77,16 +164,15 @@ public List getEventsAdmin(AdminEventSearchParams params, } if (categories != null && !categories.isEmpty()) { - // TODO: Возможно, стоит проверить, существуют ли такие категории predicate.and(qEvent.category.id.in(categories)); } if (rangeStart != null) { - predicate.and(qEvent.eventDate.goe(rangeStart)); // greater or equal + predicate.and(qEvent.eventDate.goe(rangeStart)); } if (rangeEnd != null) { - predicate.and(qEvent.eventDate.loe(rangeEnd)); // lower or equal + predicate.and(qEvent.eventDate.loe(rangeEnd)); } Pageable pageable = PageRequest.of(from / size, size, Sort.by(Sort.Direction.ASC, "id")); @@ -107,14 +193,14 @@ public EventFullDto moderateEventByAdmin(Long eventId, UpdateEventAdminRequestDt log.info("Admin: Moderating event id={} with data: {}", eventId, requestDto); Event event = eventRepository.findById(eventId) - .orElseThrow(() -> new EntityNotFoundException("Event with id=" + eventId + " not found.")); + .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.")); + .orElseThrow(() -> new EntityNotFoundException("Category with id=" + requestDto.getCategory() + " not found for event update.")); event.setCategory(category); } if (requestDto.getDescription() != null) { @@ -144,12 +230,12 @@ public EventFullDto moderateEventByAdmin(Long eventId, UpdateEventAdminRequestDt 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()); + "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())); + 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)); @@ -157,7 +243,7 @@ public EventFullDto moderateEventByAdmin(Long eventId, UpdateEventAdminRequestDt 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()); + "Cannot reject the event because it has already been published. Current state: " + event.getState()); } event.setState(EventState.CANCELED); break; @@ -198,8 +284,8 @@ public EventFullDto updateEventByOwner(Long userId, Long eventId, UpdateEventUse 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))); + .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()); @@ -210,16 +296,16 @@ public EventFullDto updateEventByOwner(Long userId, Long eventId, UpdateEventUse } if (requestDto.getCategory() != null) { Category category = categoryRepository.findById(requestDto.getCategory()) - .orElseThrow(() -> new EntityNotFoundException("Category with id=" + requestDto.getCategory() + " not found.")); + .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."); - } + 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) { @@ -262,8 +348,8 @@ public EventFullDto getEventPrivate(Long userId, Long eventId) { log.debug("Fetching event id: {} for user id: {}", eventId, userId); Event event = eventRepository.findByIdAndInitiatorId(eventId, userId) - .orElseThrow(() -> new EntityNotFoundException( - String.format("Event with id=%d and initiatorId=%d not found", 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); @@ -272,7 +358,6 @@ public EventFullDto getEventPrivate(Long userId, Long eventId) { @Override public EventFullDto addEventPrivate(Long userId, NewEventDto newEventDto) { - log.info("Добавление события {} пользователем {}", newEventDto, userId); User user = userRepository.findById(userId).orElseThrow(() -> new EntityNotFoundException("Пользователь " + 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..ef0f769 --- /dev/null +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/service/params/PublicEventSearchParams.java @@ -0,0 +1,19 @@ +package ru.practicum.explorewithme.main.service.params; + +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Builder +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 From d483167caa95f146e16da7ea09e16581441e1574 Mon Sep 17 00:00:00 2001 From: impatient0 Date: Mon, 26 May 2025 09:00:27 +0300 Subject: [PATCH 55/73] =?UTF-8?q?=D0=9F=D1=83=D0=B1=D0=BB=D0=B8=D1=87?= =?UTF-8?q?=D0=BD=D1=8B=D0=B5=20API=20=D1=81=D0=BE=D0=B1=D1=8B=D1=82=D0=B8?= =?UTF-8?q?=D0=B9=20=D0=B8=20=D0=B8=D0=BD=D1=82=D0=B5=D0=B3=D1=80=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8F=20StatsClient=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * create the hit logging aspect * apply the stat aspect to relevant controller methods * change logging level for local config * change the way StatsClient is pulled * add unit tests for hit logging aspect * fix hit stat aspect to correctly process the IP header * add PublicEventController tests * fully implement view & request stats in EventServiceImpl * remove stubbings from mapper * add more unit tests for EventServiceImpl * sort by views manually as DB doesn't have views data * add more EventService integration tests * update EventMapper tests * remove redundant argument from service methods * improve onlyAvailable filtering efficiency * set views and confirmedRequests in getEventsAdmin * switch to @Formula for confirmedRequests * add validation to DTOs because tests expect 400 even though API contract clearly states 409 * update EventMapper tests * cleanup * fix test not persisting entities for some reason * cleanup * update README.md --------- Co-authored-by: Pepe Ronin --- README.md | 156 +++---- main-service/pom.xml | 4 + .../main/MainServiceApplication.java | 3 + .../main/aspect/LogStatsHit.java | 11 + .../main/aspect/StatsHitAspect.java | 75 ++++ .../controller/pub/PublicEventController.java | 7 +- .../explorewithme/main/dto/NewEventDto.java | 2 + .../main/dto/UpdateEventAdminRequestDto.java | 2 + .../main/dto/UpdateEventUserRequestDto.java | 2 + .../main/mapper/EventMapper.java | 7 +- .../explorewithme/main/model/Event.java | 7 + .../main/repository/EventRepository.java | 3 + .../main/repository/RequestRepository.java | 15 + .../main/service/EventService.java | 4 +- .../main/service/EventServiceImpl.java | 177 ++++++-- .../params/PublicEventSearchParams.java | 2 + .../src/main/resources/application-local.yaml | 6 +- .../main/aspect/StatsHitAspectTest.java | 115 ++++++ .../pub/PublicEventControllerTest.java | 379 ++++++++++++++++++ .../main/mapper/EventMapperTest.java | 139 ++++++- .../main/service/EventServiceImplTest.java | 161 +++++++- .../service/EventServiceIntegrationTest.java | 302 +++++++++++++- .../stats/client/StatsClientImpl.java | 2 + .../StatsClientModuleConfiguration.java | 9 + 24 files changed, 1457 insertions(+), 133 deletions(-) create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/aspect/LogStatsHit.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/aspect/StatsHitAspect.java create mode 100644 main-service/src/test/java/ru/practicum/explorewithme/main/aspect/StatsHitAspectTest.java create mode 100644 main-service/src/test/java/ru/practicum/explorewithme/main/controller/pub/PublicEventControllerTest.java create mode 100644 stats-service/stats-client/src/main/java/ru/practicum/explorewithme/stats/client/config/StatsClientModuleConfiguration.java diff --git a/README.md b/README.md index 980a2bd..c4c1792 100644 --- a/README.md +++ b/README.md @@ -6,13 +6,15 @@ - [Технологии](#технологии) - [Структура проекта](#структура-проекта) +- [API Спецификации](#api-спецификации) - [Начало работы](#начало-работы) - [Предварительные требования](#предварительные-требования) - [Сборка проекта](#сборка-проекта) - [Запуск с использованием Docker Compose](#запуск-с-использованием-docker-compose) - [Локальный запуск для разработки (IntelliJ IDEA)](#локальный-запуск-для-разработки-intellij-idea) - - [Локальный запуск Stats Service](#локальный-запуск-stats-service) - - [Локальный запуск Main Service](#локальный-запуск-main-service) + - [Локальный запуск Stats Service](#локальный-запуск-stats-service-stats-server) + - [Локальный запуск Main Service](#локальный-запуск-main-service-main-service) +- [Примеры использования API (публичные эндпоинты)](#примеры-использования-api-публичные-эндпоинты) - [Тестирование](#тестирование) - [Дополнительная функциональность](#дополнительная-функциональность) - [Планы по использованию OpenAPI Generator](#планы-по-использованию-openapi-generator) @@ -21,13 +23,14 @@ ## Технологии - Java 21 -- Spring Boot 3.4.5 # Убедитесь, что версия актуальна (в вашем корневом pom.xml указана эта версия) -- Spring Data JPA -- Spring MVC -- PostgreSQL 16.1 # Можно уточнить версию PostgreSQL +- 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 (для контроля качества кода) @@ -37,14 +40,23 @@ Проект является многомодульным Maven-проектом и состоит из следующих основных частей: - `explore-with-me` (корневой POM) - - `ewm-common`: Общий модуль, содержащий классы, используемые как основным сервисом, так и сервисом статистики (например, `ApiError.java`). - - `main-service`: Основной сервис приложения. Отвечает за бизнес-логику, управление пользователями, событиями, категориями, подборками и запросами на участие. + - `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-клиент для взаимодействия с сервисом статистики (используется `main-service`). - - `stats-server`: Сервис статистики (сбор и предоставление данных о запросах к эндпоинтам). - - `Dockerfile` + * `stats-client`: HTTP-клиент для взаимодействия с API сервиса статистики (используется `main-service`). + * `stats-server`: Сервис статистики (сбор и предоставление данных о запросах к эндпоинтам). + * `Dockerfile` + * `schema.sql` (для инициализации схемы БД `ewm_stats_db`) + +## API Спецификации + +- [Спецификация основного сервиса (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://raw.githubusercontent.com/yandex-praktikum/java-explore-with-me/main/ewm-stats-service-spec.json) + +*Рекомендуется просматривать через Swagger Editor или аналогичный инструмент.* ## Начало работы @@ -52,45 +64,37 @@ Для работы с проектом вам понадобятся: -- JDK 21 (или выше, совместимая с Java 21) +- JDK 21 - Apache Maven 3.6+ - Docker и Docker Compose -- IntelliJ IDEA (рекомендуется) или другая IDE с поддержкой Maven и Spring Boot. +- IntelliJ IDEA (рекомендуется) ### Сборка проекта -Для сборки всех модулей проекта выполните следующую команду в корневой директории: - +Для сборки всех модулей проекта (включая генерацию Q-типов QueryDSL и реализаций MapStruct) выполните: ```bash mvn clean install ``` -Эта команда также запустит статические анализаторы кода (Checkstyle, Spotbugs) и юнит-тесты. +Эта команда также запустит статические анализаторы кода и юнит-тесты. ### Запуск с использованием Docker Compose -Наиболее предпочтительный способ запуска всего приложения – использование Docker Compose. Это обеспечит запуск всех сервисов (`main-service`, `stats-server`) и их соответствующих баз данных PostgreSQL в изолированных контейнерах. +Это основной способ запуска всего приложения для проверки взаимодействия сервисов. -1. **Соберите проект (если не делали ранее):** - ```bash - mvn clean install - ``` +1. **Соберите проект:** `mvn clean install` 2. **Запустите сервисы:** В корневой директории проекта выполните: ```bash docker-compose up --build -d ``` - Ключ `-d` запускает контейнеры в фоновом режиме. - Эта команда соберет Docker-образы для `stats-server` и `main-service` и запустит их вместе с необходимыми базами данных PostgreSQL. - - Сервис статистики (`stats-server`) будет доступен по адресу: `http://localhost:9090` - - Основной сервис (`main-service`) будет доступен по адресу: `http://localhost:8080` + - Сервис статистики (`stats-server`): `http://localhost:9090` + - Основной сервис (`main-service`): `http://localhost:8080` -3. **Просмотр логов (при запуске с `-d`):** +3. **Просмотр логов:** ```bash docker-compose logs -f main-service docker-compose logs -f stats-server - # или docker-compose logs -f для всех сервисов ``` - 4. **Остановка сервисов:** ```bash docker-compose down @@ -99,73 +103,79 @@ mvn clean install ```bash docker-compose down -v ``` + *Примечание: При первом запуске `docker-compose up` скрипты `schema.sql` из каждого сервиса будут выполнены для создания таблиц в соответствующих базах данных.* ### Локальный запуск для разработки (IntelliJ IDEA) -Для удобства разработки и отладки можно запускать сервисы локально из IntelliJ IDEA. +#### Локальный запуск Stats Service (`stats-server`) -#### Локальный запуск Stats Service +Предусмотрен профиль запуска `stat-local` в IntelliJ IDEA. -Предусмотрен профиль запуска `stat-local` в IntelliJ IDEA для `stats-server`. - -1. **Настройка базы данных для `stats-server`:** - Убедитесь, что у вас локально запущен экземпляр PostgreSQL, доступный по адресу, указанному в `stats-service/stats-server/src/main/resources/application-local.yml`. - Примерные параметры для `application-local.yml`: +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 # Убедитесь, что порт и имя БД соответствуют вашей локальной PG для stats-db - username: stats_user # Ваш пользователь - password: stats_password # Ваш пароль + url: jdbc:postgresql://localhost:6543/ewm_stats_db # Пример + username: stats_user + password: stats_password jpa: hibernate: - ddl-auto: update # или create-drop для локальной разработки - # ... другие настройки, если нужны ... + ddl-auto: validate # Используется schema.sql из classpath (src/main/resources) + sql: + init: + mode: always # Для выполнения schema.sql при локальном запуске ``` - *Примечание: Вам может потребоваться создать базу данных `ewm_stats_db` и пользователя `stats_user` вручную, если они еще не существуют.* - -2. **Запуск `StatsServerApplication`:** - - Откройте проект в IntelliJ IDEA. - - Найдите класс `StatsServerApplication.java` в модуле `stats-server`. - - В репозитории должна быть предустановленная Run Configuration "stat-local" (проверьте `.idea/runConfigurations/`). Если нет, создайте новую конфигурацию Spring Boot: - - **Main class:** `ru.practicum.explorewithme.stats.server.StatsServerApplication` - - **VM options:** `-Dspring.profiles.active=local` (активирует `application-local.yml`) - - **Working directory:** Корневая директория модуля `stats-server`. - - Запустите эту конфигурацию. +2. **Запуск `StatsServerApplication`:** Используйте Run Configuration "stat-local" (VM options: `-Dspring.profiles.active=local`). -#### Локальный запуск Main Service +#### Локальный запуск Main Service (`main-service`) -Аналогично можно настроить локальный запуск для `main-service`. +Предусмотрен профиль запуска `main-local` в IntelliJ IDEA. -1. **Настройка базы данных для `main-service`:** - Убедитесь, что у вас локально запущен экземпляр PostgreSQL, доступный по адресу, указанному в `main-service/src/main/resources/application-local.yml`. - Создайте файл `application-local.yml` в `main-service/src/main/resources/` (если его еще нет) с примерным содержанием: +1. **База данных для `main-service`:** Настройте локальный PostgreSQL согласно `main-service/src/main/resources/application-local.yml`. ```yaml - # URL сервиса статистики для локального запуска main-service, - # если stats-server тоже запущен локально на порту 9090 + # main-service/src/main/resources/application-local.yml stats-server: - url: http://localhost:9090 + url: http://localhost:9090 # Если stats-server тоже запущен локально spring: datasource: - url: jdbc:postgresql://localhost:5432/ewm_main_db # Убедитесь, что порт и имя БД соответствуют вашей локальной PG для ewm-db - username: ewm_user # Ваш пользователь - password: ewm_password # Ваш пароль + url: jdbc:postgresql://localhost:5432/ewm_main_db # Пример + username: ewm_user + password: ewm_password jpa: hibernate: - ddl-auto: update # или create-drop для локальной разработки - # ... другие настройки, если нужны ... + ddl-auto: validate # Используется schema.sql из classpath + sql: + init: + mode: always # Для выполнения schema.sql при локальном запуске ``` - *Примечание: Вам может потребоваться создать базу данных `ewm_main_db` и пользователя `ewm_user` вручную, если они еще не существуют.* - *Также убедитесь, что сервис статистики (`stats-server`) запущен (локально или в Docker), если `main-service` будет к нему обращаться.* - -2. **Запуск `MainServiceApplication`:** - - Найдите класс `MainServiceApplication.java` в модуле `main-service`. - - В репозитории должна быть предустановленная Run Configuration "main-local" (проверьте `.idea/runConfigurations/`). Если нет, создайте новую конфигурацию Spring Boot: - - **Main class:** `ru.practicum.explorewithme.main.MainServiceApplication` - - **VM options:** `-Dspring.profiles.active=local` (активирует `application-local.yml`) - - **Working directory:** Корневая директория модуля `main-service`. - - Запустите эту конфигурацию. +2. **Запуск `MainServiceApplication`:** Используйте Run Configuration "main-local" (VM options: `-Dspring.profiles.active=local`). Убедитесь, что `stats-server` уже запущен (локально или в Docker), так как `main-service` от него зависит. + +## Примеры использования API (публичные эндпоинты) + +После запуска `main-service` (например, через Docker Compose на `http://localhost:8080`), вы можете протестировать публичные эндпоинты: + +- **Получение списка событий с фильтрацией:** + `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-кодировать даты и время, если отправляете запрос не через Postman)* + +- **Получение подробной информации о событии:** + `GET http://localhost:8080/events/{eventId}` (замените `{eventId}` на ID существующего опубликованного события) + +- **Получение списка категорий:** + `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}` + +*(Для админских и приватных эндпоинтов потребуется аутентификация, которая в данном проекте предполагается внешней).* ## Тестирование diff --git a/main-service/pom.xml b/main-service/pom.xml index dfc3f8a..1ac60b0 100644 --- a/main-service/pom.xml +++ b/main-service/pom.xml @@ -62,6 +62,10 @@ org.springframework.boot spring-boot-starter-validation + + org.springframework.boot + spring-boot-starter-aop + org.springframework.boot spring-boot-starter-test 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 index 7ca3ccb..7856da2 100644 --- a/main-service/src/main/java/ru/practicum/explorewithme/main/MainServiceApplication.java +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/MainServiceApplication.java @@ -2,8 +2,11 @@ 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/controller/pub/PublicEventController.java b/main-service/src/main/java/ru/practicum/explorewithme/main/controller/pub/PublicEventController.java index a2c596d..95df592 100644 --- 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 @@ -8,6 +8,7 @@ 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; @@ -29,6 +30,7 @@ public class PublicEventController { @GetMapping @ResponseStatus(HttpStatus.OK) + @LogStatsHit public List getEvents( @RequestParam(name = "text", required = false) String text, @RequestParam(name = "categories", required = false) List categories, @@ -57,18 +59,19 @@ public List getEvents( .sort(sort) .build(); - List events = eventService.getEventsPublic(params, from, size, ipAddress); + 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, ipAddress); + EventFullDto event = eventService.getEventByIdPublic(eventId); log.info("Public: Found event: {}", event); return event; } 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 index 4130a07..381b9bd 100644 --- 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 @@ -2,6 +2,7 @@ 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; @@ -34,6 +35,7 @@ public class NewEventDto { String description; @NotNull(message = "Поле eventDate не может быть пустым") + @Future(message = "Поле eventDate должно быть в будущем") @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = DATE_TIME_FORMAT_PATTERN) LocalDateTime eventDate; 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 index 092d05c..137c613 100644 --- 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 @@ -3,6 +3,7 @@ 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.AllArgsConstructor; @@ -28,6 +29,7 @@ public class UpdateEventAdminRequestDto { private String description; @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = DATE_TIME_FORMAT_PATTERN) + @Future(message = "Event date must be in the future") private LocalDateTime eventDate; private Location location; 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 index 4e82b89..0de1c61 100644 --- 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 @@ -3,6 +3,7 @@ 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.AllArgsConstructor; @@ -28,6 +29,7 @@ public class UpdateEventUserRequestDto { private String description; @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = DATE_TIME_FORMAT_PATTERN) + @Future(message = "Event date must be in the future") private LocalDateTime eventDate; private Location location; 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 index 48abe9f..1761b0b 100644 --- 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 @@ -15,8 +15,7 @@ public interface EventMapper { @Mappings({ @Mapping(source = "category", target = "category"), @Mapping(source = "initiator", target = "initiator"), - @Mapping(target = "confirmedRequests", expression = "java(0L)"), // Заглушка - @Mapping(target = "views", expression = "java(0L)") // Заглушка + @Mapping(source = "confirmedRequestsCount", target = "confirmedRequests") }) EventFullDto toEventFullDto(Event event); @@ -33,8 +32,8 @@ public interface EventMapper { @Mappings({ @Mapping(source = "category", target = "category"), @Mapping(source = "initiator", target = "initiator"), - @Mapping(target = "confirmedRequests", expression = "java(0L)"), // Заглушка - @Mapping(target = "views", expression = "java(0L)") // Заглушка + @Mapping(source = "confirmedRequestsCount", target = "confirmedRequests"), + @Mapping(target = "views", ignore = true) }) EventShortDto toEventShortDto(Event event); 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 index 0f7dbbb..6ca81e6 100644 --- 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 @@ -6,6 +6,7 @@ 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; @@ -117,4 +118,10 @@ public class Event { @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; + } 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 index 3be3431..f8ad046 100644 --- 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 @@ -6,6 +6,7 @@ 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); @@ -16,4 +17,6 @@ public interface EventRepository extends JpaRepository, QuerydslPre 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 index d40816d..b9beb29 100644 --- 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 @@ -1,5 +1,6 @@ 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; @@ -34,4 +35,18 @@ public interface RequestRepository extends JpaRepository 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/service/EventService.java b/main-service/src/main/java/ru/practicum/explorewithme/main/service/EventService.java index 2350b09..c786c49 100644 --- 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 @@ -26,7 +26,7 @@ List getEventsAdmin( EventFullDto moderateEventByAdmin(Long eventId, UpdateEventAdminRequestDto requestDto); - List getEventsPublic(PublicEventSearchParams params, int from, int size, String ipAddress); + List getEventsPublic(PublicEventSearchParams params, int from, int size); - EventFullDto getEventByIdPublic(Long eventId, String ipAddress); + 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 index ec83707..60b7366 100644 --- 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 @@ -5,8 +5,13 @@ 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; @@ -27,12 +32,16 @@ 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 @@ -44,13 +53,15 @@ public class EventServiceImpl implements EventService { 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, String ipAddress) { - log.debug("Public search for events with params: {}", params); + 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(); @@ -61,19 +72,18 @@ public List getEventsPublic(PublicEventSearchParams params, int f String sort = params.getSort(); if (rangeStart != null && rangeEnd != null && rangeStart.isAfter(rangeEnd)) { - throw new IllegalArgumentException("rangeStart cannot be after 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().contains(searchText) - .or(qEvent.description.lower().contains(searchText))); + predicate.and(qEvent.annotation.lower().like("%" + searchText + "%") + .or(qEvent.description.lower().like("%" + searchText + "%"))); } if (categories != null && !categories.isEmpty()) { @@ -84,55 +94,102 @@ public List getEventsPublic(PublicEventSearchParams params, int f predicate.and(qEvent.paid.eq(paid)); } - if (rangeStart != null) { - predicate.and(qEvent.eventDate.goe(rangeStart)); + if (rangeStart == null && rangeEnd == null) { + predicate.and(qEvent.eventDate.after(LocalDateTime.now())); } else { - predicate.and(qEvent.eventDate.goe(LocalDateTime.now())); - } - - if (rangeEnd != null) { - predicate.and(qEvent.eventDate.loe(rangeEnd)); - } - - if (onlyAvailable) { - // Заглушка: проверка на доступность (participantLimit > confirmedRequests) - predicate.and(qEvent.participantLimit.eq(0)); + if (rangeStart != null) { + predicate.and(qEvent.eventDate.goe(rangeStart)); + } + if (rangeEnd != null) { + predicate.and(qEvent.eventDate.loe(rangeEnd)); + } } - Sort sortOption = sort != null && sort.equals("VIEWS") ? - Sort.by(Sort.Direction.DESC, "views") : - Sort.by(Sort.Direction.ASC, "eventDate"); + Sort sortOption = Sort.by(Sort.Direction.ASC, "eventDate"); Pageable pageable = PageRequest.of(from / size, size, sortOption); - Page eventPage = eventRepository.findAll(predicate, pageable); + Page eventPage = eventRepository.findAll(predicate.getValue(), pageable); if (eventPage.isEmpty()) { return Collections.emptyList(); } - // TODO: Реализовать логику учета просмотров через ipAddress (StatsClient) - List result = eventMapper.toEventShortDtoList(eventPage.getContent()); - log.debug("Public search found {} events", result.size()); - return result; + 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, String ipAddress) { - log.debug("Public: Fetching event id={}", eventId); + 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()); + } - Event event = eventRepository.findById(eventId) - .orElseThrow(() -> new EntityNotFoundException("Event with id=" + eventId + " not found.")); + long confirmedRequestsCount = requestRepository.countByEventIdAndStatus(eventId, RequestStatus.CONFIRMED); + log.debug("Public: Confirmed requests for event id={}: {}", eventId, confirmedRequestsCount); - if (event.getState() != EventState.PUBLISHED) { - throw new EntityNotFoundException("Event with id=" + eventId + " is not published."); - } + EventFullDto resultDto = eventMapper.toEventFullDto(event); + resultDto.setViews(views); + resultDto.setConfirmedRequests(confirmedRequestsCount); - // TODO: Реализовать логику учета просмотров через ipAddress (StatsClient) - EventFullDto result = eventMapper.toEventFullDto(event); - log.debug("Public: Found event: {}", result); - return result; + log.info("Public: Found event id={} with title='{}', views={}, confirmedRequests={}", + eventId, resultDto.getTitle(), resultDto.getViews(), resultDto.getConfirmedRequests()); + return resultDto; } @Override @@ -184,6 +241,10 @@ public List getEventsAdmin(AdminEventSearchParams params, int from } 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; } @@ -347,6 +408,10 @@ public EventFullDto updateEventByOwner(Long userId, Long eventId, UpdateEventUse 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))); @@ -376,4 +441,42 @@ public EventFullDto addEventPrivate(Long userId, NewEventDto 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/params/PublicEventSearchParams.java b/main-service/src/main/java/ru/practicum/explorewithme/main/service/params/PublicEventSearchParams.java index ef0f769..fa08ef4 100644 --- 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 @@ -1,6 +1,7 @@ package ru.practicum.explorewithme.main.service.params; import lombok.Builder; +import lombok.EqualsAndHashCode; import lombok.Getter; import java.time.LocalDateTime; @@ -8,6 +9,7 @@ @Getter @Builder +@EqualsAndHashCode public class PublicEventSearchParams { private final String text; private final List categories; diff --git a/main-service/src/main/resources/application-local.yaml b/main-service/src/main/resources/application-local.yaml index 961a77d..43b09bd 100644 --- a/main-service/src/main/resources/application-local.yaml +++ b/main-service/src/main/resources/application-local.yaml @@ -5,4 +5,8 @@ spring: datasource: url: jdbc:postgresql://localhost:5432/ewm_main_db username: ewm_user - password: ewm_password \ No newline at end of file + password: ewm_password +logging: + level: + root: INFO + ru.practicum.explorewithme.main: DEBUG \ 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/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/EventMapperTest.java b/main-service/src/test/java/ru/practicum/explorewithme/main/mapper/EventMapperTest.java index 2e0cab7..4d457db 100644 --- 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 @@ -12,19 +12,17 @@ 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.junit.jupiter.MockitoExtension; 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; -@ExtendWith(MockitoExtension.class) @DisplayName("Тесты для EventMapper") @ActiveProfiles("mapper_test") @SpringBootTest @@ -59,6 +57,7 @@ void toEventFullDto_shouldMapAllFieldsCorrectly() { .requestModeration(true) .state(EventState.PUBLISHED) .title("Test Event Title") + .confirmedRequestsCount(42L) .build(); EventFullDto dto = eventMapper.toEventFullDto(event); @@ -89,8 +88,10 @@ void toEventFullDto_shouldMapAllFieldsCorrectly() { assertEquals(locationModel.getLat(), dto.getLocation().getLat()); assertEquals(locationModel.getLon(), dto.getLocation().getLon()); - assertEquals(0L, dto.getConfirmedRequests()); - assertEquals(0L, dto.getViews()); + assertEquals(event.getConfirmedRequestsCount(), dto.getConfirmedRequests()); + + // Не мапит просмотры и потдверждённые запросы. + assertNull(dto.getViews()); } @Test @@ -180,4 +181,132 @@ void toEventFullDtoList_shouldHandleEmptyList() { 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/service/EventServiceImplTest.java b/main-service/src/test/java/ru/practicum/explorewithme/main/service/EventServiceImplTest.java index a827b4f..8a73363 100644 --- 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 @@ -35,6 +35,7 @@ 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; @@ -457,6 +458,163 @@ void addEventPrivate_shouldSetInitiatorAndCategoryCorrectly() { } } + @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 { @@ -523,7 +681,6 @@ void setUpUpdateTests() { @Test @DisplayName("Должен успешно обновлять событие, если все условия соблюдены") void updateEventByOwner_whenValidRequestAndState_shouldUpdateAndReturnDto() { - // Arrange when(eventRepository.findByIdAndInitiatorId(existingEventId, testUser.getId())) .thenReturn(Optional.of(existingEvent)); when(eventRepository.save(any(Event.class))).thenReturn(updatedEventFromRepo); @@ -886,6 +1043,4 @@ void moderateEventByAdmin_whenUpdateWithNonExistentCategory_shouldThrowEntityNot verify(eventRepository, never()).save(any()); } } - - // ... TODO: Добавить тесты для других методов EventService, когда они появятся ... } \ 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 index 851ae70..86c8fc4 100644 --- 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 @@ -1,5 +1,7 @@ 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; @@ -9,6 +11,7 @@ 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; @@ -23,14 +26,23 @@ 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 @@ -49,6 +61,9 @@ static void registerPgProperties(DynamicPropertyRegistry registry) { registry.add("spring.jpa.hibernate.ddl-auto", () -> "create"); } + @Autowired + private EntityManager entityManager; + @Autowired private EventService eventService; @@ -61,26 +76,35 @@ static void registerPgProperties(DynamicPropertyRegistry registry) { @Autowired private CategoryRepository categoryRepository; - private User user1, user2; + @Autowired + private RequestRepository requestRepository; + + @MockitoBean + private StatsClient statsClient; + + private User user1, user2, user3; private Category category1, category2; - private Location location1; + private Location location1, location2; private LocalDateTime now; @BeforeEach void setUp() { - eventRepository.deleteAll(); - categoryRepository.deleteAll(); - userRepository.deleteAll(); + 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 @@ -274,7 +298,7 @@ void getEventsByOwner_whenUserHasEvents_thenReturnsTheirEventsPaged() { @Test @DisplayName("Должен возвращать пустой список, если у пользователя нет событий") void getEventsByOwner_whenUserHasNoEvents_thenReturnsEmptyList() { - User userWithNoEvents = userRepository.save(User.builder().name("User Three").email("user3@events.com").build()); + User userWithNoEvents = user3; // У user3 нет событий. List result = eventService.getEventsByOwner(userWithNoEvents.getId(), 0, 10); assertTrue(result.isEmpty()); } @@ -612,4 +636,270 @@ void moderateEventByAdmin_whenUpdatingWithNonExistentCategory_thenThrowsEntityNo 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/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 index 02bd111..69f749f 100644 --- 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 @@ -5,6 +5,7 @@ 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; @@ -21,6 +22,7 @@ public class StatsClientImpl implements StatsClient { private final RestClient restClient; + @Autowired public StatsClientImpl(@Value("${stats-server.url}") String statsServerUrl) { this.restClient = RestClient.builder() .baseUrl(statsServerUrl) 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 From ee70d87d733952fe9abb05827fc7b041ebb3fc0b Mon Sep 17 00:00:00 2001 From: impatient0 Date: Mon, 26 May 2025 20:09:26 +0300 Subject: [PATCH 56/73] =?UTF-8?q?=D0=9E=D1=82=D1=80=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=82=D0=BA=D0=B0=20=D1=84=D0=B8=D0=B4=D0=B1=D0=B5=D0=BA=D0=B0?= =?UTF-8?q?=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20(#82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * make repository methods return pages instead of lists * remove redundant field * cleanup --------- Co-authored-by: Pepe Ronin --- .../main/repository/CompilationRepository.java | 4 +--- .../explorewithme/main/repository/UserRepository.java | 3 ++- .../explorewithme/main/service/CategoryServiceImpl.java | 2 +- .../main/service/CompilationServiceImpl.java | 4 ++-- .../explorewithme/main/service/RequestServiceImpl.java | 9 ++------- .../explorewithme/main/service/UserServiceImpl.java | 2 +- 6 files changed, 9 insertions(+), 15 deletions(-) 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 index da6c4b7..9052f08 100644 --- 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 @@ -9,13 +9,11 @@ import org.springframework.stereotype.Repository; import ru.practicum.explorewithme.main.model.Compilation; -import java.util.List; - @Repository public interface CompilationRepository extends JpaRepository { @EntityGraph(attributePaths = {"events", "events.category", "events.initiator"}) - List findByPinned(Boolean pinned, Pageable pageable); + Page findByPinned(Boolean pinned, Pageable pageable); @Override @EntityGraph(attributePaths = {"events", "events.category", "events.initiator"}) 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 index 76ce710..0019f01 100644 --- 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 @@ -1,5 +1,6 @@ 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; @@ -12,6 +13,6 @@ public interface UserRepository extends JpaRepository { boolean existsByEmail(String email); - List findAllByIdIn(List ids, Pageable pageable); + Page findAllByIdIn(List ids, Pageable pageable); } 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 index 108c46e..9c9a4e1 100644 --- 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 @@ -59,7 +59,7 @@ public CategoryDto updateCategory(Long categoryId, NewCategoryDto newCategoryDto @Transactional public void deleteCategory(Long categoryId) { - if (!categoryRepository.findById(categoryId).isPresent()) { + if (categoryRepository.findById(categoryId).isEmpty()) { throw new EntityNotFoundException("Category", "Id", categoryId); } if (eventRepository.existsByCategoryId(categoryId)) { 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 index e4fda55..834a9a9 100644 --- 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 @@ -37,7 +37,7 @@ public class CompilationServiceImpl implements CompilationService { 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) + ? compilationRepository.findByPinned(pinned, pageable).getContent() : compilationRepository.findAll(pageable).getContent(); List result = compilations.stream() .map(compilationMapper::toDto) @@ -53,7 +53,7 @@ public List getCompilations(Boolean pinned, Integer from, Intege 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) + ? compilationRepository.findByPinned(pinned, pageable).getContent() : compilationRepository.findAll(pageable).getContent(); List result = compilations.stream() .map(compilationMapper::toDto) 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 index 4b558fc..f4f9540 100644 --- 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 @@ -1,6 +1,5 @@ package ru.practicum.explorewithme.main.service; -import jakarta.persistence.EntityManager; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -29,8 +28,6 @@ public class RequestServiceImpl implements RequestService { private final EventRepository eventRepository; private final UserRepository userRepository; - private final EntityManager entityManager; - @Override @Transactional public ParticipationRequestDto createRequest(Long userId, Long requestEventId) { @@ -55,10 +52,9 @@ public ParticipationRequestDto cancelRequest(Long userId, Long requestId) { public List getRequests(Long userId) { userRepository.findById(userId) .orElseThrow(() -> new EntityNotFoundException("User", "Id", userId)); - List result = requestRepository.findByRequester_Id(userId).stream() + return requestRepository.findByRequester_Id(userId).stream() .sorted(Comparator.comparing(ParticipationRequest::getCreated).reversed()) .map(requestMapper::toRequestDto).toList(); - return result; } @Override @@ -66,10 +62,9 @@ public List getRequests(Long userId) { public List getEventRequests(Long userId, Long eventId) { if (!eventRepository.existsByIdAndInitiator_Id(eventId, userId)) throw new EntityNotFoundException("Event with Id = " + eventId + " when initiator", "Id", userId); - List result = requestRepository.findByEvent_Id(eventId).stream() + return requestRepository.findByEvent_Id(eventId).stream() .sorted(Comparator.comparing(ParticipationRequest::getCreated).reversed()) .map(requestMapper::toRequestDto).toList(); - return result; } @Override 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 index 8b9053a..2ffc259 100644 --- 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 @@ -42,7 +42,7 @@ public void deleteUser(Long userId) { Optional existingUser = userRepository.findById(userId); - if (!existingUser.isPresent()) { + if (existingUser.isEmpty()) { throw new EntityNotFoundException("User", "Id", userId); } From 51ae3c7e1e5f15f89d6d4fb22785c63c523fd089 Mon Sep 17 00:00:00 2001 From: Gagarskiy-Andrey Date: Mon, 26 May 2025 20:09:55 +0300 Subject: [PATCH 57/73] =?UTF-8?q?=D1=84=D0=B8=D0=BA=D1=81=D0=B8=D0=BA?= =?UTF-8?q?=D0=B8=20(#81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../explorewithme/main/dto/CategoryDto.java | 4 +-- .../main/dto/CompilationDto.java | 3 +- .../explorewithme/main/dto/EventFullDto.java | 32 +++++++++---------- .../EventRequestStatusUpdateRequestDto.java | 7 ++-- .../EventRequestStatusUpdateResultDto.java | 7 ++-- .../explorewithme/main/dto/EventShortDto.java | 23 ++++++------- .../main/dto/NewCategoryDto.java | 9 +++--- .../main/dto/NewCompilationDto.java | 11 ++++--- .../main/dto/NewUserRequestDto.java | 11 +++---- .../main/dto/ParticipationRequestDto.java | 12 ++++--- .../main/dto/UpdateCompilationRequestDto.java | 11 ++++--- .../main/dto/UpdateEventAdminRequestDto.java | 27 ++++++++-------- .../main/dto/UpdateEventUserRequestDto.java | 27 ++++++++-------- .../explorewithme/main/dto/UserDto.java | 13 ++++---- .../explorewithme/main/dto/UserShortDto.java | 4 +-- ...EventRequestStatusUpdateRequestParams.java | 10 +++--- .../params/GetListUsersParameters.java | 7 ++-- .../stats/dto/EndpointHitDto.java | 20 ++++++------ .../explorewithme/stats/dto/ViewStatsDto.java | 13 ++++---- 19 files changed, 121 insertions(+), 130 deletions(-) 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 index 976ba91..f9170fc 100644 --- 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 @@ -10,8 +10,8 @@ @FieldDefaults(level = AccessLevel.PRIVATE) public class CategoryDto { - private Long id; + Long id; - private String name; + String name; } \ 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 index 5089342..ba194f1 100644 --- 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 @@ -5,8 +5,7 @@ import java.util.Set; -@Getter -@Setter +@Data @AllArgsConstructor @NoArgsConstructor @FieldDefaults(level = AccessLevel.PRIVATE) 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 index cc3c3f6..1d3f38a 100644 --- 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 @@ -19,23 +19,23 @@ @Builder @FieldDefaults(level = AccessLevel.PRIVATE) public class EventFullDto { - private Long id; - private String annotation; - private CategoryDto category; - private Long confirmedRequests; + Long id; + String annotation; + CategoryDto category; + Long confirmedRequests; @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = DATE_TIME_FORMAT_PATTERN) - private LocalDateTime createdOn; - private String description; + LocalDateTime createdOn; + String description; @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = DATE_TIME_FORMAT_PATTERN) - private LocalDateTime eventDate; - private UserShortDto initiator; - private Location location; - private boolean paid; - private int participantLimit; + LocalDateTime eventDate; + UserShortDto initiator; + Location location; + boolean paid; + int participantLimit; @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = DATE_TIME_FORMAT_PATTERN) - private LocalDateTime publishedOn; - private boolean requestModeration; - private EventState state; - private String title; - private Long views; + LocalDateTime publishedOn; + boolean requestModeration; + EventState state; + String title; + Long views; } \ 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 index 3806dda..543a73a 100644 --- 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 @@ -2,10 +2,8 @@ import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; +import lombok.*; +import lombok.experimental.FieldDefaults; import ru.practicum.explorewithme.main.model.RequestStatus; import java.util.List; @@ -14,6 +12,7 @@ @Builder @NoArgsConstructor @AllArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE) public class EventRequestStatusUpdateRequestDto { @NotEmpty 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 index 4ad51f0..ad150ad 100644 --- 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 @@ -1,9 +1,7 @@ package ru.practicum.explorewithme.main.dto; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; +import lombok.*; +import lombok.experimental.FieldDefaults; import java.util.ArrayList; import java.util.List; @@ -12,6 +10,7 @@ @Builder @NoArgsConstructor @AllArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE) public class EventRequestStatusUpdateResultDto { @Builder.Default 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 index 0eb8c76..b89f7c8 100644 --- 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 @@ -2,25 +2,26 @@ 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; -@Getter -@Setter +@Data @AllArgsConstructor @NoArgsConstructor @Builder +@FieldDefaults(level = AccessLevel.PRIVATE) public class EventShortDto { - private Long id; - private String annotation; - private CategoryDto category; - private Long confirmedRequests; + Long id; + String annotation; + CategoryDto category; + Long confirmedRequests; @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = DATE_TIME_FORMAT_PATTERN) - private LocalDateTime eventDate; - private UserShortDto initiator; - private Boolean paid; - private String title; - private Long views; + 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 index 53f55a2..f7e159a 100644 --- 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 @@ -2,19 +2,18 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; +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 символов") - private String name; + String name; } 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 index d958196..5be6917 100644 --- 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 @@ -3,19 +3,20 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; import lombok.*; +import lombok.experimental.FieldDefaults; import java.util.List; @Builder -@Getter -@Setter +@Data @AllArgsConstructor @NoArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE) public class NewCompilationDto { @Builder.Default - private Boolean pinned = false; + Boolean pinned = false; @NotBlank(message = "Название подборки не может быть пустым") @Size(max = 50, message = "Название подборки должно быть до 50 символов") - private String title; - private List events; + String title; + List events; } \ No newline at end of file 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 index 9033b3d..48615f2 100644 --- 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 @@ -1,25 +1,24 @@ package ru.practicum.explorewithme.main.dto; import jakarta.validation.constraints.Size; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; +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 символов") - private String name; + String name; @NotBlank(message = "Email не может быть пустым") @Size(min = 6, max = 254, message = "Email должен быть от 6 до 254 символов") @Email(message = "Некорректный формат email") - private String 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 index f8947b4..70258a9 100644 --- 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 @@ -3,6 +3,7 @@ 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; @@ -13,19 +14,20 @@ @Builder @NoArgsConstructor @AllArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE) public class ParticipationRequestDto { - private Long id; + Long id; @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = DATE_TIME_FORMAT_PATTERN) - private LocalDateTime created; + LocalDateTime created; @JsonProperty("requester") - private Long requesterId; + Long requesterId; @JsonProperty("event") - private Long eventId; + Long eventId; - private RequestStatus status; + RequestStatus status; } 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 index fa24e75..fa84b68 100644 --- 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 @@ -2,16 +2,17 @@ import jakarta.validation.constraints.Size; import lombok.*; +import lombok.experimental.FieldDefaults; import java.util.List; -@Getter -@Setter +@Data @AllArgsConstructor @NoArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE) public class UpdateCompilationRequestDto { - private Boolean pinned; + Boolean pinned; @Size(min = 1, max = 50, message = "Название подборки должно быть от 1 до 50 символов") - private String title; - private List events; + 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 index 137c613..9ea4f6b 100644 --- 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 @@ -6,10 +6,8 @@ import jakarta.validation.constraints.Future; import jakarta.validation.constraints.PositiveOrZero; import jakarta.validation.constraints.Size; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; +import lombok.*; +import lombok.experimental.FieldDefaults; import ru.practicum.explorewithme.main.model.Location; import java.time.LocalDateTime; @@ -18,33 +16,34 @@ @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") - private String annotation; + String annotation; - private Long category; + Long category; @Size(min = 20, max = 7000, message = "Description length must be between 20 and 7000 characters") - private String description; + String description; @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = DATE_TIME_FORMAT_PATTERN) @Future(message = "Event date must be in the future") - private LocalDateTime eventDate; + LocalDateTime eventDate; - private Location location; + Location location; - private Boolean paid; + Boolean paid; @PositiveOrZero(message = "Participant limit must be positive or zero") - private Integer participantLimit; + Integer participantLimit; - private Boolean requestModeration; + Boolean requestModeration; - private StateActionAdmin stateAction; + StateActionAdmin stateAction; @Size(min = 3, max = 120, message = "Title length must be between 3 and 120 characters") - private String title; + String title; public enum StateActionAdmin { PUBLISH_EVENT, 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 index 0de1c61..e339f0a 100644 --- 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 @@ -6,10 +6,8 @@ import jakarta.validation.constraints.Future; import jakarta.validation.constraints.PositiveOrZero; import jakarta.validation.constraints.Size; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; +import lombok.*; +import lombok.experimental.FieldDefaults; import ru.practicum.explorewithme.main.model.Location; import java.time.LocalDateTime; @@ -18,33 +16,34 @@ @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") - private String annotation; + String annotation; - private Long category; + Long category; @Size(min = 20, max = 7000, message = "Description length must be between 20 and 7000 characters") - private String description; + String description; @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = DATE_TIME_FORMAT_PATTERN) @Future(message = "Event date must be in the future") - private LocalDateTime eventDate; + LocalDateTime eventDate; - private Location location; + Location location; - private Boolean paid; + Boolean paid; @PositiveOrZero(message = "Participant limit must be positive or zero") - private Integer participantLimit; + Integer participantLimit; - private Boolean requestModeration; + Boolean requestModeration; - private StateActionUser stateAction; + StateActionUser stateAction; @Size(min = 3, max = 120, message = "Title length must be between 3 and 120 characters") - private String title; + String title; public enum StateActionUser { SEND_TO_REVIEW, 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 index 3c9b76f..30d7156 100644 --- 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 @@ -1,16 +1,15 @@ package ru.practicum.explorewithme.main.dto; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; +import lombok.*; +import lombok.experimental.FieldDefaults; @Data @Builder @NoArgsConstructor @AllArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE) public class UserDto { - private Long id; - private String name; - private String email; + 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 index 50f4af7..6c025ef 100644 --- 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 @@ -13,6 +13,6 @@ @AllArgsConstructor @FieldDefaults(level = AccessLevel.PRIVATE) public class UserShortDto { - private Long id; - private String name; + Long id; + String name; } \ 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 index a70dc72..7310410 100644 --- 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 @@ -3,18 +3,16 @@ import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; -import lombok.NoArgsConstructor; import ru.practicum.explorewithme.main.model.RequestStatus; import java.util.List; @Data @Builder -@NoArgsConstructor @AllArgsConstructor public class EventRequestStatusUpdateRequestParams { - Long userId; - Long eventId; - List requestIds; - RequestStatus status; + 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 index beb61f7..5250444 100644 --- 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 @@ -6,10 +6,9 @@ @Data @Builder -@NoArgsConstructor @AllArgsConstructor public class GetListUsersParameters { - List ids; - int from; - int size; + private final List ids; + private final int from; + private final int size; } 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 index 37243d0..5930d52 100644 --- 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 @@ -7,37 +7,35 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.PastOrPresent; import jakarta.validation.constraints.Size; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; +import lombok.*; import jakarta.validation.constraints.NotNull; +import lombok.experimental.FieldDefaults; + import java.time.LocalDateTime; -@Getter -@Setter +@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 символов") - private String app; + String app; @NotBlank(message = "Поле uri не может быть пустым") @Size(min = 1, max = 128, message = "Поле uri должно быть от 1 до 128 символов") - private String uri; + String uri; @NotBlank(message = "Поле ip не может быть пустым") @Size(min = 7, max = 16, message = "Поле ip должно быть от 7 до 16 символов") - private String ip; + String ip; @NotNull(message = "Поле timestamp не может быть пустым") @PastOrPresent(message = "Поле timestamp должно быть не позже текущей даты и времени") @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = DATE_TIME_FORMAT_PATTERN) - private LocalDateTime timestamp; + 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 index 3451cab..71c28e3 100644 --- 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 @@ -1,16 +1,15 @@ package ru.practicum.explorewithme.stats.dto; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; +import lombok.*; +import lombok.experimental.FieldDefaults; @Data @AllArgsConstructor @NoArgsConstructor @Builder +@FieldDefaults(level = AccessLevel.PRIVATE) public class ViewStatsDto { - private String app; - private String uri; - private Long hits; + String app; + String uri; + Long hits; } \ No newline at end of file From 4063b69434a1e5a5c4e596024d4bb6413e83aa6d Mon Sep 17 00:00:00 2001 From: Pepe Ronin Date: Tue, 27 May 2025 14:25:14 +0300 Subject: [PATCH 58/73] add comments functionality to API spec --- ewm-main-service-spec.json | 749 +++++++++++++++++++++++++++++++++---- 1 file changed, 669 insertions(+), 80 deletions(-) diff --git a/ewm-main-service-spec.json b/ewm-main-service-spec.json index f28d141..5193880 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": { @@ -2145,23 +2687,6 @@ "paid": true, "title": "Знаменитое шоу 'Летающая кукуруза'", "views": 999 - }, - { - "annotation": "За почти три десятилетия группа 'Java Core' закрепились на сцене как группа, объединяющая поколения.", - "category": { - "id": 1, - "name": "Концерты" - }, - "confirmedRequests": 555, - "eventDate": "2025-09-13 21:00:00", - "id": 1, - "initiator": { - "id": 3, - "name": "Паша Петров" - }, - "paid": true, - "title": "Концерт рок-группы 'Java Core'", - "views": 991 } ], "items": { @@ -2215,6 +2740,7 @@ }, "createdOn": { "type": "string", + "format": "date-time", "description": "Дата и время создания события (в формате \"yyyy-MM-dd HH:mm:ss\")", "example": "2022-09-06 11:00:23" }, @@ -2225,6 +2751,7 @@ }, "eventDate": { "type": "string", + "format": "date-time", "description": "Дата и время на которые намечено событие (в формате \"yyyy-MM-dd HH:mm:ss\")", "example": "2024-12-31 15:10:05" }, @@ -2254,6 +2781,7 @@ }, "publishedOn": { "type": "string", + "format": "date-time", "description": "Дата и время публикации события (в формате \"yyyy-MM-dd HH:mm:ss\")", "example": "2022-09-06 15:10:05" }, @@ -2283,6 +2811,11 @@ "description": "Количество просмотрев события", "format": "int64", "example": 999 + }, + "commentsEnabled": { + "type": "boolean", + "description": "Разрешены ли комментарии для данного события.", + "default": true } } }, @@ -2360,6 +2893,7 @@ }, "eventDate": { "type": "string", + "format": "date-time", "description": "Дата и время на которые намечено событие (в формате \"yyyy-MM-dd HH:mm:ss\")", "example": "2024-12-31 15:10:05" }, @@ -2389,43 +2923,7 @@ "example": 999 } }, - "description": "Краткая информация о событии", - "example": [ - { - "annotation": "Эксклюзивность нашего шоу гарантирует привлечение максимальной зрительской аудитории", - "category": { - "id": 1, - "name": "Концерты" - }, - "confirmedRequests": 5, - "eventDate": "2024-03-10 14:30:00", - "id": 1, - "initiator": { - "id": 3, - "name": "Фёдоров Матвей" - }, - "paid": true, - "title": "Знаменитое шоу 'Летающая кукуруза'", - "views": 999 - }, - { - "annotation": "За почти три десятилетия группа 'Java Core' закрепились на сцене как группа, объединяющая поколения.", - "category": { - "id": 1, - "name": "Концерты" - }, - "confirmedRequests": 555, - "eventDate": "2025-09-13 21:00:00", - "id": 1, - "initiator": { - "id": 3, - "name": "Паша Петров" - }, - "paid": true, - "title": "Концерт рок-группы 'Java Core'", - "views": 991 - } - ] + "description": "Краткая информация о событии" }, "Location": { "type": "object", @@ -2531,6 +3029,7 @@ }, "eventDate": { "type": "string", + "format": "date-time", "description": "Дата и время на которые намечено событие. Дата и время указываются в формате \"yyyy-MM-dd HH:mm:ss\"", "example": "2024-12-31 15:10:05" }, @@ -2562,6 +3061,11 @@ "type": "string", "description": "Заголовок события", "example": "Сплав на байдарках" + }, + "commentsEnabled": { + "type": "boolean", + "description": "Разрешены ли комментарии для события. По умолчанию true.", + "default": true } }, "description": "Новое событие" @@ -2595,6 +3099,7 @@ "properties": { "created": { "type": "string", + "format": "date-time", "description": "Дата и время создания заявки", "example": "2022-09-06T21:10:05.432" }, @@ -2677,6 +3182,7 @@ }, "eventDate": { "type": "string", + "format": "date-time", "description": "Новые дата и время на которые намечено событие. Дата и время указываются в формате \"yyyy-MM-dd HH:mm:ss\"", "example": "2023-10-11 23:10:05" }, @@ -2713,6 +3219,10 @@ "type": "string", "description": "Новый заголовок", "example": "Сап прогулки по рекам и каналам" + }, + "commentsEnabled": { + "type": "boolean", + "description": "Новое значение флага, разрешены ли комментарии для события." } }, "description": "Данные для изменения информации о событии. Если поле в запросе не указано (равно null) - значит изменение этих данных не треубется." @@ -2742,6 +3252,7 @@ }, "eventDate": { "type": "string", + "format": "date-time", "description": "Новые дата и время на которые намечено событие. Дата и время указываются в формате \"yyyy-MM-dd HH:mm:ss\"", "example": "2023-10-11 23:10:05" }, @@ -2779,6 +3290,10 @@ "type": "string", "description": "Новый заголовок", "example": "Сап прогулки по рекам и каналам" + }, + "commentsEnabled": { + "type": "boolean", + "description": "Новое значение флага, разрешены ли комментарии для события." } }, "description": "Данные для изменения информации о событии. Если поле в запросе не указано (равно null) - значит изменение этих данных не треубется." @@ -2830,7 +3345,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 From 7c84bce67fa695a44249f658854ac6853efa9c76 Mon Sep 17 00:00:00 2001 From: Gagarskiy-Andrey Date: Wed, 28 May 2025 18:17:48 +0300 Subject: [PATCH 59/73] =?UTF-8?q?=D1=83=D1=81=D1=82=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D0=B8?= =?UTF-8?q?=20Surefire?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pom.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pom.xml b/pom.xml index d0ad82f..abec5be 100644 --- a/pom.xml +++ b/pom.xml @@ -202,6 +202,8 @@ maven-surefire-plugin 3.5.3 + 1 + false test From 6da7fbe9704f7d4db0ac285747df70108eb9c882 Mon Sep 17 00:00:00 2001 From: impatient0 Date: Thu, 29 May 2025 00:14:13 +0300 Subject: [PATCH 60/73] =?UTF-8?q?=D0=9A=D0=BE=D0=BC=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D0=B0=D1=80=D0=B8=D0=B8=20-=20=D0=A1=D1=83=D1=89=D0=BD?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D1=8C,=20=D0=A0=D0=B5=D0=BF=D0=BE=D0=B7?= =?UTF-8?q?=D0=B8=D1=82=D0=BE=D1=80=D0=B8=D0=B9,=20DTO=20=D0=B8=20=D0=91?= =?UTF-8?q?=D0=B0=D0=B7=D0=BE=D0=B2=D1=8B=D0=B9=20=D0=9C=D0=B0=D0=BF=D0=BF?= =?UTF-8?q?=D0=B8=D0=BD=D0=B3=20(#94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add the "commentsEnabled" flag to Event * create Comment entity * create Comment DTOs * add comment repository and mapper * create CommentMapper unit tests * checkstyle * add empty postman collection to pass checks --------- Co-authored-by: Pepe Ronin --- .../explorewithme/main/dto/CommentDto.java | 37 ++++ .../explorewithme/main/dto/EventFullDto.java | 1 + .../explorewithme/main/dto/NewCommentDto.java | 22 ++ .../explorewithme/main/dto/NewEventDto.java | 3 + .../main/dto/UpdateCommentDto.java | 22 ++ .../main/mapper/CommentMapper.java | 44 ++++ .../main/mapper/EventMapper.java | 10 +- .../explorewithme/main/model/Comment.java | 81 +++++++ .../explorewithme/main/model/Event.java | 8 +- .../main/repository/CommentRepository.java | 8 + main-service/src/main/resources/schema.sql | 14 ++ .../main/mapper/CommentMapperTest.java | 207 ++++++++++++++++++ postman/feature.json | 9 + 13 files changed, 459 insertions(+), 7 deletions(-) create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/dto/CommentDto.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/dto/NewCommentDto.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/dto/UpdateCommentDto.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/mapper/CommentMapper.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/model/Comment.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/repository/CommentRepository.java create mode 100644 main-service/src/test/java/ru/practicum/explorewithme/main/mapper/CommentMapperTest.java create mode 100644 postman/feature.json 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/EventFullDto.java b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/EventFullDto.java index 1d3f38a..a10fc4a 100644 --- 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 @@ -38,4 +38,5 @@ public class EventFullDto { 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/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/NewEventDto.java b/main-service/src/main/java/ru/practicum/explorewithme/main/dto/NewEventDto.java index 381b9bd..08f70d2 100644 --- 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 @@ -55,4 +55,7 @@ public class NewEventDto { @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/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/mapper/CommentMapper.java b/main-service/src/main/java/ru/practicum/explorewithme/main/mapper/CommentMapper.java new file mode 100644 index 0000000..66cca95 --- /dev/null +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/mapper/CommentMapper.java @@ -0,0 +1,44 @@ +package ru.practicum.explorewithme.main.mapper; + +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.Mappings; +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. + * Поле eventId извлекается из comment.getEvent().getId(). + */ + @Mappings({ + @Mapping(source = "event.id", target = "eventId"), + @Mapping(source = "edited", target = "isEdited") + }) + CommentDto toDto(Comment comment); + + List toDtoList(List comments); + +} \ 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 index 1761b0b..72abfc1 100644 --- 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 @@ -13,9 +13,8 @@ public interface EventMapper { @Mappings({ - @Mapping(source = "category", target = "category"), - @Mapping(source = "initiator", target = "initiator"), - @Mapping(source = "confirmedRequestsCount", target = "confirmedRequests") + @Mapping(source = "confirmedRequestsCount", target = "confirmedRequests"), + @Mapping(target = "views", ignore = true) }) EventFullDto toEventFullDto(Event event); @@ -23,15 +22,14 @@ public interface EventMapper { @Mapping(target = "publishedOn", ignore = true) @Mapping(target = "compilations", ignore = true) @Mapping(target = "initiator", ignore = true) - @Mapping(source = "category", target = "category") + @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 = "category", target = "category"), - @Mapping(source = "initiator", target = "initiator"), @Mapping(source = "confirmedRequestsCount", target = "confirmedRequests"), @Mapping(target = "views", ignore = true) }) 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/Event.java b/main-service/src/main/java/ru/practicum/explorewithme/main/model/Event.java index 6ca81e6..0000d06 100644 --- 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 @@ -122,6 +122,12 @@ public class Event { * Количество подтверждённых заявок */ @Formula("(SELECT COUNT(r.id) FROM requests r WHERE r.event_id = id AND r.status = 'CONFIRMED')") - private Long confirmedRequestsCount; + private long confirmedRequestsCount; + + /** + * Разрешены ли комментарии + */ + @Column(name = "comments_enabled", nullable = false) + private boolean commentsEnabled; } 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..24773c6 --- /dev/null +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/repository/CommentRepository.java @@ -0,0 +1,8 @@ +package ru.practicum.explorewithme.main.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import ru.practicum.explorewithme.main.model.Comment; + +public interface CommentRepository extends JpaRepository { + +} \ No newline at end of file diff --git a/main-service/src/main/resources/schema.sql b/main-service/src/main/resources/schema.sql index ca97922..03af2df 100644 --- a/main-service/src/main/resources/schema.sql +++ b/main-service/src/main/resources/schema.sql @@ -32,10 +32,24 @@ CREATE TABLE IF NOT EXISTS events ( 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, 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..014ce24 --- /dev/null +++ b/main-service/src/test/java/ru/practicum/explorewithme/main/mapper/CommentMapperTest.java @@ -0,0 +1,207 @@ +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.CommentDto; +import ru.practicum.explorewithme.main.dto.NewCommentDto; +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()); + } + } +} \ No newline at end of file diff --git a/postman/feature.json b/postman/feature.json new file mode 100644 index 0000000..72e8187 --- /dev/null +++ b/postman/feature.json @@ -0,0 +1,9 @@ +{ + "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": [] +} \ No newline at end of file From cd80e89a514a2b1ac26e48e43d943a4e2c2a7fd5 Mon Sep 17 00:00:00 2001 From: Sergey Filippovskikh <116564864+SergikF@users.noreply.github.com> Date: Fri, 30 May 2025 19:54:56 +0300 Subject: [PATCH 61/73] =?UTF-8?q?COMMENTS-PUBLIC:=20=D0=9F=D0=BE=D0=BB?= =?UTF-8?q?=D1=83=D1=87=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BE=D0=BC=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=B0=D1=80=D0=B8=D0=B5=D0=B2=20=D0=BA=20?= =?UTF-8?q?=D1=81=D0=BE=D0=B1=D1=8B=D1=82=D0=B8=D1=8E=20(#95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * COMMENTS-PUBLIC: Получение комментариев к событию #85 Первичное исполнение без тестов. * Исправление стиля. * Исправление замечаний и добавление тестов * Исправление стиля --- .../pub/PublicCommentController.java | 55 ++++ .../main/repository/CommentRepository.java | 7 + .../main/service/CommentService.java | 11 + .../main/service/CommentServiceImpl.java | 46 ++++ ...EventRequestStatusUpdateRequestParams.java | 7 +- .../params/GetListUsersParameters.java | 3 +- .../params/PublicCommentParameters.java | 14 + .../pub/PublicCommentControllerTest.java | 170 ++++++++++++ .../CommentServiceIntegrationTest.java | 259 ++++++++++++++++++ 9 files changed, 567 insertions(+), 5 deletions(-) create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/controller/pub/PublicCommentController.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/service/CommentService.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/service/CommentServiceImpl.java create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/service/params/PublicCommentParameters.java create mode 100644 main-service/src/test/java/ru/practicum/explorewithme/main/controller/pub/PublicCommentControllerTest.java create mode 100644 main-service/src/test/java/ru/practicum/explorewithme/main/service/CommentServiceIntegrationTest.java 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/repository/CommentRepository.java b/main-service/src/main/java/ru/practicum/explorewithme/main/repository/CommentRepository.java index 24773c6..4977c33 100644 --- 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 @@ -1,8 +1,15 @@ 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 ru.practicum.explorewithme.main.model.Comment; public interface CommentRepository extends JpaRepository { + @EntityGraph(attributePaths = {"author"}) + Page findByEventIdAndIsDeletedFalse(Long eventId, Pageable pageable); + } \ No newline at end of file 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..19d0e41 --- /dev/null +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/service/CommentService.java @@ -0,0 +1,11 @@ +package ru.practicum.explorewithme.main.service; + +import ru.practicum.explorewithme.main.dto.CommentDto; +import ru.practicum.explorewithme.main.service.params.PublicCommentParameters; + +import java.util.List; + +public interface CommentService { + + List getCommentsForEvent(Long eventId, PublicCommentParameters commentParameters); +} 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..8691914 --- /dev/null +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/service/CommentServiceImpl.java @@ -0,0 +1,46 @@ +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.CommentDto; +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.repository.CommentRepository; +import ru.practicum.explorewithme.main.repository.EventRepository; +import ru.practicum.explorewithme.main.service.params.PublicCommentParameters; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class CommentServiceImpl implements CommentService { + + 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); + } +} 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 index 7310410..53b089b 100644 --- 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 @@ -1,14 +1,13 @@ package ru.practicum.explorewithme.main.service.params; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; +import lombok.*; import ru.practicum.explorewithme.main.model.RequestStatus; import java.util.List; -@Data +@Getter @Builder +@EqualsAndHashCode @AllArgsConstructor public class EventRequestStatusUpdateRequestParams { private final Long userId; 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 index 5250444..5c625d8 100644 --- 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 @@ -4,8 +4,9 @@ import java.util.List; -@Data +@Getter @Builder +@EqualsAndHashCode @AllArgsConstructor public class GetListUsersParameters { private final List ids; 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/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/service/CommentServiceIntegrationTest.java b/main-service/src/test/java/ru/practicum/explorewithme/main/service/CommentServiceIntegrationTest.java new file mode 100644 index 0000000..07b49db --- /dev/null +++ b/main-service/src/test/java/ru/practicum/explorewithme/main/service/CommentServiceIntegrationTest.java @@ -0,0 +1,259 @@ +package ru.practicum.explorewithme.main.service; + +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.CommentDto; +import ru.practicum.explorewithme.main.error.EntityNotFoundException; +import ru.practicum.explorewithme.main.model.*; +import ru.practicum.explorewithme.main.repository.CommentRepository; +import ru.practicum.explorewithme.main.repository.EventRepository; +import ru.practicum.explorewithme.main.service.params.PublicCommentParameters; +import ru.practicum.explorewithme.main.repository.CategoryRepository; +import ru.practicum.explorewithme.main.repository.UserRepository; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.UUID; + +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; + +@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; + + @Nested + @DisplayName("Получение комментариев к событию") + class GetCommentsForEvent { + + @Test + @DisplayName("Возвращает комментарии опубликованного события с включёнными комментариями") + void shouldReturnComments_whenEventPublishedAndCommentsEnabled() { + + Event event = saveEvent(true, EventState.PUBLISHED); + saveComment(event, "Первый комментарий", + LocalDateTime.parse("2025-01-01 10:00:00", DateTimeFormatter.ofPattern(DATE_TIME_FORMAT_PATTERN))); + saveComment(event, "Второй комментарий", + LocalDateTime.parse("2025-01-02 10:00:00", DateTimeFormatter.ofPattern(DATE_TIME_FORMAT_PATTERN))); + + + 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); + saveComment(event, "Отключённый комментарий", LocalDateTime.now()); + + 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); + + 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 = saveEvent(true, EventState.PUBLISHED); + for (int i = 0; i < 5; i++) { + saveComment(event, "Комментарий " + i, + LocalDateTime.now().plusSeconds(i)); + } + + 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); // комментарии включены, но мы их не создаём + + 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 = saveEvent(true, EventState.PUBLISHED); + + 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).isEmpty(); + } + + @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)); + } + + } + + /* ---------- Вспомогательные методы ---------- */ + private Event saveEvent(boolean commentsEnabled, EventState state) { + + + Category category = categoryRepository.save( + Category.builder() + .name("Тестовая категория") + .build()); + + User initiator = userRepository.save( + User.builder() + .name("Инициатор") + .email("initiator@example.com") + .build()); + + Event event = Event.builder() + .title("Событие") + .annotation("Краткое описание события") + .description("Описание") + .state(state) + .commentsEnabled(commentsEnabled) + .category(category) + .initiator(initiator) + .eventDate(LocalDateTime.now().plusDays(1)) + .location(new Location(55.7522F, 37.6156F)) + .paid(false) + .participantLimit(0) + .requestModeration(false) + .build(); + + return eventRepository.save(event); + } + + private void saveComment(Event event, String text, LocalDateTime createdOn) { + + User author = userRepository.save( + User.builder() + .name("Автор") + .email("author_" + UUID.randomUUID() + "@example.com") + .build()); + + Comment comment = Comment.builder() + .event(event) + .author(author) // ← задаём автора + .text(text) + .createdOn(createdOn) + .isDeleted(false) + .build(); + + commentRepository.save(comment); + } +} \ No newline at end of file From d27c7b71c4cceeebf6ac72faf0483bf313159389 Mon Sep 17 00:00:00 2001 From: Gagarskiy-Andrey Date: Sat, 31 May 2025 13:09:02 +0300 Subject: [PATCH 62/73] =?UTF-8?q?COMMENTS-PRIVATE:=20=D0=A1=D0=BE=D0=B7?= =?UTF-8?q?=D0=B4=D0=B0=D0=BD=D0=B8=D0=B5/=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=B0=D1=80=D0=B8=D1=8F=20=D0=BF=D0=BE=D0=BB=D1=8C?= =?UTF-8?q?=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D0=B5=D0=BC=20#84=20?= =?UTF-8?q?=D0=B8=20#86=20(#97)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * COMMENTS-PRIVATE: Создание комментария пользователем * COMMENTS-PRIVATE: Обновление своего комментария * fixiki * fixiki * правки * правки * fixiki --- .../priv/PrivateCommentController.java | 50 ++++ .../main/service/CommentService.java | 6 + .../main/service/CommentServiceImpl.java | 64 +++++ .../priv/PrivateCommentControllerTest.java | 216 +++++++++++++++++ .../main/service/CommentServiceImplTest.java | 219 ++++++++++++++++++ 5 files changed, 555 insertions(+) create mode 100644 main-service/src/main/java/ru/practicum/explorewithme/main/controller/priv/PrivateCommentController.java create mode 100644 main-service/src/test/java/ru/practicum/explorewithme/main/controller/priv/PrivateCommentControllerTest.java create mode 100644 main-service/src/test/java/ru/practicum/explorewithme/main/service/CommentServiceImplTest.java 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..c14849b --- /dev/null +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/controller/priv/PrivateCommentController.java @@ -0,0 +1,50 @@ +package ru.practicum.explorewithme.main.controller.priv; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Positive; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import ru.practicum.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; + +@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}") + @ResponseStatus(HttpStatus.OK) + 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)); + } +} 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 index 19d0e41..79bce6a 100644 --- 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 @@ -1,6 +1,8 @@ package ru.practicum.explorewithme.main.service; 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.PublicCommentParameters; import java.util.List; @@ -8,4 +10,8 @@ public interface CommentService { List getCommentsForEvent(Long eventId, PublicCommentParameters commentParameters); + + CommentDto addComment(Long userId, Long eventId, NewCommentDto newCommentDto); + + CommentDto updateUserComment(Long userId, Long commentId, UpdateCommentDto updateCommentDto); } 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 index 8691914..6c6e63d 100644 --- 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 @@ -6,21 +6,29 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; 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.PublicCommentParameters; +import java.time.LocalDateTime; +import java.util.Optional; import java.util.List; @Service @RequiredArgsConstructor public class CommentServiceImpl implements CommentService { + private final UserRepository userRepository; private final CommentRepository commentRepository; private final EventRepository eventRepository; private final CommentMapper commentMapper; @@ -43,4 +51,60 @@ public List getCommentsForEvent(Long eventId, PublicCommentParameter 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() == true) { + 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.save(existedComment)); + } } 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..d6d7cf5 --- /dev/null +++ b/main-service/src/test/java/ru/practicum/explorewithme/main/controller/priv/PrivateCommentControllerTest.java @@ -0,0 +1,216 @@ +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 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.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; // <-- для post-запроса +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; + + +@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.")); + } + } +} 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..7ebbc11 --- /dev/null +++ b/main-service/src/test/java/ru/practicum/explorewithme/main/service/CommentServiceImplTest.java @@ -0,0 +1,219 @@ +package ru.practicum.explorewithme.main.service; + +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +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 java.time.LocalDateTime; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@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; + + 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.save(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).save(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("Время для редактирования истекло")); + } + } +} \ No newline at end of file From 12458d20652e6ce0d65f417abf5169a0abfa46a2 Mon Sep 17 00:00:00 2001 From: impatient0 Date: Sat, 31 May 2025 13:54:45 +0300 Subject: [PATCH 63/73] create compound test config (#99) Co-authored-by: Pepe Ronin --- .run/full-local.run.xml | 7 +++++++ .run/main-db.run.xml | 16 ++++++++++++++++ .run/main-local.run.xml | 2 +- .run/stats-db.run.xml | 16 ++++++++++++++++ .run/stats-local.run.xml | 2 +- 5 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 .run/full-local.run.xml create mode 100644 .run/main-db.run.xml create mode 100644 .run/stats-db.run.xml 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 index 93a802a..33b0c90 100644 --- a/.run/main-local.run.xml +++ b/.run/main-local.run.xml @@ -6,7 +6,7 @@ \ 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 index e68e3da..845626e 100644 --- a/.run/stats-local.run.xml +++ b/.run/stats-local.run.xml @@ -6,7 +6,7 @@