diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index 8af972cde..000000000 --- a/.gitattributes +++ /dev/null @@ -1,3 +0,0 @@ -/gradlew text eol=lf -*.bat text eol=crlf -*.jar binary diff --git a/.gitignore b/.gitignore index c2065bc26..b2b7bf3d7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,19 @@ -HELP.md .gradle build/ !gradle/wrapper/gradle-wrapper.jar !**/src/main/**/build/ !**/src/test/**/build/ -### STS ### +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### Eclipse ### .apt_generated .classpath .factorypath @@ -17,15 +25,6 @@ bin/ !**/src/main/**/bin/ !**/src/test/**/bin/ -### IntelliJ IDEA ### -.idea -*.iws -*.iml -*.ipr -out/ -!**/src/main/**/out/ -!**/src/test/**/out/ - ### NetBeans ### /nbproject/private/ /nbbuild/ @@ -35,3 +34,13 @@ out/ ### VS Code ### .vscode/ + +### Mac OS ### +.DS_Store + +### Discodeit ### +.discodeit + +### 숨김 파일 ### +.* +!.gitignore \ No newline at end of file diff --git a/HELP.md b/HELP.md new file mode 100644 index 000000000..42c5f0023 --- /dev/null +++ b/HELP.md @@ -0,0 +1,22 @@ +# Getting Started + +### Reference Documentation +For further reference, please consider the following sections: + +* [Official Gradle documentation](https://docs.gradle.org) +* [Spring Boot Gradle Plugin Reference Guide](https://docs.spring.io/spring-boot/3.4.0/gradle-plugin) +* [Create an OCI image](https://docs.spring.io/spring-boot/3.4.0/gradle-plugin/packaging-oci-image.html) +* [Spring Web](https://docs.spring.io/spring-boot/3.4.0/reference/web/servlet.html) + +### Guides +The following guides illustrate how to use some features concretely: + +* [Building a RESTful Web Service](https://spring.io/guides/gs/rest-service/) +* [Serving Web Content with Spring MVC](https://spring.io/guides/gs/serving-web-content/) +* [Building REST services with Spring](https://spring.io/guides/tutorials/rest/) + +### Additional Links +These additional references should also help you: + +* [Gradle Build Scans – insights for your project's build](https://scans.gradle.com#gradle) + diff --git a/README.md b/README.md new file mode 100644 index 000000000..815bede54 --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# 0-spring-mission +스프린트 미션 모범 답안 리포지토리입니다. diff --git a/admin/.gitignore b/admin/.gitignore new file mode 100644 index 000000000..c2065bc26 --- /dev/null +++ b/admin/.gitignore @@ -0,0 +1,37 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ diff --git a/admin/build.gradle b/admin/build.gradle new file mode 100644 index 000000000..289b38542 --- /dev/null +++ b/admin/build.gradle @@ -0,0 +1,38 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.5.3' + id 'io.spring.dependency-management' version '1.1.7' +} + +group = 'com.sprint.mission' +version = '0.0.1-SNAPSHOT' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +repositories { + mavenCentral() +} + +ext { + set('springBootAdminVersion', "3.5.0") +} + +dependencies { + implementation 'de.codecentric:spring-boot-admin-starter-server' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + +dependencyManagement { + imports { + mavenBom "de.codecentric:spring-boot-admin-dependencies:${springBootAdminVersion}" + } +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/admin/gradle/wrapper/gradle-wrapper.jar b/admin/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..1b33c55ba Binary files /dev/null and b/admin/gradle/wrapper/gradle-wrapper.jar differ diff --git a/admin/gradle/wrapper/gradle-wrapper.properties b/admin/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..ff23a68d7 --- /dev/null +++ b/admin/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.2-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/admin/gradlew b/admin/gradlew new file mode 100644 index 000000000..23d15a936 --- /dev/null +++ b/admin/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/admin/gradlew.bat b/admin/gradlew.bat new file mode 100644 index 000000000..db3a6ac20 --- /dev/null +++ b/admin/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/admin/settings.gradle b/admin/settings.gradle new file mode 100644 index 000000000..e83a19cde --- /dev/null +++ b/admin/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'admin' diff --git a/admin/src/main/java/com/sprint/mission/admin/AdminApplication.java b/admin/src/main/java/com/sprint/mission/admin/AdminApplication.java new file mode 100644 index 000000000..9245ccc8a --- /dev/null +++ b/admin/src/main/java/com/sprint/mission/admin/AdminApplication.java @@ -0,0 +1,15 @@ +package com.sprint.mission.admin; + +import de.codecentric.boot.admin.server.config.EnableAdminServer; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +@EnableAdminServer +public class AdminApplication { + + public static void main(String[] args) { + SpringApplication.run(AdminApplication.class, args); + } + +} diff --git a/admin/src/main/resources/application.yaml b/admin/src/main/resources/application.yaml new file mode 100644 index 000000000..02abe4d0a --- /dev/null +++ b/admin/src/main/resources/application.yaml @@ -0,0 +1,5 @@ +spring: + application: + name: admin +server: + port: 9090 \ No newline at end of file diff --git a/admin/src/test/java/com/sprint/mission/admin/AdminApplicationTests.java b/admin/src/test/java/com/sprint/mission/admin/AdminApplicationTests.java new file mode 100644 index 000000000..4b524de52 --- /dev/null +++ b/admin/src/test/java/com/sprint/mission/admin/AdminApplicationTests.java @@ -0,0 +1,13 @@ +package com.sprint.mission.admin; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class AdminApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/api-docs_1.2.json b/api-docs_1.2.json new file mode 100644 index 000000000..7253644c9 --- /dev/null +++ b/api-docs_1.2.json @@ -0,0 +1,1278 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Discodeit API 문서", + "description": "Discodeit 프로젝트의 Swagger API 문서입니다.", + "version": "1.2" + }, + "servers": [ + { + "url": "http://localhost:8080", + "description": "로컬 서버" + } + ], + "tags": [ + { + "name": "Channel", + "description": "Channel API" + }, + { + "name": "ReadStatus", + "description": "Message 읽음 상태 API" + }, + { + "name": "Message", + "description": "Message API" + }, + { + "name": "User", + "description": "User API" + }, + { + "name": "BinaryContent", + "description": "첨부 파일 API" + }, + { + "name": "Auth", + "description": "인증 API" + } + ], + "paths": { + "/api/users": { + "get": { + "tags": [ + "User" + ], + "summary": "전체 User 목록 조회", + "operationId": "findAll", + "responses": { + "200": { + "description": "User 목록 조회 성공", + "content": { + "*/*": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserDto" + } + } + } + } + } + } + }, + "post": { + "tags": [ + "User" + ], + "summary": "User 등록", + "operationId": "create", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "userCreateRequest": { + "$ref": "#/components/schemas/UserCreateRequest" + }, + "profile": { + "type": "string", + "format": "binary", + "description": "User 프로필 이미지" + } + }, + "required": [ + "userCreateRequest" + ] + } + } + } + }, + "responses": { + "201": { + "description": "User가 성공적으로 생성됨", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/UserDto" + } + } + } + }, + "400": { + "description": "같은 email 또는 username를 사용하는 User가 이미 존재함", + "content": { + "*/*": { + "example": "User with email {email} already exists" + } + } + } + } + } + }, + "/api/readStatuses": { + "get": { + "tags": [ + "ReadStatus" + ], + "summary": "User의 Message 읽음 상태 목록 조회", + "operationId": "findAllByUserId", + "parameters": [ + { + "name": "userId", + "in": "query", + "description": "조회할 User ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Message 읽음 상태 목록 조회 성공", + "content": { + "*/*": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReadStatusDto" + } + } + } + } + } + } + }, + "post": { + "tags": [ + "ReadStatus" + ], + "summary": "Message 읽음 상태 생성", + "operationId": "create_1", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadStatusCreateRequest" + } + } + }, + "required": true + }, + "responses": { + "400": { + "description": "이미 읽음 상태가 존재함", + "content": { + "*/*": { + "example": "ReadStatus with userId {userId} and channelId {channelId} already exists" + } + } + }, + "201": { + "description": "Message 읽음 상태가 성공적으로 생성됨", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ReadStatusDto" + } + } + } + }, + "404": { + "description": "Channel 또는 User를 찾을 수 없음", + "content": { + "*/*": { + "example": "Channel | User with id {channelId | userId} not found" + } + } + } + } + } + }, + "/api/messages": { + "get": { + "tags": [ + "Message" + ], + "summary": "Channel의 Message 목록 조회", + "operationId": "findAllByChannelId", + "parameters": [ + { + "name": "channelId", + "in": "query", + "description": "조회할 Channel ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "cursor", + "in": "query", + "description": "페이징 커서 정보", + "required": true, + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "pageable", + "in": "query", + "description": "페이징 정보", + "required": true, + "schema": { + "$ref": "#/components/schemas/Pageable" + }, + "example": { + "size": 50, + "sort": "createdAt,desc" + } + } + ], + "responses": { + "200": { + "description": "Message 목록 조회 성공", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/PageResponse" + } + } + } + } + } + }, + "post": { + "tags": [ + "Message" + ], + "summary": "Message 생성", + "operationId": "create_2", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "messageCreateRequest": { + "$ref": "#/components/schemas/MessageCreateRequest" + }, + "attachments": { + "type": "array", + "description": "Message 첨부 파일들", + "items": { + "type": "string", + "format": "binary" + } + } + }, + "required": [ + "messageCreateRequest" + ] + } + } + } + }, + "responses": { + "201": { + "description": "Message가 성공적으로 생성됨", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/MessageDto" + } + } + } + }, + "404": { + "description": "Channel 또는 User를 찾을 수 없음", + "content": { + "*/*": { + "example": "Channel | Author with id {channelId | authorId} not found" + } + } + } + } + } + }, + "/api/channels/public": { + "post": { + "tags": [ + "Channel" + ], + "summary": "Public Channel 생성", + "operationId": "create_3", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicChannelCreateRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Public Channel이 성공적으로 생성됨", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ChannelDto" + } + } + } + } + } + } + }, + "/api/channels/private": { + "post": { + "tags": [ + "Channel" + ], + "summary": "Private Channel 생성", + "operationId": "create_4", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PrivateChannelCreateRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Private Channel이 성공적으로 생성됨", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ChannelDto" + } + } + } + } + } + } + }, + "/api/auth/login": { + "post": { + "tags": [ + "Auth" + ], + "summary": "로그인", + "operationId": "login", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "로그인 성공", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/UserDto" + } + } + } + }, + "404": { + "description": "사용자를 찾을 수 없음", + "content": { + "*/*": { + "example": "User with username {username} not found" + } + } + }, + "400": { + "description": "비밀번호가 일치하지 않음", + "content": { + "*/*": { + "example": "Wrong password" + } + } + } + } + } + }, + "/api/users/{userId}": { + "delete": { + "tags": [ + "User" + ], + "summary": "User 삭제", + "operationId": "delete", + "parameters": [ + { + "name": "userId", + "in": "path", + "description": "삭제할 User ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "404": { + "description": "User를 찾을 수 없음", + "content": { + "*/*": { + "example": "User with id {id} not found" + } + } + }, + "204": { + "description": "User가 성공적으로 삭제됨" + } + } + }, + "patch": { + "tags": [ + "User" + ], + "summary": "User 정보 수정", + "operationId": "update", + "parameters": [ + { + "name": "userId", + "in": "path", + "description": "수정할 User ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "userUpdateRequest": { + "$ref": "#/components/schemas/UserUpdateRequest" + }, + "profile": { + "type": "string", + "format": "binary", + "description": "수정할 User 프로필 이미지" + } + }, + "required": [ + "userUpdateRequest" + ] + } + } + } + }, + "responses": { + "400": { + "description": "같은 email 또는 username를 사용하는 User가 이미 존재함", + "content": { + "*/*": { + "example": "user with email {newEmail} already exists" + } + } + }, + "200": { + "description": "User 정보가 성공적으로 수정됨", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/UserDto" + } + } + } + }, + "404": { + "description": "User를 찾을 수 없음", + "content": { + "*/*": { + "example": "User with id {userId} not found" + } + } + } + } + } + }, + "/api/users/{userId}/userStatus": { + "patch": { + "tags": [ + "User" + ], + "summary": "User 온라인 상태 업데이트", + "operationId": "updateUserStatusByUserId", + "parameters": [ + { + "name": "userId", + "in": "path", + "description": "상태를 변경할 User ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserStatusUpdateRequest" + } + } + }, + "required": true + }, + "responses": { + "404": { + "description": "해당 User의 UserStatus를 찾을 수 없음", + "content": { + "*/*": { + "example": "UserStatus with userId {userId} not found" + } + } + }, + "200": { + "description": "User 온라인 상태가 성공적으로 업데이트됨", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/UserStatusDto" + } + } + } + } + } + } + }, + "/api/readStatuses/{readStatusId}": { + "patch": { + "tags": [ + "ReadStatus" + ], + "summary": "Message 읽음 상태 수정", + "operationId": "update_1", + "parameters": [ + { + "name": "readStatusId", + "in": "path", + "description": "수정할 읽음 상태 ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadStatusUpdateRequest" + } + } + }, + "required": true + }, + "responses": { + "404": { + "description": "Message 읽음 상태를 찾을 수 없음", + "content": { + "*/*": { + "example": "ReadStatus with id {readStatusId} not found" + } + } + }, + "200": { + "description": "Message 읽음 상태가 성공적으로 수정됨", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ReadStatusDto" + } + } + } + } + } + } + }, + "/api/messages/{messageId}": { + "delete": { + "tags": [ + "Message" + ], + "summary": "Message 삭제", + "operationId": "delete_1", + "parameters": [ + { + "name": "messageId", + "in": "path", + "description": "삭제할 Message ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Message가 성공적으로 삭제됨" + }, + "404": { + "description": "Message를 찾을 수 없음", + "content": { + "*/*": { + "example": "Message with id {messageId} not found" + } + } + } + } + }, + "patch": { + "tags": [ + "Message" + ], + "summary": "Message 내용 수정", + "operationId": "update_2", + "parameters": [ + { + "name": "messageId", + "in": "path", + "description": "수정할 Message ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessageUpdateRequest" + } + } + }, + "required": true + }, + "responses": { + "404": { + "description": "Message를 찾을 수 없음", + "content": { + "*/*": { + "example": "Message with id {messageId} not found" + } + } + }, + "200": { + "description": "Message가 성공적으로 수정됨", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/MessageDto" + } + } + } + } + } + } + }, + "/api/channels/{channelId}": { + "delete": { + "tags": [ + "Channel" + ], + "summary": "Channel 삭제", + "operationId": "delete_2", + "parameters": [ + { + "name": "channelId", + "in": "path", + "description": "삭제할 Channel ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "404": { + "description": "Channel을 찾을 수 없음", + "content": { + "*/*": { + "example": "Channel with id {channelId} not found" + } + } + }, + "204": { + "description": "Channel이 성공적으로 삭제됨" + } + } + }, + "patch": { + "tags": [ + "Channel" + ], + "summary": "Channel 정보 수정", + "operationId": "update_3", + "parameters": [ + { + "name": "channelId", + "in": "path", + "description": "수정할 Channel ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicChannelUpdateRequest" + } + } + }, + "required": true + }, + "responses": { + "404": { + "description": "Channel을 찾을 수 없음", + "content": { + "*/*": { + "example": "Channel with id {channelId} not found" + } + } + }, + "400": { + "description": "Private Channel은 수정할 수 없음", + "content": { + "*/*": { + "example": "Private channel cannot be updated" + } + } + }, + "200": { + "description": "Channel 정보가 성공적으로 수정됨", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ChannelDto" + } + } + } + } + } + } + }, + "/api/channels": { + "get": { + "tags": [ + "Channel" + ], + "summary": "User가 참여 중인 Channel 목록 조회", + "operationId": "findAll_1", + "parameters": [ + { + "name": "userId", + "in": "query", + "description": "조회할 User ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Channel 목록 조회 성공", + "content": { + "*/*": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ChannelDto" + } + } + } + } + } + } + } + }, + "/api/binaryContents": { + "get": { + "tags": [ + "BinaryContent" + ], + "summary": "여러 첨부 파일 조회", + "operationId": "findAllByIdIn", + "parameters": [ + { + "name": "binaryContentIds", + "in": "query", + "description": "조회할 첨부 파일 ID 목록", + "required": true, + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + } + ], + "responses": { + "200": { + "description": "첨부 파일 목록 조회 성공", + "content": { + "*/*": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BinaryContentDto" + } + } + } + } + } + } + } + }, + "/api/binaryContents/{binaryContentId}": { + "get": { + "tags": [ + "BinaryContent" + ], + "summary": "첨부 파일 조회", + "operationId": "find", + "parameters": [ + { + "name": "binaryContentId", + "in": "path", + "description": "조회할 첨부 파일 ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "첨부 파일 조회 성공", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/BinaryContentDto" + } + } + } + }, + "404": { + "description": "첨부 파일을 찾을 수 없음", + "content": { + "*/*": { + "example": "BinaryContent with id {binaryContentId} not found" + } + } + } + } + } + }, + "/api/binaryContents/{binaryContentId}/download": { + "get": { + "tags": [ + "BinaryContent" + ], + "summary": "파일 다운로드", + "operationId": "download", + "parameters": [ + { + "name": "binaryContentId", + "in": "path", + "description": "다운로드할 파일 ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "파일 다운로드 성공", + "content": { + "*/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "UserCreateRequest": { + "type": "object", + "description": "User 생성 정보", + "properties": { + "username": { + "type": "string" + }, + "email": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, + "BinaryContentDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "fileName": { + "type": "string" + }, + "size": { + "type": "integer", + "format": "int64" + }, + "contentType": { + "type": "string" + } + } + }, + "UserDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "username": { + "type": "string" + }, + "email": { + "type": "string" + }, + "profile": { + "$ref": "#/components/schemas/BinaryContentDto" + }, + "online": { + "type": "boolean" + } + } + }, + "ReadStatusCreateRequest": { + "type": "object", + "description": "Message 읽음 상태 생성 정보", + "properties": { + "userId": { + "type": "string", + "format": "uuid" + }, + "channelId": { + "type": "string", + "format": "uuid" + }, + "lastReadAt": { + "type": "string", + "format": "date-time" + } + } + }, + "ReadStatusDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "userId": { + "type": "string", + "format": "uuid" + }, + "channelId": { + "type": "string", + "format": "uuid" + }, + "lastReadAt": { + "type": "string", + "format": "date-time" + } + } + }, + "MessageCreateRequest": { + "type": "object", + "description": "Message 생성 정보", + "properties": { + "content": { + "type": "string" + }, + "channelId": { + "type": "string", + "format": "uuid" + }, + "authorId": { + "type": "string", + "format": "uuid" + } + } + }, + "MessageDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "content": { + "type": "string" + }, + "channelId": { + "type": "string", + "format": "uuid" + }, + "author": { + "$ref": "#/components/schemas/UserDto" + }, + "attachments": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BinaryContentDto" + } + } + } + }, + "PublicChannelCreateRequest": { + "type": "object", + "description": "Public Channel 생성 정보", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + } + }, + "ChannelDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "type": { + "type": "string", + "enum": [ + "PUBLIC", + "PRIVATE" + ] + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "participants": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserDto" + } + }, + "lastMessageAt": { + "type": "string", + "format": "date-time" + } + } + }, + "PrivateChannelCreateRequest": { + "type": "object", + "description": "Private Channel 생성 정보", + "properties": { + "participantIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + } + }, + "LoginRequest": { + "type": "object", + "description": "로그인 정보", + "properties": { + "username": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, + "UserUpdateRequest": { + "type": "object", + "description": "수정할 User 정보", + "properties": { + "newUsername": { + "type": "string" + }, + "newEmail": { + "type": "string" + }, + "newPassword": { + "type": "string" + } + } + }, + "UserStatusUpdateRequest": { + "type": "object", + "description": "변경할 User 온라인 상태 정보", + "properties": { + "newLastActiveAt": { + "type": "string", + "format": "date-time" + } + } + }, + "UserStatusDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "userId": { + "type": "string", + "format": "uuid" + }, + "lastActiveAt": { + "type": "string", + "format": "date-time" + } + } + }, + "ReadStatusUpdateRequest": { + "type": "object", + "description": "수정할 읽음 상태 정보", + "properties": { + "newLastReadAt": { + "type": "string", + "format": "date-time" + } + } + }, + "MessageUpdateRequest": { + "type": "object", + "description": "수정할 Message 내용", + "properties": { + "newContent": { + "type": "string" + } + } + }, + "PublicChannelUpdateRequest": { + "type": "object", + "description": "수정할 Channel 정보", + "properties": { + "newName": { + "type": "string" + }, + "newDescription": { + "type": "string" + } + } + }, + "Pageable": { + "type": "object", + "properties": { + "page": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "size": { + "type": "integer", + "format": "int32", + "minimum": 1 + }, + "sort": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PageResponse": { + "type": "object", + "properties": { + "content": { + "type": "array", + "items": { + "type": "object" + } + }, + "nextCursor": { + "type": "object" + }, + "size": { + "type": "integer", + "format": "int32" + }, + "hasNext": { + "type": "boolean" + }, + "totalElements": { + "type": "integer", + "format": "int64" + } + } + } + } + } +} \ No newline at end of file diff --git a/build.gradle b/build.gradle index 2506dba0c..e5ef0484e 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,12 @@ plugins { id 'java' - id 'org.springframework.boot' version '3.4.4' - id 'io.spring.dependency-management' version '1.1.7' + id 'org.springframework.boot' version '3.4.0' + id 'io.spring.dependency-management' version '1.1.6' + id 'jacoco' + +} +ext { + springBootAdminVersion = "3.4.7" } group = 'com.sprint.mission' @@ -25,22 +30,40 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.4' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - implementation 'org.springframework.boot:spring-boot-starter-jdbc' - compileOnly 'org.projectlombok:lombok' - annotationProcessor 'org.projectlombok:lombok' - testImplementation 'org.springframework.boot:spring-boot-starter-test' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'de.codecentric:spring-boot-admin-starter-client' runtimeOnly 'org.postgresql:postgresql' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' - - // https://mvnrepository.com/artifact/org.springdoc/springdoc-openapi-starter-webmvc-ui - implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.6' + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' implementation 'org.mapstruct:mapstruct:1.6.3' - annotationProcessor 'org.mapstruct:mapstruct-processor:1.6.3' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + runtimeOnly 'com.h2database:h2' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} +dependencyManagement { + imports { + mavenBom "de.codecentric:spring-boot-admin-dependencies:$springBootAdminVersion" + } } tasks.named('test') { useJUnitPlatform() } + +test { + finalizedBy jacocoTestReport +} + +jacocoTestReport { + dependsOn test + reports { + xml.required = true + html.required = true + } +} \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 9bbc975c7..a4b76b953 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 37f853b1c..e2847c820 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index faf93008b..f5feea6d6 100644 --- a/gradlew +++ b/gradlew @@ -86,7 +86,8 @@ done # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -205,7 +206,7 @@ fi DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, # and any embedded shellness will be escaped. # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # treated as '${Hostname}' itself on the command line. diff --git a/lena.jpg b/lena.jpg deleted file mode 100644 index 51860d3a0..000000000 Binary files a/lena.jpg and /dev/null differ diff --git a/src/main/java/com/sprint/mission/discodeit/DiscodeitApplication.java b/src/main/java/com/sprint/mission/discodeit/DiscodeitApplication.java index a178f6631..8f61230d4 100644 --- a/src/main/java/com/sprint/mission/discodeit/DiscodeitApplication.java +++ b/src/main/java/com/sprint/mission/discodeit/DiscodeitApplication.java @@ -2,9 +2,7 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.data.jpa.repository.config.EnableJpaAuditing; -@EnableJpaAuditing @SpringBootApplication public class DiscodeitApplication { diff --git a/src/main/java/com/sprint/mission/discodeit/aop/LoggingAspect.java b/src/main/java/com/sprint/mission/discodeit/aop/LoggingAspect.java new file mode 100644 index 000000000..36cde9613 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/aop/LoggingAspect.java @@ -0,0 +1,176 @@ +package com.sprint.mission.discodeit.aop; + +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.*; +import org.springframework.stereotype.Component; +import org.springframework.util.StopWatch; + +import java.util.Arrays; +import java.util.UUID; + +@Slf4j +@Aspect +@Component +public class LoggingAspect { + + // 컨트롤러 메소드들을 대상으로 하는 포인트컷 + @Pointcut("execution(* com.sprint.mission.discodeit.controller..*(..))") + public void controllerMethods() { + } + + // 서비스 메소드들을 대상으로 하는 포인트컷 + @Pointcut("execution(* com.sprint.mission.discodeit.service..*(..))") + public void serviceMethods() { + } + + // CRUD 관련 메소드들을 대상으로 하는 포인트컷 + @Pointcut("execution(* *.create*(..)) || execution(* *.update*(..)) || execution(* *.delete*(..))") + public void crudMethods() { + } + + /** + * 컨트롤러 메소드 실행 전후 로깅 (Around Advice) + */ + @Around("controllerMethods()") + public Object logControllerExecution(ProceedingJoinPoint joinPoint) throws Throwable { + StopWatch stopWatch = new StopWatch(); + String methodName = joinPoint.getSignature().getName(); + String className = joinPoint.getTarget().getClass().getSimpleName(); + + // 메소드 파라미터 로깅 (민감한 정보 제외) + Object[] args = joinPoint.getArgs(); + String sanitizedArgs = sanitizeArguments(args); + + log.info("[{}::{}] 요청 시작 - 파라미터: {}", className, methodName, sanitizedArgs); + + stopWatch.start(); + try { + Object result = joinPoint.proceed(); + stopWatch.stop(); + + log.info("[{}::{}] 요청 완료 - 실행시간: {}ms", + className, methodName, stopWatch.getTotalTimeMillis()); + + return result; + } catch (Exception e) { + stopWatch.stop(); + log.error("[{}::{}] 요청 실패 - 실행시간: {}ms, 오류: {}", + className, methodName, stopWatch.getTotalTimeMillis(), e.getMessage(), e); + throw e; + } + } + + /** + * 서비스 레이어의 CRUD 메소드 로깅 (Around Advice) + */ + @Around("serviceMethods() && crudMethods()") + public Object logServiceCrudExecution(ProceedingJoinPoint joinPoint) throws Throwable { + String methodName = joinPoint.getSignature().getName(); + String className = joinPoint.getTarget().getClass().getSimpleName(); + Object[] args = joinPoint.getArgs(); + + // 주요 식별자 추출 (UUID 등) + String identifier = extractIdentifier(args); + + log.debug("[{}::{}] 서비스 메소드 시작 - 대상: {}", className, methodName, identifier); + + try { + Object result = joinPoint.proceed(); + log.info("[{}::{}] {} 작업 성공 - 대상: {}", + className, methodName, getOperationType(methodName), identifier); + return result; + } catch (IllegalArgumentException e) { + log.warn("️ [{}::{}] {} 작업 실패 (잘못된 요청) - 대상: {}, 사유: {}", + className, methodName, getOperationType(methodName), identifier, e.getMessage()); + throw e; + } catch (Exception e) { + log.error(" [{}::{}] {} 작업 실패 (시스템 오류) - 대상: {}, 오류: {}", + className, methodName, getOperationType(methodName), identifier, e.getMessage(), e); + throw e; + } + } + + + /** + * 민감한 정보를 제거하고 파라미터를 문자열로 변환 + */ + private String sanitizeArguments(Object[] args) { + if (args == null || args.length == 0) { + return "없음"; + } + + return Arrays.stream(args) + .map(arg -> { + if (arg == null) { + return "null"; + } + + String argStr = arg.toString(); + String className = arg.getClass().getSimpleName(); + + // 민감한 정보가 포함된 클래스들은 클래스명만 표시 + if (className.toLowerCase().contains("password") || + className.toLowerCase().contains("request") || + className.toLowerCase().contains("multipart")) { + return className; + } + + // UUID는 앞 8자리만 표시 + if (arg instanceof UUID) { + return argStr.substring(0, 8) + "..."; + } + + // 문자열이 너무 길면 자르기 + if (argStr.length() > 50) { + return argStr.substring(0, 50) + "..."; + } + + return argStr; + }) + .reduce((a, b) -> a + ", " + b) + .orElse("없음"); + } + + /** + * 주요 식별자 추출 (UUID, ID 등) + */ + private String extractIdentifier(Object[] args) { + if (args == null || args.length == 0) { + return "미지정"; + } + + for (Object arg : args) { + if (arg instanceof UUID) { + return "ID:" + arg.toString().substring(0, 8) + "..."; + } + if (arg != null && arg.getClass().getSimpleName().toLowerCase().contains("request")) { + // Request 객체에서 username이나 email 등을 추출할 수 있음 + return "Request:" + arg.getClass().getSimpleName(); + } + } + + return "ID:미지정"; + } + + + /** + * 메소드명으로부터 작업 유형 추출 + */ + private String getOperationType(String methodName) { + if (methodName.startsWith("create")) { + return "생성"; + } + if (methodName.startsWith("update")) { + return "수정"; + } + if (methodName.startsWith("delete")) { + return "삭제"; + } + if (methodName.startsWith("find")) { + return "조회"; + } + return "처리"; + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/config/AppConfig.java b/src/main/java/com/sprint/mission/discodeit/config/AppConfig.java new file mode 100644 index 000000000..96010621f --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/config/AppConfig.java @@ -0,0 +1,10 @@ +package com.sprint.mission.discodeit.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +public class AppConfig { + +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/config/MDCLoggingInterCeptor.java b/src/main/java/com/sprint/mission/discodeit/config/MDCLoggingInterCeptor.java new file mode 100644 index 000000000..84b07de01 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/config/MDCLoggingInterCeptor.java @@ -0,0 +1,39 @@ +package com.sprint.mission.discodeit.config; + + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.util.UUID; +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.MDC; +import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.web.servlet.ModelAndView; + +@Slf4j +public class MDCLoggingInterCeptor implements HandlerInterceptor { + + @Override + public boolean preHandle(@NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull Object handler) throws Exception { + String requestId = UUID.randomUUID().toString().substring(0, 8); + String requestMethod = request.getMethod(); + String requestURL = request.getRequestURI(); + + // MDC에 컨텍스트 정보 설정 + MDC.put("RequestID", requestId); + MDC.put("RequestMethod", requestMethod); + MDC.put("RequestURL", requestURL); + + response.setHeader("Discodeit-Request-ID", requestId); + return true; + } + + @Override + public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, + ModelAndView modelAndView) throws Exception { + + MDC.clear(); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/config/SwaggerConfig.java b/src/main/java/com/sprint/mission/discodeit/config/SwaggerConfig.java new file mode 100644 index 000000000..15a777199 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/config/SwaggerConfig.java @@ -0,0 +1,25 @@ +package com.sprint.mission.discodeit.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.servers.Server; +import java.util.List; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI customOpenAPI() { + return new OpenAPI() + .info(new Info() + .title("Discodeit API 문서") + .description("Discodeit 프로젝트의 Swagger API 문서입니다.") + .version("1.2") + ) + .servers(List.of( + new Server().url("http://localhost:8080").description("로컬 서버") + )); + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/config/WebMvcConfig.java b/src/main/java/com/sprint/mission/discodeit/config/WebMvcConfig.java new file mode 100644 index 000000000..f6ec0b789 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/config/WebMvcConfig.java @@ -0,0 +1,17 @@ +package com.sprint.mission.discodeit.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Slf4j +@Configuration +public class WebMvcConfig implements WebMvcConfigurer { + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(new MDCLoggingInterCeptor()) + .addPathPatterns("/api/**"); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/configuration/SwaggerConfig.java b/src/main/java/com/sprint/mission/discodeit/configuration/SwaggerConfig.java deleted file mode 100644 index 56298e465..000000000 --- a/src/main/java/com/sprint/mission/discodeit/configuration/SwaggerConfig.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.sprint.mission.discodeit.configuration; - -import io.swagger.v3.oas.annotations.OpenAPIDefinition; -import io.swagger.v3.oas.annotations.info.Info; -import io.swagger.v3.oas.annotations.servers.Server; -import org.springframework.context.annotation.Configuration; - - -@OpenAPIDefinition( - info = @Info( - title = "Discodeit API 문서", - description = "Discodeit 프로젝트의 Swagger API 문서입니다."), - servers = @Server( - url = "http://localhost:8080", - description = "로컬 서버") -) - -@Configuration -public class SwaggerConfig { - -} diff --git a/src/main/java/com/sprint/mission/discodeit/controller/AuthController.java b/src/main/java/com/sprint/mission/discodeit/controller/AuthController.java index 239b3846f..d62dc6008 100644 --- a/src/main/java/com/sprint/mission/discodeit/controller/AuthController.java +++ b/src/main/java/com/sprint/mission/discodeit/controller/AuthController.java @@ -3,9 +3,8 @@ import com.sprint.mission.discodeit.controller.api.AuthApi; import com.sprint.mission.discodeit.dto.data.UserDto; import com.sprint.mission.discodeit.dto.request.LoginRequest; -import com.sprint.mission.discodeit.entity.User; -import com.sprint.mission.discodeit.mapper.UserMapper; import com.sprint.mission.discodeit.service.AuthService; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -19,13 +18,13 @@ @RequestMapping("/api/auth") public class AuthController implements AuthApi { - private final AuthService authService; + private final AuthService authService; - @PostMapping(path = "login") - public ResponseEntity login(@RequestBody LoginRequest loginRequest) { - UserDto userDto = authService.login(loginRequest); - return ResponseEntity - .status(HttpStatus.OK) - .body(userDto); - } + @PostMapping(path = "login") + public ResponseEntity login(@Valid @RequestBody LoginRequest loginRequest) { + UserDto user = authService.login(loginRequest); + return ResponseEntity + .status(HttpStatus.OK) + .body(user); + } } diff --git a/src/main/java/com/sprint/mission/discodeit/controller/BinaryContentController.java b/src/main/java/com/sprint/mission/discodeit/controller/BinaryContentController.java index 84f3c42b6..664e38ec4 100644 --- a/src/main/java/com/sprint/mission/discodeit/controller/BinaryContentController.java +++ b/src/main/java/com/sprint/mission/discodeit/controller/BinaryContentController.java @@ -2,13 +2,11 @@ import com.sprint.mission.discodeit.controller.api.BinaryContentApi; import com.sprint.mission.discodeit.dto.data.BinaryContentDto; -import com.sprint.mission.discodeit.entity.BinaryContent; import com.sprint.mission.discodeit.service.BinaryContentService; import com.sprint.mission.discodeit.storage.BinaryContentStorage; import java.util.List; import java.util.UUID; import lombok.RequiredArgsConstructor; -import org.springframework.core.io.Resource; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -23,14 +21,15 @@ public class BinaryContentController implements BinaryContentApi { private final BinaryContentService binaryContentService; + private final BinaryContentStorage binaryContentStorage; @GetMapping(path = "{binaryContentId}") public ResponseEntity find( @PathVariable("binaryContentId") UUID binaryContentId) { - BinaryContentDto binaryContentDto = binaryContentService.find(binaryContentId); + BinaryContentDto binaryContent = binaryContentService.find(binaryContentId); return ResponseEntity .status(HttpStatus.OK) - .body(binaryContentDto); + .body(binaryContent); } @GetMapping @@ -42,9 +41,10 @@ public ResponseEntity> findAllByIdIn( .body(binaryContents); } - @GetMapping("{binaryContentId}/download") + @GetMapping(path = "{binaryContentId}/download") public ResponseEntity download( @PathVariable("binaryContentId") UUID binaryContentId) { - return binaryContentService.download(binaryContentId); + BinaryContentDto binaryContentDto = binaryContentService.find(binaryContentId); + return binaryContentStorage.download(binaryContentDto); } } diff --git a/src/main/java/com/sprint/mission/discodeit/controller/ChannelController.java b/src/main/java/com/sprint/mission/discodeit/controller/ChannelController.java index abcd59ab3..074a278a3 100644 --- a/src/main/java/com/sprint/mission/discodeit/controller/ChannelController.java +++ b/src/main/java/com/sprint/mission/discodeit/controller/ChannelController.java @@ -5,8 +5,8 @@ import com.sprint.mission.discodeit.dto.request.PrivateChannelCreateRequest; import com.sprint.mission.discodeit.dto.request.PublicChannelCreateRequest; import com.sprint.mission.discodeit.dto.request.PublicChannelUpdateRequest; -import com.sprint.mission.discodeit.entity.Channel; import com.sprint.mission.discodeit.service.ChannelService; +import jakarta.validation.Valid; import java.util.List; import java.util.UUID; import lombok.RequiredArgsConstructor; @@ -27,46 +27,48 @@ @RequestMapping("/api/channels") public class ChannelController implements ChannelApi { - private final ChannelService channelService; + private final ChannelService channelService; - @PostMapping(path = "public") - public ResponseEntity create(@RequestBody PublicChannelCreateRequest request) { - ChannelDto createdChannelDto = channelService.create(request); - return ResponseEntity - .status(HttpStatus.CREATED) - .body(createdChannelDto); - } + @PostMapping(path = "public") + public ResponseEntity create( + @Valid @RequestBody PublicChannelCreateRequest request) { + ChannelDto createdChannel = channelService.create(request); + return ResponseEntity + .status(HttpStatus.CREATED) + .body(createdChannel); + } - @PostMapping(path = "private") - public ResponseEntity create(@RequestBody PrivateChannelCreateRequest request) { - ChannelDto createdChannelDto = channelService.create(request); - return ResponseEntity - .status(HttpStatus.CREATED) - .body(createdChannelDto); - } + @PostMapping(path = "private") + public ResponseEntity create( + @Valid @RequestBody PrivateChannelCreateRequest request) { + ChannelDto createdChannel = channelService.create(request); + return ResponseEntity + .status(HttpStatus.CREATED) + .body(createdChannel); + } - @PatchMapping(path = "{channelId}") - public ResponseEntity update(@PathVariable("channelId") UUID channelId, - @RequestBody PublicChannelUpdateRequest request) { - ChannelDto udpatedChannelDto = channelService.update(channelId, request); - return ResponseEntity - .status(HttpStatus.OK) - .body(udpatedChannelDto); - } + @PatchMapping(path = "{channelId}") + public ResponseEntity update(@PathVariable("channelId") UUID channelId, + @Valid @RequestBody PublicChannelUpdateRequest request) { + ChannelDto updatedChannel = channelService.update(channelId, request); + return ResponseEntity + .status(HttpStatus.OK) + .body(updatedChannel); + } - @DeleteMapping(path = "{channelId}") - public ResponseEntity delete(@PathVariable("channelId") UUID channelId) { - channelService.delete(channelId); - return ResponseEntity - .status(HttpStatus.NO_CONTENT) - .build(); - } + @DeleteMapping(path = "{channelId}") + public ResponseEntity delete(@PathVariable("channelId") UUID channelId) { + channelService.delete(channelId); + return ResponseEntity + .status(HttpStatus.NO_CONTENT) + .build(); + } - @GetMapping - public ResponseEntity> findAll(@RequestParam("userId") UUID userId) { - List channelsDto = channelService.findAllByUserId(userId); - return ResponseEntity - .status(HttpStatus.OK) - .body(channelsDto); - } + @GetMapping + public ResponseEntity> findAll(@RequestParam("userId") UUID userId) { + List channels = channelService.findAllByUserId(userId); + return ResponseEntity + .status(HttpStatus.OK) + .body(channels); + } } diff --git a/src/main/java/com/sprint/mission/discodeit/controller/MessageController.java b/src/main/java/com/sprint/mission/discodeit/controller/MessageController.java index 6db171ac8..25d28bdf5 100644 --- a/src/main/java/com/sprint/mission/discodeit/controller/MessageController.java +++ b/src/main/java/com/sprint/mission/discodeit/controller/MessageController.java @@ -5,9 +5,9 @@ import com.sprint.mission.discodeit.dto.request.BinaryContentCreateRequest; import com.sprint.mission.discodeit.dto.request.MessageCreateRequest; import com.sprint.mission.discodeit.dto.request.MessageUpdateRequest; -import com.sprint.mission.discodeit.dto.respond.PageResponse; -import com.sprint.mission.discodeit.mapper.PageResponseMapper; +import com.sprint.mission.discodeit.dto.response.PageResponse; import com.sprint.mission.discodeit.service.MessageService; +import jakarta.validation.Valid; import java.io.IOException; import java.time.Instant; import java.util.ArrayList; @@ -15,10 +15,7 @@ import java.util.Optional; import java.util.UUID; import lombok.RequiredArgsConstructor; -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.data.domain.Sort.Direction; import org.springframework.data.web.PageableDefault; import org.springframework.http.HttpStatus; @@ -41,62 +38,65 @@ @RequestMapping("/api/messages") public class MessageController implements MessageApi { - private final MessageService messageService; - private final PageResponseMapper pageResponseMapper; + private final MessageService messageService; - @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - public ResponseEntity create( - @RequestPart("messageCreateRequest") MessageCreateRequest messageCreateRequest, - @RequestPart(value = "attachments", required = false) List attachments - ) { - List attachmentRequests = Optional.ofNullable(attachments) - .map(files -> files.stream() - .map(file -> { - try { - return new BinaryContentCreateRequest( - file.getOriginalFilename(), - file.getContentType(), - file.getBytes() - ); - } catch (IOException e) { - throw new RuntimeException(e); - } - }) - .toList()) - .orElse(new ArrayList<>()); - MessageDto createdMessageDto = messageService.create(messageCreateRequest, attachmentRequests); - return ResponseEntity - .status(HttpStatus.CREATED) - .body(createdMessageDto); - } + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity create( + @Valid @RequestPart("messageCreateRequest") MessageCreateRequest messageCreateRequest, + @RequestPart(value = "attachments", required = false) List attachments + ) { + List attachmentRequests = Optional.ofNullable(attachments) + .map(files -> files.stream() + .map(file -> { + try { + return new BinaryContentCreateRequest( + file.getOriginalFilename(), + file.getContentType(), + file.getBytes() + ); + } catch (IOException e) { + throw new RuntimeException(e); + } + }) + .toList()) + .orElse(new ArrayList<>()); + MessageDto createdMessage = messageService.create(messageCreateRequest, attachmentRequests); + return ResponseEntity + .status(HttpStatus.CREATED) + .body(createdMessage); + } - @PatchMapping(path = "{messageId}") - public ResponseEntity update(@PathVariable("messageId") UUID messageId, - @RequestBody MessageUpdateRequest request) { - MessageDto updatedMessageDto = messageService.update(messageId, request); - return ResponseEntity - .status(HttpStatus.OK) - .body(updatedMessageDto); - } + @PatchMapping(path = "{messageId}") + public ResponseEntity update(@PathVariable("messageId") UUID messageId, + @Valid @RequestBody MessageUpdateRequest request) { + MessageDto updatedMessage = messageService.update(messageId, request); + return ResponseEntity + .status(HttpStatus.OK) + .body(updatedMessage); + } - @DeleteMapping(path = "{messageId}") - public ResponseEntity delete(@PathVariable("messageId") UUID messageId) { - messageService.delete(messageId); - return ResponseEntity - .status(HttpStatus.NO_CONTENT) - .build(); - } + @DeleteMapping(path = "{messageId}") + public ResponseEntity delete(@PathVariable("messageId") UUID messageId) { + messageService.delete(messageId); + return ResponseEntity + .status(HttpStatus.NO_CONTENT) + .build(); + } - @GetMapping - public ResponseEntity> findAllByChannelId( - @RequestParam("channelId") UUID channelId, - @RequestParam(name = "cursor", required = false) Instant cursor, - @PageableDefault(page = 0, size = 50, sort = "createdAt", direction = Direction.DESC) Pageable pageable) { - PageResponse pageResponse = messageService.findAllByChannelId(channelId, cursor, - pageable); - - return ResponseEntity - .status(HttpStatus.OK) - .body(pageResponse); - } + @GetMapping + public ResponseEntity> findAllByChannelId( + @RequestParam("channelId") UUID channelId, + @RequestParam(value = "cursor", required = false) Instant cursor, + @PageableDefault( + size = 50, + page = 0, + sort = "createdAt", + direction = Direction.DESC + ) Pageable pageable) { + PageResponse messages = messageService.findAllByChannelId(channelId, cursor, + pageable); + return ResponseEntity + .status(HttpStatus.OK) + .body(messages); + } } diff --git a/src/main/java/com/sprint/mission/discodeit/controller/ReadStatusController.java b/src/main/java/com/sprint/mission/discodeit/controller/ReadStatusController.java index 0a93fad67..8e5f9a8d2 100644 --- a/src/main/java/com/sprint/mission/discodeit/controller/ReadStatusController.java +++ b/src/main/java/com/sprint/mission/discodeit/controller/ReadStatusController.java @@ -4,8 +4,8 @@ import com.sprint.mission.discodeit.dto.data.ReadStatusDto; import com.sprint.mission.discodeit.dto.request.ReadStatusCreateRequest; import com.sprint.mission.discodeit.dto.request.ReadStatusUpdateRequest; -import com.sprint.mission.discodeit.entity.ReadStatus; import com.sprint.mission.discodeit.service.ReadStatusService; +import jakarta.validation.Valid; import java.util.List; import java.util.UUID; import lombok.RequiredArgsConstructor; @@ -25,30 +25,32 @@ @RequestMapping("/api/readStatuses") public class ReadStatusController implements ReadStatusApi { - private final ReadStatusService readStatusService; + private final ReadStatusService readStatusService; - @PostMapping - public ResponseEntity create(@RequestBody ReadStatusCreateRequest request) { - ReadStatusDto createdReadStatusDto = readStatusService.create(request); - return ResponseEntity - .status(HttpStatus.CREATED) - .body(createdReadStatusDto); - } + @PostMapping + public ResponseEntity create( + @Valid @RequestBody ReadStatusCreateRequest request) { + ReadStatusDto createdReadStatus = readStatusService.create(request); + return ResponseEntity + .status(HttpStatus.CREATED) + .body(createdReadStatus); + } - @PatchMapping(path = "{readStatusId}") - public ResponseEntity update(@PathVariable("readStatusId") UUID readStatusId, - @RequestBody ReadStatusUpdateRequest request) { - ReadStatusDto updatedReadStatusDto = readStatusService.update(readStatusId, request); - return ResponseEntity - .status(HttpStatus.OK) - .body(updatedReadStatusDto); - } + @PatchMapping(path = "{readStatusId}") + public ResponseEntity update(@PathVariable("readStatusId") UUID readStatusId, + @Valid @RequestBody ReadStatusUpdateRequest request) { + ReadStatusDto updatedReadStatus = readStatusService.update(readStatusId, request); + return ResponseEntity + .status(HttpStatus.OK) + .body(updatedReadStatus); + } - @GetMapping - public ResponseEntity> findAllByUserId(@RequestParam("userId") UUID userId) { - List readStatusesDto = readStatusService.findAllByUserId(userId); - return ResponseEntity - .status(HttpStatus.OK) - .body(readStatusesDto); - } + @GetMapping + public ResponseEntity> findAllByUserId( + @RequestParam("userId") UUID userId) { + List readStatuses = readStatusService.findAllByUserId(userId); + return ResponseEntity + .status(HttpStatus.OK) + .body(readStatuses); + } } diff --git a/src/main/java/com/sprint/mission/discodeit/controller/UserController.java b/src/main/java/com/sprint/mission/discodeit/controller/UserController.java index 4c88f1e7a..09261bc7c 100644 --- a/src/main/java/com/sprint/mission/discodeit/controller/UserController.java +++ b/src/main/java/com/sprint/mission/discodeit/controller/UserController.java @@ -7,15 +7,15 @@ import com.sprint.mission.discodeit.dto.request.UserCreateRequest; import com.sprint.mission.discodeit.dto.request.UserStatusUpdateRequest; import com.sprint.mission.discodeit.dto.request.UserUpdateRequest; -import com.sprint.mission.discodeit.entity.User; -import com.sprint.mission.discodeit.entity.UserStatus; import com.sprint.mission.discodeit.service.UserService; import com.sprint.mission.discodeit.service.UserStatusService; +import jakarta.validation.Valid; import java.io.IOException; import java.util.List; import java.util.Optional; import java.util.UUID; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -35,83 +35,87 @@ @RequestMapping("/api/users") public class UserController implements UserApi { - private final UserService userService; - private final UserStatusService userStatusService; + private final UserService userService; + private final UserStatusService userStatusService; - @PostMapping(consumes = {MediaType.MULTIPART_FORM_DATA_VALUE}) - @Override - public ResponseEntity create( - @RequestPart("userCreateRequest") UserCreateRequest userCreateRequest, - @RequestPart(value = "profile", required = false) MultipartFile profile - ) { - Optional profileRequest = Optional.ofNullable(profile) - .flatMap(this::resolveProfileRequest); - UserDto createdUserDto = userService.create(userCreateRequest, profileRequest); - return ResponseEntity - .status(HttpStatus.CREATED) - .body(createdUserDto); - } + @PostMapping(consumes = {MediaType.MULTIPART_FORM_DATA_VALUE}) + @Override + public ResponseEntity create( + @Valid @RequestPart("userCreateRequest") UserCreateRequest userCreateRequest, + @RequestPart(value = "profile", required = false) MultipartFile profile + ) { + Optional profileRequest = Optional.ofNullable(profile) + .flatMap(this::resolveProfileRequest); + UserDto createdUser = userService.create(userCreateRequest, profileRequest); + return ResponseEntity + .status(HttpStatus.CREATED) + .body(createdUser); + } + + @PatchMapping( + path = "{userId}", + consumes = {MediaType.MULTIPART_FORM_DATA_VALUE} + ) + @Override + public ResponseEntity update( + @PathVariable("userId") UUID userId, + @Valid @RequestPart("userUpdateRequest") UserUpdateRequest userUpdateRequest, + @RequestPart(value = "profile", required = false) MultipartFile profile + ) { + + Optional profileRequest = Optional.ofNullable(profile) + .flatMap(this::resolveProfileRequest); + UserDto updatedUser = userService.update(userId, userUpdateRequest, profileRequest); + return ResponseEntity + .status(HttpStatus.OK) + .body(updatedUser); + } - @PatchMapping( - path = "{userId}", - consumes = {MediaType.MULTIPART_FORM_DATA_VALUE} - ) - @Override - public ResponseEntity update( - @PathVariable("userId") UUID userId, - @RequestPart("userUpdateRequest") UserUpdateRequest userUpdateRequest, - @RequestPart(value = "profile", required = false) MultipartFile profile - ) { - Optional profileRequest = Optional.ofNullable(profile) - .flatMap(this::resolveProfileRequest); - UserDto updatedUserDto = userService.update(userId, userUpdateRequest, profileRequest); - return ResponseEntity - .status(HttpStatus.OK) - .body(updatedUserDto); - } + @DeleteMapping(path = "{userId}") + @Override + public ResponseEntity delete(@PathVariable("userId") UUID userId) { - @DeleteMapping(path = "{userId}") - @Override - public ResponseEntity delete(@PathVariable("userId") UUID userId) { - userService.delete(userId); - return ResponseEntity - .status(HttpStatus.NO_CONTENT) - .build(); - } + userService.delete(userId); + return ResponseEntity + .status(HttpStatus.NO_CONTENT) + .build(); + } + + @GetMapping + @Override + public ResponseEntity> findAll() { - @GetMapping - @Override - public ResponseEntity> findAll() { - List users = userService.findAll(); - return ResponseEntity - .status(HttpStatus.OK) - .body(users); - } + List users = userService.findAll(); + return ResponseEntity + .status(HttpStatus.OK) + .body(users); + } - @PatchMapping(path = "{userId}/userStatus") - @Override - public ResponseEntity updateUserStatusByUserId(@PathVariable("userId") UUID userId, - @RequestBody UserStatusUpdateRequest request) { - UserStatusDto updatedUserStatusDto = userStatusService.updateByUserId(userId, request); - return ResponseEntity - .status(HttpStatus.OK) - .body(updatedUserStatusDto); - } + @PatchMapping(path = "{userId}/userStatus") + @Override + public ResponseEntity updateUserStatusByUserId( + @PathVariable("userId") UUID userId, + @Valid @RequestBody UserStatusUpdateRequest request) { + UserStatusDto updatedUserStatus = userStatusService.updateByUserId(userId, request); + return ResponseEntity + .status(HttpStatus.OK) + .body(updatedUserStatus); + } - private Optional resolveProfileRequest(MultipartFile profileFile) { - if (profileFile.isEmpty()) { - return Optional.empty(); - } else { - try { - BinaryContentCreateRequest binaryContentCreateRequest = new BinaryContentCreateRequest( - profileFile.getOriginalFilename(), - profileFile.getContentType(), - profileFile.getBytes() - ); - return Optional.of(binaryContentCreateRequest); - } catch (IOException e) { - throw new RuntimeException(e); - } + private Optional resolveProfileRequest(MultipartFile profileFile) { + if (profileFile.isEmpty()) { + return Optional.empty(); + } else { + try { + BinaryContentCreateRequest binaryContentCreateRequest = new BinaryContentCreateRequest( + profileFile.getOriginalFilename(), + profileFile.getContentType(), + profileFile.getBytes() + ); + return Optional.of(binaryContentCreateRequest); + } catch (IOException e) { + throw new RuntimeException(e); + } + } } - } } diff --git a/src/main/java/com/sprint/mission/discodeit/controller/api/AuthApi.java b/src/main/java/com/sprint/mission/discodeit/controller/api/AuthApi.java index 1c7a2dc8c..ee9ce79f9 100644 --- a/src/main/java/com/sprint/mission/discodeit/controller/api/AuthApi.java +++ b/src/main/java/com/sprint/mission/discodeit/controller/api/AuthApi.java @@ -2,7 +2,6 @@ import com.sprint.mission.discodeit.dto.data.UserDto; import com.sprint.mission.discodeit.dto.request.LoginRequest; -import com.sprint.mission.discodeit.entity.User; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; diff --git a/src/main/java/com/sprint/mission/discodeit/controller/api/BinaryContentApi.java b/src/main/java/com/sprint/mission/discodeit/controller/api/BinaryContentApi.java index 376fe530c..883ab8a88 100644 --- a/src/main/java/com/sprint/mission/discodeit/controller/api/BinaryContentApi.java +++ b/src/main/java/com/sprint/mission/discodeit/controller/api/BinaryContentApi.java @@ -1,7 +1,6 @@ package com.sprint.mission.discodeit.controller.api; import com.sprint.mission.discodeit.dto.data.BinaryContentDto; -import com.sprint.mission.discodeit.entity.BinaryContent; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; @@ -49,11 +48,10 @@ ResponseEntity> findAllByIdIn( @ApiResponses(value = { @ApiResponse( responseCode = "200", description = "파일 다운로드 성공", - content = @Content(schema = @Schema(type = "string", format = "binary")) + content = @Content(schema = @Schema(implementation = Resource.class)) ) }) ResponseEntity download( @Parameter(description = "다운로드할 파일 ID") UUID binaryContentId ); - -} \ No newline at end of file +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/controller/api/ChannelApi.java b/src/main/java/com/sprint/mission/discodeit/controller/api/ChannelApi.java index 6f65e261e..af8c7afc7 100644 --- a/src/main/java/com/sprint/mission/discodeit/controller/api/ChannelApi.java +++ b/src/main/java/com/sprint/mission/discodeit/controller/api/ChannelApi.java @@ -4,7 +4,6 @@ import com.sprint.mission.discodeit.dto.request.PrivateChannelCreateRequest; import com.sprint.mission.discodeit.dto.request.PublicChannelCreateRequest; import com.sprint.mission.discodeit.dto.request.PublicChannelUpdateRequest; -import com.sprint.mission.discodeit.entity.Channel; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; diff --git a/src/main/java/com/sprint/mission/discodeit/controller/api/MessageApi.java b/src/main/java/com/sprint/mission/discodeit/controller/api/MessageApi.java index 7e6ce4fe7..c9a7aebbd 100644 --- a/src/main/java/com/sprint/mission/discodeit/controller/api/MessageApi.java +++ b/src/main/java/com/sprint/mission/discodeit/controller/api/MessageApi.java @@ -3,11 +3,9 @@ import com.sprint.mission.discodeit.dto.data.MessageDto; import com.sprint.mission.discodeit.dto.request.MessageCreateRequest; import com.sprint.mission.discodeit.dto.request.MessageUpdateRequest; -import com.sprint.mission.discodeit.dto.respond.PageResponse; -import com.sprint.mission.discodeit.entity.Message; +import com.sprint.mission.discodeit.dto.response.PageResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.ExampleObject; import io.swagger.v3.oas.annotations.media.Schema; @@ -87,6 +85,6 @@ ResponseEntity delete( ResponseEntity> findAllByChannelId( @Parameter(description = "조회할 Channel ID") UUID channelId, @Parameter(description = "페이징 커서 정보") Instant cursor, - @Parameter(description = "페이징 정보") Pageable pageable + @Parameter(description = "페이징 정보", example = "{\"size\": 50, \"sort\": \"createdAt,desc\"}") Pageable pageable ); } \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/controller/api/ReadStatusApi.java b/src/main/java/com/sprint/mission/discodeit/controller/api/ReadStatusApi.java index 73bee396b..eb08b359f 100644 --- a/src/main/java/com/sprint/mission/discodeit/controller/api/ReadStatusApi.java +++ b/src/main/java/com/sprint/mission/discodeit/controller/api/ReadStatusApi.java @@ -3,7 +3,6 @@ import com.sprint.mission.discodeit.dto.data.ReadStatusDto; import com.sprint.mission.discodeit.dto.request.ReadStatusCreateRequest; import com.sprint.mission.discodeit.dto.request.ReadStatusUpdateRequest; -import com.sprint.mission.discodeit.entity.ReadStatus; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; diff --git a/src/main/java/com/sprint/mission/discodeit/controller/api/UserApi.java b/src/main/java/com/sprint/mission/discodeit/controller/api/UserApi.java index 6c93df913..9d40bc1ce 100644 --- a/src/main/java/com/sprint/mission/discodeit/controller/api/UserApi.java +++ b/src/main/java/com/sprint/mission/discodeit/controller/api/UserApi.java @@ -5,8 +5,6 @@ import com.sprint.mission.discodeit.dto.request.UserCreateRequest; import com.sprint.mission.discodeit.dto.request.UserStatusUpdateRequest; import com.sprint.mission.discodeit.dto.request.UserUpdateRequest; -import com.sprint.mission.discodeit.entity.User; -import com.sprint.mission.discodeit.entity.UserStatus; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; diff --git a/src/main/java/com/sprint/mission/discodeit/dto/data/BinaryContentDto.java b/src/main/java/com/sprint/mission/discodeit/dto/data/BinaryContentDto.java index c33d4e9ab..d44aee484 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/data/BinaryContentDto.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/data/BinaryContentDto.java @@ -1,18 +1,12 @@ package com.sprint.mission.discodeit.dto.data; import java.util.UUID; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.Setter; -@Getter -@Setter -@AllArgsConstructor -public class BinaryContentDto { +public record BinaryContentDto( + UUID id, + String fileName, + Long size, + String contentType +) { - private UUID id; - private String fileName; - private Long size; - private String contentType; - private byte[] bytes; } diff --git a/src/main/java/com/sprint/mission/discodeit/dto/data/UserDto.java b/src/main/java/com/sprint/mission/discodeit/dto/data/UserDto.java index 2f6e837ac..aa696a69f 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/data/UserDto.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/data/UserDto.java @@ -1,7 +1,5 @@ package com.sprint.mission.discodeit.dto.data; -import com.sprint.mission.discodeit.entity.BinaryContent; -import java.time.Instant; import java.util.UUID; public record UserDto( diff --git a/src/main/java/com/sprint/mission/discodeit/dto/data/UserStatusDto.java b/src/main/java/com/sprint/mission/discodeit/dto/data/UserStatusDto.java index 0d7395d9b..87ee9d000 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/data/UserStatusDto.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/data/UserStatusDto.java @@ -6,7 +6,6 @@ public record UserStatusDto( UUID id, UUID userId, - Instant lastActiveAt -) { + Instant lastActiveAt) { } diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/BinaryContentCreateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/BinaryContentCreateRequest.java index d86eb9898..1164922e3 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/request/BinaryContentCreateRequest.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/BinaryContentCreateRequest.java @@ -1,8 +1,25 @@ package com.sprint.mission.discodeit.dto.request; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + public record BinaryContentCreateRequest( + + @NotBlank(message = "파일 명은 필수입니다.") + @Size(min = 1, max = 255, message = "파일명은 1자 이상 255자 이하여야 합니다") String fileName, + + @NotBlank(message = "컨텐츠 타입명은 필수입니다.") + @Pattern( + regexp = "^(image|video|audio|application|text)/[a-zA-Z0-9][a-zA-Z0-9!#$&\\-\\^_]*$", + message = "올바른 MIME 타입 형식이 아닙니다 (예: image/jpeg, application/pdf)" + ) String contentType, + + @NotNull(message = "파일 데이터는 필수입니다") + @Size(min = 1, max = 10485760, message = "파일 크기는 1바이트 이상 50MB 이하여야 합니다") // 10MB = 10 * 1024 * 1024 byte[] bytes ) { diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/LoginRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/LoginRequest.java index 51ca9e620..2febc7a8a 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/request/LoginRequest.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/LoginRequest.java @@ -1,7 +1,17 @@ package com.sprint.mission.discodeit.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + public record LoginRequest( + + @NotBlank(message = "사용자명은 필수입니다") + @Size(max = 50, message = "사용자명은 50자 이하여야 합니다") String username, + + @NotBlank(message = "비밀번호는 필수입니다") + @Size(max = 60, message = "비밀번호는 60자 이하여야 합니다") String password ) { diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/MessageCreateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/MessageCreateRequest.java index 0f65742b1..a65bd1b68 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/request/MessageCreateRequest.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/MessageCreateRequest.java @@ -1,10 +1,20 @@ package com.sprint.mission.discodeit.dto.request; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; import java.util.UUID; public record MessageCreateRequest( + + @NotBlank(message = "메시지 내용은 필수입니다.") + @Pattern(regexp = "^(?!\\s*$).+", message = "메시지는 공백만으로 구성될 수 없습니다") String content, + + @NotNull(message = "채널 ID는 필수입니다") UUID channelId, + + @NotNull(message = "작성자 ID는 필수입니다") UUID authorId ) { diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/MessageUpdateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/MessageUpdateRequest.java index d786b1e8c..445598bb5 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/request/MessageUpdateRequest.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/MessageUpdateRequest.java @@ -1,6 +1,10 @@ package com.sprint.mission.discodeit.dto.request; +import jakarta.validation.constraints.NotBlank; + public record MessageUpdateRequest( + + @NotBlank(message = "수정할 메시지 내용은 필수입니다") String newContent ) { diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/PrivateChannelCreateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/PrivateChannelCreateRequest.java index 7edd4e823..6b22d7266 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/request/PrivateChannelCreateRequest.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/PrivateChannelCreateRequest.java @@ -1,10 +1,15 @@ package com.sprint.mission.discodeit.dto.request; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; import java.util.List; import java.util.UUID; public record PrivateChannelCreateRequest( - List participantIds + + @NotEmpty(message = "참여자 목록은 비어있을 수 없습니다") + List<@NotNull(message = "참여자 ID는 null일 수 없습니다") UUID> participantIds ) { } diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/PublicChannelCreateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/PublicChannelCreateRequest.java index 48e26327a..171e4d061 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/request/PublicChannelCreateRequest.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/PublicChannelCreateRequest.java @@ -1,7 +1,13 @@ package com.sprint.mission.discodeit.dto.request; +import jakarta.validation.constraints.Size; + public record PublicChannelCreateRequest( + + @Size(max = 100, message = "채널명은 100자 이하여야 합니다") String name, + + @Size(max = 500, message = "채널 설명은 500자 이하여야 합니다") String description ) { diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/PublicChannelUpdateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/PublicChannelUpdateRequest.java index d6e515410..aaf7b8bdf 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/request/PublicChannelUpdateRequest.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/PublicChannelUpdateRequest.java @@ -1,7 +1,13 @@ package com.sprint.mission.discodeit.dto.request; +import jakarta.validation.constraints.Size; + public record PublicChannelUpdateRequest( + + @Size(max = 100, message = "채널명은 100자 이하여야 합니다") String newName, + + @Size(max = 500, message = "채널 설명은 500자 이하여야 합니다") String newDescription ) { diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/ReadStatusCreateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/ReadStatusCreateRequest.java index 046a48808..a7863f576 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/request/ReadStatusCreateRequest.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/ReadStatusCreateRequest.java @@ -1,11 +1,19 @@ package com.sprint.mission.discodeit.dto.request; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PastOrPresent; import java.time.Instant; import java.util.UUID; public record ReadStatusCreateRequest( + @NotNull(message = "사용자 ID는 필수입니다") UUID userId, + + @NotNull(message = "채널 ID는 필수입니다") UUID channelId, + + @NotNull(message = "마지막 읽은 시간은 필수입니다") + @PastOrPresent(message = "마지막 읽은 시간은 현재 시간보다 미래일 수 없습니다") Instant lastReadAt ) { diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/ReadStatusUpdateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/ReadStatusUpdateRequest.java index 16b0c27ce..8dd43a46c 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/request/ReadStatusUpdateRequest.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/ReadStatusUpdateRequest.java @@ -1,8 +1,12 @@ package com.sprint.mission.discodeit.dto.request; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PastOrPresent; import java.time.Instant; public record ReadStatusUpdateRequest( + @NotNull(message = "새로운 마지막 읽은 시간은 필수입니다") + @PastOrPresent(message = "마지막 읽은 시간은 현재 시간보다 미래일 수 없습니다") Instant newLastReadAt ) { diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/UserCreateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/UserCreateRequest.java index e10e0ec57..7c3214a87 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/request/UserCreateRequest.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/UserCreateRequest.java @@ -1,8 +1,22 @@ package com.sprint.mission.discodeit.dto.request; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + public record UserCreateRequest( + + @NotBlank(message = "사용자명은 필수입니다") + @Size(max = 50, message = "사용자명은 50자 이하여야 합니다") String username, + + @NotBlank(message = "이메일은 필수입니다") + @Email(message = "올바른 이메일 형식이 아닙니다") + @Size(max = 100, message = "이메일은 100자 이하여야 합니다") String email, + + @NotBlank(message = "비밀번호는 필수입니다") + @Size(max = 60, message = "비밀번호는 60자 이하여야 합니다") String password ) { diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/UserStatusCreateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/UserStatusCreateRequest.java index 71c92abba..2e038456f 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/request/UserStatusCreateRequest.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/UserStatusCreateRequest.java @@ -1,10 +1,16 @@ package com.sprint.mission.discodeit.dto.request; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PastOrPresent; import java.time.Instant; import java.util.UUID; public record UserStatusCreateRequest( + @NotNull(message = "사용자 ID는 필수입니다") UUID userId, + + @NotNull(message = "마지막 활동 시간은 필수입니다") + @PastOrPresent(message = "마지막 활동 시간은 현재 시간보다 미래일 수 없습니다") Instant lastActiveAt ) { diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/UserStatusUpdateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/UserStatusUpdateRequest.java index c69b2610f..236c28d50 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/request/UserStatusUpdateRequest.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/UserStatusUpdateRequest.java @@ -1,8 +1,12 @@ package com.sprint.mission.discodeit.dto.request; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PastOrPresent; import java.time.Instant; public record UserStatusUpdateRequest( + @NotNull(message = "새로운 마지막 활동 시간은 필수입니다") + @PastOrPresent(message = "마지막 활동 시간은 현재 시간보다 미래일 수 없습니다") Instant newLastActiveAt ) { diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/UserUpdateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/UserUpdateRequest.java index 1e14e2cbd..2d12d23f3 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/request/UserUpdateRequest.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/UserUpdateRequest.java @@ -1,8 +1,22 @@ package com.sprint.mission.discodeit.dto.request; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + public record UserUpdateRequest( + + @NotBlank(message = "사용자명은 필수입니다") + @Size(max = 50, message = "사용자명은 50자 이하여야 합니다") String newUsername, + + @NotBlank(message = "이메일은 필수입니다") + @Email(message = "올바른 이메일 형식이 아닙니다") + @Size(max = 100, message = "이메일은 100자 이하여야 합니다") String newEmail, + + @NotBlank(message = "비밀번호는 필수입니다") + @Size(max = 60, message = "비밀번호는 60자 이하여야 합니다") String newPassword ) { diff --git a/src/main/java/com/sprint/mission/discodeit/dto/respond/PageResponse.java b/src/main/java/com/sprint/mission/discodeit/dto/respond/PageResponse.java deleted file mode 100644 index 19aabe4b3..000000000 --- a/src/main/java/com/sprint/mission/discodeit/dto/respond/PageResponse.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.sprint.mission.discodeit.dto.respond; - -import java.util.List; - -public class PageResponse { - - public List content; - public Object nextCursor; - public int size; - public boolean hasNext; - public Long totalElements; -} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/response/ErrorResponse.java b/src/main/java/com/sprint/mission/discodeit/dto/response/ErrorResponse.java new file mode 100644 index 000000000..3b9e8f2a1 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/response/ErrorResponse.java @@ -0,0 +1,17 @@ +package com.sprint.mission.discodeit.dto.response; + +import java.time.Instant; +import java.util.Map; + + +public record ErrorResponse( + Instant timestamp, + String code, + String message, + Map details, + String exceptionType, + int status +) { + +} + diff --git a/src/main/java/com/sprint/mission/discodeit/dto/response/PageResponse.java b/src/main/java/com/sprint/mission/discodeit/dto/response/PageResponse.java new file mode 100644 index 000000000..181d532d7 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/response/PageResponse.java @@ -0,0 +1,13 @@ +package com.sprint.mission.discodeit.dto.response; + +import java.util.List; + +public record PageResponse( + List content, + Object nextCursor, + int size, + boolean hasNext, + Long totalElements +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/entity/BinaryContent.java b/src/main/java/com/sprint/mission/discodeit/entity/BinaryContent.java index 7f0798978..88a096848 100644 --- a/src/main/java/com/sprint/mission/discodeit/entity/BinaryContent.java +++ b/src/main/java/com/sprint/mission/discodeit/entity/BinaryContent.java @@ -3,29 +3,27 @@ import com.sprint.mission.discodeit.entity.base.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; -import jakarta.persistence.Id; import jakarta.persistence.Table; -import java.io.Serializable; -import java.time.Instant; -import java.util.UUID; -import lombok.AllArgsConstructor; +import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; -@Getter @Entity @Table(name = "binary_contents") -@NoArgsConstructor -@AllArgsConstructor +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) public class BinaryContent extends BaseEntity { - @Column(name = "file_name") + @Column(nullable = false) private String fileName; - - @Column(name = "size") + @Column(nullable = false) private Long size; - - @Column(name = "content_type") + @Column(length = 100, nullable = false) private String contentType; + public BinaryContent(String fileName, Long size, String contentType) { + this.fileName = fileName; + this.size = size; + this.contentType = contentType; + } } diff --git a/src/main/java/com/sprint/mission/discodeit/entity/Channel.java b/src/main/java/com/sprint/mission/discodeit/entity/Channel.java index 0c7272fe8..101b737bd 100644 --- a/src/main/java/com/sprint/mission/discodeit/entity/Channel.java +++ b/src/main/java/com/sprint/mission/discodeit/entity/Channel.java @@ -6,43 +6,36 @@ import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; import jakarta.persistence.Table; -import java.io.Serializable; -import java.time.Instant; -import java.util.UUID; -import lombok.AllArgsConstructor; +import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; -@Getter @Entity @Table(name = "channels") -@NoArgsConstructor -@AllArgsConstructor +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) public class Channel extends BaseUpdatableEntity { @Enumerated(EnumType.STRING) - @Column(name = "type") + @Column(nullable = false) private ChannelType type; - - @Column(name = "name") + @Column(length = 100) private String name; - - @Column(name = "description") + @Column(length = 500) private String description; + public Channel(ChannelType type, String name, String description) { + this.type = type; + this.name = name; + this.description = description; + } + public void update(String newName, String newDescription) { - boolean anyValueUpdated = false; if (newName != null && !newName.equals(this.name)) { this.name = newName; - anyValueUpdated = true; } if (newDescription != null && !newDescription.equals(this.description)) { this.description = newDescription; - anyValueUpdated = true; - } - - if (anyValueUpdated) { - this.updatedAt = Instant.now(); } } } diff --git a/src/main/java/com/sprint/mission/discodeit/entity/Message.java b/src/main/java/com/sprint/mission/discodeit/entity/Message.java index ad741fad1..7fe8865ea 100644 --- a/src/main/java/com/sprint/mission/discodeit/entity/Message.java +++ b/src/main/java/com/sprint/mission/discodeit/entity/Message.java @@ -10,50 +10,46 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.OneToMany; import jakarta.persistence.Table; -import java.io.Serializable; -import java.time.Instant; +import java.util.ArrayList; import java.util.List; -import java.util.UUID; -import lombok.AllArgsConstructor; +import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; +import org.hibernate.annotations.BatchSize; -@Getter @Entity @Table(name = "messages") -@NoArgsConstructor -@AllArgsConstructor +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) public class Message extends BaseUpdatableEntity { - @Column(name = "content") + @Column(columnDefinition = "text", nullable = false) private String content; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "channel_id") + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "channel_id", columnDefinition = "uuid") private Channel channel; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "author_id") + @JoinColumn(name = "author_id", columnDefinition = "uuid") private User author; - - @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) + @BatchSize(size = 100) + @OneToMany(fetch = FetchType.LAZY, orphanRemoval = true, cascade = CascadeType.ALL) @JoinTable( name = "message_attachments", joinColumns = @JoinColumn(name = "message_id"), inverseJoinColumns = @JoinColumn(name = "attachment_id") ) - private List attachments; + private List attachments = new ArrayList<>(); + public Message(String content, Channel channel, User author, List attachments) { + this.channel = channel; + this.content = content; + this.author = author; + this.attachments = attachments; + } public void update(String newContent) { - boolean anyValueUpdated = false; if (newContent != null && !newContent.equals(this.content)) { this.content = newContent; - anyValueUpdated = true; - } - - if (anyValueUpdated) { - this.updatedAt = Instant.now(); } } } diff --git a/src/main/java/com/sprint/mission/discodeit/entity/ReadStatus.java b/src/main/java/com/sprint/mission/discodeit/entity/ReadStatus.java index 3ec6edc95..d51448b96 100644 --- a/src/main/java/com/sprint/mission/discodeit/entity/ReadStatus.java +++ b/src/main/java/com/sprint/mission/discodeit/entity/ReadStatus.java @@ -7,41 +7,41 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; -import java.io.Serializable; +import jakarta.persistence.UniqueConstraint; import java.time.Instant; -import java.util.UUID; -import lombok.AllArgsConstructor; +import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; -@Getter @Entity -@Table(name = "read_statuses") -@NoArgsConstructor -@AllArgsConstructor +@Table( + name = "read_statuses", + uniqueConstraints = { + @UniqueConstraint(columnNames = {"user_id", "channel_id"}) + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) public class ReadStatus extends BaseUpdatableEntity { - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id") + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", columnDefinition = "uuid") private User user; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "channel_id") + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "channel_id", columnDefinition = "uuid") private Channel channel; - - @Column(name = "last_read_at") + @Column(columnDefinition = "timestamp with time zone", nullable = false) private Instant lastReadAt; + public ReadStatus(User user, Channel channel, Instant lastReadAt) { + this.user = user; + this.channel = channel; + this.lastReadAt = lastReadAt; + } public void update(Instant newLastReadAt) { - boolean anyValueUpdated = false; if (newLastReadAt != null && !newLastReadAt.equals(this.lastReadAt)) { this.lastReadAt = newLastReadAt; - anyValueUpdated = true; - } - - if (anyValueUpdated) { - this.updatedAt = Instant.now(); } } -} +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/entity/User.java b/src/main/java/com/sprint/mission/discodeit/entity/User.java index 637aab2d8..7961aaecc 100644 --- a/src/main/java/com/sprint/mission/discodeit/entity/User.java +++ b/src/main/java/com/sprint/mission/discodeit/entity/User.java @@ -1,5 +1,6 @@ package com.sprint.mission.discodeit.entity; +import com.fasterxml.jackson.annotation.JsonManagedReference; import com.sprint.mission.discodeit.entity.base.BaseUpdatableEntity; import jakarta.persistence.CascadeType; import jakarta.persistence.Column; @@ -8,31 +9,28 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.OneToOne; import jakarta.persistence.Table; -import java.time.Instant; -import lombok.AllArgsConstructor; +import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Setter; -@Getter @Entity @Table(name = "users") -@NoArgsConstructor +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) // JPA를 위한 기본 생성자 public class User extends BaseUpdatableEntity { - @Column(name = "username") + @Column(length = 50, nullable = false, unique = true) private String username; - - @Column(name = "email") + @Column(length = 100, nullable = false, unique = true) private String email; - - @Column(name = "password") + @Column(length = 60, nullable = false) private String password; - @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) - @JoinColumn(name = "profile_id") + @JoinColumn(name = "profile_id", columnDefinition = "uuid") private BinaryContent profile; - // BinaryContent - + @JsonManagedReference + @Setter(AccessLevel.PROTECTED) @OneToOne(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) private UserStatus status; @@ -43,33 +41,19 @@ public User(String username, String email, String password, BinaryContent profil this.profile = profile; } - public void setStatus(UserStatus status) { - this.status = status; - status.setUser(this); - } - public void update(String newUsername, String newEmail, String newPassword, BinaryContent newProfile) { - boolean anyValueUpdated = false; if (newUsername != null && !newUsername.equals(this.username)) { this.username = newUsername; - anyValueUpdated = true; } if (newEmail != null && !newEmail.equals(this.email)) { this.email = newEmail; - anyValueUpdated = true; } if (newPassword != null && !newPassword.equals(this.password)) { this.password = newPassword; - anyValueUpdated = true; } - if (newProfile != null && !newProfile.equals(this.profile)) { + if (newProfile != null) { this.profile = newProfile; - anyValueUpdated = true; - } - - if (anyValueUpdated) { - this.updatedAt = Instant.now(); } } } diff --git a/src/main/java/com/sprint/mission/discodeit/entity/UserStatus.java b/src/main/java/com/sprint/mission/discodeit/entity/UserStatus.java index 4eb759a92..9726f73c7 100644 --- a/src/main/java/com/sprint/mission/discodeit/entity/UserStatus.java +++ b/src/main/java/com/sprint/mission/discodeit/entity/UserStatus.java @@ -1,5 +1,6 @@ package com.sprint.mission.discodeit.entity; +import com.fasterxml.jackson.annotation.JsonBackReference; import com.sprint.mission.discodeit.entity.base.BaseUpdatableEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -7,57 +8,43 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.OneToOne; import jakarta.persistence.Table; -import java.io.Serializable; import java.time.Duration; import java.time.Instant; -import java.util.UUID; -import lombok.AllArgsConstructor; +import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; -import lombok.RequiredArgsConstructor; -@Getter @Entity @Table(name = "user_statuses") -@NoArgsConstructor +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) public class UserStatus extends BaseUpdatableEntity { - @OneToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id") + @JsonBackReference + @OneToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false, unique = true) private User user; - - @Column(name = "last_active_at") + @Column(columnDefinition = "timestamp with time zone", nullable = false) private Instant lastActiveAt; - - public void setUser(User user) { - this.user = user; - } - - public UserStatus(Instant lastActiveAt) { - this.lastActiveAt = lastActiveAt; - } - public UserStatus(User user, Instant lastActiveAt) { - this.user = user; + setUser(user); this.lastActiveAt = lastActiveAt; } public void update(Instant lastActiveAt) { - boolean anyValueUpdated = false; if (lastActiveAt != null && !lastActiveAt.equals(this.lastActiveAt)) { this.lastActiveAt = lastActiveAt; - anyValueUpdated = true; - } - - if (anyValueUpdated) { - this.updatedAt = Instant.now(); } } public Boolean isOnline() { Instant instantFiveMinutesAgo = Instant.now().minus(Duration.ofMinutes(5)); - return lastActiveAt.isAfter(instantFiveMinutesAgo); } + + protected void setUser(User user) { + this.user = user; + user.setStatus(this); + } } diff --git a/src/main/java/com/sprint/mission/discodeit/entity/base/BaseEntity.java b/src/main/java/com/sprint/mission/discodeit/entity/base/BaseEntity.java index d9bec6edf..f28210164 100644 --- a/src/main/java/com/sprint/mission/discodeit/entity/base/BaseEntity.java +++ b/src/main/java/com/sprint/mission/discodeit/entity/base/BaseEntity.java @@ -1,34 +1,31 @@ package com.sprint.mission.discodeit.entity.base; import jakarta.persistence.Column; -import jakarta.persistence.DiscriminatorColumn; -import jakarta.persistence.Entity; import jakarta.persistence.EntityListeners; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; -import jakarta.persistence.Inheritance; -import jakarta.persistence.InheritanceType; import jakarta.persistence.MappedSuperclass; -import jakarta.persistence.PrePersist; import java.time.Instant; import java.util.UUID; +import lombok.AccessLevel; import lombok.Getter; +import lombok.NoArgsConstructor; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; - -@MappedSuperclass @Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@MappedSuperclass @EntityListeners(AuditingEntityListener.class) public abstract class BaseEntity { @Id @GeneratedValue(strategy = GenerationType.UUID) - @Column(name = "id") - protected UUID id; + @Column(columnDefinition = "uuid", updatable = false, nullable = false) + private UUID id; @CreatedDate - @Column(name = "created_at") - protected Instant createdAt; + @Column(columnDefinition = "timestamp with time zone", updatable = false, nullable = false) + private Instant createdAt; } diff --git a/src/main/java/com/sprint/mission/discodeit/entity/base/BaseUpdatableEntity.java b/src/main/java/com/sprint/mission/discodeit/entity/base/BaseUpdatableEntity.java index af5fd9c5f..57d1d3169 100644 --- a/src/main/java/com/sprint/mission/discodeit/entity/base/BaseUpdatableEntity.java +++ b/src/main/java/com/sprint/mission/discodeit/entity/base/BaseUpdatableEntity.java @@ -1,20 +1,19 @@ package com.sprint.mission.discodeit.entity.base; import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.EntityListeners; import jakarta.persistence.MappedSuperclass; import java.time.Instant; +import lombok.AccessLevel; import lombok.Getter; +import lombok.NoArgsConstructor; import org.springframework.data.annotation.LastModifiedDate; -import org.springframework.data.jpa.domain.support.AuditingEntityListener; @Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) @MappedSuperclass -@EntityListeners(AuditingEntityListener.class) public abstract class BaseUpdatableEntity extends BaseEntity { @LastModifiedDate - @Column(name = "updated_at") - protected Instant updatedAt; + @Column(columnDefinition = "timestamp with time zone") + private Instant updatedAt; } diff --git a/src/main/java/com/sprint/mission/discodeit/exception/DiscodeitException.java b/src/main/java/com/sprint/mission/discodeit/exception/DiscodeitException.java new file mode 100644 index 000000000..5871beaf9 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/DiscodeitException.java @@ -0,0 +1,22 @@ +package com.sprint.mission.discodeit.exception; + +import java.time.Instant; +import java.util.Map; +import lombok.Getter; + +@Getter +public class DiscodeitException extends RuntimeException { + + private final Instant timestamp; + private final ErrorCode errorCode; + private final Map details; + + public DiscodeitException(ErrorCode errorCode, Map details) { + super(errorCode.getMessage()); + this.timestamp = Instant.now(); + this.errorCode = errorCode; + this.details = details; + } + + +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/ErrorCode.java b/src/main/java/com/sprint/mission/discodeit/exception/ErrorCode.java new file mode 100644 index 000000000..bac3a74e5 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/ErrorCode.java @@ -0,0 +1,52 @@ +package com.sprint.mission.discodeit.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@RequiredArgsConstructor +@Getter +public enum ErrorCode { + // User 관련 에러 + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_001", "사용자를 찾을 수 없습니다."), + USER_ALREADY_EXISTS(HttpStatus.CONFLICT, "USER_002", "이미 존재하는 사용자입니다."), + USER_INVALID_INPUT(HttpStatus.BAD_REQUEST, "USER_003", "사용자 정보가 유효하지 않습니다."), + USER_UNKNOWN_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "USER_999", "사용자 처리 중 알 수 없는 오류가 발생했습니다."), + + // Channel 관련 에러 + CHANNEL_NOT_FOUND(HttpStatus.NOT_FOUND, "CHANNEL_001", "채널을 찾을 수 없습니다."), + CHANNEL_ALREADY_EXISTS(HttpStatus.CONFLICT, "CHANNEL_002", "이미 존재하는 채널입니다."), + PRIVATE_CHANNEL_UPDATE_FORBIDDEN(HttpStatus.FORBIDDEN, "CHANNEL_003", "비공개 채널은 수정할 수 없습니다."), + CHANNEL_UNKNOWN_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "CHANNEL_999", + "채널 처리 중 알 수 없는 오류가 발생했습니다."), + + // Message 관련 에러 + MESSAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "MESSAGE_001", "메시지를 찾을 수 없습니다."), + MESSAGE_INVALID_CONTENT(HttpStatus.BAD_REQUEST, "MESSAGE_002", "메시지 내용이 유효하지 않습니다."), + MESSAGE_TOO_LONG(HttpStatus.BAD_REQUEST, "MESSAGE_003", "메시지가 너무 깁니다."), + MESSAGE_UNKNOWN_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "MESSAGE_999", + "메시지 처리 중 알 수 없는 오류가 발생했습니다."), + + //BinaryContent 관련 에러 + BINARY_CONTENT_NOT_FOUND(HttpStatus.NOT_FOUND, "BINARY_CONTENT_001", "첨부파일을 찾을 수 없습니다."), + BINARY_CONTENT_UNKNOWN_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "BINARY_CONTENT_999", + "첨부 파일을 처리 중 알 수 없는 오류가 발생했습니다."), + + //ReadStatus 관련 에러 + READ_STATUS_NOT_FOUND(HttpStatus.NOT_FOUND, "READ_STATUS_001", "읽기 상태을 찾을 수 없습니다."), + READ_STATUS_UNKNOWN_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "READ_STATUS_999", + "읽기 상태 처리 중 알 수 없는 오류가 발생했습니다."), + + //UserStatus 관련 에러 + USER_STATUS_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_STATUS_001", "유저 상태을 찾을 수 없습니다."), + USER_STATUS_ALREADY_EXISTS(HttpStatus.CONFLICT, "USER_STATUS_002", "이미 존재하는 유저 상태입니다."), + USER_STATUS_UNKNOWN_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "USER_STATUS_999", + "유저 상태 처리 중 알 수 없는 오류가 발생했습니다."), + + //Validation 관련 에러 + VALIDATION_FAILED(HttpStatus.BAD_REQUEST, "VALIDATION_001", "조건에 맞지 않는 입력값입니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/GlobalExceptionHandler.java b/src/main/java/com/sprint/mission/discodeit/exception/GlobalExceptionHandler.java index 513caead2..bd048d97c 100644 --- a/src/main/java/com/sprint/mission/discodeit/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/sprint/mission/discodeit/exception/GlobalExceptionHandler.java @@ -1,35 +1,56 @@ package com.sprint.mission.discodeit.exception; +import com.sprint.mission.discodeit.dto.response.ErrorResponse; +import com.sprint.mission.discodeit.exception.channel.ChannelAlreadyExistException; +import java.nio.charset.CharacterCodingException; +import java.time.Instant; +import java.util.Map; import java.util.NoSuchElementException; +import java.util.stream.Collectors; +import org.springframework.boot.autoconfigure.integration.IntegrationProperties.Error; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @RestControllerAdvice public class GlobalExceptionHandler { - @ExceptionHandler(IllegalArgumentException.class) - public ResponseEntity handleException(IllegalArgumentException e) { - e.printStackTrace(); - return ResponseEntity - .status(HttpStatus.BAD_REQUEST) - .body(e.getMessage()); - } - - @ExceptionHandler(NoSuchElementException.class) - public ResponseEntity handleException(NoSuchElementException e) { - e.printStackTrace(); - return ResponseEntity - .status(HttpStatus.NOT_FOUND) - .body(e.getMessage()); - } - - @ExceptionHandler(Exception.class) - public ResponseEntity handleException(Exception e) { - e.printStackTrace(); - return ResponseEntity - .status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(e.getMessage()); - } + /** + * 커스텀 예외 처리 + */ + @ExceptionHandler(DiscodeitException.class) + public ResponseEntity handleException(DiscodeitException e) { + ErrorResponse errorResponse = new ErrorResponse(e.getTimestamp(), + e.getErrorCode().getCode(), + e.getErrorCode().getMessage(), e.getDetails(), e.getClass().getSimpleName(), + e.getErrorCode().getHttpStatus().value()); + + return ResponseEntity + .status(e.getErrorCode().getHttpStatus()) + .body(errorResponse); + } + + // @Valid 실패 시 자동 호출됨 + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleException( + MethodArgumentNotValidException e) { + + // validation 에러를 정리해서 응답 + Map errors = e.getBindingResult() + .getFieldErrors() + .stream() + .collect(Collectors.toMap( + FieldError::getField, + FieldError::getDefaultMessage + )); + + ErrorCode errorCode = ErrorCode.VALIDATION_FAILED; + return ResponseEntity.badRequest() + .body(new ErrorResponse(Instant.now(), errorCode.getCode(), errorCode.getMessage(), + errors, e.getClass().getSimpleName(), errorCode.getHttpStatus().value())); + + } } diff --git a/src/main/java/com/sprint/mission/discodeit/exception/binarycontent/BinaryContentException.java b/src/main/java/com/sprint/mission/discodeit/exception/binarycontent/BinaryContentException.java new file mode 100644 index 000000000..f073785ce --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/binarycontent/BinaryContentException.java @@ -0,0 +1,12 @@ +package com.sprint.mission.discodeit.exception.binarycontent; + +import com.sprint.mission.discodeit.exception.DiscodeitException; +import com.sprint.mission.discodeit.exception.ErrorCode; +import java.util.Map; + +public class BinaryContentException extends DiscodeitException { + + public BinaryContentException(ErrorCode errorCode, Map details) { + super(errorCode, details); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/binarycontent/BinaryContentNotFoundException.java b/src/main/java/com/sprint/mission/discodeit/exception/binarycontent/BinaryContentNotFoundException.java new file mode 100644 index 000000000..fc44bfe02 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/binarycontent/BinaryContentNotFoundException.java @@ -0,0 +1,11 @@ +package com.sprint.mission.discodeit.exception.binarycontent; + +import com.sprint.mission.discodeit.exception.ErrorCode; +import java.util.Map; + +public class BinaryContentNotFoundException extends BinaryContentException { + + public BinaryContentNotFoundException(ErrorCode errorCode, Map details) { + super(errorCode, details); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/channel/ChannelAlreadyExistException.java b/src/main/java/com/sprint/mission/discodeit/exception/channel/ChannelAlreadyExistException.java new file mode 100644 index 000000000..801856a4c --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/channel/ChannelAlreadyExistException.java @@ -0,0 +1,11 @@ +package com.sprint.mission.discodeit.exception.channel; + +import com.sprint.mission.discodeit.exception.ErrorCode; +import java.util.Map; + +public class ChannelAlreadyExistException extends ChannelException { + + public ChannelAlreadyExistException(ErrorCode errorCode, Map details) { + super(errorCode, details); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/channel/ChannelException.java b/src/main/java/com/sprint/mission/discodeit/exception/channel/ChannelException.java new file mode 100644 index 000000000..c23ffdfb0 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/channel/ChannelException.java @@ -0,0 +1,14 @@ +package com.sprint.mission.discodeit.exception.channel; + +import com.sprint.mission.discodeit.exception.DiscodeitException; +import com.sprint.mission.discodeit.exception.ErrorCode; +import java.util.Map; +import lombok.Getter; + +public class ChannelException extends DiscodeitException { + + public ChannelException(ErrorCode errorCode, Map details) { + super(errorCode, details); + } + +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/channel/ChannelNotFoundException.java b/src/main/java/com/sprint/mission/discodeit/exception/channel/ChannelNotFoundException.java new file mode 100644 index 000000000..3c3ed43fa --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/channel/ChannelNotFoundException.java @@ -0,0 +1,13 @@ +package com.sprint.mission.discodeit.exception.channel; + +import com.sprint.mission.discodeit.exception.ErrorCode; +import java.time.Instant; +import java.util.Map; + +public class ChannelNotFoundException extends ChannelException { + + public ChannelNotFoundException(ErrorCode errorCode, Map details) { + super(errorCode, details); + } + +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/channel/PrivateChannelUpdateException.java b/src/main/java/com/sprint/mission/discodeit/exception/channel/PrivateChannelUpdateException.java new file mode 100644 index 000000000..fb06cbb31 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/channel/PrivateChannelUpdateException.java @@ -0,0 +1,13 @@ +package com.sprint.mission.discodeit.exception.channel; + +import com.sprint.mission.discodeit.exception.ErrorCode; +import java.time.Instant; +import java.util.Map; + +public class PrivateChannelUpdateException extends ChannelException { + + public PrivateChannelUpdateException(ErrorCode errorCode, Map details) { + super(errorCode, details); + } + +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/message/MessageException.java b/src/main/java/com/sprint/mission/discodeit/exception/message/MessageException.java new file mode 100644 index 000000000..b2b5560a3 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/message/MessageException.java @@ -0,0 +1,13 @@ +package com.sprint.mission.discodeit.exception.message; + +import com.sprint.mission.discodeit.exception.DiscodeitException; +import com.sprint.mission.discodeit.exception.ErrorCode; +import java.time.Instant; +import java.util.Map; + +public class MessageException extends DiscodeitException { + + public MessageException(ErrorCode errorCode, Map details) { + super(errorCode, details); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/message/MessageInvalidContentException.java b/src/main/java/com/sprint/mission/discodeit/exception/message/MessageInvalidContentException.java new file mode 100644 index 000000000..635aca144 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/message/MessageInvalidContentException.java @@ -0,0 +1,11 @@ +package com.sprint.mission.discodeit.exception.message; + +import com.sprint.mission.discodeit.exception.ErrorCode; +import java.util.Map; + +public class MessageInvalidContentException extends MessageException { + + public MessageInvalidContentException(ErrorCode errorCode, Map details) { + super(errorCode, details); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/message/MessageNotFoundException.java b/src/main/java/com/sprint/mission/discodeit/exception/message/MessageNotFoundException.java new file mode 100644 index 000000000..737dcb5ff --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/message/MessageNotFoundException.java @@ -0,0 +1,12 @@ +package com.sprint.mission.discodeit.exception.message; + +import com.sprint.mission.discodeit.exception.ErrorCode; +import java.time.Instant; +import java.util.Map; + +public class MessageNotFoundException extends MessageException { + + public MessageNotFoundException(ErrorCode errorCode, Map details) { + super(errorCode, details); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/message/MessageTooLongException.java b/src/main/java/com/sprint/mission/discodeit/exception/message/MessageTooLongException.java new file mode 100644 index 000000000..86304fbed --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/message/MessageTooLongException.java @@ -0,0 +1,11 @@ +package com.sprint.mission.discodeit.exception.message; + +import com.sprint.mission.discodeit.exception.ErrorCode; +import java.util.Map; + +public class MessageTooLongException extends MessageException { + + public MessageTooLongException(ErrorCode errorCode, Map details) { + super(errorCode, details); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/readstatus/ReadStatusException.java b/src/main/java/com/sprint/mission/discodeit/exception/readstatus/ReadStatusException.java new file mode 100644 index 000000000..41f86d020 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/readstatus/ReadStatusException.java @@ -0,0 +1,12 @@ +package com.sprint.mission.discodeit.exception.readstatus; + +import com.sprint.mission.discodeit.exception.DiscodeitException; +import com.sprint.mission.discodeit.exception.ErrorCode; +import java.util.Map; + +public class ReadStatusException extends DiscodeitException { + + public ReadStatusException(ErrorCode errorCode, Map details) { + super(errorCode, details); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/readstatus/ReadStatusNotFoundException.java b/src/main/java/com/sprint/mission/discodeit/exception/readstatus/ReadStatusNotFoundException.java new file mode 100644 index 000000000..a66a47657 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/readstatus/ReadStatusNotFoundException.java @@ -0,0 +1,11 @@ +package com.sprint.mission.discodeit.exception.readstatus; + +import com.sprint.mission.discodeit.exception.ErrorCode; +import java.util.Map; + +public class ReadStatusNotFoundException extends ReadStatusException { + + public ReadStatusNotFoundException(ErrorCode errorCode, Map details) { + super(errorCode, details); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/user/UserAlreadyExistException.java b/src/main/java/com/sprint/mission/discodeit/exception/user/UserAlreadyExistException.java new file mode 100644 index 000000000..a90d1f2d6 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/user/UserAlreadyExistException.java @@ -0,0 +1,13 @@ +package com.sprint.mission.discodeit.exception.user; + +import com.sprint.mission.discodeit.exception.ErrorCode; +import java.time.Instant; +import java.util.Map; + +public class UserAlreadyExistException extends UserException { + + public UserAlreadyExistException(ErrorCode errorCode, + Map details) { + super(errorCode, details); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/user/UserException.java b/src/main/java/com/sprint/mission/discodeit/exception/user/UserException.java new file mode 100644 index 000000000..ade282263 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/user/UserException.java @@ -0,0 +1,12 @@ +package com.sprint.mission.discodeit.exception.user; + +import com.sprint.mission.discodeit.exception.DiscodeitException; +import com.sprint.mission.discodeit.exception.ErrorCode; +import java.util.Map; + +public class UserException extends DiscodeitException { + + public UserException(ErrorCode errorCode, Map details) { + super(errorCode, details); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/user/UserInvalidInputException.java b/src/main/java/com/sprint/mission/discodeit/exception/user/UserInvalidInputException.java new file mode 100644 index 000000000..93255f37d --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/user/UserInvalidInputException.java @@ -0,0 +1,11 @@ +package com.sprint.mission.discodeit.exception.user; + +import com.sprint.mission.discodeit.exception.ErrorCode; +import java.util.Map; + +public class UserInvalidInputException extends UserException { + + public UserInvalidInputException(ErrorCode errorCode, Map details) { + super(errorCode, details); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/user/UserNotFoundException.java b/src/main/java/com/sprint/mission/discodeit/exception/user/UserNotFoundException.java new file mode 100644 index 000000000..722f5b82b --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/user/UserNotFoundException.java @@ -0,0 +1,12 @@ +package com.sprint.mission.discodeit.exception.user; + +import com.sprint.mission.discodeit.exception.ErrorCode; +import java.time.Instant; +import java.util.Map; + +public class UserNotFoundException extends UserException { + + public UserNotFoundException(ErrorCode errorCode, Map details) { + super(errorCode, details); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/userstatus/UserStatusAlreadyExistException.java b/src/main/java/com/sprint/mission/discodeit/exception/userstatus/UserStatusAlreadyExistException.java new file mode 100644 index 000000000..23e59e207 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/userstatus/UserStatusAlreadyExistException.java @@ -0,0 +1,11 @@ +package com.sprint.mission.discodeit.exception.userstatus; + +import com.sprint.mission.discodeit.exception.ErrorCode; +import java.util.Map; + +public class UserStatusAlreadyExistException extends UserStatusException { + + public UserStatusAlreadyExistException(ErrorCode errorCode, Map details) { + super(errorCode, details); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/userstatus/UserStatusException.java b/src/main/java/com/sprint/mission/discodeit/exception/userstatus/UserStatusException.java new file mode 100644 index 000000000..d49e673b8 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/userstatus/UserStatusException.java @@ -0,0 +1,12 @@ +package com.sprint.mission.discodeit.exception.userstatus; + +import com.sprint.mission.discodeit.exception.DiscodeitException; +import com.sprint.mission.discodeit.exception.ErrorCode; +import java.util.Map; + +public class UserStatusException extends DiscodeitException { + + public UserStatusException(ErrorCode errorCode, Map details) { + super(errorCode, details); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/userstatus/UserStatusNotFoundException.java b/src/main/java/com/sprint/mission/discodeit/exception/userstatus/UserStatusNotFoundException.java new file mode 100644 index 000000000..abf8fba8f --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/userstatus/UserStatusNotFoundException.java @@ -0,0 +1,11 @@ +package com.sprint.mission.discodeit.exception.userstatus; + +import com.sprint.mission.discodeit.exception.ErrorCode; +import java.util.Map; + +public class UserStatusNotFoundException extends UserStatusException { + + public UserStatusNotFoundException(ErrorCode errorCode, Map details) { + super(errorCode, details); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/mapper/BinaryContentMapper.java b/src/main/java/com/sprint/mission/discodeit/mapper/BinaryContentMapper.java index 5f0136b00..d3ea1f137 100644 --- a/src/main/java/com/sprint/mission/discodeit/mapper/BinaryContentMapper.java +++ b/src/main/java/com/sprint/mission/discodeit/mapper/BinaryContentMapper.java @@ -1,14 +1,11 @@ package com.sprint.mission.discodeit.mapper; - import com.sprint.mission.discodeit.dto.data.BinaryContentDto; import com.sprint.mission.discodeit.entity.BinaryContent; import org.mapstruct.Mapper; -import org.mapstruct.ReportingPolicy; -@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) +@Mapper(componentModel = "spring") public interface BinaryContentMapper { - public BinaryContentDto toDto(BinaryContent binaryContent); - + BinaryContentDto toDto(BinaryContent binaryContent); } diff --git a/src/main/java/com/sprint/mission/discodeit/mapper/ChannelMapper.java b/src/main/java/com/sprint/mission/discodeit/mapper/ChannelMapper.java index 9d4bf88e9..f39a5809c 100644 --- a/src/main/java/com/sprint/mission/discodeit/mapper/ChannelMapper.java +++ b/src/main/java/com/sprint/mission/discodeit/mapper/ChannelMapper.java @@ -1,45 +1,48 @@ package com.sprint.mission.discodeit.mapper; import com.sprint.mission.discodeit.dto.data.ChannelDto; +import com.sprint.mission.discodeit.dto.data.UserDto; import com.sprint.mission.discodeit.entity.Channel; -import com.sprint.mission.discodeit.entity.Message; +import com.sprint.mission.discodeit.entity.ChannelType; import com.sprint.mission.discodeit.entity.ReadStatus; import com.sprint.mission.discodeit.repository.MessageRepository; import com.sprint.mission.discodeit.repository.ReadStatusRepository; import java.time.Instant; -import java.util.Comparator; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; +import java.util.ArrayList; +import java.util.List; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.springframework.beans.factory.annotation.Autowired; -@Component -@RequiredArgsConstructor -public class ChannelMapper { +@Mapper(componentModel = "spring", uses = {UserMapper.class}) +public abstract class ChannelMapper { - private final MessageRepository messageRepository; - private final ReadStatusRepository readStatusRepository; - private final UserMapper userMapper; + @Autowired + private MessageRepository messageRepository; + @Autowired + private ReadStatusRepository readStatusRepository; + @Autowired + private UserMapper userMapper; - public ChannelDto toDto(Channel channel) { - return new ChannelDto( - channel.getId(), - channel.getType(), - channel.getName(), - channel.getDescription(), - readStatusRepository - .findAllByChannelId(channel.getId()) - .stream() - .map(ReadStatus::getUser) - .map(userMapper::toDto) - .toList(), - messageRepository.findAllByChannelId(channel.getId()) - .stream() - .sorted(Comparator.comparing(Message::getCreatedAt).reversed()) - .map(Message::getCreatedAt) - .limit(1) - .findFirst() - .orElse(Instant.MIN) - ); + @Mapping(target = "participants", expression = "java(resolveParticipants(channel))") + @Mapping(target = "lastMessageAt", expression = "java(resolveLastMessageAt(channel))") + abstract public ChannelDto toDto(Channel channel); + protected Instant resolveLastMessageAt(Channel channel) { + return messageRepository.findLastMessageAtByChannelId( + channel.getId()) + .orElse(Instant.MIN); + } + protected List resolveParticipants(Channel channel) { + List participants = new ArrayList<>(); + if (channel.getType().equals(ChannelType.PRIVATE)) { + readStatusRepository.findAllByChannelIdWithUser(channel.getId()) + .stream() + .map(ReadStatus::getUser) + .map(userMapper::toDto) + .forEach(participants::add); + } + return participants; } } diff --git a/src/main/java/com/sprint/mission/discodeit/mapper/MessageMapper.java b/src/main/java/com/sprint/mission/discodeit/mapper/MessageMapper.java index 57226b363..e0301ac08 100644 --- a/src/main/java/com/sprint/mission/discodeit/mapper/MessageMapper.java +++ b/src/main/java/com/sprint/mission/discodeit/mapper/MessageMapper.java @@ -2,28 +2,12 @@ import com.sprint.mission.discodeit.dto.data.MessageDto; import com.sprint.mission.discodeit.entity.Message; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; -@Component -@RequiredArgsConstructor -public class MessageMapper { - - private final BinaryContentMapper binaryContentMapper; - private final UserMapper userMapper; - - public MessageDto toDto(Message message) { - return new MessageDto( - message.getId(), - message.getCreatedAt(), - message.getUpdatedAt(), - message.getContent(), - message.getChannel().getId(), - userMapper.toDto(message.getAuthor()), - message.getAttachments().stream() - .map(binaryContentMapper::toDto) - .toList() - ); - } +@Mapper(componentModel = "spring", uses = {BinaryContentMapper.class, UserMapper.class}) +public interface MessageMapper { + @Mapping(target = "channelId", source = "channel.id") + MessageDto toDto(Message message); } diff --git a/src/main/java/com/sprint/mission/discodeit/mapper/PageResponseMapper.java b/src/main/java/com/sprint/mission/discodeit/mapper/PageResponseMapper.java index 391fb9ce9..108a9b59d 100644 --- a/src/main/java/com/sprint/mission/discodeit/mapper/PageResponseMapper.java +++ b/src/main/java/com/sprint/mission/discodeit/mapper/PageResponseMapper.java @@ -1,32 +1,30 @@ package com.sprint.mission.discodeit.mapper; -import com.sprint.mission.discodeit.dto.data.MessageDto; -import com.sprint.mission.discodeit.dto.respond.PageResponse; -import lombok.RequiredArgsConstructor; +import com.sprint.mission.discodeit.dto.response.PageResponse; +import org.mapstruct.Mapper; import org.springframework.data.domain.Page; import org.springframework.data.domain.Slice; -import org.springframework.stereotype.Component; -@Component -@RequiredArgsConstructor -public class PageResponseMapper { +@Mapper(componentModel = "spring") +public interface PageResponseMapper { - public PageResponse fromSlice(Slice slice) { - PageResponse response = new PageResponse<>(); - response.content = slice.getContent(); - response.hasNext = slice.hasNext(); - response.size = slice.getSize(); - response.totalElements = null; - return response; + default PageResponse fromSlice(Slice slice, Object nextCursor) { + return new PageResponse<>( + slice.getContent(), + nextCursor, + slice.getSize(), + slice.hasNext(), + null + ); } - public PageResponse fromPage(Page page) { - PageResponse response = new PageResponse<>(); - response.content = page.getContent(); - response.hasNext = page.hasNext(); - response.size = page.getSize(); - response.totalElements = page.getTotalElements(); - return response; + default PageResponse fromPage(Page page, Object nextCursor) { + return new PageResponse<>( + page.getContent(), + nextCursor, + page.getSize(), + page.hasNext(), + page.getTotalElements() + ); } - } diff --git a/src/main/java/com/sprint/mission/discodeit/mapper/ReadStatusMapper.java b/src/main/java/com/sprint/mission/discodeit/mapper/ReadStatusMapper.java index 54df75baf..af9b85279 100644 --- a/src/main/java/com/sprint/mission/discodeit/mapper/ReadStatusMapper.java +++ b/src/main/java/com/sprint/mission/discodeit/mapper/ReadStatusMapper.java @@ -2,20 +2,13 @@ import com.sprint.mission.discodeit.dto.data.ReadStatusDto; import com.sprint.mission.discodeit.entity.ReadStatus; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; -@Component -@RequiredArgsConstructor -public class ReadStatusMapper { - - public ReadStatusDto toDto(ReadStatus readStatus) { - return new ReadStatusDto( - readStatus.getId(), - readStatus.getUser().getId(), - readStatus.getChannel().getId(), - readStatus.getLastReadAt() - ); - } +@Mapper(componentModel = "spring") +public interface ReadStatusMapper { + @Mapping(target = "userId", source = "user.id") + @Mapping(target = "channelId", source = "channel.id") + ReadStatusDto toDto(ReadStatus readStatus); } diff --git a/src/main/java/com/sprint/mission/discodeit/mapper/UserMapper.java b/src/main/java/com/sprint/mission/discodeit/mapper/UserMapper.java index c90a2091a..c040a2edb 100644 --- a/src/main/java/com/sprint/mission/discodeit/mapper/UserMapper.java +++ b/src/main/java/com/sprint/mission/discodeit/mapper/UserMapper.java @@ -2,22 +2,12 @@ import com.sprint.mission.discodeit.dto.data.UserDto; import com.sprint.mission.discodeit.entity.User; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; -@Component -@RequiredArgsConstructor -public class UserMapper { +@Mapper(componentModel = "spring", uses = {BinaryContentMapper.class, UserStatusMapper.class}) +public interface UserMapper { - private final BinaryContentMapper binaryContentMapper; - - public UserDto toDto(User user) { - return new UserDto( - user.getId(), - user.getUsername(), - user.getEmail(), - binaryContentMapper.toDto(user.getProfile()), - user.getStatus().isOnline() - ); - } + @Mapping(target = "online", expression = "java(user.getStatus().isOnline())") + UserDto toDto(User user); } diff --git a/src/main/java/com/sprint/mission/discodeit/mapper/UserStatusMapper.java b/src/main/java/com/sprint/mission/discodeit/mapper/UserStatusMapper.java index 30530afb8..202e56a18 100644 --- a/src/main/java/com/sprint/mission/discodeit/mapper/UserStatusMapper.java +++ b/src/main/java/com/sprint/mission/discodeit/mapper/UserStatusMapper.java @@ -2,20 +2,12 @@ import com.sprint.mission.discodeit.dto.data.UserStatusDto; import com.sprint.mission.discodeit.entity.UserStatus; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor -public class UserStatusMapper { - - public UserStatusDto toDto(UserStatus userStatus) { - return new UserStatusDto( - userStatus.getId(), - userStatus.getUser().getId(), - userStatus.getLastActiveAt() - ); - } +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +@Mapper(componentModel = "spring") +public interface UserStatusMapper { + @Mapping(target = "userId", source = "user.id") + UserStatusDto toDto(UserStatus userStatus); } diff --git a/src/main/java/com/sprint/mission/discodeit/repository/BinaryContentRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/BinaryContentRepository.java index 272e1336e..cbd8c79cf 100644 --- a/src/main/java/com/sprint/mission/discodeit/repository/BinaryContentRepository.java +++ b/src/main/java/com/sprint/mission/discodeit/repository/BinaryContentRepository.java @@ -1,20 +1,9 @@ package com.sprint.mission.discodeit.repository; import com.sprint.mission.discodeit.entity.BinaryContent; -import java.util.List; -import java.util.Optional; import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; public interface BinaryContentRepository extends JpaRepository { - BinaryContent save(BinaryContent binaryContent); - - Optional findById(UUID id); - - List findAllByIdIn(List ids); - - boolean existsById(UUID id); - - void deleteById(UUID id); } diff --git a/src/main/java/com/sprint/mission/discodeit/repository/ChannelRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/ChannelRepository.java index 66030f409..e4b1fd235 100644 --- a/src/main/java/com/sprint/mission/discodeit/repository/ChannelRepository.java +++ b/src/main/java/com/sprint/mission/discodeit/repository/ChannelRepository.java @@ -1,21 +1,12 @@ package com.sprint.mission.discodeit.repository; import com.sprint.mission.discodeit.entity.Channel; +import com.sprint.mission.discodeit.entity.ChannelType; import java.util.List; -import java.util.Optional; import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; public interface ChannelRepository extends JpaRepository { - Channel save(Channel channel); - - Optional findById(UUID id); - - List findAll(); - - boolean existsById(UUID id); - - void deleteById(UUID id); + List findAllByTypeOrIdIn(ChannelType type, List ids); } diff --git a/src/main/java/com/sprint/mission/discodeit/repository/MessageRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/MessageRepository.java index 5c5a7b082..ac649b75f 100644 --- a/src/main/java/com/sprint/mission/discodeit/repository/MessageRepository.java +++ b/src/main/java/com/sprint/mission/discodeit/repository/MessageRepository.java @@ -2,35 +2,31 @@ import com.sprint.mission.discodeit.entity.Message; import java.time.Instant; -import java.util.List; import java.util.Optional; import java.util.UUID; -import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; public interface MessageRepository extends JpaRepository { - Message save(Message message); - - Optional findById(UUID id); - - @Query("SELECT DISTINCT m FROM Message m JOIN FETCH m.channel WHERE m.channel.id = :channelId") - List findAllByChannelId(@Param("channelId") UUID channelId); + @Query("SELECT m FROM Message m " + + "LEFT JOIN FETCH m.author a " + + "JOIN FETCH a.status " + + "LEFT JOIN FETCH a.profile " + + "WHERE m.channel.id=:channelId AND m.createdAt < :createdAt") + Slice findAllByChannelIdWithAuthor(@Param("channelId") UUID channelId, + @Param("createdAt") Instant createdAt, + Pageable pageable); - boolean existsById(UUID id); - void deleteById(UUID id); + @Query("SELECT m.createdAt " + + "FROM Message m " + + "WHERE m.channel.id = :channelId " + + "ORDER BY m.createdAt DESC LIMIT 1") + Optional findLastMessageAtByChannelId(@Param("channelId") UUID channelId); void deleteAllByChannelId(UUID channelId); - - @Query("SELECT DISTINCT m FROM Message m JOIN FETCH m.channel WHERE m.channel.id=:channelId") - Page findAllByChannelId(@Param("channelId") UUID channelId, Pageable pageable); - - - @Query("SELECT DISTINCT m FROM Message m JOIN FETCH m.channel WHERE m.createdAt > :cursor AND m.channel.id=:channelId ORDER BY m.createdAt ASC") - Page findAllByChannelId(@Param("channelId") UUID channelId, Instant cursor, - Pageable pageable); } diff --git a/src/main/java/com/sprint/mission/discodeit/repository/ReadStatusRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/ReadStatusRepository.java index 88574f28a..f1d469af1 100644 --- a/src/main/java/com/sprint/mission/discodeit/repository/ReadStatusRepository.java +++ b/src/main/java/com/sprint/mission/discodeit/repository/ReadStatusRepository.java @@ -2,7 +2,6 @@ import com.sprint.mission.discodeit.entity.ReadStatus; import java.util.List; -import java.util.Optional; import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -10,19 +9,17 @@ public interface ReadStatusRepository extends JpaRepository { - ReadStatus save(ReadStatus readStatus); - Optional findById(UUID id); + List findAllByUserId(UUID userId); - @Query("SELECT DISTINCT r FROM ReadStatus r JOIN FETCH r.user WHERE r.user.id =:userId ") - List findAllByUserId(@Param("userId") UUID userId); + @Query("SELECT r FROM ReadStatus r " + + "JOIN FETCH r.user u " + + "JOIN FETCH u.status " + + "LEFT JOIN FETCH u.profile " + + "WHERE r.channel.id = :channelId") + List findAllByChannelIdWithUser(@Param("channelId") UUID channelId); - @Query("SELECT DISTINCT r FROM ReadStatus r JOIN FETCH r.channel WHERE r.channel.id = :channelId") - List findAllByChannelId(@Param("channelId") UUID channelId); - - boolean existsById(UUID id); - - void deleteById(UUID id); + Boolean existsByUserIdAndChannelId(UUID userId, UUID channelId); void deleteAllByChannelId(UUID channelId); } diff --git a/src/main/java/com/sprint/mission/discodeit/repository/UserRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/UserRepository.java index 8b46bcee6..f7103705f 100644 --- a/src/main/java/com/sprint/mission/discodeit/repository/UserRepository.java +++ b/src/main/java/com/sprint/mission/discodeit/repository/UserRepository.java @@ -9,20 +9,14 @@ public interface UserRepository extends JpaRepository { - User save(User user); - - Optional findById(UUID id); - Optional findByUsername(String username); - @Query("SELECT u FROM User u JOIN FETCH u.status") - List findAll(); - - boolean existsById(UUID id); - - void deleteById(UUID id); - boolean existsByEmail(String email); boolean existsByUsername(String username); + + @Query("SELECT u FROM User u " + + "LEFT JOIN FETCH u.profile " + + "JOIN FETCH u.status") + List findAllWithProfileAndStatus(); } diff --git a/src/main/java/com/sprint/mission/discodeit/repository/UserStatusRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/UserStatusRepository.java index 3ada92f70..46102abf5 100644 --- a/src/main/java/com/sprint/mission/discodeit/repository/UserStatusRepository.java +++ b/src/main/java/com/sprint/mission/discodeit/repository/UserStatusRepository.java @@ -1,26 +1,11 @@ package com.sprint.mission.discodeit.repository; import com.sprint.mission.discodeit.entity.UserStatus; -import java.util.List; import java.util.Optional; import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; public interface UserStatusRepository extends JpaRepository { - UserStatus save(UserStatus userStatus); - - Optional findById(UUID id); - Optional findByUserId(UUID userId); - - @Query("SELECT us FROM UserStatus us JOIN FETCH us.user") - List findAll(); - - boolean existsById(UUID id); - - void deleteById(UUID id); - - void deleteByUserId(UUID userId); } diff --git a/src/main/java/com/sprint/mission/discodeit/service/AuthService.java b/src/main/java/com/sprint/mission/discodeit/service/AuthService.java index 6eecdd0a9..a1caf1d2d 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/AuthService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/AuthService.java @@ -2,7 +2,6 @@ import com.sprint.mission.discodeit.dto.data.UserDto; import com.sprint.mission.discodeit.dto.request.LoginRequest; -import com.sprint.mission.discodeit.entity.User; public interface AuthService { diff --git a/src/main/java/com/sprint/mission/discodeit/service/BinaryContentService.java b/src/main/java/com/sprint/mission/discodeit/service/BinaryContentService.java index 51312299d..23836a446 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/BinaryContentService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/BinaryContentService.java @@ -2,11 +2,8 @@ import com.sprint.mission.discodeit.dto.data.BinaryContentDto; import com.sprint.mission.discodeit.dto.request.BinaryContentCreateRequest; -import com.sprint.mission.discodeit.entity.BinaryContent; import java.util.List; import java.util.UUID; -import org.springframework.core.io.Resource; -import org.springframework.http.ResponseEntity; public interface BinaryContentService { @@ -17,6 +14,4 @@ public interface BinaryContentService { List findAllByIdIn(List binaryContentIds); void delete(UUID binaryContentId); - - ResponseEntity download(UUID binaryContentId); } diff --git a/src/main/java/com/sprint/mission/discodeit/service/ChannelService.java b/src/main/java/com/sprint/mission/discodeit/service/ChannelService.java index 128fb0a57..a082c9ff9 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/ChannelService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/ChannelService.java @@ -4,7 +4,6 @@ import com.sprint.mission.discodeit.dto.request.PrivateChannelCreateRequest; import com.sprint.mission.discodeit.dto.request.PublicChannelCreateRequest; import com.sprint.mission.discodeit.dto.request.PublicChannelUpdateRequest; -import com.sprint.mission.discodeit.entity.Channel; import java.util.List; import java.util.UUID; diff --git a/src/main/java/com/sprint/mission/discodeit/service/MessageService.java b/src/main/java/com/sprint/mission/discodeit/service/MessageService.java index 17cb5cd96..8ac5ee924 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/MessageService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/MessageService.java @@ -4,12 +4,10 @@ import com.sprint.mission.discodeit.dto.request.BinaryContentCreateRequest; import com.sprint.mission.discodeit.dto.request.MessageCreateRequest; import com.sprint.mission.discodeit.dto.request.MessageUpdateRequest; -import com.sprint.mission.discodeit.dto.respond.PageResponse; -import com.sprint.mission.discodeit.entity.Message; +import com.sprint.mission.discodeit.dto.response.PageResponse; import java.time.Instant; import java.util.List; import java.util.UUID; -import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; public interface MessageService { @@ -19,8 +17,7 @@ MessageDto create(MessageCreateRequest messageCreateRequest, MessageDto find(UUID messageId); - // PageResponse findAllByChannelId(UUID channelId, Pageable pageable); - PageResponse findAllByChannelId(UUID channelId, Instant cursor, Pageable pageable); + PageResponse findAllByChannelId(UUID channelId, Instant createdAt, Pageable pageable); MessageDto update(UUID messageId, MessageUpdateRequest request); diff --git a/src/main/java/com/sprint/mission/discodeit/service/ReadStatusService.java b/src/main/java/com/sprint/mission/discodeit/service/ReadStatusService.java index 9c4c91409..8b0c80a31 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/ReadStatusService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/ReadStatusService.java @@ -3,7 +3,6 @@ import com.sprint.mission.discodeit.dto.data.ReadStatusDto; import com.sprint.mission.discodeit.dto.request.ReadStatusCreateRequest; import com.sprint.mission.discodeit.dto.request.ReadStatusUpdateRequest; -import com.sprint.mission.discodeit.entity.ReadStatus; import java.util.List; import java.util.UUID; diff --git a/src/main/java/com/sprint/mission/discodeit/service/UserService.java b/src/main/java/com/sprint/mission/discodeit/service/UserService.java index c909569bb..444118780 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/UserService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/UserService.java @@ -4,7 +4,6 @@ import com.sprint.mission.discodeit.dto.request.BinaryContentCreateRequest; import com.sprint.mission.discodeit.dto.request.UserCreateRequest; import com.sprint.mission.discodeit.dto.request.UserUpdateRequest; -import com.sprint.mission.discodeit.entity.User; import java.util.List; import java.util.Optional; import java.util.UUID; diff --git a/src/main/java/com/sprint/mission/discodeit/service/UserStatusService.java b/src/main/java/com/sprint/mission/discodeit/service/UserStatusService.java index 44a608b54..3c5c55e6e 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/UserStatusService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/UserStatusService.java @@ -3,7 +3,6 @@ import com.sprint.mission.discodeit.dto.data.UserStatusDto; import com.sprint.mission.discodeit.dto.request.UserStatusCreateRequest; import com.sprint.mission.discodeit.dto.request.UserStatusUpdateRequest; -import com.sprint.mission.discodeit.entity.UserStatus; import java.util.List; import java.util.UUID; diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicAuthService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicAuthService.java index aa3fe4415..c822dc4c9 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicAuthService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicAuthService.java @@ -3,9 +3,13 @@ import com.sprint.mission.discodeit.dto.data.UserDto; import com.sprint.mission.discodeit.dto.request.LoginRequest; import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.exception.ErrorCode; +import com.sprint.mission.discodeit.exception.user.UserInvalidInputException; +import com.sprint.mission.discodeit.exception.user.UserNotFoundException; import com.sprint.mission.discodeit.mapper.UserMapper; import com.sprint.mission.discodeit.repository.UserRepository; import com.sprint.mission.discodeit.service.AuthService; +import java.util.Map; import java.util.NoSuchElementException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -15,23 +19,25 @@ @Service public class BasicAuthService implements AuthService { - private final UserRepository userRepository; - private final UserMapper userMapper; + private final UserRepository userRepository; + private final UserMapper userMapper; - @Transactional - @Override - public UserDto login(LoginRequest loginRequest) { - String username = loginRequest.username(); - String password = loginRequest.password(); + @Transactional(readOnly = true) + @Override + public UserDto login(LoginRequest loginRequest) { + String username = loginRequest.username(); + String password = loginRequest.password(); - User user = userRepository.findByUsername(username) - .orElseThrow( - () -> new NoSuchElementException("User with username " + username + " not found")); + User user = userRepository.findByUsername(username) + .orElseThrow( + () -> new UserNotFoundException(ErrorCode.USER_NOT_FOUND, + Map.of("로그인 시도한 사용자의 아이디 정보", username))); - if (!user.getPassword().equals(password)) { - throw new IllegalArgumentException("Wrong password"); - } + if (!user.getPassword().equals(password)) { + throw new UserInvalidInputException(ErrorCode.USER_INVALID_INPUT, + Map.of("아이디 또는 비밀번호가 일치하지 않습니다.", username)); + } - return userMapper.toDto(user); - } + return userMapper.toDto(user); + } } diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicBinaryContentService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicBinaryContentService.java index fe0b77dc9..46e746cb7 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicBinaryContentService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicBinaryContentService.java @@ -3,20 +3,16 @@ import com.sprint.mission.discodeit.dto.data.BinaryContentDto; import com.sprint.mission.discodeit.dto.request.BinaryContentCreateRequest; import com.sprint.mission.discodeit.entity.BinaryContent; +import com.sprint.mission.discodeit.exception.ErrorCode; +import com.sprint.mission.discodeit.exception.binarycontent.BinaryContentNotFoundException; import com.sprint.mission.discodeit.mapper.BinaryContentMapper; import com.sprint.mission.discodeit.repository.BinaryContentRepository; import com.sprint.mission.discodeit.service.BinaryContentService; import com.sprint.mission.discodeit.storage.BinaryContentStorage; -import java.io.IOException; -import java.io.InputStream; -import java.util.ArrayList; import java.util.List; -import java.util.NoSuchElementException; -import java.util.Optional; +import java.util.Map; import java.util.UUID; import lombok.RequiredArgsConstructor; -import org.springframework.core.io.Resource; -import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -24,85 +20,50 @@ @Service public class BasicBinaryContentService implements BinaryContentService { - private final BinaryContentStorage binaryContentStorage; - private final BinaryContentRepository binaryContentRepository; - private final BinaryContentMapper binaryContentMapper; + private final BinaryContentRepository binaryContentRepository; + private final BinaryContentMapper binaryContentMapper; + private final BinaryContentStorage binaryContentStorage; - @Transactional - @Override - public BinaryContentDto create(BinaryContentCreateRequest request) { - String fileName = request.fileName(); - byte[] bytes = request.bytes(); - String contentType = request.contentType(); - BinaryContent binaryContent = new BinaryContent( - fileName, - (long) bytes.length, - contentType - ); - binaryContentStorage.put(binaryContent.getId(), bytes); - binaryContentRepository.save(binaryContent); - BinaryContentDto dto = binaryContentMapper.toDto(binaryContent); - dto.setBytes(bytes); - return dto; - } + @Transactional + @Override + public BinaryContentDto create(BinaryContentCreateRequest request) { + String fileName = request.fileName(); + byte[] bytes = request.bytes(); + String contentType = request.contentType(); + BinaryContent binaryContent = new BinaryContent( + fileName, + (long) bytes.length, + contentType + ); + binaryContentRepository.save(binaryContent); + binaryContentStorage.put(binaryContent.getId(), bytes); - @Override - @Transactional(readOnly = true) - public BinaryContentDto find(UUID binaryContentId) { - try { - BinaryContentDto binaryContentDto = binaryContentRepository - .findById(binaryContentId) - .map(binaryContentMapper::toDto).orElseThrow(() -> new NoSuchElementException( - "BinaryContent with id " + binaryContentId + " not found")); - InputStream inputStream = binaryContentStorage.get(binaryContentId); - byte[] data = inputStream.readAllBytes(); - binaryContentDto.setBytes(data); - inputStream.close(); - return binaryContentDto; - } catch (IOException e) { - throw new RuntimeException(e); + return binaryContentMapper.toDto(binaryContent); } - } - @Transactional(readOnly = true) - @Override - public List findAllByIdIn(List binaryContentIds) { - List binaryContentDtos = new ArrayList<>(); - binaryContentRepository.findAllByIdIn(binaryContentIds) - .forEach(binaryContent -> { - try { - BinaryContentDto binaryContentDto = binaryContentMapper.toDto(binaryContent); - InputStream inputStream = binaryContentStorage.get(binaryContent.getId()); - byte[] data = inputStream.readAllBytes(); - binaryContentDto.setBytes(data); - binaryContentDtos.add(binaryContentDto); - } catch (IOException e) { - throw new RuntimeException(e); - } - }); - return binaryContentDtos; - } + @Override + public BinaryContentDto find(UUID binaryContentId) { + return binaryContentRepository.findById(binaryContentId) + .map(binaryContentMapper::toDto) + .orElseThrow( + () -> new BinaryContentNotFoundException(ErrorCode.BINARY_CONTENT_NOT_FOUND, + Map.of("조회 시도한 첨부파일의 ID 정보", binaryContentId))); + } - @Transactional - @Override - public void delete(UUID binaryContentId) { - if (!binaryContentRepository.existsById(binaryContentId)) { - throw new NoSuchElementException("BinaryContent with id " + binaryContentId + " not found"); + @Override + public List findAllByIdIn(List binaryContentIds) { + return binaryContentRepository.findAllById(binaryContentIds).stream() + .map(binaryContentMapper::toDto) + .toList(); } - binaryContentRepository.deleteById(binaryContentId); - } - @Transactional - @Override - public ResponseEntity download(UUID binaryContentId) { - BinaryContentDto binaryContentDto = binaryContentRepository.findById(binaryContentId) - .map(binaryContentMapper::toDto).orElseThrow(NoSuchElementException::new); - try { - byte[] bytes = binaryContentStorage.get(binaryContentId).readAllBytes(); - binaryContentDto.setBytes(bytes); - } catch (IOException e) { - throw new RuntimeException(e); + @Transactional + @Override + public void delete(UUID binaryContentId) { + if (!binaryContentRepository.existsById(binaryContentId)) { + throw new BinaryContentNotFoundException(ErrorCode.BINARY_CONTENT_NOT_FOUND, + Map.of("조회 시도한 첨부파일의 ID 정보", binaryContentId)); + } + binaryContentRepository.deleteById(binaryContentId); } - return binaryContentStorage.download(binaryContentDto); - } } diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicChannelService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicChannelService.java index 266e7373d..d61190c0d 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicChannelService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicChannelService.java @@ -7,14 +7,17 @@ import com.sprint.mission.discodeit.entity.Channel; import com.sprint.mission.discodeit.entity.ChannelType; import com.sprint.mission.discodeit.entity.ReadStatus; +import com.sprint.mission.discodeit.exception.ErrorCode; +import com.sprint.mission.discodeit.exception.channel.ChannelNotFoundException; +import com.sprint.mission.discodeit.exception.channel.PrivateChannelUpdateException; import com.sprint.mission.discodeit.mapper.ChannelMapper; import com.sprint.mission.discodeit.repository.ChannelRepository; import com.sprint.mission.discodeit.repository.MessageRepository; import com.sprint.mission.discodeit.repository.ReadStatusRepository; import com.sprint.mission.discodeit.repository.UserRepository; import com.sprint.mission.discodeit.service.ChannelService; -import java.time.Instant; import java.util.List; +import java.util.Map; import java.util.NoSuchElementException; import java.util.UUID; import lombok.RequiredArgsConstructor; @@ -25,92 +28,91 @@ @Service public class BasicChannelService implements ChannelService { - private final ChannelRepository channelRepository; - private final ChannelMapper channelMapper; - // - private final ReadStatusRepository readStatusRepository; - private final MessageRepository messageRepository; - private final UserRepository userRepository; + private final ChannelRepository channelRepository; + // + private final ReadStatusRepository readStatusRepository; + private final MessageRepository messageRepository; + private final UserRepository userRepository; + private final ChannelMapper channelMapper; - @Transactional - @Override - public ChannelDto create(PublicChannelCreateRequest request) { - String name = request.name(); - String description = request.description(); - Channel channel = new Channel(ChannelType.PUBLIC, name, description); - channelRepository.save(channel); - return channelMapper.toDto(channel); - } + @Transactional + @Override + public ChannelDto create(PublicChannelCreateRequest request) { + String name = request.name(); + String description = request.description(); + Channel channel = new Channel(ChannelType.PUBLIC, name, description); - @Transactional - @Override - public ChannelDto create(PrivateChannelCreateRequest request) { - Channel channel = new Channel(ChannelType.PRIVATE, null, null); - Channel createdChannel = channelRepository.save(channel); + channelRepository.save(channel); + return channelMapper.toDto(channel); + } - request.participantIds().stream() - .map(userId -> new ReadStatus( - userRepository.findById(userId).orElseThrow(NoSuchElementException::new), - createdChannel, - Instant.EPOCH)) - .forEach(readStatusRepository::save); + @Transactional + @Override + public ChannelDto create(PrivateChannelCreateRequest request) { + Channel channel = new Channel(ChannelType.PRIVATE, null, null); + channelRepository.save(channel); - return channelMapper.toDto(createdChannel); - } + List readStatuses = userRepository.findAllById(request.participantIds()) + .stream() + .map(user -> new ReadStatus(user, channel, channel.getCreatedAt())) + .toList(); + readStatusRepository.saveAll(readStatuses); - @Transactional(readOnly = true) - @Override - public ChannelDto find(UUID channelId) { - return channelRepository.findById(channelId) - .map(channelMapper::toDto) - .orElseThrow( - () -> new NoSuchElementException("Channel with id " + channelId + " not found")); - } + return channelMapper.toDto(channel); + } - @Transactional(readOnly = true) - @Override - public List findAllByUserId(UUID userId) { - List mySubscribedChannelIds = readStatusRepository.findAllByUserId(userId).stream() - .map(ReadStatus::getChannel) - .map(Channel::getId) - .toList(); + @Transactional(readOnly = true) + @Override + public ChannelDto find(UUID channelId) { + return channelRepository.findById(channelId) + .map(channelMapper::toDto) + .orElseThrow( + () -> new ChannelNotFoundException(ErrorCode.CHANNEL_NOT_FOUND, + Map.of("조회 시도한 채널의 ID 정보", channelId))); + } - return channelRepository.findAll().stream() - .filter(channel -> - channel.getType().equals(ChannelType.PUBLIC) - || mySubscribedChannelIds.contains(channel.getId()) - ) - .map(channelMapper::toDto) - .toList(); - } + @Transactional(readOnly = true) + @Override + public List findAllByUserId(UUID userId) { + List mySubscribedChannelIds = readStatusRepository.findAllByUserId(userId).stream() + .map(ReadStatus::getChannel) + .map(Channel::getId) + .toList(); - @Transactional - @Override - public ChannelDto update(UUID channelId, PublicChannelUpdateRequest request) { - String newName = request.newName(); - String newDescription = request.newDescription(); - Channel channel = channelRepository.findById(channelId) - .orElseThrow( - () -> new NoSuchElementException("Channel with id " + channelId + " not found")); - if (channel.getType().equals(ChannelType.PRIVATE)) { - throw new IllegalArgumentException("Private channel cannot be updated"); + return channelRepository.findAllByTypeOrIdIn(ChannelType.PUBLIC, mySubscribedChannelIds) + .stream() + .map(channelMapper::toDto) + .toList(); } - channel.update(newName, newDescription); - channelRepository.save(channel); - return channelMapper.toDto(channel); - } - @Transactional - @Override - public void delete(UUID channelId) { - Channel channel = channelRepository.findById(channelId) - .orElseThrow( - () -> new NoSuchElementException("Channel with id " + channelId + " not found")); + @Transactional + @Override + public ChannelDto update(UUID channelId, PublicChannelUpdateRequest request) { + String newName = request.newName(); + String newDescription = request.newDescription(); + Channel channel = channelRepository.findById(channelId) + .orElseThrow( + () -> new ChannelNotFoundException(ErrorCode.CHANNEL_NOT_FOUND, + Map.of("조회 시도한 채널의 ID 정보", channelId))); + if (channel.getType().equals(ChannelType.PRIVATE)) { + throw new PrivateChannelUpdateException(ErrorCode.PRIVATE_CHANNEL_UPDATE_FORBIDDEN, + Map.of("업데이트 하고자 하는 Private Channel Id", channelId)); + } + channel.update(newName, newDescription); + return channelMapper.toDto(channel); + } - messageRepository.deleteAllByChannelId(channel.getId()); - readStatusRepository.deleteAllByChannelId(channel.getId()); + @Transactional + @Override + public void delete(UUID channelId) { + if (!channelRepository.existsById(channelId)) { + throw new ChannelNotFoundException(ErrorCode.CHANNEL_NOT_FOUND, + Map.of("삭제 하려고하는 채널의 ID 정보", channelId)); + } - channelRepository.deleteById(channelId); - } + messageRepository.deleteAllByChannelId(channelId); + readStatusRepository.deleteAllByChannelId(channelId); + channelRepository.deleteById(channelId); + } } diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicMessageService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicMessageService.java index ab0f06fd0..2a64364ac 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicMessageService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicMessageService.java @@ -4,12 +4,15 @@ import com.sprint.mission.discodeit.dto.request.BinaryContentCreateRequest; import com.sprint.mission.discodeit.dto.request.MessageCreateRequest; import com.sprint.mission.discodeit.dto.request.MessageUpdateRequest; -import com.sprint.mission.discodeit.dto.respond.PageResponse; +import com.sprint.mission.discodeit.dto.response.PageResponse; import com.sprint.mission.discodeit.entity.BinaryContent; import com.sprint.mission.discodeit.entity.Channel; import com.sprint.mission.discodeit.entity.Message; import com.sprint.mission.discodeit.entity.User; -import com.sprint.mission.discodeit.entity.base.BaseEntity; +import com.sprint.mission.discodeit.exception.ErrorCode; +import com.sprint.mission.discodeit.exception.channel.ChannelNotFoundException; +import com.sprint.mission.discodeit.exception.message.MessageNotFoundException; +import com.sprint.mission.discodeit.exception.user.UserNotFoundException; import com.sprint.mission.discodeit.mapper.MessageMapper; import com.sprint.mission.discodeit.mapper.PageResponseMapper; import com.sprint.mission.discodeit.repository.BinaryContentRepository; @@ -20,11 +23,13 @@ import com.sprint.mission.discodeit.storage.BinaryContentStorage; import java.time.Instant; import java.util.List; +import java.util.Map; import java.util.NoSuchElementException; +import java.util.Optional; import java.util.UUID; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -32,119 +37,106 @@ @Service public class BasicMessageService implements MessageService { - private final BinaryContentRepository binaryContentRepository; - private final BinaryContentStorage binaryContentStorage; - private final MessageMapper messageMapper; - private final MessageRepository messageRepository; - private final PageResponseMapper pageResponseMapper; - // - private final ChannelRepository channelRepository; - private final UserRepository userRepository; - - @Transactional - @Override - public MessageDto create(MessageCreateRequest messageCreateRequest, - List binaryContentCreateRequests) { - UUID channelId = messageCreateRequest.channelId(); - UUID authorId = messageCreateRequest.authorId(); - - if (!channelRepository.existsById(channelId)) { - throw new NoSuchElementException("Channel with id " + channelId + " does not exist"); + private final MessageRepository messageRepository; + // + private final ChannelRepository channelRepository; + private final UserRepository userRepository; + private final MessageMapper messageMapper; + private final BinaryContentStorage binaryContentStorage; + private final BinaryContentRepository binaryContentRepository; + private final PageResponseMapper pageResponseMapper; + + @Transactional + @Override + public MessageDto create(MessageCreateRequest messageCreateRequest, + List binaryContentCreateRequests) { + UUID channelId = messageCreateRequest.channelId(); + UUID authorId = messageCreateRequest.authorId(); + + Channel channel = channelRepository.findById(channelId) + .orElseThrow( + () -> new ChannelNotFoundException(ErrorCode.CHANNEL_NOT_FOUND, + Map.of("생성하고자 하는 메세지의 채널 ID", channelId))); + User author = userRepository.findById(authorId) + .orElseThrow( + () -> new UserNotFoundException(ErrorCode.USER_NOT_FOUND, + Map.of("생성하고자 하는 메세지의 유저 ID", authorId)) + ); + + List attachments = binaryContentCreateRequests.stream() + .map(attachmentRequest -> { + String fileName = attachmentRequest.fileName(); + String contentType = attachmentRequest.contentType(); + byte[] bytes = attachmentRequest.bytes(); + + BinaryContent binaryContent = new BinaryContent(fileName, (long) bytes.length, + contentType); + binaryContentRepository.save(binaryContent); + binaryContentStorage.put(binaryContent.getId(), bytes); + return binaryContent; + }) + .toList(); + + String content = messageCreateRequest.content(); + Message message = new Message( + content, + channel, + author, + attachments + ); + + messageRepository.save(message); + return messageMapper.toDto(message); } - if (!userRepository.existsById(authorId)) { - throw new NoSuchElementException("Author with id " + authorId + " does not exist"); + + @Transactional(readOnly = true) + @Override + public MessageDto find(UUID messageId) { + return messageRepository.findById(messageId) + .map(messageMapper::toDto) + .orElseThrow( + () -> new MessageNotFoundException(ErrorCode.MESSAGE_NOT_FOUND, + Map.of("조회 시도한 메세지의 ID", messageId))); + } + + @Transactional(readOnly = true) + @Override + public PageResponse findAllByChannelId(UUID channelId, Instant createAt, + Pageable pageable) { + Slice slice = messageRepository.findAllByChannelIdWithAuthor(channelId, + Optional.ofNullable(createAt).orElse(Instant.now()), + pageable) + .map(messageMapper::toDto); + + Instant nextCursor = null; + if (!slice.getContent().isEmpty()) { + nextCursor = slice.getContent().get(slice.getContent().size() - 1) + .createdAt(); + } + + return pageResponseMapper.fromSlice(slice, nextCursor); } - Channel channel = channelRepository.findById(channelId) - .orElseThrow( - () -> new NoSuchElementException("Channel with id " + channelId + " not found")); - - User user = userRepository.findById(authorId) - .orElseThrow(() -> new NoSuchElementException("Author with id " + authorId + " not found")); - - List attachments = binaryContentCreateRequests.stream() - .map(attachmentRequest -> { - String fileName = attachmentRequest.fileName(); - String contentType = attachmentRequest.contentType(); - byte[] bytes = attachmentRequest.bytes(); - - BinaryContent binaryContent = new BinaryContent(fileName, (long) bytes.length, - contentType); - BinaryContent save = binaryContentRepository.save(binaryContent); - binaryContentStorage.put(binaryContent.getId(), bytes); - return save; - }) - .toList(); - - String content = messageCreateRequest.content(); - Message message = new Message( - content, - channel, - user, - attachments - ); - messageRepository.save(message); - return messageMapper.toDto(message); - } - - @Transactional(readOnly = true) - @Override - public MessageDto find(UUID messageId) { - return messageRepository.findById(messageId) - .map(messageMapper::toDto) - .orElseThrow( - () -> new NoSuchElementException("Message with id " + messageId + " not found")) - ; - } - -// @Transactional(readOnly = true) -// @Override -// public PageResponse findAllByChannelId(UUID channelId, Pageable pageable) { -// return pageResponseMapper.fromPage(messageRepository -// .findAllByChannelId(channelId, pageable) -// .map(messageMapper::toDto)); -// } - - @Transactional(readOnly = true) - @Override - public PageResponse findAllByChannelId(UUID channelId, Instant cursor, - Pageable pageable) { - if (cursor != null) { - return pageResponseMapper.fromPage(messageRepository - .findAllByChannelId(channelId, cursor, pageable) - .map(messageMapper::toDto)); - } else { - return pageResponseMapper.fromPage(messageRepository - .findAllByChannelId(channelId, pageable) - .map(messageMapper::toDto)); + @Transactional + @Override + public MessageDto update(UUID messageId, MessageUpdateRequest request) { + String newContent = request.newContent(); + Message message = messageRepository.findById(messageId) + .orElseThrow( + () -> new MessageNotFoundException(ErrorCode.MESSAGE_NOT_FOUND, + Map.of("업데이트 시도한 Message ID", messageId))); + message.update(newContent); + return messageMapper.toDto(message); } - } - - @Transactional - @Override - public MessageDto update(UUID messageId, MessageUpdateRequest request) { - String newContent = request.newContent(); - Message message = messageRepository.findById(messageId) - .orElseThrow( - () -> new NoSuchElementException("Message with id " + messageId + " not found")); - message.update(newContent); - messageRepository.save(message); - return messageMapper.toDto(message); - } - - @Transactional - @Override - public void delete(UUID messageId) { - Message message = messageRepository.findById(messageId) - .orElseThrow( - () -> new NoSuchElementException("Message with id " + messageId + " not found")); - - message.getAttachments() - .stream() - .map(BinaryContent::getId) - .forEach(binaryContentRepository::deleteById); - - messageRepository.deleteById(messageId); - } + @Transactional + @Override + public void delete(UUID messageId) { + if (!messageRepository.existsById(messageId)) { + throw new MessageNotFoundException(ErrorCode.MESSAGE_NOT_FOUND, + Map.of("삭제 시도한 Message ID", messageId)); + } + + messageRepository.deleteById(messageId); + } } diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicReadStatusService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicReadStatusService.java index 23eb2d738..22a53bde2 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicReadStatusService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicReadStatusService.java @@ -6,6 +6,10 @@ import com.sprint.mission.discodeit.entity.Channel; import com.sprint.mission.discodeit.entity.ReadStatus; import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.exception.ErrorCode; +import com.sprint.mission.discodeit.exception.channel.ChannelNotFoundException; +import com.sprint.mission.discodeit.exception.readstatus.ReadStatusNotFoundException; +import com.sprint.mission.discodeit.exception.user.UserNotFoundException; import com.sprint.mission.discodeit.mapper.ReadStatusMapper; import com.sprint.mission.discodeit.repository.ChannelRepository; import com.sprint.mission.discodeit.repository.ReadStatusRepository; @@ -13,6 +17,7 @@ import com.sprint.mission.discodeit.service.ReadStatusService; import java.time.Instant; import java.util.List; +import java.util.Map; import java.util.NoSuchElementException; import java.util.UUID; import lombok.RequiredArgsConstructor; @@ -23,77 +28,74 @@ @Service public class BasicReadStatusService implements ReadStatusService { - private final ReadStatusRepository readStatusRepository; - private final UserRepository userRepository; - private final ChannelRepository channelRepository; - private final ReadStatusMapper readStatusMapper; + private final ReadStatusRepository readStatusRepository; + private final UserRepository userRepository; + private final ChannelRepository channelRepository; + private final ReadStatusMapper readStatusMapper; - @Transactional - @Override - public ReadStatusDto create(ReadStatusCreateRequest request) { - UUID userId = request.userId(); - UUID channelId = request.channelId(); + @Transactional + @Override + public ReadStatusDto create(ReadStatusCreateRequest request) { + UUID userId = request.userId(); + UUID channelId = request.channelId(); - if (!userRepository.existsById(userId)) { - throw new NoSuchElementException("User with id " + userId + " does not exist"); - } - if (!channelRepository.existsById(channelId)) { - throw new NoSuchElementException("Channel with id " + channelId + " does not exist"); - } + User user = userRepository.findById(userId) + .orElseThrow( + () -> new UserNotFoundException(ErrorCode.USER_NOT_FOUND, + Map.of("조회 시도한 유저 ID 정보", userId))); + Channel channel = channelRepository.findById(channelId) + .orElseThrow( + () -> new ChannelNotFoundException(ErrorCode.CHANNEL_NOT_FOUND, + Map.of("조회 시도한 채널 ID 정보", channelId)) + ); - Channel channel = channelRepository.findById(channelId) - .orElseThrow( - () -> new NoSuchElementException("Channel with id " + channelId + " not found")); + if (readStatusRepository.existsByUserIdAndChannelId(user.getId(), channel.getId())) { + throw new ReadStatusNotFoundException(ErrorCode.READ_STATUS_NOT_FOUND, + Map.of("조회 시도한 유저 ID 정보", userId, "조회 시도한 채널ID 정보", channelId)); + } - User user = userRepository.findById(userId) - .orElseThrow(() -> new NoSuchElementException("Author with id " + userId + " not found")); + Instant lastReadAt = request.lastReadAt(); + ReadStatus readStatus = new ReadStatus(user, channel, lastReadAt); + readStatusRepository.save(readStatus); - if (readStatusRepository.findAllByUserId(userId).stream() - .anyMatch( - readStatus -> readStatus.getChannel().equals(channelRepository.findById(channelId)))) { - throw new IllegalArgumentException( - "ReadStatus with userId " + userId + " and channelId " + channelId + " already exists"); + return readStatusMapper.toDto(readStatus); } - Instant lastReadAt = request.lastReadAt(); - ReadStatus readStatus = new ReadStatus(user, channel, lastReadAt); - return readStatusMapper.toDto(readStatusRepository.save(readStatus)); - } - - @Transactional(readOnly = true) - @Override - public ReadStatusDto find(UUID readStatusId) { - return readStatusRepository.findById(readStatusId) - .map(readStatusMapper::toDto) - .orElseThrow( - () -> new NoSuchElementException("ReadStatus with id " + readStatusId + " not found")); - } + @Override + public ReadStatusDto find(UUID readStatusId) { + return readStatusRepository.findById(readStatusId) + .map(readStatusMapper::toDto) + .orElseThrow( + () -> new ReadStatusNotFoundException(ErrorCode.READ_STATUS_NOT_FOUND, + Map.of("조회 시도한 읽기 상태 ID 정보", readStatusId))); + } - @Transactional(readOnly = true) - @Override - public List findAllByUserId(UUID userId) { - return readStatusRepository.findAllByUserId(userId).stream() - .map(readStatusMapper::toDto) - .toList(); - } + @Override + public List findAllByUserId(UUID userId) { + return readStatusRepository.findAllByUserId(userId).stream() + .map(readStatusMapper::toDto) + .toList(); + } - @Transactional - @Override - public ReadStatusDto update(UUID readStatusId, ReadStatusUpdateRequest request) { - Instant newLastReadAt = request.newLastReadAt(); - ReadStatus readStatus = readStatusRepository.findById(readStatusId) - .orElseThrow( - () -> new NoSuchElementException("ReadStatus with id " + readStatusId + " not found")); - readStatus.update(newLastReadAt); - return readStatusMapper.toDto(readStatusRepository.save(readStatus)); - } + @Transactional + @Override + public ReadStatusDto update(UUID readStatusId, ReadStatusUpdateRequest request) { + Instant newLastReadAt = request.newLastReadAt(); + ReadStatus readStatus = readStatusRepository.findById(readStatusId) + .orElseThrow( + () -> new ReadStatusNotFoundException(ErrorCode.READ_STATUS_NOT_FOUND, + Map.of("조회 시도한 읽기 상태 ID", readStatusId))); + readStatus.update(newLastReadAt); + return readStatusMapper.toDto(readStatus); + } - @Transactional - @Override - public void delete(UUID readStatusId) { - if (!readStatusRepository.existsById(readStatusId)) { - throw new NoSuchElementException("ReadStatus with id " + readStatusId + " not found"); + @Transactional + @Override + public void delete(UUID readStatusId) { + if (!readStatusRepository.existsById(readStatusId)) { + throw new ReadStatusNotFoundException(ErrorCode.READ_STATUS_NOT_FOUND, + Map.of("조회 시도한 읽기 상태 ID", readStatusId)); + } + readStatusRepository.deleteById(readStatusId); } - readStatusRepository.deleteById(readStatusId); - } } diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserService.java index 3e2252070..e8521179d 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserService.java @@ -7,16 +7,18 @@ import com.sprint.mission.discodeit.entity.BinaryContent; import com.sprint.mission.discodeit.entity.User; import com.sprint.mission.discodeit.entity.UserStatus; +import com.sprint.mission.discodeit.exception.ErrorCode; +import com.sprint.mission.discodeit.exception.user.UserAlreadyExistException; +import com.sprint.mission.discodeit.exception.user.UserNotFoundException; import com.sprint.mission.discodeit.mapper.UserMapper; import com.sprint.mission.discodeit.repository.BinaryContentRepository; import com.sprint.mission.discodeit.repository.UserRepository; import com.sprint.mission.discodeit.repository.UserStatusRepository; import com.sprint.mission.discodeit.service.UserService; import com.sprint.mission.discodeit.storage.BinaryContentStorage; -import java.io.IOException; -import java.io.InputStream; import java.time.Instant; import java.util.List; +import java.util.Map; import java.util.NoSuchElementException; import java.util.Optional; import java.util.UUID; @@ -28,141 +30,113 @@ @Service public class BasicUserService implements UserService { - private final UserRepository userRepository; - // - private final BinaryContentRepository binaryContentRepository; - private final UserStatusRepository userStatusRepository; - private final UserMapper userMapper; - private final BinaryContentStorage binaryContentStorage; - - @Transactional - @Override - public UserDto create(UserCreateRequest userCreateRequest, - Optional optionalProfileCreateRequest) { - String username = userCreateRequest.username(); - String email = userCreateRequest.email(); - - if (userRepository.existsByEmail(email)) { - throw new IllegalArgumentException("User with email " + email + " already exists"); + private final UserRepository userRepository; + private final UserStatusRepository userStatusRepository; + private final UserMapper userMapper; + private final BinaryContentRepository binaryContentRepository; + private final BinaryContentStorage binaryContentStorage; + + @Transactional + @Override + public UserDto create(UserCreateRequest userCreateRequest, + Optional optionalProfileCreateRequest) { + String username = userCreateRequest.username(); + String email = userCreateRequest.email(); + + if (userRepository.existsByEmail(email)) { + throw new UserAlreadyExistException(ErrorCode.USER_ALREADY_EXISTS, + Map.of("생성 시도한 유저 이메일", email)); + } + if (userRepository.existsByUsername(username)) { + throw new UserAlreadyExistException(ErrorCode.USER_ALREADY_EXISTS, + Map.of("생성 시도한 유저 이름", username)); + } + + BinaryContent nullableProfile = optionalProfileCreateRequest + .map(profileRequest -> { + String fileName = profileRequest.fileName(); + String contentType = profileRequest.contentType(); + byte[] bytes = profileRequest.bytes(); + BinaryContent binaryContent = new BinaryContent(fileName, (long) bytes.length, + contentType); + binaryContentRepository.save(binaryContent); + binaryContentStorage.put(binaryContent.getId(), bytes); + return binaryContent; + }) + .orElse(null); + String password = userCreateRequest.password(); + + User user = new User(username, email, password, nullableProfile); + Instant now = Instant.now(); + UserStatus userStatus = new UserStatus(user, now); + + userRepository.save(user); + return userMapper.toDto(user); } - if (userRepository.existsByUsername(username)) { - throw new IllegalArgumentException("User with username " + username + " already exists"); + + @Override + public UserDto find(UUID userId) { + return userRepository.findById(userId) + .map(userMapper::toDto) + .orElseThrow(() -> new UserNotFoundException(ErrorCode.USER_NOT_FOUND, + Map.of("조회 시도한 유저 ID", userId))); } - BinaryContent nullableProfile = optionalProfileCreateRequest - .map(profileRequest -> { - String fileName = profileRequest.fileName(); - String contentType = profileRequest.contentType(); - byte[] bytes = profileRequest.bytes(); - BinaryContent binaryContent = new BinaryContent(fileName, (long) bytes.length, - contentType); - BinaryContent save = binaryContentRepository.save(binaryContent); - binaryContentStorage.put(save.getId(), bytes); - return save; - }) - .orElse(null); - String password = userCreateRequest.password(); - User user = new User(username, email, password, nullableProfile); - Instant now = Instant.now(); - UserStatus userStatus = new UserStatus(user, now); - user.setStatus(userStatus); - - User createdUser = userRepository.save(user); -// userStatusRepository.save(userStatus); - - return userMapper.toDto(createdUser); - } - - @Transactional(readOnly = true) - @Override - public UserDto find(UUID userId) { - - return userRepository.findById(userId) - .map(userMapper::toDto) - .map(userDto -> { - InputStream inputStream = binaryContentStorage.get(userDto.profile().getId()); - try { - byte[] bytes = inputStream.readAllBytes(); - userDto.profile().setBytes(bytes); - return userDto; - } catch (IOException e) { - throw new RuntimeException(e); - } - }) - .orElseThrow(() -> new NoSuchElementException("User with id " + userId + " not found")); - } - - @Transactional(readOnly = true) - @Override - public List findAll() { - return userRepository.findAll() - .stream() - .map(userMapper::toDto) - .map(userDto -> { - if (userDto.profile() == null) { - return userDto; - } - InputStream inputStream = binaryContentStorage.get(userDto.profile().getId()); - try { - byte[] bytes = inputStream.readAllBytes(); - userDto.profile().setBytes(bytes); - return userDto; - } catch (IOException e) { - throw new RuntimeException(e); - } - }).toList(); - } - - @Transactional - @Override - public UserDto update(UUID userId, UserUpdateRequest userUpdateRequest, - Optional optionalProfileCreateRequest) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new NoSuchElementException("User with id " + userId + " not found")); - - String newUsername = userUpdateRequest.newUsername(); - String newEmail = userUpdateRequest.newEmail(); - if (userRepository.existsByEmail(newEmail)) { - throw new IllegalArgumentException("User with email " + newEmail + " already exists"); + @Override + public List findAll() { + return userRepository.findAllWithProfileAndStatus() + .stream() + .map(userMapper::toDto) + .toList(); } - if (userRepository.existsByUsername(newUsername)) { - throw new IllegalArgumentException("User with username " + newUsername + " already exists"); + + @Transactional + @Override + public UserDto update(UUID userId, UserUpdateRequest userUpdateRequest, + Optional optionalProfileCreateRequest) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new UserNotFoundException(ErrorCode.USER_NOT_FOUND, + Map.of("조회 시도한 유저 ID", userId))); + + String newUsername = userUpdateRequest.newUsername(); + String newEmail = userUpdateRequest.newEmail(); + if (userRepository.existsByEmail(newEmail)) { + throw new UserAlreadyExistException(ErrorCode.USER_ALREADY_EXISTS, + Map.of("업데이트 시도한 유저 이메일", newEmail)); + } + if (userRepository.existsByUsername(newUsername)) { + throw new UserAlreadyExistException(ErrorCode.USER_ALREADY_EXISTS, + Map.of("업데이트 시도한 유저 이름", newUsername)); + } + + BinaryContent nullableProfile = optionalProfileCreateRequest + .map(profileRequest -> { + + String fileName = profileRequest.fileName(); + String contentType = profileRequest.contentType(); + byte[] bytes = profileRequest.bytes(); + BinaryContent binaryContent = new BinaryContent(fileName, (long) bytes.length, + contentType); + binaryContentRepository.save(binaryContent); + binaryContentStorage.put(binaryContent.getId(), bytes); + return binaryContent; + }) + .orElse(null); + + String newPassword = userUpdateRequest.newPassword(); + user.update(newUsername, newEmail, newPassword, nullableProfile); + + return userMapper.toDto(user); } - BinaryContent nullableProfile = optionalProfileCreateRequest - .map(profileRequest -> { - Optional.ofNullable(user.getProfile()) - .map(BinaryContent::getId) - .ifPresent(binaryContentRepository::deleteById); - - String fileName = profileRequest.fileName(); - String contentType = profileRequest.contentType(); - byte[] bytes = profileRequest.bytes(); - BinaryContent binaryContent = new BinaryContent(fileName, (long) bytes.length, - contentType); - BinaryContent save = binaryContentRepository.save(binaryContent); - binaryContentStorage.put(save.getId(), bytes); - return save; - - }) - .orElse(null); - - String newPassword = userUpdateRequest.newPassword(); - user.update(newUsername, newEmail, newPassword, nullableProfile); - - return userMapper.toDto(userRepository.save(user)); - } - - @Transactional - @Override - public void delete(UUID userId) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new NoSuchElementException("User with id " + userId + " not found")); - - Optional.ofNullable(user.getProfile().getId()) - .ifPresent(binaryContentRepository::deleteById); - userStatusRepository.deleteByUserId(userId); - - userRepository.deleteById(userId); - } + @Transactional + @Override + public void delete(UUID userId) { + if (!userRepository.existsById(userId)) { + throw new UserNotFoundException(ErrorCode.USER_NOT_FOUND, + Map.of("조회 시도한 유저 ID", userId)); + } + + userRepository.deleteById(userId); + } } diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserStatusService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserStatusService.java index 074b6cf66..756fc5571 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserStatusService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserStatusService.java @@ -5,13 +5,19 @@ import com.sprint.mission.discodeit.dto.request.UserStatusUpdateRequest; import com.sprint.mission.discodeit.entity.User; import com.sprint.mission.discodeit.entity.UserStatus; +import com.sprint.mission.discodeit.exception.ErrorCode; +import com.sprint.mission.discodeit.exception.user.UserNotFoundException; +import com.sprint.mission.discodeit.exception.userstatus.UserStatusAlreadyExistException; +import com.sprint.mission.discodeit.exception.userstatus.UserStatusNotFoundException; import com.sprint.mission.discodeit.mapper.UserStatusMapper; import com.sprint.mission.discodeit.repository.UserRepository; import com.sprint.mission.discodeit.repository.UserStatusRepository; import com.sprint.mission.discodeit.service.UserStatusService; import java.time.Instant; import java.util.List; +import java.util.Map; import java.util.NoSuchElementException; +import java.util.Optional; import java.util.UUID; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -21,79 +27,81 @@ @Service public class BasicUserStatusService implements UserStatusService { - private final UserStatusRepository userStatusRepository; - private final UserRepository userRepository; - private final UserStatusMapper userStatusMapper; - - @Transactional - @Override - public UserStatusDto create(UserStatusCreateRequest request) { - UUID userId = request.userId(); - - if (!userRepository.existsById(userId)) { - throw new NoSuchElementException("User with id " + userId + " does not exist"); - } - if (userStatusRepository.findByUserId(userId).isPresent()) { - throw new IllegalArgumentException("UserStatus with id " + userId + " already exists"); + private final UserStatusRepository userStatusRepository; + private final UserRepository userRepository; + private final UserStatusMapper userStatusMapper; + + @Transactional + @Override + public UserStatusDto create(UserStatusCreateRequest request) { + UUID userId = request.userId(); + + User user = userRepository.findById(userId) + .orElseThrow(() -> new UserNotFoundException(ErrorCode.USER_NOT_FOUND, + Map.of("조회 시도한 유저 ID", userId))); + Optional.ofNullable(user.getStatus()) + .ifPresent(status -> { + throw new UserStatusAlreadyExistException(ErrorCode.USER_STATUS_ALREADY_EXISTS, + Map.of("생성 시도한 유저 상태 ID", status)); + }); + + Instant lastActiveAt = request.lastActiveAt(); + UserStatus userStatus = new UserStatus(user, lastActiveAt); + userStatusRepository.save(userStatus); + return userStatusMapper.toDto(userStatus); } - User user = userRepository.findById(userId) - .orElseThrow(() -> new NoSuchElementException("User with id " + userId + " not found")); - - Instant lastActiveAt = request.lastActiveAt(); - UserStatus userStatus = new UserStatus(user, lastActiveAt); - return userStatusMapper.toDto(userStatusRepository.save(userStatus)); - } - - @Transactional(readOnly = true) - @Override - public UserStatusDto find(UUID userStatusId) { - return userStatusRepository.findById(userStatusId) - .map(userStatusMapper::toDto) - .orElseThrow( - () -> new NoSuchElementException("UserStatus with id " + userStatusId + " not found")); - } + @Override + public UserStatusDto find(UUID userStatusId) { + return userStatusRepository.findById(userStatusId) + .map(userStatusMapper::toDto) + .orElseThrow( + () -> new UserStatusNotFoundException(ErrorCode.USER_STATUS_NOT_FOUND, + Map.of("조회 시도한 유저 상태 ID", userStatusId))); + } - @Transactional(readOnly = true) - @Override - public List findAll() { - return userStatusRepository.findAll().stream() - .map(userStatusMapper::toDto) - .toList(); - } + @Override + public List findAll() { + return userStatusRepository.findAll().stream() + .map(userStatusMapper::toDto) + .toList(); + } - @Transactional - @Override - public UserStatusDto update(UUID userStatusId, UserStatusUpdateRequest request) { - Instant newLastActiveAt = request.newLastActiveAt(); + @Transactional + @Override + public UserStatusDto update(UUID userStatusId, UserStatusUpdateRequest request) { + Instant newLastActiveAt = request.newLastActiveAt(); - UserStatus userStatus = userStatusRepository.findById(userStatusId) - .orElseThrow( - () -> new NoSuchElementException("UserStatus with id " + userStatusId + " not found")); - userStatus.update(newLastActiveAt); + UserStatus userStatus = userStatusRepository.findById(userStatusId) + .orElseThrow( + () -> new UserStatusNotFoundException(ErrorCode.USER_STATUS_NOT_FOUND, + Map.of("조회 시도한 유저 상태 ID", userStatusId))); + userStatus.update(newLastActiveAt); - return userStatusMapper.toDto(userStatusRepository.save(userStatus)); - } + return userStatusMapper.toDto(userStatus); + } - @Transactional - @Override - public UserStatusDto updateByUserId(UUID userId, UserStatusUpdateRequest request) { - Instant newLastActiveAt = request.newLastActiveAt(); + @Transactional + @Override + public UserStatusDto updateByUserId(UUID userId, UserStatusUpdateRequest request) { + Instant newLastActiveAt = request.newLastActiveAt(); - UserStatus userStatus = userStatusRepository.findByUserId(userId) - .orElseThrow( - () -> new NoSuchElementException("UserStatus with userId " + userId + " not found")); - userStatus.update(newLastActiveAt); + UserStatus userStatus = userStatusRepository.findByUserId(userId) + .orElseThrow( + () -> new UserStatusNotFoundException(ErrorCode.USER_STATUS_NOT_FOUND, + Map.of("유저 상태 조회 시도한 유저 ID", userId))); + userStatus.update(newLastActiveAt); - return userStatusMapper.toDto(userStatusRepository.save(userStatus)); - } + return userStatusMapper.toDto(userStatus); + } - @Transactional - @Override - public void delete(UUID userStatusId) { - if (!userStatusRepository.existsById(userStatusId)) { - throw new NoSuchElementException("UserStatus with id " + userStatusId + " not found"); + @Transactional + @Override + public void delete(UUID userStatusId) { + if (!userStatusRepository.existsById(userStatusId)) { + throw new UserStatusNotFoundException(ErrorCode.USER_STATUS_NOT_FOUND, + Map.of("조회 시도한 유저 상태 ID", userStatusId)); + } + userStatusRepository.deleteById(userStatusId); } - userStatusRepository.deleteById(userStatusId); - } } diff --git a/src/main/java/com/sprint/mission/discodeit/storage/BinaryContentStorage.java b/src/main/java/com/sprint/mission/discodeit/storage/BinaryContentStorage.java index 28def51c1..f00216c40 100644 --- a/src/main/java/com/sprint/mission/discodeit/storage/BinaryContentStorage.java +++ b/src/main/java/com/sprint/mission/discodeit/storage/BinaryContentStorage.java @@ -7,9 +7,9 @@ public interface BinaryContentStorage { - UUID put(UUID id, byte[] bytes); + UUID put(UUID binaryContentId, byte[] bytes); - InputStream get(UUID id); + InputStream get(UUID binaryContentId); - ResponseEntity download(BinaryContentDto binaryContentDto); + ResponseEntity download(BinaryContentDto metaData); } diff --git a/src/main/java/com/sprint/mission/discodeit/storage/LocalBinaryContentStorage.java b/src/main/java/com/sprint/mission/discodeit/storage/LocalBinaryContentStorage.java deleted file mode 100644 index afdecd8f0..000000000 --- a/src/main/java/com/sprint/mission/discodeit/storage/LocalBinaryContentStorage.java +++ /dev/null @@ -1,100 +0,0 @@ -package com.sprint.mission.discodeit.storage; - -import com.sprint.mission.discodeit.dto.data.BinaryContentDto; -import com.sprint.mission.discodeit.entity.BinaryContent; -import jakarta.annotation.PostConstruct; -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.ObjectInputStream; -import java.io.ObjectOutputStream; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.NoSuchElementException; -import java.util.UUID; -import lombok.NoArgsConstructor; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.core.io.ByteArrayResource; -import org.springframework.core.io.Resource; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Repository; - -@Repository -@ConditionalOnProperty(name = "discodeit.storage.type", havingValue = "local") -public class LocalBinaryContentStorage implements BinaryContentStorage { - - private Path root; - - public LocalBinaryContentStorage(@Value("${discodeit.storage.local.root-path:data}") Path root) { - this.root = root; - } - - @PostConstruct - public void init() { - this.root = Paths.get(System.getProperty("user.dir"), root.toString(), - BinaryContent.class.getSimpleName()); - if (Files.notExists(root)) { - try { - Files.createDirectories(root); - } catch (IOException e) { - throw new RuntimeException(e); - } - } - } - - private Path resolvePath(UUID id) { - return root.resolve(id + ".ser"); - } - - @Override - public UUID put(UUID id, byte[] bytes) { - System.out.println("id = " + id); - Path path = resolvePath(id); - try (FileOutputStream fos = new FileOutputStream(path.toFile())) { - fos.write(bytes); - } catch (IOException e) { - throw new RuntimeException(e); - } - return id; - } - - @Override - public InputStream get(UUID id) { - Path path = resolvePath(id); - if (Files.notExists(path)) { - throw new NoSuchElementException(); - } - try { - FileInputStream fis = new FileInputStream(path.toFile()); - return fis; - - } catch (IOException e) { - throw new RuntimeException(e); - } - - } - - @Override - public ResponseEntity download(BinaryContentDto binaryContentDto) { - byte[] bytes = binaryContentDto.getBytes(); - Resource resource = new ByteArrayResource(bytes); - - String filename = binaryContentDto.getFileName(); - String contentType = binaryContentDto.getContentType(); - - return ResponseEntity.ok() - .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"") - .contentType(MediaType.parseMediaType(contentType)) - .contentLength(bytes.length) - .body(resource); - - } - -} diff --git a/src/main/java/com/sprint/mission/discodeit/storage/local/LocalBinaryContentStorage.java b/src/main/java/com/sprint/mission/discodeit/storage/local/LocalBinaryContentStorage.java new file mode 100644 index 000000000..8922903c0 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/storage/local/LocalBinaryContentStorage.java @@ -0,0 +1,89 @@ +package com.sprint.mission.discodeit.storage.local; + +import com.sprint.mission.discodeit.dto.data.BinaryContentDto; +import com.sprint.mission.discodeit.storage.BinaryContentStorage; +import jakarta.annotation.PostConstruct; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.NoSuchElementException; +import java.util.UUID; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.core.io.InputStreamResource; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; + +@ConditionalOnProperty(name = "discodeit.storage.type", havingValue = "local") +@Component +public class LocalBinaryContentStorage implements BinaryContentStorage { + + private final Path root; + + public LocalBinaryContentStorage( + @Value("${discodeit.storage.local.root-path}") Path root + ) { + this.root = root; + } + + @PostConstruct + public void init() { + if (!Files.exists(root)) { + try { + Files.createDirectories(root); + } catch (IOException e) { + e.printStackTrace(); + throw new RuntimeException(e); + } + } + } + + public UUID put(UUID binaryContentId, byte[] bytes) { + Path filePath = resolvePath(binaryContentId); + if (Files.exists(filePath)) { + throw new IllegalArgumentException("File with key " + binaryContentId + " already exists"); + } + try (OutputStream outputStream = Files.newOutputStream(filePath)) { + outputStream.write(bytes); + } catch (IOException e) { + throw new RuntimeException(e); + } + return binaryContentId; + } + + public InputStream get(UUID binaryContentId) { + Path filePath = resolvePath(binaryContentId); + if (Files.notExists(filePath)) { + throw new NoSuchElementException("File with key " + binaryContentId + " does not exist"); + } + try { + return Files.newInputStream(filePath); + } catch (IOException e) { + e.printStackTrace(); + throw new RuntimeException(e); + } + } + + private Path resolvePath(UUID key) { + return root.resolve(key.toString()); + } + + @Override + public ResponseEntity download(BinaryContentDto metaData) { + InputStream inputStream = get(metaData.id()); + Resource resource = new InputStreamResource(inputStream); + + return ResponseEntity + .status(HttpStatus.OK) + .header(HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=\"" + metaData.fileName() + "\"") + .header(HttpHeaders.CONTENT_TYPE, metaData.contentType()) + .header(HttpHeaders.CONTENT_LENGTH, String.valueOf(metaData.size())) + .body(resource); + } +} diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml new file mode 100644 index 000000000..451b2c066 --- /dev/null +++ b/src/main/resources/application-dev.yaml @@ -0,0 +1,84 @@ +# 테스트 환경 설정 +spring: + boot: + admin: + client: + url: http://localhost:9090 + # 테스트용 H2 인메모리 데이터베이스 설정 + datasource: + # DB_CLOSE_DELAY: 테스트 종료 후 데이터베이스 연결 유지 시간 (음수 값은 연결 유지) + # DB_CLOSE_ON_EXIT: 프로그램 종료 시 데이터베이스 연결 닫기 여부 + url: jdbc:h2:mem:discodeit;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + driver-class-name: org.h2.Driver + username: discodeit_user + password: discodeit1234 + jpa: + hibernate: + ddl-auto: validate + properties: + hibernate: + format_sql: true + # default_batch_fetch_size: 100 + open-in-view: false + + + +logging: + config: classpath:logback-spring.xml + level: + org.hibernate.SQL: debug + org.hibernate.orm.jdbc.bind: trace + com.sprint.mission.discodeit: debug + +discodeit: + storage: + type: local + local: + root-path: .discodeit/storage + + +# Actuator 설정 +management: + endpoints: + web: + exposure: + include: health,info,metrics,loggers + base-path: /actuator + endpoint: + health: + show-details: always + info: + enabled: true + info: + env: + enabled: true + java: + enabled: true + os: + enabled: true + +# 애플리케이션 정보 +info: + app: + name: Discodeit + version: 1.7.0 + description: Spring Boot Application with Actuator + java: + version: 17 + spring: + boot: + version: 3.4.0 + database: + url: ${spring.datasource.url} + driver: ${spring.datasource.driver-class-name} + jpa: + ddl-auto: ${spring.jpa.hibernate.ddl-auto} + storage: + type: ${storage.type} + path: ${storage.path} + multipart: + max-file-size: ${spring.servlet.multipart.max-file-size} + max-request-size: ${spring.servlet.multipart.max-request-size} + +server: + port: 8080 \ No newline at end of file diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml new file mode 100644 index 000000000..111f6fc8d --- /dev/null +++ b/src/main/resources/application-prod.yaml @@ -0,0 +1,84 @@ +spring: + application: + name: discodeit + boot: + admin: + client: + url: ${SPRING_BOOT_ADMIN_CLIENT_URL} + servlet: + multipart: + maxFileSize: 10MB # 파일 하나의 최대 크기 + maxRequestSize: 30MB # 한 번에 최대 업로드 가능 용량 + datasource: + driver-class-name: org.postgresql.Driver + url: jdbc:postgresql://localhost:5432/discodeit + username: discodeit_user + password: discodeit1234 + jpa: + hibernate: + ddl-auto: validate + properties: + hibernate: + format_sql: true + # default_batch_fetch_size: 100 + open-in-view: false + + +logging: + config: classpath:logback-spring.xml + level: + org.hibernate.SQL: debug + org.hibernate.orm.jdbc.bind: trace + com.sprint.mission.discodeit: info + +discodeit: + storage: + type: local + local: + root-path: .discodeit/storage + + +# Actuator 설정 +management: + endpoints: + web: + exposure: + include: health,info,metrics,loggers + base-path: /actuator + endpoint: + health: + show-details: always + info: + enabled: true + info: + env: + enabled: true + java: + enabled: true + os: + enabled: true + +# 애플리케이션 정보 +info: + app: + name: Discodeit + version: 1.7.0 + description: Spring Boot Application with Actuator + java: + version: 17 + spring: + boot: + version: 3.4.0 + database: + url: ${spring.datasource.url} + driver: ${spring.datasource.driver-class-name} + jpa: + ddl-auto: ${spring.jpa.hibernate.ddl-auto} + storage: + type: ${discodeit.storage.type} + path: ${discodeit.storage.path} + multipart: + max-file-size: ${spring.servlet.multipart.max-file-size} + max-request-size: ${spring.servlet.multipart.max-request-size} +server: + port: 80 diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 4c732d10c..7215d95a3 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,44 +1,84 @@ -server: - port: 8080 - -discodeit: - storage: - type: local - spring: + profiles: + active: dev + application: + name: discodeit + boot: + admin: + client: + instance: + name: discodeit servlet: multipart: - enabled: true - max-file-size: 10MB - max-request-size: 10MB - - # DB 접속 정보 + maxFileSize: 10MB # 파일 하나의 최대 크기 + maxRequestSize: 30MB # 한 번에 최대 업로드 가능 용량 datasource: + driver-class-name: org.postgresql.Driver url: jdbc:postgresql://localhost:5432/discodeit username: discodeit_user password: discodeit1234 - driver-class-name: org.postgresql.Driver - # JPA 설정 jpa: - database: postgresql hibernate: - ddl-auto: none - show-sql: true + ddl-auto: validate properties: hibernate: format_sql: true - highlight_sql: true - use_sql_comments: true - dialect: org.hibernate.dialect.PostgreSQLDialect + # default_batch_fetch_size: 100 open-in-view: false -#로그 설정 + logging: + config: classpath:logback-spring.xml level: - org.hibernate.sql: debug + root: info + org.hibernate.SQL: debug org.hibernate.orm.jdbc.bind: trace -springdoc: - api-docs: - path: /api-docs +# Actuator 설정 +management: + endpoints: + web: + exposure: + include: health,info,metrics,loggers + base-path: /actuator + endpoint: + health: + show-details: always + info: + enabled: true + info: + env: + enabled: true + java: + enabled: true + os: + enabled: true + +# 애플리케이션 정보 +info: + app: + name: Discodeit + version: 1.7.0 + description: Spring Boot Application with Actuator + java: + version: 17 + spring: + boot: + version: 3.4.0 + database: + url: ${spring.datasource.url} + driver: ${spring.datasource.driver-class-name} + jpa: + ddl-auto: ${spring.jpa.hibernate.ddl-auto} + storage: + type: ${discodeit.storage.type} + path: ${discodeit.storage.local.root-path} + multipart: + max-file-size: ${spring.servlet.multipart.max-file-size} + max-request-size: ${spring.servlet.multipart.max-request-size} +discodeit: + storage: + type: local + local: + root-path: .discodeit/storage diff --git a/src/main/resources/fe_bundle_1.2.3.zip b/src/main/resources/fe_bundle_1.2.3.zip new file mode 100644 index 000000000..852a0869c Binary files /dev/null and b/src/main/resources/fe_bundle_1.2.3.zip differ diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 000000000..254c8b86f --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + ${LOG_PATTERN} + + + + + + ${LOG_PATH}/application.log + + ${LOG_PATTERN} + + + + + + ${LOG_PATH}/application.%d{yyyy-MM-dd}.log + + 30 + + 1GB + + + + + + ${LOG_PATH}/error.log + + ERROR + + + ${LOG_PATTERN} + + + ${LOG_PATH}/error.%d{yyyy-MM-dd}.log + 30 + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index b44688477..c0b547f24 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -1,96 +1,136 @@ --- 1. 테이블 삭제 -DROP TABLE IF EXISTS message_attachments CASCADE; -DROP TABLE IF EXISTS read_statuses CASCADE; -DROP TABLE IF EXISTS user_statuses CASCADE; -DROP TABLE IF EXISTS messages CASCADE; -DROP TABLE IF EXISTS users CASCADE; -DROP TABLE IF EXISTS channels CASCADE; -DROP TABLE IF EXISTS binary_contents CASCADE; - - --- 2. 테이블 생성 -CREATE TABLE IF NOT EXISTS binary_contents -( - id uuid PRIMARY KEY, - created_at timestamptz NOT NULL, - file_name varchar(255) NOT NULL, - size bigint NOT NULL, - content_type varchar(100) NOT NULL -); +DROP TABLE IF EXISTS read_statuses; +DROP TABLE IF EXISTS user_statuses; +DROP TABLE IF EXISTS message_attachments; +DROP TABLE IF EXISTS messages; +DROP TABLE IF EXISTS channels; +DROP TABLE IF EXISTS users; +DROP TABLE IF EXISTS binary_contents; -CREATE TABLE IF NOT EXISTS users + + +-- 테이블 +-- User +CREATE TABLE users ( id uuid PRIMARY KEY, - created_at timestamptz NOT NULL, - updated_at timestamptz, - username varchar(50) UNIQUE NOT NULL, - email varchar(100) UNIQUE NOT NULL, - password varchar(60) NOT NULL, - profile_id uuid, - - -- 제약조건 - CONSTRAINT fk_profile_id FOREIGN KEY (profile_id) REFERENCES binary_contents (id) ON DELETE SET NULL + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone, + username varchar(50) UNIQUE NOT NULL, + email varchar(100) UNIQUE NOT NULL, + password varchar(60) NOT NULL, + profile_id uuid ); +-- BinaryContent +CREATE TABLE binary_contents +( + id uuid PRIMARY KEY, + created_at timestamp with time zone NOT NULL, + file_name varchar(255) NOT NULL, + size bigint NOT NULL, + content_type varchar(100) NOT NULL +-- ,bytes bytea NOT NULL +); -CREATE TABLE IF NOT EXISTS user_statuses +-- UserStatus +CREATE TABLE user_statuses ( id uuid PRIMARY KEY, - created_at timestamptz NOT NULL, - updated_at timestamptz, - user_id uuid UNIQUE NOT NULL, - last_active_at timestamptz NOT NULL, - - --제약조건 - CONSTRAINT fk_user_id FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone, + user_id uuid UNIQUE NOT NULL, + last_active_at timestamp with time zone NOT NULL ); +-- Channel CREATE TABLE channels ( - id UUID PRIMARY KEY, - created_at timestamptz NOT NULL, - updated_at timestamptz, + id uuid PRIMARY KEY, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone, name varchar(100), description varchar(500), - type varchar(10) check (type in ('PUBLIC', 'PRIVATE')) + type varchar(10) NOT NULL ); +-- Message CREATE TABLE messages ( - id UUID PRIMARY KEY, - created_at timestamptz NOT NULL, - updated_at timestamptz, + id uuid PRIMARY KEY, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone, content text, - channel_id uuid NOT NULL, - author_id uuid, + channel_id uuid NOT NULL, + author_id uuid +); - --제약조건 - CONSTRAINT fk_channel_id FOREIGN KEY (channel_id) REFERENCES channels (id) ON DELETE CASCADE, - CONSTRAINT fk_author_id FOREIGN KEY (author_id) REFERENCES users (id) ON DELETE SET NULL +-- Message.attachments +CREATE TABLE message_attachments +( + message_id uuid, + attachment_id uuid, + PRIMARY KEY (message_id, attachment_id) ); -CREATE TABLE IF NOT EXISTS read_statuses +-- ReadStatus +CREATE TABLE read_statuses ( id uuid PRIMARY KEY, - created_at timestamptz NOT NULL, - updated_at timestamptz, - user_id uuid NOT NULL, - channel_id uuid NOT NULL, - last_read_at timestamptz not null, - - --제약조건 - UNIQUE (user_id, channel_id), - CONSTRAINT fk_user_id FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, - CONSTRAINT fk_channel_id FOREIGN KEY (channel_id) REFERENCES channels (id) ON DELETE CASCADE + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone, + user_id uuid NOT NULL, + channel_id uuid NOT NULL, + last_read_at timestamp with time zone NOT NULL, + UNIQUE (user_id, channel_id) ); -CREATE TABLE message_attachments -( - message_id uuid, - attachment_id uuid, - --제약조건 - CONSTRAINT fk_message_id FOREIGN KEY (message_id) REFERENCES messages (id) ON DELETE CASCADE, - CONSTRAINT fk_attachment_id FOREIGN KEY (attachment_id) REFERENCES binary_contents (id) ON DELETE CASCADE -) +-- 제약 조건 +-- User (1) -> BinaryContent (1) +ALTER TABLE users + ADD CONSTRAINT fk_user_binary_content + FOREIGN KEY (profile_id) + REFERENCES binary_contents (id) + ON DELETE SET NULL; + +-- UserStatus (1) -> User (1) +ALTER TABLE user_statuses + ADD CONSTRAINT fk_user_status_user + FOREIGN KEY (user_id) + REFERENCES users (id) + ON DELETE CASCADE; + +-- Message (N) -> Channel (1) +ALTER TABLE messages + ADD CONSTRAINT fk_message_channel + FOREIGN KEY (channel_id) + REFERENCES channels (id) + ON DELETE CASCADE; + +-- Message (N) -> Author (1) +ALTER TABLE messages + ADD CONSTRAINT fk_message_user + FOREIGN KEY (author_id) + REFERENCES users (id) + ON DELETE SET NULL; + +-- MessageAttachment (1) -> BinaryContent (1) +ALTER TABLE message_attachments + ADD CONSTRAINT fk_message_attachment_binary_content + FOREIGN KEY (attachment_id) + REFERENCES binary_contents (id) + ON DELETE CASCADE; + +-- ReadStatus (N) -> User (1) +ALTER TABLE read_statuses + ADD CONSTRAINT fk_read_status_user + FOREIGN KEY (user_id) + REFERENCES users (id) + ON DELETE CASCADE; +-- ReadStatus (N) -> User (1) +ALTER TABLE read_statuses + ADD CONSTRAINT fk_read_status_channel + FOREIGN KEY (channel_id) + REFERENCES channels (id) + ON DELETE CASCADE; \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/DiscodeitApplicationTests.java b/src/test/java/com/sprint/mission/discodeit/DiscodeitApplicationTests.java index 7ab8d98cb..3a987a214 100644 --- a/src/test/java/com/sprint/mission/discodeit/DiscodeitApplicationTests.java +++ b/src/test/java/com/sprint/mission/discodeit/DiscodeitApplicationTests.java @@ -6,8 +6,8 @@ @SpringBootTest class DiscodeitApplicationTests { - @Test - void contextLoads() { - } + @Test + void contextLoads() { + } } diff --git a/src/test/java/com/sprint/mission/discodeit/controller/ChannelControllerTest.java b/src/test/java/com/sprint/mission/discodeit/controller/ChannelControllerTest.java new file mode 100644 index 000000000..20c85d0b1 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/controller/ChannelControllerTest.java @@ -0,0 +1,243 @@ +package com.sprint.mission.discodeit.controller; + +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willDoNothing; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.dto.data.ChannelDto; +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.PrivateChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelUpdateRequest; +import com.sprint.mission.discodeit.entity.ChannelType; +import com.sprint.mission.discodeit.service.ChannelService; +import java.time.Instant; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; +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.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(ChannelController.class) +@ActiveProfiles("test") +@DisplayName("ChannelController 테스트") +class ChannelControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private ChannelService channelService; + + @Test + @DisplayName("공개 채널 생성 - 성공") + void createPublicChannel_Success() throws Exception { + // given + PublicChannelCreateRequest request = new PublicChannelCreateRequest( + "Test Channel", + "Test channel description" + ); + + ChannelDto responseDto = new ChannelDto( + UUID.randomUUID(), + ChannelType.PUBLIC, + "Test Channel", + "Test channel description", + Arrays.asList(), + null + ); + + given(channelService.create(request)).willReturn(responseDto); + + // when & then + mockMvc.perform(post("/api/channels/public") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.name").value("Test Channel")) + .andExpect(jsonPath("$.description").value("Test channel description")) + .andExpect(jsonPath("$.type").value("PUBLIC")); + } + + @Test + @DisplayName("공개 채널 생성 - 실패 (유효성 검증 오류)") + void createPublicChannel_ValidationFailure() throws Exception { + // given + PublicChannelCreateRequest invalidRequest = new PublicChannelCreateRequest( + "asdqweaxczxcfsfrkljsertfklshvfujsgefrjykhawgfjhszgchjzsfdvshfvxcchgszgfejhzsfjhkxgvyxdujkfgbzsjhfgbszjhkfgzxjhvg", + "Test description" + ); + + // when & then + mockMvc.perform(post("/api/channels/public") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(invalidRequest))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("비공개 채널 생성 - 성공") + void createPrivateChannel_Success() throws Exception { + // given + PrivateChannelCreateRequest request = new PrivateChannelCreateRequest( + Arrays.asList(UUID.randomUUID(), UUID.randomUUID())); + + List participants = Arrays.asList( + new UserDto(UUID.randomUUID(), "user1", "user1@example.com", null, true), + new UserDto(UUID.randomUUID(), "user2", "user2@example.com", null, false) + ); + + ChannelDto responseDto = new ChannelDto( + UUID.randomUUID(), + ChannelType.PRIVATE, + null, + null, + participants, + null + ); + + given(channelService.create(request)).willReturn(responseDto); + + // when & then + mockMvc.perform(post("/api/channels/private") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.type").value("PRIVATE")) + .andExpect(jsonPath("$.participants").isArray()) + .andExpect(jsonPath("$.participants.length()").value(2)); + } + + @Test + @DisplayName("비공개 채널 생성 - 실패 (빈 참가자 목록)") + void createPrivateChannel_ValidationFailure() throws Exception { + // given + PrivateChannelCreateRequest invalidRequest = new PrivateChannelCreateRequest( + Arrays.asList()); + + // when & then + mockMvc.perform(post("/api/channels/private") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(invalidRequest))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("채널 수정 - 성공") + void updateChannel_Success() throws Exception { + // given + UUID channelId = UUID.randomUUID(); + PublicChannelUpdateRequest request = new PublicChannelUpdateRequest("updatedChannel", + "updated Description"); + + ChannelDto responseDto = new ChannelDto( + channelId, + ChannelType.PUBLIC, + "Updated Channel", + "Updated description", + Arrays.asList(), + null + ); + + given(channelService.update(channelId, request)).willReturn(responseDto); + + // when & then + mockMvc.perform(patch("/api/channels/{channelId}", channelId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(channelId.toString())) + .andExpect(jsonPath("$.name").value("Updated Channel")) + .andExpect(jsonPath("$.description").value("Updated description")); + } + + @Test + @DisplayName("채널 삭제 - 성공") + void deleteChannel_Success() throws Exception { + // given + UUID channelId = UUID.randomUUID(); + willDoNothing().given(channelService).delete(channelId); + + // when & then + mockMvc.perform(delete("/api/channels/{channelId}", channelId)) + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("사용자별 채널 목록 조회 - 성공") + void findAllChannelsByUserId_Success() throws Exception { + // given + UUID userId = UUID.randomUUID(); + List channels = Arrays.asList( + new ChannelDto( + UUID.randomUUID(), + ChannelType.PUBLIC, + "Channel 1", + "Description 1", + Arrays.asList(), + Instant.now().minusSeconds(3600) + ), + new ChannelDto( + UUID.randomUUID(), + ChannelType.PRIVATE, + null, + null, + Arrays.asList( + new UserDto(UUID.randomUUID(), "user1", "user1@example.com", null, true), + new UserDto(userId, "currentUser", "current@example.com", null, true) + ), + Instant.now().minusSeconds(1800) + ) + ); + + given(channelService.findAllByUserId(userId)).willReturn(channels); + + // when & then + mockMvc.perform(get("/api/channels") + .param("userId", userId.toString())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$.length()").value(2)) + .andExpect(jsonPath("$[0].name").value("Channel 1")) + .andExpect(jsonPath("$[0].type").value("PUBLIC")) + .andExpect(jsonPath("$[1].type").value("PRIVATE")); + } + + @Test + @DisplayName("사용자별 채널 목록 조회 - 빈 결과") + void findAllChannelsByUserId_Empty() throws Exception { + // given + UUID userId = UUID.randomUUID(); + given(channelService.findAllByUserId(userId)).willReturn(Arrays.asList()); + + // when & then + mockMvc.perform(get("/api/channels") + .param("userId", userId.toString())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$.length()").value(0)); + } + + @Test + @DisplayName("사용자별 채널 목록 조회 - 실패 (userId 파라미터 누락)") + void findAllChannelsByUserId_MissingParameter() throws Exception { + // when & then + mockMvc.perform(get("/api/channels")) + .andExpect(status().isBadRequest()); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/controller/MessageControllerTest.java b/src/test/java/com/sprint/mission/discodeit/controller/MessageControllerTest.java new file mode 100644 index 000000000..a614b2179 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/controller/MessageControllerTest.java @@ -0,0 +1,168 @@ +package com.sprint.mission.discodeit.controller; + + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.dto.data.MessageDto; +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.MessageCreateRequest; +import com.sprint.mission.discodeit.dto.response.PageResponse; +import com.sprint.mission.discodeit.service.MessageService; +import java.time.Instant; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; +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.data.domain.Pageable; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(MessageController.class) +@ActiveProfiles("test") +@DisplayName("MessageController 테스트") +public class MessageControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private MessageService messageService; + + @Test + @DisplayName("메세지 생성 - 성공") + void createMessage_Success() throws Exception { + //Given + MessageCreateRequest request = new MessageCreateRequest( + "Test Message", + UUID.randomUUID(), + UUID.randomUUID() + ); + + MessageDto responseDto = new MessageDto( + UUID.randomUUID(), + Instant.now(), + null, + request.content(), + UUID.randomUUID(), + new UserDto(UUID.randomUUID(), "Test User", "Test Email", null, true), + null + ); + + MockMultipartFile requestPart = new MockMultipartFile( + "messageCreateRequest", + "", + MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(request) + ); + + given(messageService.create(eq(request), any(List.class))).willReturn(responseDto); + //When & Then + mockMvc.perform(multipart("/api/messages") + .file(requestPart) + .contentType(MediaType.MULTIPART_FORM_DATA)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.content").value("Test Message")); + } + + @Test + @DisplayName("메세지 생성 - 유효성 검사 실패") + void createMessage_ValidationFailure() throws Exception { + //Given + MessageCreateRequest request = new MessageCreateRequest( + "", + UUID.randomUUID(), + UUID.randomUUID() + ); + + MessageDto responseDto = new MessageDto( + UUID.randomUUID(), + Instant.now(), + null, + request.content(), + UUID.randomUUID(), + new UserDto(UUID.randomUUID(), "Test User", "Test Email", null, true), + null + ); + + MockMultipartFile requestPart = new MockMultipartFile( + "messageCreateRequest", + "", + MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(request) + ); + + given(messageService.create(eq(request), any(List.class))).willReturn(responseDto); + //When & Then + mockMvc.perform(multipart("/api/messages") + .file(requestPart) + .contentType(MediaType.MULTIPART_FORM_DATA)) + .andExpect(status().isBadRequest()); + } + + + @Test + @DisplayName("채널별 메시지 조회 - 성공 (커서 포함)") + void findAllMessagesByChannelId_WithCursor_Success() throws Exception { + // given + UUID channelId = UUID.randomUUID(); + Instant cursor = Instant.now(); + + UserDto author = new UserDto(UUID.randomUUID(), "user1", "user1@example.com", null, true); + + List messages = Arrays.asList( + new MessageDto( + UUID.randomUUID(), + cursor.minusSeconds(3600), + cursor.minusSeconds(3600), + "Older message", + channelId, + author, + Arrays.asList() + ) + ); + + PageResponse pageResponse = new PageResponse(messages, + Instant.now(), 10, + true, 1L); + + given(messageService.findAllByChannelId(eq(channelId), eq(cursor), any(Pageable.class))) + .willReturn(pageResponse); + + // when & then + mockMvc.perform(get("/api/messages") + .param("channelId", channelId.toString()) + .param("cursor", cursor.toString())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content.length()").value(1)) + .andExpect(jsonPath("$.content[0].content").value("Older message")) + .andExpect(jsonPath("$.hasNext").value(true)); + } + + + @Test + @DisplayName("채널별 메시지 조회 - 실패(channelId 누락)") + void findAllMessagesByChannelId_MissingParameter() throws Exception { + + //when && then + mockMvc.perform(get("/api/messages")) + .andExpect(status().isBadRequest()); + + } +} diff --git a/src/test/java/com/sprint/mission/discodeit/controller/UserControllerTest.java b/src/test/java/com/sprint/mission/discodeit/controller/UserControllerTest.java new file mode 100644 index 000000000..fb4e83953 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/controller/UserControllerTest.java @@ -0,0 +1,174 @@ +package com.sprint.mission.discodeit.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willDoNothing; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +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 com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.dto.data.BinaryContentDto; +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.data.UserStatusDto; +import com.sprint.mission.discodeit.dto.request.UserCreateRequest; +import com.sprint.mission.discodeit.dto.request.UserStatusUpdateRequest; +import com.sprint.mission.discodeit.dto.request.UserUpdateRequest; +import com.sprint.mission.discodeit.service.UserService; +import com.sprint.mission.discodeit.service.UserStatusService; +import java.time.Instant; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +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.mock.web.MockMultipartFile; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(UserController.class) +@ActiveProfiles("test") +@DisplayName("UserController 테스트") +class UserControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private UserService userService; + + @MockitoBean + private UserStatusService userStatusService; + + @Test + @DisplayName("사용자 생성 - 성공") + void createUser_Success() throws Exception { + // given + UserCreateRequest request = new UserCreateRequest( + "testuser", + "test@example.com", + "password123" + ); + + UserDto responseDto = new UserDto( + UUID.randomUUID(), + "testuser", + "test@example.com", + null, // profile + true // online + ); + + given(userService.create(any(UserCreateRequest.class), any(Optional.class))) + .willReturn(responseDto); + + MockMultipartFile requestPart = new MockMultipartFile( + "userCreateRequest", + "", + MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(request) + ); + + // when & then + mockMvc.perform(multipart("/api/users") + .file(requestPart) + .contentType(MediaType.MULTIPART_FORM_DATA)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.username").value("testuser")) + .andExpect(jsonPath("$.email").value("test@example.com")) + .andExpect(jsonPath("$.online").value(true)); + } + + @Test + @DisplayName("사용자 생성 - 실패 (유효성 검증 오류)") + void createUser_ValidationFailure() throws Exception { + // given + UserCreateRequest invalidRequest = new UserCreateRequest( + "", + "invalid-email", + "123" + ); + + MockMultipartFile requestPart = new MockMultipartFile( + "userCreateRequest", + "", + MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(invalidRequest) + ); + + // when & then + mockMvc.perform(multipart("/api/users") + .file(requestPart) + .contentType(MediaType.MULTIPART_FORM_DATA)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("사용자 수정 - 성공") + void updateUser_Success() throws Exception { + // given + UUID userId = UUID.randomUUID(); + + UserUpdateRequest request = new UserUpdateRequest( + "updateduser", + "updated@example.com", + "updatedpassword123" + ); + + UserDto responseDto = new UserDto( + userId, + "updateduser", + "updated@example.com", + null, + true + ); + + given(userService.update(eq(userId), eq(request), any(Optional.class))) + .willReturn(responseDto); + + MockMultipartFile requestPart = new MockMultipartFile( + "userUpdateRequest", + "", + MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(request) + ); + + // when & then + mockMvc.perform(multipart("/api/users/{userId}", userId) + .file(requestPart) + .with(req -> { + req.setMethod("PATCH"); + return req; + }) + .contentType(MediaType.MULTIPART_FORM_DATA)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(userId.toString())) + .andExpect(jsonPath("$.username").value("updateduser")) + .andExpect(jsonPath("$.email").value("updated@example.com")); + } + + @Test + @DisplayName("사용자 상태 수정 - 실패 (유효성 검증 오류)") + void updateUserStatus_ValidationFailure() throws Exception { + // given + UUID userId = UUID.randomUUID(); + UserStatusUpdateRequest invalidRequest = new UserStatusUpdateRequest(null); + + // when & then + mockMvc.perform(patch("/api/users/{userId}/userStatus", userId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(invalidRequest))) + .andExpect(status().isBadRequest()); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/integration/ChannelIntegrationTest.java b/src/test/java/com/sprint/mission/discodeit/integration/ChannelIntegrationTest.java new file mode 100644 index 000000000..cf67e09f8 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/integration/ChannelIntegrationTest.java @@ -0,0 +1,251 @@ +package com.sprint.mission.discodeit.integration; + + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.sprint.mission.discodeit.controller.ChannelController; +import com.sprint.mission.discodeit.dto.data.ChannelDto; +import com.sprint.mission.discodeit.dto.request.PrivateChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelUpdateRequest; +import com.sprint.mission.discodeit.entity.Channel; +import com.sprint.mission.discodeit.entity.ChannelType; +import com.sprint.mission.discodeit.entity.ReadStatus; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.entity.UserStatus; +import com.sprint.mission.discodeit.exception.channel.ChannelNotFoundException; +import com.sprint.mission.discodeit.exception.channel.PrivateChannelUpdateException; +import com.sprint.mission.discodeit.mapper.ChannelMapper; +import com.sprint.mission.discodeit.repository.ChannelRepository; +import com.sprint.mission.discodeit.repository.ReadStatusRepository; +import com.sprint.mission.discodeit.repository.UserRepository; +import com.sprint.mission.discodeit.repository.UserStatusRepository; +import com.sprint.mission.discodeit.service.ChannelService; +import java.time.Instant; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +@ActiveProfiles("test") +@SpringBootTest +@DisplayName("채널 통합 테스트") +@Transactional +public class ChannelIntegrationTest { + + @Autowired + private ChannelRepository channelRepository; + + @Autowired + private ChannelService channelService; + + @Autowired + private ChannelController channelController; + + @Autowired + private ChannelMapper channelMapper; + + @Autowired + private UserRepository userRepository; + + @Autowired + private ReadStatusRepository readStatusRepository; + + @Test + @DisplayName("공개채널을 생성할 수 있어야 한다") + void createPublicChannel_Success() { + //given + PublicChannelCreateRequest request = new PublicChannelCreateRequest("Test Channel", + "Test Channel Description"); + + //when + ChannelDto result = channelController.create(request).getBody(); + + //then + assertThat(result).isNotNull(); + assertThat(result.type()).isEqualTo(ChannelType.PUBLIC); + assertThat(result.name()).isEqualTo("Test Channel"); + assertThat(result.description()).isEqualTo("Test Channel Description"); + assertThat(result.id()).isNotNull(); + + // Database 검증 + assertThat(channelRepository.count()).isEqualTo(1); + Channel savedChannel = channelRepository.findAll().get(0); + assertThat(savedChannel.getName()).isEqualTo("Test Channel"); + assertThat(savedChannel.getDescription()).isEqualTo("Test Channel Description"); + assertThat(savedChannel.getType()).isEqualTo(ChannelType.PUBLIC); + } + + @Test + @DisplayName("비공개채널을 생성할 수 있어야 한다") + void createPrivateChannel_Success() { + //given + User user1 = new User("user1", "EMAIL1", "password1", null); + User user2 = new User("user2", "EMAIL2", "password2", null); + UserStatus status1 = new UserStatus(user1, Instant.now()); + UserStatus status2 = new UserStatus(user2, Instant.now()); + + userRepository.save(user1); + userRepository.save(user2); + PrivateChannelCreateRequest request = new PrivateChannelCreateRequest(Arrays.asList( + user1.getId(), user2.getId())); + + //when + ChannelDto result = channelController.create(request).getBody(); + + //then + assertThat(result).isNotNull(); + assertThat(result.type()).isEqualTo(ChannelType.PRIVATE); + + // Database 검증 + assertThat(channelRepository.count()).isEqualTo(1); + Channel savedChannel = channelRepository.findAll().get(0); + assertThat(savedChannel.getType()).isEqualTo(ChannelType.PRIVATE); + + assertThat(readStatusRepository.count()).isEqualTo(2); + List readStatuses = readStatusRepository.findAllByChannelIdWithUser( + savedChannel.getId()); + assertThat(readStatuses).hasSize(2); + List actualParticipantIds = readStatuses.stream() + .map(rs -> rs.getUser().getId()) + .toList(); + assertThat(actualParticipantIds).containsExactlyInAnyOrderElementsOf(Arrays.asList( + user1.getId(), user2.getId())); + } + + @Test + @DisplayName("비공개 채널 생성할때 존재하지 않는 사용자를 포함하면 생성되지 않아야 한다") + @Transactional + void createPrivateChannel_NonExistentUser_Fail() { + // Given - 일부 사용자만 생성 + User user1 = new User("user1", "user1@example.com", "password1", null); + UserStatus userStatus = new UserStatus(user1, Instant.now()); + userRepository.save(user1); + + UUID nonExistentUserId = UUID.randomUUID(); + List participantIds = Arrays.asList(user1.getId(), nonExistentUserId); + PrivateChannelCreateRequest request = new PrivateChannelCreateRequest(participantIds); + + // Database 검증 - 채널이 생성되지 않았는지 확인 + assertThat(channelRepository.count()).isEqualTo(0); + assertThat(readStatusRepository.count()).isEqualTo(0); + } + + @Test + @DisplayName("공개 채널을 수정할 수 있어야 한다") + @Transactional + void updatePublicChannel_Success() { + // Given - 기존 공개 채널 생성 + Channel channel = new Channel(ChannelType.PUBLIC, "old-name", "old description"); + channelRepository.save(channel); + UUID channelId = channel.getId(); + + PublicChannelUpdateRequest request = new PublicChannelUpdateRequest( + "new-name", + "new description" + ); + + // When + ChannelDto result = channelController.update(channelId, request).getBody(); + + // Then + assertThat(result).isNotNull(); + assertThat(result.id()).isEqualTo(channelId); + assertThat(result.name()).isEqualTo("new-name"); + assertThat(result.description()).isEqualTo("new description"); + assertThat(result.type()).isEqualTo(ChannelType.PUBLIC); + + // Database 검증 + Channel updatedChannel = channelRepository.findById(channelId).orElse(null); + assertThat(updatedChannel).isNotNull(); + assertThat(updatedChannel.getName()).isEqualTo("new-name"); + assertThat(updatedChannel.getDescription()).isEqualTo("new description"); + } + + @Test + @DisplayName("공개 채널 수정시 존재하지 않는 채널을 수정시도할때 실패해야한다") + @Transactional + void updatePublicChannel_NotFound_Fail() { + // Given - 존재하지 않는 채널 ID + UUID nonExistentChannelId = UUID.randomUUID(); + PublicChannelUpdateRequest request = new PublicChannelUpdateRequest( + "new-name", + "new description" + ); + + // When & Then + assertThatThrownBy(() -> channelController.update(nonExistentChannelId, request)) + .isInstanceOf(ChannelNotFoundException.class); + + // Database 검증 + assertThat(channelRepository.count()).isEqualTo(0); + } + + @Test + @DisplayName("비공개 채널 수정 시도시 예외를 출력해야한다.") + @Transactional + void updatePrivateChannel_Forbidden_Fail() { + // Given - 비공개 채널 생성 + Channel privateChannel = new Channel(ChannelType.PRIVATE, null, null); + channelRepository.save(privateChannel); + UUID channelId = privateChannel.getId(); + + PublicChannelUpdateRequest request = new PublicChannelUpdateRequest( + "new-name", + "new description" + ); + + // When & Then + assertThatThrownBy(() -> channelController.update(channelId, request)) + .isInstanceOf(PrivateChannelUpdateException.class); + + // Database 검증 - 채널이 변경되지 않았는지 확인 + Channel unchangedChannel = channelRepository.findById(channelId).orElse(null); + assertThat(unchangedChannel).isNotNull(); + assertThat(unchangedChannel.getType()).isEqualTo(ChannelType.PRIVATE); + assertThat(unchangedChannel.getName()).isNull(); + assertThat(unchangedChannel.getDescription()).isNull(); + } + + @Test + @DisplayName("채널 삭제를 할 수 있어야 한다") + @Transactional + void deleteChannel_Success() { + // Given - 테스트 채널 생성 + Channel channel = new Channel(ChannelType.PUBLIC, "test-channel", "test description"); + channelRepository.save(channel); + UUID channelId = channel.getId(); + + // When + channelController.delete(channelId); + + // Then - Database 검증 + assertThat(channelRepository.count()).isEqualTo(0); + assertThat(channelRepository.findById(channelId)).isEmpty(); + } + + @Test + @DisplayName("존재하지 않는 채널을 삭제 시도시 예외가 발생해야 한다") + @Transactional + void deleteChannel_NotFound_Fail() { + // Given - 존재하지 않는 채널 ID + UUID nonExistentChannelId = UUID.randomUUID(); + + // When & Then + assertThatThrownBy(() -> channelService.delete(nonExistentChannelId)) + .isInstanceOf(ChannelNotFoundException.class); + + // Database 검증 + assertThat(channelRepository.count()).isEqualTo(0); + } +} + + diff --git a/src/test/java/com/sprint/mission/discodeit/integration/MessageIntegrationTest.java b/src/test/java/com/sprint/mission/discodeit/integration/MessageIntegrationTest.java new file mode 100644 index 000000000..2325cc3f0 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/integration/MessageIntegrationTest.java @@ -0,0 +1,241 @@ +package com.sprint.mission.discodeit.integration; + + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.sprint.mission.discodeit.controller.MessageController; +import com.sprint.mission.discodeit.dto.data.MessageDto; +import com.sprint.mission.discodeit.dto.request.MessageCreateRequest; +import com.sprint.mission.discodeit.dto.response.PageResponse; +import com.sprint.mission.discodeit.entity.Channel; +import com.sprint.mission.discodeit.entity.ChannelType; +import com.sprint.mission.discodeit.entity.Message; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.entity.UserStatus; +import com.sprint.mission.discodeit.exception.channel.ChannelNotFoundException; +import com.sprint.mission.discodeit.exception.message.MessageNotFoundException; +import com.sprint.mission.discodeit.mapper.MessageMapper; +import com.sprint.mission.discodeit.mapper.PageResponseMapper; +import com.sprint.mission.discodeit.repository.BinaryContentRepository; +import com.sprint.mission.discodeit.repository.ChannelRepository; +import com.sprint.mission.discodeit.repository.MessageRepository; +import com.sprint.mission.discodeit.repository.UserRepository; +import com.sprint.mission.discodeit.service.MessageService; +import com.sprint.mission.discodeit.storage.BinaryContentStorage; +import java.time.Instant; +import java.util.Collections; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +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.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@DisplayName("메세지 통합 테스트") +@ActiveProfiles("test") +@Transactional +public class MessageIntegrationTest { + + @Autowired + private MessageService messageService; + + @Autowired + private MessageController messageController; + + @Autowired + private MessageRepository messageRepository; + + @Autowired + private ChannelRepository channelRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private MessageMapper messageMapper; + + @Autowired + private BinaryContentStorage binaryContentStorage; + + @Autowired + private BinaryContentRepository binaryContentRepository; + + @Autowired + private PageResponseMapper pageResponseMapper; + + + @Test + @DisplayName("메세지를 생성 할 수 있어야 한다") + @Transactional + void createMessage_Success() { + // Given - 사용자와 채널 생성 + User user = new User("testuser", "test@example.com", "password", null); + UserStatus userStatus = new UserStatus(user, Instant.now()); + userRepository.save(user); + + Channel channel = new Channel(ChannelType.PUBLIC, "test-channel", "test description"); + channelRepository.save(channel); + + MessageCreateRequest request = new MessageCreateRequest( + "Hello, world!", + channel.getId(), + user.getId() + ); + + // When + MessageDto result = messageController.create(request, Collections.emptyList()).getBody(); + + // Then + assertThat(result).isNotNull(); + assertThat(result.content()).isEqualTo("Hello, world!"); + assertThat(result.channelId()).isEqualTo(channel.getId()); + assertThat(result.author().id()).isEqualTo(user.getId()); + assertThat(result.author().username()).isEqualTo("testuser"); + assertThat(result.id()).isNotNull(); + assertThat(result.createdAt()).isNotNull(); + assertThat(result.attachments()).isEmpty(); + + // Database 검증 + assertThat(messageRepository.count()).isEqualTo(1); + Message savedMessage = messageRepository.findAll().get(0); + assertThat(savedMessage.getContent()).isEqualTo("Hello, world!"); + assertThat(savedMessage.getChannel().getId()).isEqualTo(channel.getId()); + assertThat(savedMessage.getAuthor().getId()).isEqualTo(user.getId()); + } + + @Test + @DisplayName("메시지 생성할때 존재하지 않는 채널로 생성하면 실패해야한다") + @Transactional + void createMessage_ChannelNotFound_Fail() { + // Given - 사용자만 생성 (채널은 생성하지 않음) + User user = new User("testuser", "test@example.com", "password", null); + UserStatus userStatus = new UserStatus(user, Instant.now()); + userRepository.save(user); + + UUID nonExistentChannelId = UUID.randomUUID(); + MessageCreateRequest request = new MessageCreateRequest( + "Hello, world!", + nonExistentChannelId, + user.getId() + ); + + // When & Then + assertThatThrownBy(() -> messageController.create(request, Collections.emptyList())) + .isInstanceOf(ChannelNotFoundException.class); + + // Database 검증 + assertThat(messageRepository.count()).isEqualTo(0); + } + + @Test + @DisplayName("메시지 목록 조회를 할 수 있어야한다") + @Transactional + void findMessagesByChannelId_Success() { + // Given - 사용자, 채널, 메시지들 생성 + User user = new User("testuser", "test@example.com", "password", null); + UserStatus userStatus = new UserStatus(user, Instant.now()); + userRepository.save(user); + + Channel channel = new Channel(ChannelType.PUBLIC, "test-channel", "test description"); + channelRepository.save(channel); + + Message message1 = new Message("First message", channel, user, Collections.emptyList()); + Message message2 = new Message("Second message", channel, user, Collections.emptyList()); + messageRepository.save(message1); + messageRepository.save(message2); + + Pageable pageable = PageRequest.of(0, 10); + + // When + PageResponse result = messageController.findAllByChannelId( + channel.getId(), + Instant.now().plusSeconds(60), + pageable + ).getBody(); + + // Then + assertThat(result).isNotNull(); + assertThat(result.content()) + .extracting(MessageDto::content) + .containsExactlyInAnyOrder("First message", "Second message"); + assertThat(result.content().get(0).author().username()).isEqualTo("testuser"); + + // Database 검증 + assertThat(messageRepository.count()).isEqualTo(2); + } + + @Test + @DisplayName("메세지 목록을 조회할때 메세지가 없으면 빈 목록을 리턴한다") + @Transactional + void findMessagesByChannelId_EmptyList() { + // Given - 채널만 생성 (메시지는 없음) + Channel channel = new Channel(ChannelType.PUBLIC, "test-channel", "test description"); + channelRepository.save(channel); + + Pageable pageable = PageRequest.of(0, 10); + + // When + PageResponse result = messageController.findAllByChannelId( + channel.getId(), + Instant.now(), + pageable + ).getBody(); + + // Then + assertThat(result).isNotNull(); + assertThat(result.content()).isEmpty(); + assertThat(result.hasNext()).isFalse(); + + // Database 검증 + assertThat(messageRepository.count()).isEqualTo(0); + } + + @Test + @DisplayName("메시지 삭제시 성공해야 한다") + @Transactional + void deleteMessage_Success() { + // Given - 사용자, 채널, 메시지 생성 + User user = new User("testuser", "test@example.com", "password", null); + UserStatus userStatus = new UserStatus(user, Instant.now()); + userRepository.save(user); + + Channel channel = new Channel(ChannelType.PUBLIC, "test-channel", "test description"); + channelRepository.save(channel); + + Message message = new Message("Test message", channel, user, Collections.emptyList()); + messageRepository.save(message); + UUID messageId = message.getId(); + + // 삭제 전 검증 + assertThat(messageRepository.count()).isEqualTo(1); + assertThat(messageRepository.findById(messageId)).isPresent(); + + // When + messageController.delete(messageId); + + // Then - Database 검증 + assertThat(messageRepository.count()).isEqualTo(0); + assertThat(messageRepository.findById(messageId)).isEmpty(); + } + + @Test + @DisplayName("존재하지 않는 메시지 삭제시도시 예외를 출력해야 한다 ") + @Transactional + void deleteMessage_NotFound_Fail() { + // Given - 존재하지 않는 메시지 ID + UUID nonExistentMessageId = UUID.randomUUID(); + + // When & Then + assertThatThrownBy(() -> messageController.delete(nonExistentMessageId)) + .isInstanceOf(MessageNotFoundException.class); + + // Database 검증 + assertThat(messageRepository.count()).isEqualTo(0); + } + +} diff --git a/src/test/java/com/sprint/mission/discodeit/integration/UserIntegrationTest.java b/src/test/java/com/sprint/mission/discodeit/integration/UserIntegrationTest.java new file mode 100644 index 000000000..3fe0099d7 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/integration/UserIntegrationTest.java @@ -0,0 +1,216 @@ +package com.sprint.mission.discodeit.integration; + + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; + +import com.sprint.mission.discodeit.controller.UserController; +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.UserCreateRequest; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.entity.UserStatus; +import com.sprint.mission.discodeit.exception.user.UserAlreadyExistException; +import com.sprint.mission.discodeit.exception.user.UserNotFoundException; +import com.sprint.mission.discodeit.mapper.UserMapper; +import com.sprint.mission.discodeit.repository.UserRepository; +import com.sprint.mission.discodeit.repository.UserStatusRepository; +import com.sprint.mission.discodeit.service.UserService; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@ActiveProfiles("test") +@DisplayName("사용자 통합 테스트") +@Transactional +public class UserIntegrationTest { + + @Autowired + private UserRepository userRepository; + + @Autowired + private UserStatusRepository userStatusRepository; + + @Autowired + private UserService userService; + + @Autowired + private UserController userController; + + @Autowired + private UserMapper userMapper; + + @Test + @DisplayName("사용자를 생성할 수 있어야한다") + @Transactional + void createUser_Success() { + // Given + UserCreateRequest request = new UserCreateRequest( + "testuser", + "test@example.com", + "password123" + ); + + // When + ResponseEntity userDtoResponseEntity = userController.create(request, null); + UserDto result = userDtoResponseEntity.getBody(); + + // Then + assertThat(result).isNotNull(); + assertThat(result.username()).isEqualTo("testuser"); + assertThat(result.email()).isEqualTo("test@example.com"); + assertThat(result.id()).isNotNull(); + + // Database 검증 + assertThat(userRepository.count()).isEqualTo(1); + User savedUser = userRepository.findByUsername("testuser").orElse(null); + assertThat(savedUser).isNotNull(); + assertThat(savedUser.getUsername()).isEqualTo("testuser"); + assertThat(savedUser.getEmail()).isEqualTo("test@example.com"); + assertThat(savedUser.getPassword()).isEqualTo("password123"); + + } + + @Test + @DisplayName("중복된 사용자명으로 사용자생성을 시도하면 예외를 호출해야 한다") + @Transactional + void createUser_DuplicateUsername_Fail() { + // Given - 기존 사용자 생성 + User existingUser = new User("testuser", "existing@example.com", "password", null); + UserStatus userStatus = new UserStatus(existingUser, Instant.now()); + userRepository.save(existingUser); + + UserCreateRequest request = new UserCreateRequest( + "testuser", // 중복된 사용자명 + "new@example.com", + "password123" + ); + + // When & Then + assertThatThrownBy(() -> userController.create(request, null)) + .isInstanceOf(UserAlreadyExistException.class); + + // Database 검증 - 기존 사용자만 존재해야 함 + assertThat(userRepository.count()).isEqualTo(1); + assertThat(userRepository.existsByUsername("testuser")).isTrue(); + assertThat(userRepository.existsByEmail("new@example.com")).isFalse(); + } + + @Test + @DisplayName("중복된 이메일이 있을시 사용자 생성이 실패해야 한다") + @Transactional + void createUser_DuplicateEmail_Fail() { + // Given - 기존 사용자 생성 + User existingUser = new User("existinguser", "test@example.com", "password", null); + UserStatus userStatus = new UserStatus(existingUser, Instant.now()); + userRepository.save(existingUser); + + UserCreateRequest request = new UserCreateRequest( + "newuser", + "test@example.com", // 중복된 이메일 + "password123" + ); + + // When & Then + assertThatThrownBy(() -> userController.create(request, null)) + .isInstanceOf(UserAlreadyExistException.class); + + // Database 검증 - 기존 사용자만 존재해야 함 + assertThat(userRepository.count()).isEqualTo(1); + assertThat(userRepository.existsByEmail("test@example.com")).isTrue(); + assertThat(userRepository.existsByUsername("newuser")).isFalse(); + } + + @Test + @DisplayName("사용자 목록 조회를 할 수 있어야 한다") + @Transactional + void findAllUsers_Success() { + // Given - 테스트 사용자들 생성 + User user1 = new User("user1", "user1@example.com", "password1", null); + User user2 = new User("user2", "user2@example.com", "password2", null); + + UserStatus status1 = new UserStatus(user1, Instant.now()); + UserStatus status2 = new UserStatus(user2, Instant.now()); + + userRepository.save(user1); + userRepository.save(user2); + + // When + ResponseEntity> all = userController.findAll(); + List result = all.getBody(); + + // Then + assertThat(result).hasSize(2); + assertThat(result).extracting(UserDto::username) + .containsExactlyInAnyOrder("user1", "user2"); + assertThat(result).extracting(UserDto::email) + .containsExactlyInAnyOrder("user1@example.com", "user2@example.com"); + + // Database 검증 + assertThat(userRepository.count()).isEqualTo(2); + } + + @Test + @DisplayName("사용자 목록 조회를 할때 사용자가 없다면 빈 리스트을 출력해야한다") + @Transactional + void findAllUsers_EmptyList() { + // Given - 사용자가 없는 상태 + + // When + ResponseEntity> all = userController.findAll(); + List result = all.getBody(); + + // Then + assertThat(result).isEmpty(); + + // Database 검증 + assertThat(userRepository.count()).isEqualTo(0); + } + + @Test + @DisplayName("사용자 삭제를 삭제 할 수 있어야 한다") + @Transactional + void deleteUser_Success() { + // Given - 테스트 사용자 생성 + User user = new User("testuser", "test@example.com", "password", null); + UserStatus userStatus = new UserStatus(user, Instant.now()); + userRepository.save(user); + UUID userId = user.getId(); + + // 삭제 전 검증 + assertThat(userRepository.count()).isEqualTo(1); + assertThat(userRepository.findById(userId)).isPresent(); + + // When + userController.delete(userId); + + // Then - Database 검증 + assertThat(userRepository.count()).isEqualTo(0); + assertThat(userRepository.findById(userId)).isEmpty(); + } + + @Test + @DisplayName("존재하지 않는 사용자를 삭제 할 경우 예외를 호출해야한다") + @Transactional + void deleteUser_NotFound_Fail() { + // Given - 존재하지 않는 사용자 ID + UUID nonExistentUserId = UUID.randomUUID(); + + // When & Then + assertThatThrownBy(() -> userController.delete(nonExistentUserId)) + .isInstanceOf(UserNotFoundException.class); + + // Database 검증 + assertThat(userRepository.count()).isEqualTo(0); + } + +} + diff --git a/src/test/java/com/sprint/mission/discodeit/repository/ChannelRepositoryTest.java b/src/test/java/com/sprint/mission/discodeit/repository/ChannelRepositoryTest.java new file mode 100644 index 000000000..042389616 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/repository/ChannelRepositoryTest.java @@ -0,0 +1,154 @@ +package com.sprint.mission.discodeit.repository; + + +import static org.assertj.core.api.Assertions.assertThat; + +import com.sprint.mission.discodeit.entity.Channel; +import com.sprint.mission.discodeit.entity.ChannelType; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; +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.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.test.context.ActiveProfiles; + +@DataJpaTest +@ActiveProfiles("test") +@EnableJpaAuditing +@DisplayName("ChannelRepository 테스트") +class ChannelRepositoryTest { + + @Autowired + private TestEntityManager entityManager; + + @Autowired + private ChannelRepository channelRepository; + + private Channel publicChannel1; + private Channel publicChannel2; + private Channel privateChannel1; + private Channel privateChannel2; + + @BeforeEach + void setUp() { + publicChannel1 = new Channel(ChannelType.PUBLIC, "Public Channel 1", + "Public channel description 1"); + publicChannel2 = new Channel(ChannelType.PUBLIC, "Public Channel 2", + "Public channel description 2"); + privateChannel1 = new Channel(ChannelType.PRIVATE, "Private Channel 1", + "Private channel description 1"); + privateChannel2 = new Channel(ChannelType.PRIVATE, "Private Channel 2", + "Private channel description 2"); + + entityManager.persistAndFlush(publicChannel1); + entityManager.persistAndFlush(publicChannel2); + entityManager.persistAndFlush(privateChannel1); + entityManager.persistAndFlush(privateChannel2); + } + + @Test + @DisplayName("타입 또는 ID 목록으로 채널 조회 - 성공 (타입으로 조회)") + void findAllByTypeOrIdIn_ByType_Success() { + // given + List emptyIds = Arrays.asList(); + + // when + List result = channelRepository.findAllByTypeOrIdIn(ChannelType.PUBLIC, emptyIds); + + // then + assertThat(result).hasSize(2); + assertThat(result).extracting(Channel::getName) + .contains("Public Channel 1", "Public Channel 2"); + assertThat(result).allMatch(channel -> channel.getType() == ChannelType.PUBLIC); + } + + @Test + @DisplayName("타입 또는 ID 목록으로 채널 조회 - 성공 (ID 목록으로 조회)") + void findAllByTypeOrIdIn_ByIds_Success() { + // given + List channelIds = Arrays.asList( + privateChannel1.getId(), + privateChannel2.getId() + ); + + // when + List result = channelRepository.findAllByTypeOrIdIn(ChannelType.PUBLIC, + channelIds); + + // then + assertThat(result).hasSize(4); // PUBLIC 채널 2개 + 지정된 ID 채널 2개 + assertThat(result).extracting(Channel::getName) + .contains( + "Public Channel 1", + "Public Channel 2", + "Private Channel 1", + "Private Channel 2" + ); + } + + @Test + @DisplayName("타입 또는 ID 목록으로 채널 조회 - 성공 (특정 ID만 조회)") + void findAllByTypeOrIdIn_SpecificIds_Success() { + // given + List channelIds = Arrays.asList(privateChannel1.getId()); + + // when + List result = channelRepository.findAllByTypeOrIdIn(ChannelType.PRIVATE, + channelIds); + + // then + assertThat(result).hasSize(2); // PRIVATE 채널 2개 + assertThat(result).extracting(Channel::getName) + .contains("Private Channel 1", "Private Channel 2"); + } + + @Test + @DisplayName("타입 또는 ID 목록으로 채널 조회 - 실패 (존재하지 않는 타입과 ID)") + void findAllByTypeOrIdIn_NotFound() { + // given + List nonExistentIds = Arrays.asList(UUID.randomUUID()); + + // when + List result = channelRepository.findAllByTypeOrIdIn(null, nonExistentIds); + + // then + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("타입 또는 ID 목록으로 채널 조회 - 빈 결과 (빈 조건)") + void findAllByTypeOrIdIn_EmptyConditions() { + // given + List emptyIds = Arrays.asList(); + + // when - null 타입과 빈 ID 목록 + List result = channelRepository.findAllByTypeOrIdIn(null, emptyIds); + + // then + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("타입 또는 ID 목록으로 채널 조회 - 중복 제거 확인") + void findAllByTypeOrIdIn_DuplicateRemoval() { + // given - PUBLIC 채널의 ID를 명시적으로 포함 + List channelIds = Arrays.asList( + publicChannel1.getId(), + publicChannel2.getId() + ); + + // when - PUBLIC 타입과 PUBLIC 채널 ID들을 모두 조건에 포함 + List result = channelRepository.findAllByTypeOrIdIn(ChannelType.PUBLIC, + channelIds); + + // then - 중복이 제거되어 2개만 반환되어야 함 + assertThat(result).hasSize(2); + assertThat(result).extracting(Channel::getName) + .contains("Public Channel 1", "Public Channel 2"); + } +} diff --git a/src/test/java/com/sprint/mission/discodeit/repository/MessageRepositoryTest.java b/src/test/java/com/sprint/mission/discodeit/repository/MessageRepositoryTest.java new file mode 100644 index 000000000..037b3b0d6 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/repository/MessageRepositoryTest.java @@ -0,0 +1,191 @@ +package com.sprint.mission.discodeit.repository; + + +import static org.assertj.core.api.Assertions.assertThat; + +import com.sprint.mission.discodeit.entity.Channel; +import com.sprint.mission.discodeit.entity.ChannelType; +import com.sprint.mission.discodeit.entity.Message; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.entity.UserStatus; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Arrays; +import java.util.Optional; +import java.util.UUID; +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.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.test.context.ActiveProfiles; + +@DataJpaTest +@ActiveProfiles("test") +@EnableJpaAuditing +@DisplayName("MessageRepository 테스트") +class MessageRepositoryTest { + + @Autowired + private TestEntityManager entityManager; + + @Autowired + private MessageRepository messageRepository; + + private Channel testChannel; + private User testUser; + private Message message1; + private Message message2; + private Message message3; + private Instant baseTime; + + @BeforeEach + void setUp() { + baseTime = Instant.now(); + + // User 생성 + testUser = new User("testuser", "test@example.com", "password", null); + + // UserStatus 생성 및 연관관계 설정 + UserStatus userStatus = new UserStatus(testUser, Instant.now()); + + // Channel 생성 + testChannel = new Channel(ChannelType.PUBLIC, "Test Channel", "Test channel description"); + + entityManager.persistAndFlush(testUser); + entityManager.persistAndFlush(testChannel); + + // Message 생성 (시간 순서대로) + message1 = new Message("First message", testChannel, testUser, Arrays.asList()); + message2 = new Message("Second message", testChannel, testUser, Arrays.asList()); + message3 = new Message("Third message", testChannel, testUser, Arrays.asList()); + + entityManager.persistAndFlush(message1); + entityManager.persistAndFlush(message2); + entityManager.persistAndFlush(message3); + + // 생성 시간 수정 - 커서페이지네이션 테스트를 위해서 + entityManager.getEntityManager() + .createQuery("UPDATE Message m SET m.createdAt = :time1 WHERE m.id = :id1") + .setParameter("time1", baseTime.minus(2, ChronoUnit.HOURS)) + .setParameter("id1", message1.getId()) + .executeUpdate(); + + entityManager.getEntityManager() + .createQuery("UPDATE Message m SET m.createdAt = :time2 WHERE m.id = :id2") + .setParameter("time2", baseTime.minus(1, ChronoUnit.HOURS)) + .setParameter("id2", message2.getId()) + .executeUpdate(); + + entityManager.getEntityManager() + .createQuery("UPDATE Message m SET m.createdAt = :time3 WHERE m.id = :id3") + .setParameter("time3", baseTime) + .setParameter("id3", message3.getId()) + .executeUpdate(); + + entityManager.flush(); + entityManager.clear(); + } + + @Test + @DisplayName("채널 ID와 작성자 정보로 메시지 조회 - 성공") + void findAllByChannelIdWithAuthor_Success() { + // given + Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "createdAt")); + Instant cursor = baseTime.plus(1, ChronoUnit.HOURS); // 모든 메시지보다 미래 시간 + + // when + Slice result = messageRepository.findAllByChannelIdWithAuthor( + testChannel.getId(), cursor, pageable); + + // then + assertThat(result.getContent()).hasSize(3); + assertThat(result.getContent()).extracting(Message::getContent) + .containsExactly("Third message", "Second message", "First message"); // DESC 정렬 + + } + + @Test + @DisplayName("채널 ID와 작성자 정보로 메시지 조회 - 커서 기반 페이징") + void findAllByChannelIdWithAuthor_WithCursor() { + // given + Pageable pageable = PageRequest.of(0, 2, Sort.by(Sort.Direction.DESC, "createdAt")); + Instant cursor = baseTime.minus(30, ChronoUnit.MINUTES); // message2와 message1만 조회되도록 + + // when + Slice result = messageRepository.findAllByChannelIdWithAuthor( + testChannel.getId(), cursor, pageable); + + // then + assertThat(result.getContent()).hasSize(2); + assertThat(result.getContent()).extracting(Message::getContent) + .containsExactly("Second message", "First message"); + } + + @Test + @DisplayName("채널의 마지막 메시지 시간 조회 - 성공") + void findLastMessageAtByChannelId_Success() { + // when + Optional result = messageRepository.findLastMessageAtByChannelId( + testChannel.getId()); + + // then + assertThat(result).isPresent(); + // 가장 최근 메시지(message3)의 시간이 반환되어야 함 + assertThat(result.get()).isAfter(baseTime.minus(1, ChronoUnit.MINUTES)); + } + + @Test + @DisplayName("채널의 마지막 메시지 시간 조회 - 빈 결과 (메시지가 없는 채널)") + void findLastMessageAtByChannelId_Empty() { + // given + Channel emptyChannel = new Channel(ChannelType.PUBLIC, "Empty Channel", + "Channel with no messages"); + entityManager.persistAndFlush(emptyChannel); + + // when + Optional result = messageRepository.findLastMessageAtByChannelId( + emptyChannel.getId()); + + // then + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("채널 ID로 모든 메시지 삭제 - 성공") + void deleteAllByChannelId_Success() { + // given + long initialCount = messageRepository.count(); + assertThat(initialCount).isEqualTo(3); + + // when + messageRepository.deleteAllByChannelId(testChannel.getId()); + entityManager.flush(); + + // then + long finalCount = messageRepository.count(); + assertThat(finalCount).isEqualTo(0); + } + + @Test + @DisplayName("채널 ID로 모든 메시지 삭제 - 존재하지 않는 채널") + void deleteAllByChannelId_NonExistentChannel() { + // given + long initialCount = messageRepository.count(); + assertThat(initialCount).isEqualTo(3); + + // when + messageRepository.deleteAllByChannelId(UUID.randomUUID()); + entityManager.flush(); + + // then + long finalCount = messageRepository.count(); + assertThat(finalCount).isEqualTo(3); // 변화 없음 + } +} diff --git a/src/test/java/com/sprint/mission/discodeit/repository/UserRepositoryTest.java b/src/test/java/com/sprint/mission/discodeit/repository/UserRepositoryTest.java new file mode 100644 index 000000000..9f453010f --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/repository/UserRepositoryTest.java @@ -0,0 +1,140 @@ +package com.sprint.mission.discodeit.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.entity.UserStatus; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.test.context.ActiveProfiles; + +@DataJpaTest +@ActiveProfiles("test") +@EnableJpaAuditing +@DisplayName("UserRepository 테스트") +class UserRepositoryTest { + + @Autowired + private TestEntityManager entityManager; + + @Autowired + private UserRepository userRepository; + + private User testUser1; + private User testUser2; + + @BeforeEach + void setUp() { + // User 생성 + testUser1 = new User("testuser1", "test1@example.com", "password1", null); + testUser2 = new User("testuser2", "test2@example.com", "password2", null); + + // UserStatus 생성 및 연관관계 설정 + UserStatus status1 = new UserStatus(testUser1, Instant.now().minusSeconds(60)); + UserStatus status2 = new UserStatus(testUser2, Instant.now().minusSeconds(600)); + + entityManager.persistAndFlush(testUser1); + entityManager.persistAndFlush(testUser2); + } + + @Test + @DisplayName("사용자명으로 사용자 조회 - 성공") + void findByUsername_Success() { + // when + Optional result = userRepository.findByUsername("testuser1"); + + // then + assertThat(result).isPresent(); + assertThat(result.get().getUsername()).isEqualTo("testuser1"); + assertThat(result.get().getEmail()).isEqualTo("test1@example.com"); + } + + @Test + @DisplayName("사용자명으로 사용자 조회 - 실패 (존재하지 않는 사용자)") + void findByUsername_NotFound() { + // when + Optional result = userRepository.findByUsername("nonexistent"); + + // then + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("이메일 존재 여부 확인 - 성공 (존재함)") + void existsByEmail_True() { + // when + boolean exists = userRepository.existsByEmail("test1@example.com"); + + // then + assertThat(exists).isTrue(); + } + + @Test + @DisplayName("이메일 존재 여부 확인 - 실패 (존재하지 않음)") + void existsByEmail_False() { + // when + boolean exists = userRepository.existsByEmail("nonexistent@example.com"); + + // then + assertThat(exists).isFalse(); + } + + @Test + @DisplayName("사용자명 존재 여부 확인 - 성공 (존재함)") + void existsByUsername_True() { + // when + boolean exists = userRepository.existsByUsername("testuser1"); + + // then + assertThat(exists).isTrue(); + } + + @Test + @DisplayName("사용자명 존재 여부 확인 - 실패 (존재하지 않음)") + void existsByUsername_False() { + // when + boolean exists = userRepository.existsByUsername("nonexistent"); + + // then + assertThat(exists).isFalse(); + } + + @Test + @DisplayName("프로필과 상태를 포함한 모든 사용자 조회 - 성공") + void findAllWithProfileAndStatus_Success() { + // when + List users = userRepository.findAllWithProfileAndStatus(); + + // then + assertThat(users).hasSize(2); + assertThat(users).extracting(User::getUsername) + .contains("testuser1", "testuser2"); + } + + @Test + @DisplayName("프로필과 상태를 포함한 모든 사용자 조회 - 빈 결과") + void findAllWithProfileAndStatus_Empty() { + // given + entityManager.getEntityManager() + .createQuery("DELETE FROM UserStatus") + .executeUpdate(); + entityManager.getEntityManager() + .createQuery("DELETE FROM User") + .executeUpdate(); + entityManager.flush(); + + // when + List users = userRepository.findAllWithProfileAndStatus(); + + // then + assertThat(users).isEmpty(); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/service/basic/ChannelServiceTest.java b/src/test/java/com/sprint/mission/discodeit/service/basic/ChannelServiceTest.java new file mode 100644 index 000000000..7dad5aef3 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/service/basic/ChannelServiceTest.java @@ -0,0 +1,255 @@ +package com.sprint.mission.discodeit.service.basic; + +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.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; + +import com.sprint.mission.discodeit.dto.data.ChannelDto; +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.PrivateChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelUpdateRequest; +import com.sprint.mission.discodeit.entity.Channel; +import com.sprint.mission.discodeit.entity.ChannelType; +import com.sprint.mission.discodeit.entity.ReadStatus; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.exception.channel.ChannelNotFoundException; +import com.sprint.mission.discodeit.exception.channel.PrivateChannelUpdateException; +import com.sprint.mission.discodeit.mapper.ChannelMapper; +import com.sprint.mission.discodeit.repository.ChannelRepository; +import com.sprint.mission.discodeit.repository.MessageRepository; +import com.sprint.mission.discodeit.repository.ReadStatusRepository; +import com.sprint.mission.discodeit.repository.UserRepository; +import java.time.Instant; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +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; + +@ExtendWith(MockitoExtension.class) +public class ChannelServiceTest { + + @Mock + private ChannelRepository channelRepository; + + @Mock + private ReadStatusRepository readStatusRepository; + + @Mock + private MessageRepository messageRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private ChannelMapper channelMapper; + + @InjectMocks + private BasicChannelService channelService; + + @Test + @DisplayName("공개 채널 생성 - 성공") + void createPublicChannel_Success() { + //Given + String name = "testChannel"; + String description = "testDescription"; + PublicChannelCreateRequest request = new PublicChannelCreateRequest(name, description); + List users = List.of(); + + Channel savedChannel = new Channel(ChannelType.PUBLIC, name, description); + ChannelDto expectedDto = new ChannelDto(UUID.randomUUID(), ChannelType.PUBLIC, name, + description, users, Instant.now()); + + given(channelRepository.save(any(Channel.class))).willReturn(savedChannel); + given(channelMapper.toDto(any(Channel.class))).willReturn(expectedDto); + + //When + ChannelDto result = channelService.create(request); + + //Then + assertThat(result).isEqualTo(expectedDto); + then(channelRepository).should().save(any(Channel.class)); + then(channelMapper).should().toDto(any(Channel.class)); + + } + + + @Test + @DisplayName("비공개 채널 생성 - 성공") + void createPrivateChannel_Success() { + //Given + UUID user1Id = UUID.randomUUID(); + UUID user2Id = UUID.randomUUID(); + List participantIds = Arrays.asList(user1Id, user2Id); + + PrivateChannelCreateRequest request = new PrivateChannelCreateRequest(participantIds); + UserDto userDto1 = new UserDto(user1Id, "user1", "user1@example.com", null, null); + UserDto userDto2 = new UserDto(user2Id, "user2", "user2@example.com", null, null); + List userDtoList = Arrays.asList(userDto1, userDto2); + + User user1 = new User("user1", "user1@example.com", "password", null); + User user2 = new User("user2", "user2@example.com", "password", null); + List users = Arrays.asList(user1, user2); + + Channel savedChannel = new Channel(ChannelType.PRIVATE, null, null); + ChannelDto expectedDto = new ChannelDto(UUID.randomUUID(), ChannelType.PUBLIC, null, + null, userDtoList, Instant.now()); + + List readStatusList = Arrays.asList( + new ReadStatus(user1, savedChannel, Instant.now()), + new ReadStatus(user2, savedChannel, Instant.now())); + + given(channelRepository.save(any(Channel.class))).willReturn(savedChannel); + given(userRepository.findAllById(participantIds)).willReturn(users); + given(channelMapper.toDto(any(Channel.class))).willReturn(expectedDto); + + //When + ChannelDto result = channelService.create(request); + + //Then + assertThat(result).isEqualTo(expectedDto); + then(channelRepository).should().save(any(Channel.class)); + then(userRepository).should().findAllById(participantIds); + then(channelMapper).should().toDto(any(Channel.class)); + } + + + @Test + @DisplayName("공개 채널 수정 - 성공") + void update_Success() { + //Given + UUID channelId = UUID.randomUUID(); + String newName = "newTestChannel"; + String newDescription = "newTestDescription"; + PublicChannelUpdateRequest request = new PublicChannelUpdateRequest(newName, + newDescription); + List users = List.of(); + + Channel existingChannel = new Channel(ChannelType.PUBLIC, "oldName", "oldDescription"); + ChannelDto expectedDto = new ChannelDto(channelId, ChannelType.PUBLIC, newName, + newDescription, users, Instant.now()); + + given(channelRepository.findById(channelId)).willReturn(Optional.of(existingChannel)); + given(channelMapper.toDto(existingChannel)).willReturn(expectedDto); + + //When + ChannelDto result = channelService.update(channelId, request); + + //Then + assertThat(result).isEqualTo(expectedDto); + then(channelMapper).should().toDto(existingChannel); + + } + + @Test + @DisplayName("채널 수정 - 비공개 채널 수정 시도로 실패") + void update_FailWhenChannelIsNotPublic() { + // Given + UUID channelId = UUID.randomUUID(); + PublicChannelUpdateRequest request = new PublicChannelUpdateRequest("New Name", + "New description"); + + Channel privateChannel = new Channel(ChannelType.PRIVATE, null, null); + + given(channelRepository.findById(channelId)).willReturn(Optional.of(privateChannel)); + + // When & Then + assertThatThrownBy(() -> channelService.update(channelId, request)) + .isInstanceOf(PrivateChannelUpdateException.class); + + then(channelRepository).should().findById(channelId); + then(channelMapper).should(never()).toDto(any()); + + } + + @Test + @DisplayName("채널 삭제 - 성공") + void delete_Success() { + // Given + UUID channelId = UUID.randomUUID(); + + given(channelRepository.existsById(channelId)).willReturn(true); + + // When + channelService.delete(channelId); + + // Then + then(channelRepository).should().existsById(channelId); + then(messageRepository).should().deleteAllByChannelId(channelId); + then(readStatusRepository).should().deleteAllByChannelId(channelId); + then(channelRepository).should().deleteById(channelId); + } + + @Test + @DisplayName("채널 삭제 - 채널을 찾을 수 없어 실패") + void delete_FailWhenChannelNotFound() { + // Given + UUID channelId = UUID.randomUUID(); + + given(channelRepository.existsById(channelId)).willReturn(false); + + // When & Then + assertThatThrownBy(() -> channelService.delete(channelId)) + .isInstanceOf(ChannelNotFoundException.class); + + then(channelRepository).should().existsById(channelId); + then(messageRepository).should(never()).deleteAllByChannelId(channelId); + then(readStatusRepository).should(never()).deleteAllByChannelId(channelId); + then(channelRepository).should(never()).deleteById(channelId); + } + + @Test + @DisplayName("사용자별 채널 조회 - 성공") + void findAllByUserId_Success() { + // Given + UUID userId = UUID.randomUUID(); + UUID channelId1 = UUID.randomUUID(); + UUID channelId2 = UUID.randomUUID(); + List users = List.of(new UserDto(userId, "name", "email", null, null)); + + Channel channel1 = mock(Channel.class); + Channel channel2 = mock(Channel.class); + given(channel1.getId()).willReturn(channelId1); + given(channel2.getId()).willReturn(channelId2); + + ReadStatus readStatus1 = new ReadStatus(null, channel1, Instant.now()); + ReadStatus readStatus2 = new ReadStatus(null, channel2, Instant.now()); + List readStatuses = Arrays.asList(readStatus1, readStatus2); + + List subscribedChannelIds = Arrays.asList(channelId1, channelId2); + List channels = Arrays.asList(channel1, channel2); + + ChannelDto channelDto1 = new ChannelDto(channelId1, ChannelType.PRIVATE, null, null, + users, Instant.now()); + ChannelDto channelDto2 = new ChannelDto(channelId2, ChannelType.PUBLIC, "Public Channel", + "Description", users, Instant.now()); + List expectedDtos = Arrays.asList(channelDto1, channelDto2); + + given(readStatusRepository.findAllByUserId(userId)).willReturn(readStatuses); + given(channelRepository.findAllByTypeOrIdIn(ChannelType.PUBLIC, + subscribedChannelIds)).willReturn(channels); + given(channelMapper.toDto(channel1)).willReturn(channelDto1); + given(channelMapper.toDto(channel2)).willReturn(channelDto2); + + // When + List result = channelService.findAllByUserId(userId); + + // Then + assertThat(result).hasSize(2); + assertThat(result).containsExactlyElementsOf(expectedDtos); + then(readStatusRepository).should().findAllByUserId(userId); + then(channelRepository).should() + .findAllByTypeOrIdIn(ChannelType.PUBLIC, subscribedChannelIds); + } + +} diff --git a/src/test/java/com/sprint/mission/discodeit/service/basic/MessageServiceTest.java b/src/test/java/com/sprint/mission/discodeit/service/basic/MessageServiceTest.java new file mode 100644 index 000000000..b726c2aae --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/service/basic/MessageServiceTest.java @@ -0,0 +1,192 @@ +package com.sprint.mission.discodeit.service.basic; + +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.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; + +import com.sprint.mission.discodeit.dto.data.MessageDto; +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.BinaryContentCreateRequest; +import com.sprint.mission.discodeit.dto.request.MessageCreateRequest; +import com.sprint.mission.discodeit.dto.request.MessageUpdateRequest; +import com.sprint.mission.discodeit.entity.Channel; +import com.sprint.mission.discodeit.entity.ChannelType; +import com.sprint.mission.discodeit.entity.Message; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.exception.channel.ChannelNotFoundException; +import com.sprint.mission.discodeit.exception.message.MessageNotFoundException; +import com.sprint.mission.discodeit.exception.user.UserNotFoundException; +import com.sprint.mission.discodeit.mapper.MessageMapper; +import com.sprint.mission.discodeit.repository.ChannelRepository; +import com.sprint.mission.discodeit.repository.MessageRepository; +import com.sprint.mission.discodeit.repository.UserRepository; +import java.time.Instant; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +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; + +@ExtendWith(MockitoExtension.class) +class MessageServiceTest { + + @Mock + private MessageRepository messageRepository; + + @Mock + private ChannelRepository channelRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private MessageMapper messageMapper; + + + @InjectMocks + private BasicMessageService messageService; + + @Test + @DisplayName("메시지 생성 - 성공") + void create_Success() { + // Given + UUID channelId = UUID.randomUUID(); + UUID authorId = UUID.randomUUID(); + UUID messageId = UUID.randomUUID(); + String content = "Hello, World!"; + MessageCreateRequest request = new MessageCreateRequest(content, channelId, authorId); + List attachments = Collections.emptyList(); + + Channel channel = new Channel(ChannelType.PUBLIC, "Test Channel", "Description"); + User author = new User("testuser", "test@example.com", "password", null); + Message savedMessage = new Message(content, null, null, Collections.emptyList()); + MessageDto expectedDto = new MessageDto(messageId, Instant.now(), null, content, channelId, + new UserDto(authorId, null, null, null, null), Collections.emptyList()); + + given(channelRepository.findById(channelId)).willReturn(Optional.of(channel)); + given(userRepository.findById(authorId)).willReturn(Optional.of(author)); + given(messageRepository.save(any(Message.class))).willReturn(savedMessage); + given(messageMapper.toDto(any(Message.class))).willReturn(expectedDto); + + // When + MessageDto result = messageService.create(request, attachments); + + // Then + assertThat(result).isEqualTo(expectedDto); + then(channelRepository).should().findById(channelId); + then(userRepository).should().findById(authorId); + then(messageRepository).should().save(any(Message.class)); + then(messageMapper).should().toDto(any(Message.class)); + } + + @Test + @DisplayName("메시지 생성 - 채널을 찾을 수 없어 실패") + void create_FailWhenChannelNotFound() { + // Given + UUID channelId = UUID.randomUUID(); + UUID authorId = UUID.randomUUID(); + MessageCreateRequest request = new MessageCreateRequest("Hello", channelId, authorId); + List attachments = Collections.emptyList(); + + given(channelRepository.findById(channelId)).willReturn(Optional.empty()); + + // When & Then + assertThatThrownBy(() -> messageService.create(request, attachments)) + .isInstanceOf(ChannelNotFoundException.class); + + then(channelRepository).should().findById(channelId); + then(userRepository).should(never()).findById(authorId); + then(messageRepository).should(never()).save(any()); + } + + @Test + @DisplayName("메시지 생성 - 사용자를 찾을 수 없어 실패") + void create_FailWhenUserNotFound() { + // Given + UUID channelId = UUID.randomUUID(); + UUID authorId = UUID.randomUUID(); + MessageCreateRequest request = new MessageCreateRequest("Hello", channelId, authorId); + List attachments = Collections.emptyList(); + + Channel channel = new Channel(ChannelType.PUBLIC, "Test Channel", "Description"); + + given(channelRepository.findById(channelId)).willReturn(Optional.of(channel)); + given(userRepository.findById(authorId)).willReturn(Optional.empty()); + + // When & Then + assertThatThrownBy(() -> messageService.create(request, attachments)) + .isInstanceOf(UserNotFoundException.class); + + then(channelRepository).should().findById(channelId); + then(userRepository).should().findById(authorId); + then(messageRepository).should(never()).save(any()); + } + + @Test + @DisplayName("메시지 수정 - 성공") + void update_Success() { + // Given + UUID messageId = UUID.randomUUID(); + UUID channelId = UUID.randomUUID(); + UUID authorId = UUID.randomUUID(); + String newContent = "Updated message content"; + MessageUpdateRequest request = new MessageUpdateRequest(newContent); + + Message existingMessage = new Message("Old content", null, null, Collections.emptyList()); + MessageDto expectedDto = new MessageDto(messageId, Instant.now(), null, newContent, + channelId, new UserDto(authorId, null, null, null, null), Collections.emptyList()); + + given(messageRepository.findById(messageId)).willReturn(Optional.of(existingMessage)); + given(messageMapper.toDto(existingMessage)).willReturn(expectedDto); + + // When + MessageDto result = messageService.update(messageId, request); + + // Then + assertThat(result).isEqualTo(expectedDto); + then(messageRepository).should().findById(messageId); + then(messageMapper).should().toDto(existingMessage); + } + + @Test + @DisplayName("메시지 수정 - 메시지를 찾을 수 없어 실패") + void update_FailWhenMessageNotFound() { + // Given + UUID messageId = UUID.randomUUID(); + MessageUpdateRequest request = new MessageUpdateRequest("Updated content"); + + given(messageRepository.findById(messageId)).willReturn(Optional.empty()); + + // When & Then + assertThatThrownBy(() -> messageService.update(messageId, request)) + .isInstanceOf(MessageNotFoundException.class); + + then(messageRepository).should().findById(messageId); + then(messageMapper).should(never()).toDto(any()); + } + + @Test + @DisplayName("메시지 삭제 - 성공") + void delete_Success() { + // Given + UUID messageId = UUID.randomUUID(); + + given(messageRepository.existsById(messageId)).willReturn(true); + + // When + messageService.delete(messageId); + + // Then + then(messageRepository).should().existsById(messageId); + then(messageRepository).should().deleteById(messageId); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/service/basic/ReadStatusServiceTest.java b/src/test/java/com/sprint/mission/discodeit/service/basic/ReadStatusServiceTest.java new file mode 100644 index 000000000..7fe4c3215 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/service/basic/ReadStatusServiceTest.java @@ -0,0 +1,161 @@ +package com.sprint.mission.discodeit.service.basic; + +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.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; + +import com.sprint.mission.discodeit.dto.data.ReadStatusDto; +import com.sprint.mission.discodeit.dto.request.ReadStatusCreateRequest; +import com.sprint.mission.discodeit.dto.request.ReadStatusUpdateRequest; +import com.sprint.mission.discodeit.entity.Channel; +import com.sprint.mission.discodeit.entity.ChannelType; +import com.sprint.mission.discodeit.entity.ReadStatus; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.exception.channel.ChannelNotFoundException; +import com.sprint.mission.discodeit.exception.readstatus.ReadStatusNotFoundException; +import com.sprint.mission.discodeit.exception.user.UserNotFoundException; +import com.sprint.mission.discodeit.mapper.ReadStatusMapper; +import com.sprint.mission.discodeit.repository.ChannelRepository; +import com.sprint.mission.discodeit.repository.ReadStatusRepository; +import com.sprint.mission.discodeit.repository.UserRepository; +import java.time.Instant; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +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; + +@ExtendWith(MockitoExtension.class) +class ReadStatusServiceTest { + + @Mock + private ReadStatusRepository readStatusRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private ChannelRepository channelRepository; + + @Mock + private ReadStatusMapper readStatusMapper; + + @InjectMocks + private BasicReadStatusService readStatusService; + + @Test + @DisplayName("읽기 상태 생성 - 성공") + void create_Success() { + // Given + UUID userId = UUID.randomUUID(); + UUID channelId = UUID.randomUUID(); + Instant lastReadAt = Instant.now(); + ReadStatusCreateRequest request = new ReadStatusCreateRequest(userId, channelId, + lastReadAt); + + User user = mock(User.class); + Channel channel = mock(Channel.class); + given(user.getId()).willReturn(userId); + given(channel.getId()).willReturn(channelId); + + ReadStatus savedReadStatus = new ReadStatus(user, channel, lastReadAt); + ReadStatusDto expectedDto = new ReadStatusDto(UUID.randomUUID(), null, null, lastReadAt); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(channelRepository.findById(channelId)).willReturn(Optional.of(channel)); + given(readStatusRepository.existsByUserIdAndChannelId(userId, channelId)).willReturn(false); + given(readStatusRepository.save(any(ReadStatus.class))).willReturn(savedReadStatus); + given(readStatusMapper.toDto(any(ReadStatus.class))).willReturn(expectedDto); + + // When + ReadStatusDto result = readStatusService.create(request); + + // Then + assertThat(result).isEqualTo(expectedDto); + then(userRepository).should().findById(userId); + then(channelRepository).should().findById(channelId); + then(readStatusRepository).should().existsByUserIdAndChannelId(userId, channelId); + then(readStatusRepository).should().save(any(ReadStatus.class)); + } + + @Test + @DisplayName("읽기 상태 생성 - 사용자를 찾을 수 없어 실패") + void create_FailWhenUserNotFound() { + // Given + UUID userId = UUID.randomUUID(); + UUID channelId = UUID.randomUUID(); + ReadStatusCreateRequest request = new ReadStatusCreateRequest(userId, channelId, + Instant.now()); + + given(userRepository.findById(userId)).willReturn(Optional.empty()); + + // When & Then + assertThatThrownBy(() -> readStatusService.create(request)) + .isInstanceOf(UserNotFoundException.class); + + then(userRepository).should().findById(userId); + then(channelRepository).should(never()).findById(channelId); + then(readStatusRepository).should(never()).save(any()); + } + + @Test + @DisplayName("읽기 상태 생성 - 채널을 찾을 수 없어 실패") + void create_FailWhenChannelNotFound() { + // Given + UUID userId = UUID.randomUUID(); + UUID channelId = UUID.randomUUID(); + ReadStatusCreateRequest request = new ReadStatusCreateRequest(userId, channelId, + Instant.now()); + + User user = new User("testuser", "test@example.com", "password", null); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(channelRepository.findById(channelId)).willReturn(Optional.empty()); + + // When & Then + assertThatThrownBy(() -> readStatusService.create(request)) + .isInstanceOf(ChannelNotFoundException.class); + + then(userRepository).should().findById(userId); + then(channelRepository).should().findById(channelId); + then(readStatusRepository).should(never()).save(any()); + } + + @Test + @DisplayName("읽기 상태 생성 - 이미 존재할 때 실패") + void create_FailWhenReadStatusAlreadyExists() { + // Given + UUID userId = UUID.randomUUID(); + UUID channelId = UUID.randomUUID(); + ReadStatusCreateRequest request = new ReadStatusCreateRequest(userId, channelId, + Instant.now()); + + User user = mock(User.class); + Channel channel = mock(Channel.class); + given(user.getId()).willReturn(userId); + given(channel.getId()).willReturn(channelId); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(channelRepository.findById(channelId)).willReturn(Optional.of(channel)); + given(readStatusRepository.existsByUserIdAndChannelId(userId, channelId)).willReturn(true); + + // When & Then + assertThatThrownBy(() -> readStatusService.create(request)) + .isInstanceOf(ReadStatusNotFoundException.class); + + then(userRepository).should().findById(userId); + then(channelRepository).should().findById(channelId); + then(readStatusRepository).should().existsByUserIdAndChannelId(userId, channelId); + then(readStatusRepository).should(never()).save(any()); + } + +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/service/basic/UserServiceTest.java b/src/test/java/com/sprint/mission/discodeit/service/basic/UserServiceTest.java new file mode 100644 index 000000000..97b5f9789 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/service/basic/UserServiceTest.java @@ -0,0 +1,207 @@ +package com.sprint.mission.discodeit.service.basic; + +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.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.never; + +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.BinaryContentCreateRequest; +import com.sprint.mission.discodeit.dto.request.UserCreateRequest; +import com.sprint.mission.discodeit.dto.request.UserUpdateRequest; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.exception.user.UserAlreadyExistException; +import com.sprint.mission.discodeit.exception.user.UserNotFoundException; +import com.sprint.mission.discodeit.mapper.UserMapper; +import com.sprint.mission.discodeit.repository.BinaryContentRepository; +import com.sprint.mission.discodeit.repository.UserRepository; +import com.sprint.mission.discodeit.repository.UserStatusRepository; +import com.sprint.mission.discodeit.storage.BinaryContentStorage; +import java.util.Optional; +import java.util.UUID; +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.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class UserServiceTest { + + @Mock + private UserRepository userRepository; + + @Mock + private UserStatusRepository userStatusRepository; + + @Mock + private UserMapper userMapper; + + @Mock + private BinaryContentRepository binaryContentRepository; + + @Mock + private BinaryContentStorage binaryContentStorage; + + @InjectMocks + private BasicUserService userService; + + private String username; + private String email; + private String password; + + @BeforeEach + void setUp() { + username = "testUser"; + email = "test@example.com"; + password = "password123"; + } + + + @Test + @DisplayName("사용자 생성 - 성공") + void create_Success() { + // Given + UserCreateRequest request = new UserCreateRequest(username, email, password); + Optional profileRequest = Optional.empty(); + + User savedUser = new User(username, email, password, null); + UserDto expectedDto = new UserDto(UUID.randomUUID(), username, email, null, null); + + given(userRepository.existsByEmail(email)).willReturn(false); + given(userRepository.existsByUsername(username)).willReturn(false); + given(userRepository.save(any(User.class))).willReturn(savedUser); + given(userMapper.toDto(any(User.class))).willReturn(expectedDto); + + // When + UserDto result = userService.create(request, profileRequest); + + // Then + assertThat(result).isEqualTo(expectedDto); + then(userRepository).should().existsByEmail(email); + then(userRepository).should().existsByUsername(username); + then(userRepository).should().save(any(User.class)); + } + + @Test + @DisplayName("사용자 생성 - 이메일 중복으로 실패") + void create_FailWhenEmailAlreadyExists() { + //given + UserCreateRequest request = new UserCreateRequest(username, email, password); + Optional profileRequest = Optional.empty(); + given(userRepository.existsByEmail(email)).willReturn(true); + + //when && then + assertThatThrownBy(() -> userService.create(request, profileRequest)) + .isInstanceOf(UserAlreadyExistException.class); + + then(userRepository).should().existsByEmail(email); + then(userRepository).should(never()).existsByUsername(username); + then(userRepository).should(never()).save(any(User.class)); + } + + @Test + @DisplayName("사용자 생성 - 이름 중복으로 실패") + void create_FailWhenUsernameAlreadyExists() { + //given + UserCreateRequest request = new UserCreateRequest(username, email, password); + Optional profileRequest = Optional.empty(); + + given(userRepository.existsByEmail(email)).willReturn(false); + given(userRepository.existsByUsername(username)).willReturn(true); + + //when && then + assertThatThrownBy(() -> userService.create(request, profileRequest)) + .isInstanceOf(UserAlreadyExistException.class); + + then(userRepository).should().existsByUsername(username); + then(userRepository).should().existsByEmail(email); + then(userRepository).should(never()).save(any(User.class)); + } + + @Test + @DisplayName("사용자 수정 - 성공") + void update_Success() { + //given + UUID userId = UUID.randomUUID(); + String newUsername = "newuser"; + String newEmail = "new@example.com"; + String newPassword = "newpassword123"; + UserUpdateRequest request = new UserUpdateRequest(newUsername, newEmail, newPassword); + Optional profileRequest = Optional.empty(); + + User existingUser = new User(username, email, password, null); + UserDto expectedDto = new UserDto(userId, newUsername, newEmail, null, null); + + given(userRepository.findById(userId)).willReturn(Optional.of(existingUser)); + given(userRepository.existsByEmail(newEmail)).willReturn(false); + given(userRepository.existsByUsername(newUsername)).willReturn(false); + given(userMapper.toDto(any(User.class))).willReturn(expectedDto); + + //when + UserDto result = userService.update(userId, request, profileRequest); + + //then + assertThat(result).isEqualTo(expectedDto); + then(userRepository).should().findById(userId); + then(userRepository).should().existsByEmail(newEmail); + then(userRepository).should().existsByUsername(newUsername); + } + + @Test + @DisplayName("사용자 수정 - 사용자를 찾을 수 없어 실패") + void update_FailWhenUserNotFound() { + // Given + UUID userId = UUID.randomUUID(); + UserUpdateRequest request = new UserUpdateRequest("newuser", "new@example.com", + "newpassword"); + Optional profileRequest = Optional.empty(); + + given(userRepository.findById(userId)).willReturn(Optional.empty()); + + // When & Then + assertThatThrownBy(() -> userService.update(userId, request, profileRequest)) + .isInstanceOf(UserNotFoundException.class); + + then(userRepository).should().findById(userId); + then(userRepository).should(never()).existsByEmail(any()); + then(userRepository).should(never()).existsByUsername(any()); + } + + @Test + @DisplayName("사용자 삭제 - 성공") + void delete_Success() { + //Given + UUID userId = UUID.randomUUID(); + given(userRepository.existsById(userId)).willReturn(true); + + //When + userService.delete(userId); + + //Then + then(userRepository).should().existsById(userId); + then(userRepository).should().deleteById(userId); + } + + @Test + @DisplayName("사용자 삭제 - 사용자를 찾을 수 없어 실패") + void delete_FailWhenUserNotFound() { + //Given + UUID userId = UUID.randomUUID(); + given(userRepository.existsById(userId)).willReturn(false); + + //When + assertThatThrownBy(() -> userService.delete(userId)) + .isInstanceOf(UserNotFoundException.class); + + //Then + then(userRepository).should().existsById(userId); + then(userRepository).should(never()).deleteById(userId); + } + + +} diff --git a/src/test/java/com/sprint/mission/discodeit/service/basic/UserStatusServiceTest.java b/src/test/java/com/sprint/mission/discodeit/service/basic/UserStatusServiceTest.java new file mode 100644 index 000000000..c21330a22 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/service/basic/UserStatusServiceTest.java @@ -0,0 +1,299 @@ + +package com.sprint.mission.discodeit.service.basic; + +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.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; + +import com.sprint.mission.discodeit.dto.data.UserStatusDto; +import com.sprint.mission.discodeit.dto.request.UserStatusCreateRequest; +import com.sprint.mission.discodeit.dto.request.UserStatusUpdateRequest; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.entity.UserStatus; +import com.sprint.mission.discodeit.exception.user.UserNotFoundException; +import com.sprint.mission.discodeit.exception.userstatus.UserStatusAlreadyExistException; +import com.sprint.mission.discodeit.exception.userstatus.UserStatusNotFoundException; +import com.sprint.mission.discodeit.mapper.UserStatusMapper; +import com.sprint.mission.discodeit.repository.UserRepository; +import com.sprint.mission.discodeit.repository.UserStatusRepository; +import java.time.Instant; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +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; + +@ExtendWith(MockitoExtension.class) +class UserStatusServiceTest { + + @Mock + private UserStatusRepository userStatusRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private UserStatusMapper userStatusMapper; + + @InjectMocks + private BasicUserStatusService userStatusService; + + @Test + @DisplayName("사용자 상태 생성 - 성공") + void create_Success() { + // Given + UUID userId = UUID.randomUUID(); + Instant lastActiveAt = Instant.now(); + UserStatusCreateRequest request = new UserStatusCreateRequest(userId, lastActiveAt); + + User user = mock(User.class); + given(user.getStatus()).willReturn(null); + + UserStatus savedUserStatus = new UserStatus(user, lastActiveAt); + UserStatusDto expectedDto = new UserStatusDto(UUID.randomUUID(), userId, lastActiveAt); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(userStatusRepository.save(any(UserStatus.class))).willReturn(savedUserStatus); + given(userStatusMapper.toDto(any(UserStatus.class))).willReturn(expectedDto); + + // When + UserStatusDto result = userStatusService.create(request); + + // Then + assertThat(result).isEqualTo(expectedDto); + then(userRepository).should().findById(userId); + then(userStatusRepository).should().save(any(UserStatus.class)); + then(userStatusMapper).should().toDto(any(UserStatus.class)); + } + + @Test + @DisplayName("사용자 상태 생성 - 사용자를 찾을 수 없어 실패") + void create_FailWhenUserNotFound() { + // Given + UUID userId = UUID.randomUUID(); + UserStatusCreateRequest request = new UserStatusCreateRequest(userId, Instant.now()); + + given(userRepository.findById(userId)).willReturn(Optional.empty()); + + // When & Then + assertThatThrownBy(() -> userStatusService.create(request)) + .isInstanceOf(UserNotFoundException.class); + + then(userRepository).should().findById(userId); + then(userStatusRepository).should(never()).save(any()); + } + + @Test + @DisplayName("사용자 상태 생성 - 이미 존재할 때 실패") + void create_FailWhenUserStatusAlreadyExists() { + // Given + UUID userId = UUID.randomUUID(); + UserStatusCreateRequest request = new UserStatusCreateRequest(userId, Instant.now()); + + User user = mock(User.class); + UserStatus existingStatus = mock(UserStatus.class); + given(user.getStatus()).willReturn(existingStatus); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + + // When & Then + assertThatThrownBy(() -> userStatusService.create(request)) + .isInstanceOf(UserStatusAlreadyExistException.class); + + then(userRepository).should().findById(userId); + then(userStatusRepository).should(never()).save(any()); + } + + @Test + @DisplayName("사용자 상태 조회 - 성공") + void find_Success() { + // Given + UUID userStatusId = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + Instant lastActiveAt = Instant.now(); + + UserStatus userStatus = mock(UserStatus.class); + UserStatusDto expectedDto = new UserStatusDto(userStatusId, userId, lastActiveAt); + + given(userStatusRepository.findById(userStatusId)).willReturn(Optional.of(userStatus)); + given(userStatusMapper.toDto(userStatus)).willReturn(expectedDto); + + // When + UserStatusDto result = userStatusService.find(userStatusId); + + // Then + assertThat(result).isEqualTo(expectedDto); + then(userStatusRepository).should().findById(userStatusId); + then(userStatusMapper).should().toDto(userStatus); + } + + @Test + @DisplayName("사용자 상태 조회 - 상태를 찾을 수 없어 실패") + void find_FailWhenUserStatusNotFound() { + // Given + UUID userStatusId = UUID.randomUUID(); + + given(userStatusRepository.findById(userStatusId)).willReturn(Optional.empty()); + + // When & Then + assertThatThrownBy(() -> userStatusService.find(userStatusId)) + .isInstanceOf(UserStatusNotFoundException.class); + + then(userStatusRepository).should().findById(userStatusId); + then(userStatusMapper).should(never()).toDto(any()); + } + + @Test + @DisplayName("전체 사용자 상태 조회 - 성공") + void findAll_Success() { + // Given + UserStatus userStatus1 = mock(UserStatus.class); + UserStatus userStatus2 = mock(UserStatus.class); + List userStatuses = Arrays.asList(userStatus1, userStatus2); + + UserStatusDto dto1 = new UserStatusDto(UUID.randomUUID(), UUID.randomUUID(), Instant.now()); + UserStatusDto dto2 = new UserStatusDto(UUID.randomUUID(), UUID.randomUUID(), Instant.now()); + List expectedDtos = Arrays.asList(dto1, dto2); + + given(userStatusRepository.findAll()).willReturn(userStatuses); + given(userStatusMapper.toDto(userStatus1)).willReturn(dto1); + given(userStatusMapper.toDto(userStatus2)).willReturn(dto2); + + // When + List result = userStatusService.findAll(); + + // Then + assertThat(result).hasSize(2); + assertThat(result).containsExactlyElementsOf(expectedDtos); + then(userStatusRepository).should().findAll(); + then(userStatusMapper).should().toDto(userStatus1); + then(userStatusMapper).should().toDto(userStatus2); + } + + @Test + @DisplayName("사용자 상태 수정 - 성공") + void update_Success() { + // Given + UUID userStatusId = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + Instant newLastActiveAt = Instant.now(); + UserStatusUpdateRequest request = new UserStatusUpdateRequest(newLastActiveAt); + + UserStatus existingUserStatus = mock(UserStatus.class); + UserStatusDto expectedDto = new UserStatusDto(userStatusId, userId, newLastActiveAt); + + given(userStatusRepository.findById(userStatusId)).willReturn( + Optional.of(existingUserStatus)); + given(userStatusMapper.toDto(existingUserStatus)).willReturn(expectedDto); + + // When + UserStatusDto result = userStatusService.update(userStatusId, request); + + // Then + assertThat(result).isEqualTo(expectedDto); + then(userStatusRepository).should().findById(userStatusId); + then(existingUserStatus).should().update(newLastActiveAt); + then(userStatusMapper).should().toDto(existingUserStatus); + } + + @Test + @DisplayName("사용자 상태 수정 - 상태를 찾을 수 없어 실패") + void update_FailWhenUserStatusNotFound() { + // Given + UUID userStatusId = UUID.randomUUID(); + UserStatusUpdateRequest request = new UserStatusUpdateRequest(Instant.now()); + + given(userStatusRepository.findById(userStatusId)).willReturn(Optional.empty()); + + // When & Then + assertThatThrownBy(() -> userStatusService.update(userStatusId, request)) + .isInstanceOf(UserStatusNotFoundException.class); + + then(userStatusRepository).should().findById(userStatusId); + then(userStatusMapper).should(never()).toDto(any()); + } + + @Test + @DisplayName("사용자 ID로 상태 수정 - 성공") + void updateByUserId_Success() { + // Given + UUID userId = UUID.randomUUID(); + UUID userStatusId = UUID.randomUUID(); + Instant newLastActiveAt = Instant.now(); + UserStatusUpdateRequest request = new UserStatusUpdateRequest(newLastActiveAt); + + UserStatus existingUserStatus = mock(UserStatus.class); + UserStatusDto expectedDto = new UserStatusDto(userStatusId, userId, newLastActiveAt); + + given(userStatusRepository.findByUserId(userId)).willReturn( + Optional.of(existingUserStatus)); + given(userStatusMapper.toDto(existingUserStatus)).willReturn(expectedDto); + + // When + UserStatusDto result = userStatusService.updateByUserId(userId, request); + + // Then + assertThat(result).isEqualTo(expectedDto); + then(userStatusRepository).should().findByUserId(userId); + then(existingUserStatus).should().update(newLastActiveAt); + then(userStatusMapper).should().toDto(existingUserStatus); + } + + @Test + @DisplayName("사용자 ID로 상태 수정 - 상태를 찾을 수 없어 실패") + void updateByUserId_FailWhenUserStatusNotFound() { + // Given + UUID userId = UUID.randomUUID(); + UserStatusUpdateRequest request = new UserStatusUpdateRequest(Instant.now()); + + given(userStatusRepository.findByUserId(userId)).willReturn(Optional.empty()); + + // When & Then + assertThatThrownBy(() -> userStatusService.updateByUserId(userId, request)) + .isInstanceOf(UserStatusNotFoundException.class); + + then(userStatusRepository).should().findByUserId(userId); + then(userStatusMapper).should(never()).toDto(any()); + } + + @Test + @DisplayName("사용자 상태 삭제 - 성공") + void delete_Success() { + // Given + UUID userStatusId = UUID.randomUUID(); + + given(userStatusRepository.existsById(userStatusId)).willReturn(true); + + // When + userStatusService.delete(userStatusId); + + // Then + then(userStatusRepository).should().existsById(userStatusId); + then(userStatusRepository).should().deleteById(userStatusId); + } + + @Test + @DisplayName("사용자 상태 삭제 - 상태를 찾을 수 없어 실패") + void delete_FailWhenUserStatusNotFound() { + // Given + UUID userStatusId = UUID.randomUUID(); + + given(userStatusRepository.existsById(userStatusId)).willReturn(false); + + // When & Then + assertThatThrownBy(() -> userStatusService.delete(userStatusId)) + .isInstanceOf(UserStatusNotFoundException.class); + + then(userStatusRepository).should().existsById(userStatusId); + then(userStatusRepository).should(never()).deleteById(userStatusId); + } +} \ No newline at end of file diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml new file mode 100644 index 000000000..3ee9ba6f5 --- /dev/null +++ b/src/test/resources/application-test.yaml @@ -0,0 +1,32 @@ +spring: + datasource: + url: jdbc:h2:mem:discodeit;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH + driver-class-name: org.h2.Driver + username: discodeit_user + password: discodeit1234 + + jpa: + hibernate: + ddl-auto: create-drop + show-sql: true + properties: + hibernate: + format_sql: true + dialect: org.hibernate.dialect.H2Dialect + defer-datasource-initialization: true + + h2: + console: + enabled: true + + sql: + init: + mode: always + +logging: + level: + org.hibernate.SQL: DEBUG + org.hibernate.type.descriptor.sql.BasicBinder: TRACE + org.springframework.web: DEBUG + com.sprint.mission.discodeit: DEBUG + root: INFO \ No newline at end of file