diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000000..9f002d3adf
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,47 @@
+.gradle
+build/
+!gradle/wrapper/gradle-wrapper.jar
+!**/src/main/**/build/
+!**/src/test/**/build/
+
+### IntelliJ IDEA ###
+.idea
+*.iws
+*.iml
+*.ipr
+out/
+!**/src/main/**/out/
+!**/src/test/**/out/
+
+### Eclipse ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+bin/
+!**/src/main/**/bin/
+!**/src/test/**/bin/
+
+### NetBeans ###
+/nbproject/private/
+/nbbuild/
+/dist/
+/nbdist/
+/.nb-gradle/
+
+### VS Code ###
+.vscode/
+
+### Mac OS ###
+.DS_Store
+
+### Discodeit ###
+.discodeit
+
+### 숨김 파일 ###
+.*
+!.gitignore
+.env
\ No newline at end of file
diff --git a/.idea/compiler.xml b/.idea/compiler.xml
new file mode 100644
index 0000000000..d3a8c61231
--- /dev/null
+++ b/.idea/compiler.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644
index 0000000000..de0c4286d7
--- /dev/null
+++ b/.idea/misc.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 0000000000..95b664df54
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/modules/discodeit.test.iml b/.idea/modules/discodeit.test.iml
new file mode 100644
index 0000000000..de7f977c76
--- /dev/null
+++ b/.idea/modules/discodeit.test.iml
@@ -0,0 +1,137 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000000..35eb1ddfbb
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/workspace.xml b/.idea/workspace.xml
new file mode 100644
index 0000000000..5079c92260
--- /dev/null
+++ b/.idea/workspace.xml
@@ -0,0 +1,378 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ "customColor": "",
+ "associatedIndex": 4
+}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ false
+ true
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ false
+ true
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ false
+ true
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ false
+ true
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 1736491744421
+
+
+ 1736491744421
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000000..d16378a68f
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,24 @@
+# Amazon Corretto 17 이미지를 베이스 이미지로 사용
+FROM amazoncorretto:17.0.7-alpine
+
+# 작업 디렉토리 설정 (/app)
+WORKDIR /app
+
+# 프로젝트 파일을 컨테이너로 복사
+COPY . .
+
+# Gradle Wrapper를 사용하여 애플리케이션 빌드
+RUN ./gradlew build -x test
+
+# 80 포트를 노출
+EXPOSE 80
+
+# 프로젝트 정보 환경 변수 설정
+ENV PROJECT_NAME=discodeit
+ENV PROJECT_VERSION=1.2-M8
+
+# JVM 옵션을 위한 환경 변수 설정 (기본값은 빈 문자열)
+ENV JVM_OPTS=""
+
+# 애플리케이션 실행 명령어 설정
+CMD ["sh", "-c", "java $JVM_OPTS -jar build/libs/${PROJECT_NAME}-${PROJECT_VERSION}.jar"]
diff --git a/HELP.md b/HELP.md
new file mode 100644
index 0000000000..42c5f00231
--- /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
deleted file mode 100644
index 81a8535d90..0000000000
--- a/README.md
+++ /dev/null
@@ -1 +0,0 @@
-# Spring 백엔드 트랙 1기 스프린트 미션 제출 리포지토리
\ No newline at end of file
diff --git a/_1-sprint-mission/.gitignore b/_1-sprint-mission/.gitignore
index edd0b002dd..b2b7bf3d7f 100644
--- a/_1-sprint-mission/.gitignore
+++ b/_1-sprint-mission/.gitignore
@@ -1,14 +1,19 @@
-HELP.md
.gradle
build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/
-**/*.log
-.idea/
-application-secret.yml
-### STS ###
+### IntelliJ IDEA ###
+.idea
+*.iws
+*.iml
+*.ipr
+out/
+!**/src/main/**/out/
+!**/src/test/**/out/
+
+### Eclipse ###
.apt_generated
.classpath
.factorypath
@@ -20,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/
@@ -38,3 +34,13 @@ out/
### VS Code ###
.vscode/
+
+### Mac OS ###
+.DS_Store
+
+### Discodeit ###
+.discodeit
+
+### 숨김 파일 ###
+.*
+!.gitignore
\ No newline at end of file
diff --git a/_1-sprint-mission/src/main/resources/application-dev.yml b/_1-sprint-mission/src/main/resources/application-dev.yml
index fdfe07f3c7..199384b16e 100644
--- a/_1-sprint-mission/src/main/resources/application-dev.yml
+++ b/_1-sprint-mission/src/main/resources/application-dev.yml
@@ -8,16 +8,14 @@ spring:
on-profile: dev
datasource:
- driver-class-name: org.postgresql.Driver
url: ${DEV.DB.URL}
username: ${DEV.DB.USERNAME}
password: ${DEV.DB.PASSWORD}
jpa:
- hibernate:
- ddl-auto: update
- show-sql: true
- open-in-view: false
+ properties:
+ hibernate:
+ format_sql: true
springdoc:
api-docs:
@@ -25,23 +23,12 @@ spring:
swagger-ui:
path: /swagger-ui.html
- servlet:
- multipart:
- max-file-size: 10MB
- max-request-size: 30MB
-
logging:
level:
- root: DEBUG
- org.hibernate.SQL: DEBUG # SQL 실행 로그를 DEBUG 레벨로 출력
+ com.sprint.mission.discodeit: debug
+ org.hibernate.SQL: debug # SQL 실행 로그를 DEBUG 레벨로 출력
org.hibernate.orm.jdbc.bind: trace
-discodeit:
- storage:
- type: local
- local:
- root-path: C:/storage/binaryContent
-
management:
endpoints:
web:
diff --git a/_1-sprint-mission/src/main/resources/application-prod.yml b/_1-sprint-mission/src/main/resources/application-prod.yml
index 6ce4909b9d..8c394b7c4e 100644
--- a/_1-sprint-mission/src/main/resources/application-prod.yml
+++ b/_1-sprint-mission/src/main/resources/application-prod.yml
@@ -1,5 +1,5 @@
server:
- port: 8080
+ port: 80
spring:
config:
@@ -8,26 +8,16 @@ spring:
on-profile: prod
datasource:
- driver-class-name: org.postgresql.Driver
url: ${PROD.DB.URL}
username: ${PROD.DB.USERNAME}
password: ${PROD.DB.PASSWORD}
jpa:
- hibernate:
- ddl-auto: none # 프로덕션 환경에서는 자동 DDL 생성을 비활성화
- show-sql: false # SQL 쿼리 출력 비활성화
- open-in-view: false
-
+ properties:
+ hibernate:
+ format_sql: false
logging:
level:
- root: info # 운영 환경에서는 info 레벨로 설정
- org.hibernate.SQL: DEBUG # SQL 실행 로그를 DEBUG 레벨로 출력
- org.hibernate.orm.jdbc.bind: trace # SQL 바인딩 로그 출력
-
-discodeit:
- storage:
- type: local
- local:
- root-path: /prod/storage/binaryContent # 프로덕션 환경에서는 다른 디렉토리 사용
+ com.sprint.mission.discodeit: info
+ org.hibernate.SQL: info
diff --git a/_1-sprint-mission/src/main/resources/application.yml b/_1-sprint-mission/src/main/resources/application.yml
index 6f09cd812f..9c53985661 100644
--- a/_1-sprint-mission/src/main/resources/application.yml
+++ b/_1-sprint-mission/src/main/resources/application.yml
@@ -2,10 +2,26 @@ spring:
application:
name: discodeit
+ servlet:
+ multipart:
+ maxFileSize: 10MB # 파일 하나의 최대 크기
+ maxRequestSize: 30MB # 한 번에 최대 업로드 가능 용량
+ datasource:
+ driver-class-name: org.postgresql.Driver
+ jpa:
+ hibernate:
+ ddl-auto: validate
+ open-in-view: false
profiles:
- active: dev
+ active:
+ - dev
logging:
level:
- root: INFO
+ root: info
+discodeit:
+ storage:
+ type: local
+ local:
+ root-path: C:/storage/binaryContent
diff --git a/_1-sprint-mission/src/main/resources/logback-spring.xml b/_1-sprint-mission/src/main/resources/logback-spring.xml
new file mode 100644
index 0000000000..5cefdcc6c6
--- /dev/null
+++ b/_1-sprint-mission/src/main/resources/logback-spring.xml
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${LOG_PATTERN}
+
+
+
+
+
+ ${LOG_FILE_PATH}/${LOG_FILE_NAME}.log
+
+ ${LOG_PATTERN}
+
+
+ ${LOG_FILE_PATH}/${LOG_FILE_NAME}.%d{yyyy-MM-dd}.log
+ 30
+
+
+
+
+
+
+
+
+
+
diff --git a/_1-sprint-mission/src/main/resources/logback.xml b/_1-sprint-mission/src/main/resources/logback.xml
deleted file mode 100644
index 8a6ab668f9..0000000000
--- a/_1-sprint-mission/src/main/resources/logback.xml
+++ /dev/null
@@ -1,33 +0,0 @@
-
-
-
-
-
-
-
- ${LOG_PATTERN}
-
-
-
-
-
-
-
-
- ${LOG_PATH}/app.log
-
- ${LOG_PATH}/app.%d{yyyy-MM-dd}.log
- 30
-
-
- ${LOG_PATTERN}
-
-
-
-
-
-
-
-
-
diff --git a/api-docs_1.2.json b/api-docs_1.2.json
new file mode 100644
index 0000000000..7253644c94
--- /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
new file mode 100644
index 0000000000..0db3817021
--- /dev/null
+++ b/build.gradle
@@ -0,0 +1,48 @@
+plugins {
+ id 'java'
+ id 'org.springframework.boot' version '3.4.0'
+ id 'io.spring.dependency-management' version '1.1.6'
+}
+
+group = 'com.sprint.mission'
+version = '1.2-M8'
+
+java {
+ toolchain {
+ languageVersion = JavaLanguageVersion.of(17)
+ }
+}
+
+configurations {
+ compileOnly {
+ extendsFrom annotationProcessor
+ }
+}
+
+repositories {
+ mavenCentral()
+}
+
+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-validation'
+ implementation 'org.springframework.boot:spring-boot-starter-actuator'
+ implementation 'software.amazon.awssdk:s3:2.31.7'
+
+ runtimeOnly 'org.postgresql:postgresql'
+
+ 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'
+ testImplementation 'com.h2database:h2'
+ testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
+}
+
+tasks.named('test') {
+ useJUnitPlatform()
+}
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000000..b172b891a9
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,47 @@
+version: '3.8'
+services:
+ app:
+ build: .
+ environment:
+ SPRING_DATASOURCE_URL: "jdbc:postgresql://postgres:5432/${DB_NAME}"
+ SPRING_DATASOURCE_USERNAME: ${DB_USER}
+ SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD}
+ # discodeit storage 관련 환경변수
+ STORAGE_TYPE: ${STORAGE_TYPE:-local} # local 또는 s3
+ STORAGE_LOCAL_ROOT_PATH: ${STORAGE_LOCAL_ROOT_PATH:-.discodeit/storage}
+ AWS_S3_ACCESS_KEY: ${AWS_S3_ACCESS_KEY}
+ AWS_S3_SECRET_KEY: ${AWS_S3_SECRET_KEY}
+ AWS_S3_REGION: ${AWS_S3_REGION}
+ AWS_S3_BUCKET: ${AWS_S3_BUCKET}
+ AWS_S3_PRESIGNED_URL_EXPIRATION: ${AWS_S3_PRESIGNED_URL_EXPIRATION:-600} # 기본값 10분
+ depends_on:
+ - postgres
+ ports:
+ - "${APP_PORT}:8080"
+ volumes:
+ - binary_content_data:/app/BinaryContentStorage
+ networks:
+ - mynetwork
+
+
+ postgres:
+ image: postgres:${POSTGRES_VERSION}
+ environment:
+ POSTGRES_DB: ${DB_NAME}
+ POSTGRES_USER: ${DB_USER}
+ POSTGRES_PASSWORD: ${DB_PASSWORD}
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+ - ./src/main/resources/schema.sql:/docker-entrypoint-initdb.d/schema.sql:ro
+ ports:
+ - "5432:5432"
+ networks:
+ - mynetwork
+ -
+
+volumes:
+ binary_content_data:
+ postgres_data:
+
+networks:
+ mynetwork:
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000000..a4b76b9530
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000000..e2847c8200
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100644
index 0000000000..f5feea6d6b
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,252 @@
+#!/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
+' "$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=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# 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, 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" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# 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/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000000..9d21a21834
--- /dev/null
+++ b/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=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+: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/settings.gradle b/settings.gradle
new file mode 100644
index 0000000000..2437dfb29c
--- /dev/null
+++ b/settings.gradle
@@ -0,0 +1 @@
+rootProject.name = 'discodeit'
diff --git a/src/main/java/com/sprint/mission/discodeit/DiscodeitApplication.java b/src/main/java/com/sprint/mission/discodeit/DiscodeitApplication.java
new file mode 100644
index 0000000000..8f61230d4c
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/DiscodeitApplication.java
@@ -0,0 +1,12 @@
+package com.sprint.mission.discodeit;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class DiscodeitApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(DiscodeitApplication.class, args);
+ }
+}
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 0000000000..96010621f2
--- /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/AwsS3config.java b/src/main/java/com/sprint/mission/discodeit/config/AwsS3config.java
new file mode 100644
index 0000000000..d3a9e8bfc6
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/config/AwsS3config.java
@@ -0,0 +1,33 @@
+package com.sprint.mission.discodeit.config;
+
+import com.sprint.mission.discodeit.storage.BinaryContentStorage;
+import com.sprint.mission.discodeit.storage.s3.S3BinaryContentStorage;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+@ConditionalOnProperty(
+ name = "discodeit.storage.type",
+ havingValue = "s3"
+)
+public class AwsS3config {
+
+ @Value("${cloud.aws.credentials.access-key}")
+ private String accessKey;
+
+ @Value("{cloud.aws.credentials.secret-key}")
+ private String secretKey;
+
+ @Value("${cloud.aws.region.static")
+ private String region;
+
+ @Value("${cloud.aws.region.static")
+ private String bucket;
+
+ @Bean
+ public S3BinaryContentStorage s3BinaryContentStorage() {
+ return new S3BinaryContentStorage(accessKey, secretKey, region, bucket);
+ }
+}
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 0000000000..15a7771999
--- /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/controller/AuthController.java b/src/main/java/com/sprint/mission/discodeit/controller/AuthController.java
new file mode 100644
index 0000000000..8d3d2a9f9f
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/controller/AuthController.java
@@ -0,0 +1,34 @@
+package com.sprint.mission.discodeit.controller;
+
+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.service.AuthService;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/api/auth")
+public class AuthController implements AuthApi {
+
+ private final AuthService authService;
+
+ @PostMapping(path = "login")
+ public ResponseEntity login(@RequestBody @Valid LoginRequest loginRequest) {
+ log.info("로그인 요청: username={}", loginRequest.username());
+ UserDto user = authService.login(loginRequest);
+ log.debug("로그인 응답: {}", user);
+ 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
new file mode 100644
index 0000000000..a0b93ffde8
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/controller/BinaryContentController.java
@@ -0,0 +1,60 @@
+package com.sprint.mission.discodeit.controller;
+
+import com.sprint.mission.discodeit.controller.api.BinaryContentApi;
+import com.sprint.mission.discodeit.dto.data.BinaryContentDto;
+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.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/api/binaryContents")
+public class BinaryContentController implements BinaryContentApi {
+
+ private final BinaryContentService binaryContentService;
+ private final BinaryContentStorage binaryContentStorage;
+
+ @GetMapping(path = "{binaryContentId}")
+ public ResponseEntity find(
+ @PathVariable("binaryContentId") UUID binaryContentId) {
+ log.info("바이너리 컨텐츠 조회 요청: id={}", binaryContentId);
+ BinaryContentDto binaryContent = binaryContentService.find(binaryContentId);
+ log.debug("바이너리 컨텐츠 조회 응답: {}", binaryContent);
+ return ResponseEntity
+ .status(HttpStatus.OK)
+ .body(binaryContent);
+ }
+
+ @GetMapping
+ public ResponseEntity> findAllByIdIn(
+ @RequestParam("binaryContentIds") List binaryContentIds) {
+ log.info("바이너리 컨텐츠 목록 조회 요청: ids={}", binaryContentIds);
+ List binaryContents = binaryContentService.findAllByIdIn(binaryContentIds);
+ log.debug("바이너리 컨텐츠 목록 조회 응답: count={}", binaryContents.size());
+ return ResponseEntity
+ .status(HttpStatus.OK)
+ .body(binaryContents);
+ }
+
+ @GetMapping(path = "{binaryContentId}/download")
+ public ResponseEntity> download(
+ @PathVariable("binaryContentId") UUID binaryContentId) {
+ log.info("바이너리 컨텐츠 다운로드 요청: id={}", binaryContentId);
+ BinaryContentDto binaryContentDto = binaryContentService.find(binaryContentId);
+ ResponseEntity> response = binaryContentStorage.download(binaryContentDto);
+ log.debug("바이너리 컨텐츠 다운로드 응답: contentType={}, contentLength={}",
+ response.getHeaders().getContentType(), response.getHeaders().getContentLength());
+ return response;
+ }
+}
diff --git a/src/main/java/com/sprint/mission/discodeit/controller/ChannelController.java b/src/main/java/com/sprint/mission/discodeit/controller/ChannelController.java
new file mode 100644
index 0000000000..3c84242362
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/controller/ChannelController.java
@@ -0,0 +1,85 @@
+package com.sprint.mission.discodeit.controller;
+
+import com.sprint.mission.discodeit.controller.api.ChannelApi;
+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.service.ChannelService;
+import jakarta.validation.Valid;
+import java.util.List;
+import java.util.UUID;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PatchMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/api/channels")
+public class ChannelController implements ChannelApi {
+
+ private final ChannelService channelService;
+
+ @PostMapping(path = "public")
+ public ResponseEntity create(@RequestBody @Valid PublicChannelCreateRequest request) {
+ log.info("공개 채널 생성 요청: {}", request);
+ ChannelDto createdChannel = channelService.create(request);
+ log.debug("공개 채널 생성 응답: {}", createdChannel);
+ return ResponseEntity
+ .status(HttpStatus.CREATED)
+ .body(createdChannel);
+ }
+
+ @PostMapping(path = "private")
+ public ResponseEntity create(@RequestBody @Valid PrivateChannelCreateRequest request) {
+ log.info("비공개 채널 생성 요청: {}", request);
+ ChannelDto createdChannel = channelService.create(request);
+ log.debug("비공개 채널 생성 응답: {}", createdChannel);
+ return ResponseEntity
+ .status(HttpStatus.CREATED)
+ .body(createdChannel);
+ }
+
+ @PatchMapping(path = "{channelId}")
+ public ResponseEntity update(
+ @PathVariable("channelId") UUID channelId,
+ @RequestBody @Valid PublicChannelUpdateRequest request) {
+ log.info("채널 수정 요청: id={}, request={}", channelId, request);
+ ChannelDto updatedChannel = channelService.update(channelId, request);
+ log.debug("채널 수정 응답: {}", updatedChannel);
+ return ResponseEntity
+ .status(HttpStatus.OK)
+ .body(updatedChannel);
+ }
+
+ @DeleteMapping(path = "{channelId}")
+ public ResponseEntity delete(@PathVariable("channelId") UUID channelId) {
+ log.info("채널 삭제 요청: id={}", channelId);
+ channelService.delete(channelId);
+ log.debug("채널 삭제 완료");
+ return ResponseEntity
+ .status(HttpStatus.NO_CONTENT)
+ .build();
+ }
+
+ @GetMapping
+ public ResponseEntity> findAll(@RequestParam("userId") UUID userId) {
+ log.info("사용자별 채널 목록 조회 요청: userId={}", userId);
+ List channels = channelService.findAllByUserId(userId);
+ log.debug("사용자별 채널 목록 조회 응답: count={}", channels.size());
+ 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
new file mode 100644
index 0000000000..5f7777d023
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/controller/MessageController.java
@@ -0,0 +1,116 @@
+package com.sprint.mission.discodeit.controller;
+
+import com.sprint.mission.discodeit.controller.api.MessageApi;
+import com.sprint.mission.discodeit.dto.data.MessageDto;
+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.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;
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Sort.Direction;
+import org.springframework.data.web.PageableDefault;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PatchMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RequestPart;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/api/messages")
+public class MessageController implements MessageApi {
+
+ private final MessageService messageService;
+
+ @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+ public ResponseEntity create(
+ @RequestPart("messageCreateRequest") @Valid MessageCreateRequest messageCreateRequest,
+ @RequestPart(value = "attachments", required = false) List attachments
+ ) {
+ log.info("메시지 생성 요청: request={}, attachmentCount={}",
+ messageCreateRequest, attachments != null ? attachments.size() : 0);
+
+ 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);
+ log.debug("메시지 생성 응답: {}", createdMessage);
+ return ResponseEntity
+ .status(HttpStatus.CREATED)
+ .body(createdMessage);
+ }
+
+ @PatchMapping(path = "{messageId}")
+ public ResponseEntity update(
+ @PathVariable("messageId") UUID messageId,
+ @RequestBody @Valid MessageUpdateRequest request) {
+ log.info("메시지 수정 요청: id={}, request={}", messageId, request);
+ MessageDto updatedMessage = messageService.update(messageId, request);
+ log.debug("메시지 수정 응답: {}", updatedMessage);
+ return ResponseEntity
+ .status(HttpStatus.OK)
+ .body(updatedMessage);
+ }
+
+ @DeleteMapping(path = "{messageId}")
+ public ResponseEntity delete(@PathVariable("messageId") UUID messageId) {
+ log.info("메시지 삭제 요청: id={}", messageId);
+ messageService.delete(messageId);
+ log.debug("메시지 삭제 완료");
+ return ResponseEntity
+ .status(HttpStatus.NO_CONTENT)
+ .build();
+ }
+
+ @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) {
+ log.info("채널별 메시지 목록 조회 요청: channelId={}, cursor={}, pageable={}",
+ channelId, cursor, pageable);
+ PageResponse messages = messageService.findAllByChannelId(channelId, cursor,
+ pageable);
+ log.debug("채널별 메시지 목록 조회 응답: totalElements={}", messages.totalElements());
+ 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
new file mode 100644
index 0000000000..ac980c066c
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/controller/ReadStatusController.java
@@ -0,0 +1,62 @@
+package com.sprint.mission.discodeit.controller;
+
+import com.sprint.mission.discodeit.controller.api.ReadStatusApi;
+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.service.ReadStatusService;
+import jakarta.validation.Valid;
+import java.util.List;
+import java.util.UUID;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PatchMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/api/readStatuses")
+public class ReadStatusController implements ReadStatusApi {
+
+ private final ReadStatusService readStatusService;
+
+ @PostMapping
+ public ResponseEntity create(@RequestBody @Valid ReadStatusCreateRequest request) {
+ log.info("읽음 상태 생성 요청: {}", request);
+ ReadStatusDto createdReadStatus = readStatusService.create(request);
+ log.debug("읽음 상태 생성 응답: {}", createdReadStatus);
+ return ResponseEntity
+ .status(HttpStatus.CREATED)
+ .body(createdReadStatus);
+ }
+
+ @PatchMapping(path = "{readStatusId}")
+ public ResponseEntity update(@PathVariable("readStatusId") UUID readStatusId,
+ @RequestBody @Valid ReadStatusUpdateRequest request) {
+ log.info("읽음 상태 수정 요청: id={}, request={}", readStatusId, request);
+ ReadStatusDto updatedReadStatus = readStatusService.update(readStatusId, request);
+ log.debug("읽음 상태 수정 응답: {}", updatedReadStatus);
+ return ResponseEntity
+ .status(HttpStatus.OK)
+ .body(updatedReadStatus);
+ }
+
+ @GetMapping
+ public ResponseEntity> findAllByUserId(@RequestParam("userId") UUID userId) {
+ log.info("사용자별 읽음 상태 목록 조회 요청: userId={}", userId);
+ List readStatuses = readStatusService.findAllByUserId(userId);
+ log.debug("사용자별 읽음 상태 목록 조회 응답: count={}", readStatuses.size());
+ 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
new file mode 100644
index 0000000000..46bb8a4458
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/controller/UserController.java
@@ -0,0 +1,123 @@
+package com.sprint.mission.discodeit.controller;
+
+import com.sprint.mission.discodeit.controller.api.UserApi;
+import com.sprint.mission.discodeit.dto.data.UserDto;
+import com.sprint.mission.discodeit.dto.data.UserStatusDto;
+import com.sprint.mission.discodeit.dto.request.BinaryContentCreateRequest;
+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 jakarta.validation.Valid;
+import java.io.IOException;
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PatchMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestPart;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/api/users")
+public class UserController implements UserApi {
+
+ private final UserService userService;
+ private final UserStatusService userStatusService;
+
+ @PostMapping(consumes = {MediaType.MULTIPART_FORM_DATA_VALUE})
+ @Override
+ public ResponseEntity create(
+ @RequestPart("userCreateRequest") @Valid UserCreateRequest userCreateRequest,
+ @RequestPart(value = "profile", required = false) MultipartFile profile
+ ) {
+ log.info("사용자 생성 요청: {}", userCreateRequest);
+ Optional profileRequest = Optional.ofNullable(profile)
+ .flatMap(this::resolveProfileRequest);
+ UserDto createdUser = userService.create(userCreateRequest, profileRequest);
+ log.debug("사용자 생성 응답: {}", createdUser);
+ return ResponseEntity
+ .status(HttpStatus.CREATED)
+ .body(createdUser);
+ }
+
+ @PatchMapping(
+ path = "{userId}",
+ consumes = {MediaType.MULTIPART_FORM_DATA_VALUE}
+ )
+ @Override
+ public ResponseEntity update(
+ @PathVariable("userId") UUID userId,
+ @RequestPart("userUpdateRequest") @Valid UserUpdateRequest userUpdateRequest,
+ @RequestPart(value = "profile", required = false) MultipartFile profile
+ ) {
+ log.info("사용자 수정 요청: id={}, request={}", userId, userUpdateRequest);
+ Optional profileRequest = Optional.ofNullable(profile)
+ .flatMap(this::resolveProfileRequest);
+ UserDto updatedUser = userService.update(userId, userUpdateRequest, profileRequest);
+ log.debug("사용자 수정 응답: {}", updatedUser);
+ return ResponseEntity
+ .status(HttpStatus.OK)
+ .body(updatedUser);
+ }
+
+ @DeleteMapping(path = "{userId}")
+ @Override
+ public ResponseEntity delete(@PathVariable("userId") UUID userId) {
+ userService.delete(userId);
+ return ResponseEntity
+ .status(HttpStatus.NO_CONTENT)
+ .build();
+ }
+
+ @GetMapping
+ @Override
+ public ResponseEntity> findAll() {
+ List users = userService.findAll();
+ return ResponseEntity
+ .status(HttpStatus.OK)
+ .body(users);
+ }
+
+ @PatchMapping(path = "{userId}/userStatus")
+ @Override
+ public ResponseEntity updateUserStatusByUserId(
+ @PathVariable("userId") UUID userId,
+ @RequestBody @Valid 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);
+ }
+ }
+ }
+}
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
new file mode 100644
index 0000000000..ee9ce79f9a
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/controller/api/AuthApi.java
@@ -0,0 +1,36 @@
+package com.sprint.mission.discodeit.controller.api;
+
+import com.sprint.mission.discodeit.dto.data.UserDto;
+import com.sprint.mission.discodeit.dto.request.LoginRequest;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.ExampleObject;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.http.ResponseEntity;
+
+@Tag(name = "Auth", description = "인증 API")
+public interface AuthApi {
+
+ @Operation(summary = "로그인")
+ @ApiResponses(value = {
+ @ApiResponse(
+ responseCode = "200", description = "로그인 성공",
+ content = @Content(schema = @Schema(implementation = UserDto.class))
+ ),
+ @ApiResponse(
+ responseCode = "404", description = "사용자를 찾을 수 없음",
+ content = @Content(examples = @ExampleObject(value = "User with username {username} not found"))
+ ),
+ @ApiResponse(
+ responseCode = "400", description = "비밀번호가 일치하지 않음",
+ content = @Content(examples = @ExampleObject(value = "Wrong password"))
+ )
+ })
+ ResponseEntity login(
+ @Parameter(description = "로그인 정보") LoginRequest loginRequest
+ );
+}
\ No newline at end of file
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
new file mode 100644
index 0000000000..883ab8a881
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/controller/api/BinaryContentApi.java
@@ -0,0 +1,57 @@
+package com.sprint.mission.discodeit.controller.api;
+
+import com.sprint.mission.discodeit.dto.data.BinaryContentDto;
+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;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import java.util.List;
+import java.util.UUID;
+import org.springframework.core.io.Resource;
+import org.springframework.http.ResponseEntity;
+
+@Tag(name = "BinaryContent", description = "첨부 파일 API")
+public interface BinaryContentApi {
+
+ @Operation(summary = "첨부 파일 조회")
+ @ApiResponses(value = {
+ @ApiResponse(
+ responseCode = "200", description = "첨부 파일 조회 성공",
+ content = @Content(schema = @Schema(implementation = BinaryContentDto.class))
+ ),
+ @ApiResponse(
+ responseCode = "404", description = "첨부 파일을 찾을 수 없음",
+ content = @Content(examples = @ExampleObject(value = "BinaryContent with id {binaryContentId} not found"))
+ )
+ })
+ ResponseEntity find(
+ @Parameter(description = "조회할 첨부 파일 ID") UUID binaryContentId
+ );
+
+ @Operation(summary = "여러 첨부 파일 조회")
+ @ApiResponses(value = {
+ @ApiResponse(
+ responseCode = "200", description = "첨부 파일 목록 조회 성공",
+ content = @Content(array = @ArraySchema(schema = @Schema(implementation = BinaryContentDto.class)))
+ )
+ })
+ ResponseEntity> findAllByIdIn(
+ @Parameter(description = "조회할 첨부 파일 ID 목록") List binaryContentIds
+ );
+
+ @Operation(summary = "파일 다운로드")
+ @ApiResponses(value = {
+ @ApiResponse(
+ responseCode = "200", description = "파일 다운로드 성공",
+ content = @Content(schema = @Schema(implementation = Resource.class))
+ )
+ })
+ ResponseEntity> download(
+ @Parameter(description = "다운로드할 파일 ID") UUID binaryContentId
+ );
+}
\ 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
new file mode 100644
index 0000000000..af8c7afc75
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/controller/api/ChannelApi.java
@@ -0,0 +1,89 @@
+package com.sprint.mission.discodeit.controller.api;
+
+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 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;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import java.util.List;
+import java.util.UUID;
+import org.springframework.http.ResponseEntity;
+
+@Tag(name = "Channel", description = "Channel API")
+public interface ChannelApi {
+
+ @Operation(summary = "Public Channel 생성")
+ @ApiResponses(value = {
+ @ApiResponse(
+ responseCode = "201", description = "Public Channel이 성공적으로 생성됨",
+ content = @Content(schema = @Schema(implementation = ChannelDto.class))
+ )
+ })
+ ResponseEntity create(
+ @Parameter(description = "Public Channel 생성 정보") PublicChannelCreateRequest request
+ );
+
+ @Operation(summary = "Private Channel 생성")
+ @ApiResponses(value = {
+ @ApiResponse(
+ responseCode = "201", description = "Private Channel이 성공적으로 생성됨",
+ content = @Content(schema = @Schema(implementation = ChannelDto.class))
+ )
+ })
+ ResponseEntity create(
+ @Parameter(description = "Private Channel 생성 정보") PrivateChannelCreateRequest request
+ );
+
+ @Operation(summary = "Channel 정보 수정")
+ @ApiResponses(value = {
+ @ApiResponse(
+ responseCode = "200", description = "Channel 정보가 성공적으로 수정됨",
+ content = @Content(schema = @Schema(implementation = ChannelDto.class))
+ ),
+ @ApiResponse(
+ responseCode = "404", description = "Channel을 찾을 수 없음",
+ content = @Content(examples = @ExampleObject(value = "Channel with id {channelId} not found"))
+ ),
+ @ApiResponse(
+ responseCode = "400", description = "Private Channel은 수정할 수 없음",
+ content = @Content(examples = @ExampleObject(value = "Private channel cannot be updated"))
+ )
+ })
+ ResponseEntity update(
+ @Parameter(description = "수정할 Channel ID") UUID channelId,
+ @Parameter(description = "수정할 Channel 정보") PublicChannelUpdateRequest request
+ );
+
+ @Operation(summary = "Channel 삭제")
+ @ApiResponses(value = {
+ @ApiResponse(
+ responseCode = "204", description = "Channel이 성공적으로 삭제됨"
+ ),
+ @ApiResponse(
+ responseCode = "404", description = "Channel을 찾을 수 없음",
+ content = @Content(examples = @ExampleObject(value = "Channel with id {channelId} not found"))
+ )
+ })
+ ResponseEntity delete(
+ @Parameter(description = "삭제할 Channel ID") UUID channelId
+ );
+
+ @Operation(summary = "User가 참여 중인 Channel 목록 조회")
+ @ApiResponses(value = {
+ @ApiResponse(
+ responseCode = "200", description = "Channel 목록 조회 성공",
+ content = @Content(array = @ArraySchema(schema = @Schema(implementation = ChannelDto.class)))
+ )
+ })
+ ResponseEntity> findAll(
+ @Parameter(description = "조회할 User ID") UUID userId
+ );
+}
\ No newline at end of file
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
new file mode 100644
index 0000000000..c9a7aebbd3
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/controller/api/MessageApi.java
@@ -0,0 +1,90 @@
+package com.sprint.mission.discodeit.controller.api;
+
+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.response.PageResponse;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.ExampleObject;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import java.time.Instant;
+import java.util.List;
+import java.util.UUID;
+import org.springframework.data.domain.Pageable;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.multipart.MultipartFile;
+
+@Tag(name = "Message", description = "Message API")
+public interface MessageApi {
+
+ @Operation(summary = "Message 생성")
+ @ApiResponses(value = {
+ @ApiResponse(
+ responseCode = "201", description = "Message가 성공적으로 생성됨",
+ content = @Content(schema = @Schema(implementation = MessageDto.class))
+ ),
+ @ApiResponse(
+ responseCode = "404", description = "Channel 또는 User를 찾을 수 없음",
+ content = @Content(examples = @ExampleObject(value = "Channel | Author with id {channelId | authorId} not found"))
+ ),
+ })
+ ResponseEntity create(
+ @Parameter(
+ description = "Message 생성 정보",
+ content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)
+ ) MessageCreateRequest messageCreateRequest,
+ @Parameter(
+ description = "Message 첨부 파일들",
+ content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE)
+ ) List attachments
+ );
+
+ @Operation(summary = "Message 내용 수정")
+ @ApiResponses(value = {
+ @ApiResponse(
+ responseCode = "200", description = "Message가 성공적으로 수정됨",
+ content = @Content(schema = @Schema(implementation = MessageDto.class))
+ ),
+ @ApiResponse(
+ responseCode = "404", description = "Message를 찾을 수 없음",
+ content = @Content(examples = @ExampleObject(value = "Message with id {messageId} not found"))
+ ),
+ })
+ ResponseEntity update(
+ @Parameter(description = "수정할 Message ID") UUID messageId,
+ @Parameter(description = "수정할 Message 내용") MessageUpdateRequest request
+ );
+
+ @Operation(summary = "Message 삭제")
+ @ApiResponses(value = {
+ @ApiResponse(
+ responseCode = "204", description = "Message가 성공적으로 삭제됨"
+ ),
+ @ApiResponse(
+ responseCode = "404", description = "Message를 찾을 수 없음",
+ content = @Content(examples = @ExampleObject(value = "Message with id {messageId} not found"))
+ ),
+ })
+ ResponseEntity delete(
+ @Parameter(description = "삭제할 Message ID") UUID messageId
+ );
+
+ @Operation(summary = "Channel의 Message 목록 조회")
+ @ApiResponses(value = {
+ @ApiResponse(
+ responseCode = "200", description = "Message 목록 조회 성공",
+ content = @Content(schema = @Schema(implementation = PageResponse.class))
+ )
+ })
+ ResponseEntity> findAllByChannelId(
+ @Parameter(description = "조회할 Channel ID") UUID channelId,
+ @Parameter(description = "페이징 커서 정보") Instant cursor,
+ @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
new file mode 100644
index 0000000000..eb08b359fc
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/controller/api/ReadStatusApi.java
@@ -0,0 +1,67 @@
+package com.sprint.mission.discodeit.controller.api;
+
+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 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;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import java.util.List;
+import java.util.UUID;
+import org.springframework.http.ResponseEntity;
+
+@Tag(name = "ReadStatus", description = "Message 읽음 상태 API")
+public interface ReadStatusApi {
+
+ @Operation(summary = "Message 읽음 상태 생성")
+ @ApiResponses(value = {
+ @ApiResponse(
+ responseCode = "201", description = "Message 읽음 상태가 성공적으로 생성됨",
+ content = @Content(schema = @Schema(implementation = ReadStatusDto.class))
+ ),
+ @ApiResponse(
+ responseCode = "404", description = "Channel 또는 User를 찾을 수 없음",
+ content = @Content(examples = @ExampleObject(value = "Channel | User with id {channelId | userId} not found"))
+ ),
+ @ApiResponse(
+ responseCode = "400", description = "이미 읽음 상태가 존재함",
+ content = @Content(examples = @ExampleObject(value = "ReadStatus with userId {userId} and channelId {channelId} already exists"))
+ )
+ })
+ ResponseEntity create(
+ @Parameter(description = "Message 읽음 상태 생성 정보") ReadStatusCreateRequest request
+ );
+
+ @Operation(summary = "Message 읽음 상태 수정")
+ @ApiResponses(value = {
+ @ApiResponse(
+ responseCode = "200", description = "Message 읽음 상태가 성공적으로 수정됨",
+ content = @Content(schema = @Schema(implementation = ReadStatusDto.class))
+ ),
+ @ApiResponse(
+ responseCode = "404", description = "Message 읽음 상태를 찾을 수 없음",
+ content = @Content(examples = @ExampleObject(value = "ReadStatus with id {readStatusId} not found"))
+ )
+ })
+ ResponseEntity update(
+ @Parameter(description = "수정할 읽음 상태 ID") UUID readStatusId,
+ @Parameter(description = "수정할 읽음 상태 정보") ReadStatusUpdateRequest request
+ );
+
+ @Operation(summary = "User의 Message 읽음 상태 목록 조회")
+ @ApiResponses(value = {
+ @ApiResponse(
+ responseCode = "200", description = "Message 읽음 상태 목록 조회 성공",
+ content = @Content(array = @ArraySchema(schema = @Schema(implementation = ReadStatusDto.class)))
+ )
+ })
+ ResponseEntity> findAllByUserId(
+ @Parameter(description = "조회할 User ID") UUID userId
+ );
+}
\ No newline at end of file
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
new file mode 100644
index 0000000000..9d40bc1cef
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/controller/api/UserApi.java
@@ -0,0 +1,109 @@
+package com.sprint.mission.discodeit.controller.api;
+
+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 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;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import java.util.List;
+import java.util.UUID;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.multipart.MultipartFile;
+
+@Tag(name = "User", description = "User API")
+public interface UserApi {
+
+ @Operation(summary = "User 등록")
+ @ApiResponses(value = {
+ @ApiResponse(
+ responseCode = "201", description = "User가 성공적으로 생성됨",
+ content = @Content(schema = @Schema(implementation = UserDto.class))
+ ),
+ @ApiResponse(
+ responseCode = "400", description = "같은 email 또는 username를 사용하는 User가 이미 존재함",
+ content = @Content(examples = @ExampleObject(value = "User with email {email} already exists"))
+ ),
+ })
+ ResponseEntity create(
+ @Parameter(
+ description = "User 생성 정보",
+ content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)
+ ) UserCreateRequest userCreateRequest,
+ @Parameter(
+ description = "User 프로필 이미지",
+ content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE)
+ ) MultipartFile profile
+ );
+
+ @Operation(summary = "User 정보 수정")
+ @ApiResponses(value = {
+ @ApiResponse(
+ responseCode = "200", description = "User 정보가 성공적으로 수정됨",
+ content = @Content(schema = @Schema(implementation = UserDto.class))
+ ),
+ @ApiResponse(
+ responseCode = "404", description = "User를 찾을 수 없음",
+ content = @Content(examples = @ExampleObject("User with id {userId} not found"))
+ ),
+ @ApiResponse(
+ responseCode = "400", description = "같은 email 또는 username를 사용하는 User가 이미 존재함",
+ content = @Content(examples = @ExampleObject("user with email {newEmail} already exists"))
+ )
+ })
+ ResponseEntity update(
+ @Parameter(description = "수정할 User ID") UUID userId,
+ @Parameter(description = "수정할 User 정보") UserUpdateRequest userUpdateRequest,
+ @Parameter(description = "수정할 User 프로필 이미지") MultipartFile profile
+ );
+
+ @Operation(summary = "User 삭제")
+ @ApiResponses(value = {
+ @ApiResponse(
+ responseCode = "204",
+ description = "User가 성공적으로 삭제됨"
+ ),
+ @ApiResponse(
+ responseCode = "404",
+ description = "User를 찾을 수 없음",
+ content = @Content(examples = @ExampleObject(value = "User with id {id} not found"))
+ )
+ })
+ ResponseEntity delete(
+ @Parameter(description = "삭제할 User ID") UUID userId
+ );
+
+ @Operation(summary = "전체 User 목록 조회")
+ @ApiResponses(value = {
+ @ApiResponse(
+ responseCode = "200", description = "User 목록 조회 성공",
+ content = @Content(array = @ArraySchema(schema = @Schema(implementation = UserDto.class)))
+ )
+ })
+ ResponseEntity> findAll();
+
+ @Operation(summary = "User 온라인 상태 업데이트")
+ @ApiResponses(value = {
+ @ApiResponse(
+ responseCode = "200", description = "User 온라인 상태가 성공적으로 업데이트됨",
+ content = @Content(schema = @Schema(implementation = UserStatusDto.class))
+ ),
+ @ApiResponse(
+ responseCode = "404", description = "해당 User의 UserStatus를 찾을 수 없음",
+ content = @Content(examples = @ExampleObject(value = "UserStatus with userId {userId} not found"))
+ )
+ })
+ ResponseEntity updateUserStatusByUserId(
+ @Parameter(description = "상태를 변경할 User ID") UUID userId,
+ @Parameter(description = "변경할 User 온라인 상태 정보") UserStatusUpdateRequest request
+ );
+}
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
new file mode 100644
index 0000000000..d44aee484b
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/dto/data/BinaryContentDto.java
@@ -0,0 +1,12 @@
+package com.sprint.mission.discodeit.dto.data;
+
+import java.util.UUID;
+
+public record BinaryContentDto(
+ UUID id,
+ String fileName,
+ Long size,
+ String contentType
+) {
+
+}
diff --git a/src/main/java/com/sprint/mission/discodeit/dto/data/ChannelDto.java b/src/main/java/com/sprint/mission/discodeit/dto/data/ChannelDto.java
new file mode 100644
index 0000000000..cf9b990802
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/dto/data/ChannelDto.java
@@ -0,0 +1,17 @@
+package com.sprint.mission.discodeit.dto.data;
+
+import com.sprint.mission.discodeit.entity.ChannelType;
+import java.time.Instant;
+import java.util.List;
+import java.util.UUID;
+
+public record ChannelDto(
+ UUID id,
+ ChannelType type,
+ String name,
+ String description,
+ List participants,
+ Instant lastMessageAt
+) {
+
+}
diff --git a/src/main/java/com/sprint/mission/discodeit/dto/data/MessageDto.java b/src/main/java/com/sprint/mission/discodeit/dto/data/MessageDto.java
new file mode 100644
index 0000000000..6bcaa0907d
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/dto/data/MessageDto.java
@@ -0,0 +1,17 @@
+package com.sprint.mission.discodeit.dto.data;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.UUID;
+
+public record MessageDto(
+ UUID id,
+ Instant createdAt,
+ Instant updatedAt,
+ String content,
+ UUID channelId,
+ UserDto author,
+ List attachments
+) {
+
+}
diff --git a/src/main/java/com/sprint/mission/discodeit/dto/data/ReadStatusDto.java b/src/main/java/com/sprint/mission/discodeit/dto/data/ReadStatusDto.java
new file mode 100644
index 0000000000..1d0bc2c125
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/dto/data/ReadStatusDto.java
@@ -0,0 +1,13 @@
+package com.sprint.mission.discodeit.dto.data;
+
+import java.time.Instant;
+import java.util.UUID;
+
+public record ReadStatusDto(
+ UUID id,
+ UUID userId,
+ UUID channelId,
+ Instant lastReadAt
+) {
+
+}
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
new file mode 100644
index 0000000000..aa696a69f3
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/dto/data/UserDto.java
@@ -0,0 +1,13 @@
+package com.sprint.mission.discodeit.dto.data;
+
+import java.util.UUID;
+
+public record UserDto(
+ UUID id,
+ String username,
+ String email,
+ BinaryContentDto profile,
+ Boolean online
+) {
+
+}
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
new file mode 100644
index 0000000000..87ee9d0005
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/dto/data/UserStatusDto.java
@@ -0,0 +1,11 @@
+package com.sprint.mission.discodeit.dto.data;
+
+import java.time.Instant;
+import java.util.UUID;
+
+public record UserStatusDto(
+ UUID id,
+ UUID userId,
+ 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
new file mode 100644
index 0000000000..4022396976
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/dto/request/BinaryContentCreateRequest.java
@@ -0,0 +1,19 @@
+package com.sprint.mission.discodeit.dto.request;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+
+public record BinaryContentCreateRequest(
+ @NotBlank(message = "파일 이름은 필수입니다")
+ @Size(max = 255, message = "파일 이름은 255자 이하여야 합니다")
+ String fileName,
+
+ @NotBlank(message = "콘텐츠 타입은 필수입니다")
+ String contentType,
+
+ @NotNull(message = "파일 데이터는 필수입니다")
+ 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
new file mode 100644
index 0000000000..40452eea21
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/dto/request/LoginRequest.java
@@ -0,0 +1,13 @@
+package com.sprint.mission.discodeit.dto.request;
+
+import jakarta.validation.constraints.NotBlank;
+
+public record LoginRequest(
+ @NotBlank(message = "사용자 이름은 필수입니다")
+ String username,
+
+ @NotBlank(message = "비밀번호는 필수입니다")
+ 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
new file mode 100644
index 0000000000..366539aeeb
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/dto/request/MessageCreateRequest.java
@@ -0,0 +1,20 @@
+package com.sprint.mission.discodeit.dto.request;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import java.util.UUID;
+
+public record MessageCreateRequest(
+ @NotBlank(message = "메시지 내용은 필수입니다")
+ @Size(max = 2000, message = "메시지 내용은 2000자 이하여야 합니다")
+ 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
new file mode 100644
index 0000000000..792ef27c21
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/dto/request/MessageUpdateRequest.java
@@ -0,0 +1,12 @@
+package com.sprint.mission.discodeit.dto.request;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+
+public record MessageUpdateRequest(
+ @NotBlank(message = "메시지 내용은 필수입니다")
+ @Size(max = 2000, message = "메시지 내용은 2000자 이하여야 합니다")
+ 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
new file mode 100644
index 0000000000..478cf4e32f
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/dto/request/PrivateChannelCreateRequest.java
@@ -0,0 +1,16 @@
+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(
+ @NotNull(message = "참여자 목록은 필수입니다")
+ @NotEmpty(message = "참여자 목록은 비어있을 수 없습니다")
+ @Size(min = 2, message = "비공개 채널에는 최소 2명의 참여자가 필요합니다")
+ List 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
new file mode 100644
index 0000000000..e2e284a023
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/dto/request/PublicChannelCreateRequest.java
@@ -0,0 +1,15 @@
+package com.sprint.mission.discodeit.dto.request;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+
+public record PublicChannelCreateRequest(
+ @NotBlank(message = "채널명은 필수입니다")
+ @Size(min = 2, max = 50, message = "채널명은 2자 이상 50자 이하여야 합니다")
+ String name,
+
+ @Size(max = 255, message = "채널 설명은 255자 이하여야 합니다")
+ 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
new file mode 100644
index 0000000000..e438f761c5
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/dto/request/PublicChannelUpdateRequest.java
@@ -0,0 +1,13 @@
+package com.sprint.mission.discodeit.dto.request;
+
+import jakarta.validation.constraints.Size;
+
+public record PublicChannelUpdateRequest(
+ @Size(min = 2, max = 50, message = "채널명은 2자 이상 50자 이하여야 합니다")
+ String newName,
+
+ @Size(max = 255, message = "채널 설명은 255자 이하여야 합니다")
+ 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
new file mode 100644
index 0000000000..f7f4851991
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/dto/request/ReadStatusCreateRequest.java
@@ -0,0 +1,20 @@
+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
new file mode 100644
index 0000000000..de197a07fe
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/dto/request/ReadStatusUpdateRequest.java
@@ -0,0 +1,13 @@
+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
new file mode 100644
index 0000000000..a8c8884233
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/dto/request/UserCreateRequest.java
@@ -0,0 +1,25 @@
+package com.sprint.mission.discodeit.dto.request;
+
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.Size;
+
+public record UserCreateRequest(
+ @NotBlank(message = "사용자 이름은 필수입니다")
+ @Size(min = 3, max = 50, message = "사용자 이름은 3자 이상 50자 이하여야 합니다")
+ String username,
+
+ @NotBlank(message = "이메일은 필수입니다")
+ @Email(message = "유효한 이메일 형식이어야 합니다")
+ @Size(max = 100, message = "이메일은 100자 이하여야 합니다")
+ String email,
+
+ @NotBlank(message = "비밀번호는 필수입니다")
+ @Size(min = 8, max = 60, message = "비밀번호는 8자 이상 60자 이하여야 합니다")
+ @Pattern(regexp = "^(?=.*[0-9])(?=.*[a-zA-Z])(?=.*[!@#$%^&*]).{8,}$",
+ message = "비밀번호는 최소 8자 이상, 숫자, 문자, 특수문자를 포함해야 합니다")
+ 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
new file mode 100644
index 0000000000..2d3970adb2
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/dto/request/UserStatusCreateRequest.java
@@ -0,0 +1,17 @@
+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
new file mode 100644
index 0000000000..6556ae56cb
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/dto/request/UserStatusUpdateRequest.java
@@ -0,0 +1,13 @@
+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
new file mode 100644
index 0000000000..19e271309c
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/dto/request/UserUpdateRequest.java
@@ -0,0 +1,21 @@
+package com.sprint.mission.discodeit.dto.request;
+
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.Size;
+
+public record UserUpdateRequest(
+ @Size(min = 3, max = 50, message = "사용자 이름은 3자 이상 50자 이하여야 합니다")
+ String newUsername,
+
+ @Email(message = "유효한 이메일 형식이어야 합니다")
+ @Size(max = 100, message = "이메일은 100자 이하여야 합니다")
+ String newEmail,
+
+ @Size(min = 8, max = 60, message = "비밀번호는 8자 이상 60자 이하여야 합니다")
+ @Pattern(regexp = "^(?=.*[0-9])(?=.*[a-zA-Z])(?=.*[!@#$%^&*]).{8,}$",
+ message = "비밀번호는 최소 8자 이상, 숫자, 문자, 특수문자를 포함해야 합니다")
+ String newPassword
+) {
+
+}
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 0000000000..181d532d7c
--- /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
new file mode 100644
index 0000000000..88a0968484
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/entity/BinaryContent.java
@@ -0,0 +1,29 @@
+package com.sprint.mission.discodeit.entity;
+
+import com.sprint.mission.discodeit.entity.base.BaseEntity;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Table;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@Table(name = "binary_contents")
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class BinaryContent extends BaseEntity {
+
+ @Column(nullable = false)
+ private String fileName;
+ @Column(nullable = false)
+ private Long size;
+ @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
new file mode 100644
index 0000000000..101b737bd6
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/entity/Channel.java
@@ -0,0 +1,41 @@
+package com.sprint.mission.discodeit.entity;
+
+import com.sprint.mission.discodeit.entity.base.BaseUpdatableEntity;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.Table;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@Table(name = "channels")
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class Channel extends BaseUpdatableEntity {
+
+ @Enumerated(EnumType.STRING)
+ @Column(nullable = false)
+ private ChannelType type;
+ @Column(length = 100)
+ private String name;
+ @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) {
+ if (newName != null && !newName.equals(this.name)) {
+ this.name = newName;
+ }
+ if (newDescription != null && !newDescription.equals(this.description)) {
+ this.description = newDescription;
+ }
+ }
+}
diff --git a/src/main/java/com/sprint/mission/discodeit/entity/ChannelType.java b/src/main/java/com/sprint/mission/discodeit/entity/ChannelType.java
new file mode 100644
index 0000000000..4fca377212
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/entity/ChannelType.java
@@ -0,0 +1,6 @@
+package com.sprint.mission.discodeit.entity;
+
+public enum ChannelType {
+ PUBLIC,
+ PRIVATE,
+}
diff --git a/src/main/java/com/sprint/mission/discodeit/entity/Message.java b/src/main/java/com/sprint/mission/discodeit/entity/Message.java
new file mode 100644
index 0000000000..7fe8865eab
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/entity/Message.java
@@ -0,0 +1,55 @@
+package com.sprint.mission.discodeit.entity;
+
+import com.sprint.mission.discodeit.entity.base.BaseUpdatableEntity;
+import jakarta.persistence.CascadeType;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.JoinTable;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.OneToMany;
+import jakarta.persistence.Table;
+import java.util.ArrayList;
+import java.util.List;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import org.hibernate.annotations.BatchSize;
+
+@Entity
+@Table(name = "messages")
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class Message extends BaseUpdatableEntity {
+
+ @Column(columnDefinition = "text", nullable = false)
+ private String content;
+ @ManyToOne(fetch = FetchType.LAZY, optional = false)
+ @JoinColumn(name = "channel_id", columnDefinition = "uuid")
+ private Channel channel;
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "author_id", columnDefinition = "uuid")
+ private User author;
+ @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 = 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) {
+ if (newContent != null && !newContent.equals(this.content)) {
+ this.content = newContent;
+ }
+ }
+}
diff --git a/src/main/java/com/sprint/mission/discodeit/entity/ReadStatus.java b/src/main/java/com/sprint/mission/discodeit/entity/ReadStatus.java
new file mode 100644
index 0000000000..d51448b96b
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/entity/ReadStatus.java
@@ -0,0 +1,47 @@
+package com.sprint.mission.discodeit.entity;
+
+import com.sprint.mission.discodeit.entity.base.BaseUpdatableEntity;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.Table;
+import jakarta.persistence.UniqueConstraint;
+import java.time.Instant;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@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, optional = false)
+ @JoinColumn(name = "user_id", columnDefinition = "uuid")
+ private User user;
+ @ManyToOne(fetch = FetchType.LAZY, optional = false)
+ @JoinColumn(name = "channel_id", columnDefinition = "uuid")
+ private Channel channel;
+ @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) {
+ if (newLastReadAt != null && !newLastReadAt.equals(this.lastReadAt)) {
+ this.lastReadAt = newLastReadAt;
+ }
+ }
+}
\ 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
new file mode 100644
index 0000000000..7961aaecc3
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/entity/User.java
@@ -0,0 +1,59 @@
+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;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.OneToOne;
+import jakarta.persistence.Table;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+@Entity
+@Table(name = "users")
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED) // JPA를 위한 기본 생성자
+public class User extends BaseUpdatableEntity {
+
+ @Column(length = 50, nullable = false, unique = true)
+ private String username;
+ @Column(length = 100, nullable = false, unique = true)
+ private String email;
+ @Column(length = 60, nullable = false)
+ private String password;
+ @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
+ @JoinColumn(name = "profile_id", columnDefinition = "uuid")
+ private BinaryContent profile;
+ @JsonManagedReference
+ @Setter(AccessLevel.PROTECTED)
+ @OneToOne(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
+ private UserStatus status;
+
+ public User(String username, String email, String password, BinaryContent profile) {
+ this.username = username;
+ this.email = email;
+ this.password = password;
+ this.profile = profile;
+ }
+
+ public void update(String newUsername, String newEmail, String newPassword,
+ BinaryContent newProfile) {
+ if (newUsername != null && !newUsername.equals(this.username)) {
+ this.username = newUsername;
+ }
+ if (newEmail != null && !newEmail.equals(this.email)) {
+ this.email = newEmail;
+ }
+ if (newPassword != null && !newPassword.equals(this.password)) {
+ this.password = newPassword;
+ }
+ if (newProfile != null) {
+ this.profile = newProfile;
+ }
+ }
+}
diff --git a/src/main/java/com/sprint/mission/discodeit/entity/UserStatus.java b/src/main/java/com/sprint/mission/discodeit/entity/UserStatus.java
new file mode 100644
index 0000000000..9726f73c79
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/entity/UserStatus.java
@@ -0,0 +1,50 @@
+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;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.OneToOne;
+import jakarta.persistence.Table;
+import java.time.Duration;
+import java.time.Instant;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@Table(name = "user_statuses")
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class UserStatus extends BaseUpdatableEntity {
+
+ @JsonBackReference
+ @OneToOne(fetch = FetchType.LAZY, optional = false)
+ @JoinColumn(name = "user_id", nullable = false, unique = true)
+ private User user;
+ @Column(columnDefinition = "timestamp with time zone", nullable = false)
+ private Instant lastActiveAt;
+
+ public UserStatus(User user, Instant lastActiveAt) {
+ setUser(user);
+ this.lastActiveAt = lastActiveAt;
+ }
+
+ public void update(Instant lastActiveAt) {
+ if (lastActiveAt != null && !lastActiveAt.equals(this.lastActiveAt)) {
+ this.lastActiveAt = lastActiveAt;
+ }
+ }
+
+ 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
new file mode 100644
index 0000000000..f282101647
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/entity/base/BaseEntity.java
@@ -0,0 +1,31 @@
+package com.sprint.mission.discodeit.entity.base;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.EntityListeners;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.MappedSuperclass;
+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;
+
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@MappedSuperclass
+@EntityListeners(AuditingEntityListener.class)
+public abstract class BaseEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.UUID)
+ @Column(columnDefinition = "uuid", updatable = false, nullable = false)
+ private UUID id;
+
+ @CreatedDate
+ @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
new file mode 100644
index 0000000000..57d1d31693
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/entity/base/BaseUpdatableEntity.java
@@ -0,0 +1,19 @@
+package com.sprint.mission.discodeit.entity.base;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.MappedSuperclass;
+import java.time.Instant;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import org.springframework.data.annotation.LastModifiedDate;
+
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@MappedSuperclass
+public abstract class BaseUpdatableEntity extends BaseEntity {
+
+ @LastModifiedDate
+ @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 0000000000..d929a51f88
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/exception/DiscodeitException.java
@@ -0,0 +1,32 @@
+package com.sprint.mission.discodeit.exception;
+
+import java.time.Instant;
+import java.util.HashMap;
+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) {
+ super(errorCode.getMessage());
+ this.timestamp = Instant.now();
+ this.errorCode = errorCode;
+ this.details = new HashMap<>();
+ }
+
+ public DiscodeitException(ErrorCode errorCode, Throwable cause) {
+ super(errorCode.getMessage(), cause);
+ this.timestamp = Instant.now();
+ this.errorCode = errorCode;
+ this.details = new HashMap<>();
+ }
+
+ public void addDetail(String key, Object value) {
+ this.details.put(key, value);
+ }
+}
\ No newline at end of file
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 0000000000..e8dc580331
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/exception/ErrorCode.java
@@ -0,0 +1,39 @@
+package com.sprint.mission.discodeit.exception;
+
+import lombok.Getter;
+
+@Getter
+public enum ErrorCode {
+ // User 관련 에러 코드
+ USER_NOT_FOUND("사용자를 찾을 수 없습니다."),
+ DUPLICATE_USER("이미 존재하는 사용자입니다."),
+ INVALID_USER_CREDENTIALS("잘못된 사용자 인증 정보입니다."),
+
+ // Channel 관련 에러 코드
+ CHANNEL_NOT_FOUND("채널을 찾을 수 없습니다."),
+ PRIVATE_CHANNEL_UPDATE("비공개 채널은 수정할 수 없습니다."),
+
+ // Message 관련 에러 코드
+ MESSAGE_NOT_FOUND("메시지를 찾을 수 없습니다."),
+
+ // BinaryContent 관련 에러 코드
+ BINARY_CONTENT_NOT_FOUND("바이너리 컨텐츠를 찾을 수 없습니다."),
+
+ // ReadStatus 관련 에러 코드
+ READ_STATUS_NOT_FOUND("읽음 상태를 찾을 수 없습니다."),
+ DUPLICATE_READ_STATUS("이미 존재하는 읽음 상태입니다."),
+
+ // UserStatus 관련 에러 코드
+ USER_STATUS_NOT_FOUND("사용자 상태를 찾을 수 없습니다."),
+ DUPLICATE_USER_STATUS("이미 존재하는 사용자 상태입니다."),
+
+ // Server 에러 코드
+ INTERNAL_SERVER_ERROR("서버 내부 오류가 발생했습니다."),
+ INVALID_REQUEST("잘못된 요청입니다.");
+
+ private final String message;
+
+ ErrorCode(String message) {
+ this.message = message;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/sprint/mission/discodeit/exception/ErrorResponse.java b/src/main/java/com/sprint/mission/discodeit/exception/ErrorResponse.java
new file mode 100644
index 0000000000..6a9ae50efe
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/exception/ErrorResponse.java
@@ -0,0 +1,27 @@
+package com.sprint.mission.discodeit.exception;
+
+import java.time.Instant;
+import java.util.HashMap;
+import java.util.Map;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@Getter
+@RequiredArgsConstructor
+public class ErrorResponse {
+ private final Instant timestamp;
+ private final String code;
+ private final String message;
+ private final Map details;
+ private final String exceptionType;
+ private final int status;
+
+ public ErrorResponse(DiscodeitException exception, int status) {
+ this(Instant.now(), exception.getErrorCode().name(), exception.getMessage(), exception.getDetails(), exception.getClass().getSimpleName(), status);
+ }
+
+ public ErrorResponse(Exception exception, int status) {
+ this(Instant.now(), exception.getClass().getSimpleName(), exception.getMessage(), new HashMap<>(), exception.getClass().getSimpleName(), status);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/sprint/mission/discodeit/exception/GlobalExceptionHandler.java b/src/main/java/com/sprint/mission/discodeit/exception/GlobalExceptionHandler.java
new file mode 100644
index 0000000000..f5ecc566a1
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/exception/GlobalExceptionHandler.java
@@ -0,0 +1,75 @@
+package com.sprint.mission.discodeit.exception;
+
+import java.time.Instant;
+import java.util.HashMap;
+import java.util.Map;
+
+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;
+
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+@RestControllerAdvice
+public class GlobalExceptionHandler {
+
+ @ExceptionHandler(Exception.class)
+ public ResponseEntity handleException(Exception e) {
+ log.error("예상치 못한 오류 발생: {}", e.getMessage(), e);
+ ErrorResponse errorResponse = new ErrorResponse(e, HttpStatus.INTERNAL_SERVER_ERROR.value());
+ return ResponseEntity
+ .status(HttpStatus.INTERNAL_SERVER_ERROR)
+ .body(errorResponse);
+ }
+
+ @ExceptionHandler(DiscodeitException.class)
+ public ResponseEntity handleDiscodeitException(DiscodeitException exception) {
+ log.error("커스텀 예외 발생: code={}, message={}", exception.getErrorCode(), exception.getMessage(), exception);
+ HttpStatus status = determineHttpStatus(exception);
+ ErrorResponse response = new ErrorResponse(exception, status.value());
+ return ResponseEntity
+ .status(status)
+ .body(response);
+ }
+
+ @ExceptionHandler(MethodArgumentNotValidException.class)
+ public ResponseEntity handleValidationExceptions(MethodArgumentNotValidException ex) {
+ log.error("요청 유효성 검사 실패: {}", ex.getMessage());
+
+ Map validationErrors = new HashMap<>();
+ ex.getBindingResult().getAllErrors().forEach(error -> {
+ String fieldName = ((FieldError) error).getField();
+ String errorMessage = error.getDefaultMessage();
+ validationErrors.put(fieldName, errorMessage);
+ });
+
+ ErrorResponse response = new ErrorResponse(
+ Instant.now(),
+ "VALIDATION_ERROR",
+ "요청 데이터 유효성 검사에 실패했습니다",
+ validationErrors,
+ ex.getClass().getSimpleName(),
+ HttpStatus.BAD_REQUEST.value()
+ );
+
+ return ResponseEntity
+ .status(HttpStatus.BAD_REQUEST)
+ .body(response);
+ }
+
+ private HttpStatus determineHttpStatus(DiscodeitException exception) {
+ ErrorCode errorCode = exception.getErrorCode();
+ return switch (errorCode) {
+ case USER_NOT_FOUND, CHANNEL_NOT_FOUND, MESSAGE_NOT_FOUND, BINARY_CONTENT_NOT_FOUND,
+ READ_STATUS_NOT_FOUND, USER_STATUS_NOT_FOUND -> HttpStatus.NOT_FOUND;
+ case DUPLICATE_USER, DUPLICATE_READ_STATUS, DUPLICATE_USER_STATUS -> HttpStatus.CONFLICT;
+ case INVALID_USER_CREDENTIALS -> HttpStatus.UNAUTHORIZED;
+ case PRIVATE_CHANNEL_UPDATE, INVALID_REQUEST -> HttpStatus.BAD_REQUEST;
+ case INTERNAL_SERVER_ERROR -> HttpStatus.INTERNAL_SERVER_ERROR;
+ };
+ }
+}
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 0000000000..368025bf24
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/exception/binarycontent/BinaryContentException.java
@@ -0,0 +1,14 @@
+package com.sprint.mission.discodeit.exception.binarycontent;
+
+import com.sprint.mission.discodeit.exception.DiscodeitException;
+import com.sprint.mission.discodeit.exception.ErrorCode;
+
+public class BinaryContentException extends DiscodeitException {
+ public BinaryContentException(ErrorCode errorCode) {
+ super(errorCode);
+ }
+
+ public BinaryContentException(ErrorCode errorCode, Throwable cause) {
+ super(errorCode, cause);
+ }
+}
\ No newline at end of file
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 0000000000..65ad82363c
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/exception/binarycontent/BinaryContentNotFoundException.java
@@ -0,0 +1,17 @@
+package com.sprint.mission.discodeit.exception.binarycontent;
+
+import com.sprint.mission.discodeit.exception.ErrorCode;
+
+import java.util.UUID;
+
+public class BinaryContentNotFoundException extends BinaryContentException {
+ public BinaryContentNotFoundException() {
+ super(ErrorCode.BINARY_CONTENT_NOT_FOUND);
+ }
+
+ public static BinaryContentNotFoundException withId(UUID binaryContentId) {
+ BinaryContentNotFoundException exception = new BinaryContentNotFoundException();
+ exception.addDetail("binaryContentId", binaryContentId);
+ return exception;
+ }
+}
\ No newline at end of file
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 0000000000..1ba3364bae
--- /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;
+
+public class ChannelException extends DiscodeitException {
+ public ChannelException(ErrorCode errorCode) {
+ super(errorCode);
+ }
+
+ public ChannelException(ErrorCode errorCode, Throwable cause) {
+ super(errorCode, cause);
+ }
+}
\ No newline at end of file
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 0000000000..ec7b1f3350
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/exception/channel/ChannelNotFoundException.java
@@ -0,0 +1,17 @@
+package com.sprint.mission.discodeit.exception.channel;
+
+import java.util.UUID;
+
+import com.sprint.mission.discodeit.exception.ErrorCode;
+
+public class ChannelNotFoundException extends ChannelException {
+ public ChannelNotFoundException() {
+ super(ErrorCode.CHANNEL_NOT_FOUND);
+ }
+
+ public static ChannelNotFoundException withId(UUID channelId) {
+ ChannelNotFoundException exception = new ChannelNotFoundException();
+ exception.addDetail("channelId", channelId);
+ return exception;
+ }
+}
\ No newline at end of file
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 0000000000..2b8b1597cc
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/exception/channel/PrivateChannelUpdateException.java
@@ -0,0 +1,17 @@
+package com.sprint.mission.discodeit.exception.channel;
+
+import com.sprint.mission.discodeit.exception.ErrorCode;
+
+import java.util.UUID;
+
+public class PrivateChannelUpdateException extends ChannelException {
+ public PrivateChannelUpdateException() {
+ super(ErrorCode.PRIVATE_CHANNEL_UPDATE);
+ }
+
+ public static PrivateChannelUpdateException forChannel(UUID channelId) {
+ PrivateChannelUpdateException exception = new PrivateChannelUpdateException();
+ exception.addDetail("channelId", channelId);
+ return exception;
+ }
+}
\ No newline at end of file
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 0000000000..289922ed32
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/exception/message/MessageException.java
@@ -0,0 +1,14 @@
+package com.sprint.mission.discodeit.exception.message;
+
+import com.sprint.mission.discodeit.exception.DiscodeitException;
+import com.sprint.mission.discodeit.exception.ErrorCode;
+
+public class MessageException extends DiscodeitException {
+ public MessageException(ErrorCode errorCode) {
+ super(errorCode);
+ }
+
+ public MessageException(ErrorCode errorCode, Throwable cause) {
+ super(errorCode, cause);
+ }
+}
\ No newline at end of file
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 0000000000..423aafbb31
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/exception/message/MessageNotFoundException.java
@@ -0,0 +1,17 @@
+package com.sprint.mission.discodeit.exception.message;
+
+import com.sprint.mission.discodeit.exception.ErrorCode;
+
+import java.util.UUID;
+
+public class MessageNotFoundException extends MessageException {
+ public MessageNotFoundException() {
+ super(ErrorCode.MESSAGE_NOT_FOUND);
+ }
+
+ public static MessageNotFoundException withId(UUID messageId) {
+ MessageNotFoundException exception = new MessageNotFoundException();
+ exception.addDetail("messageId", messageId);
+ return exception;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/sprint/mission/discodeit/exception/readstatus/DuplicateReadStatusException.java b/src/main/java/com/sprint/mission/discodeit/exception/readstatus/DuplicateReadStatusException.java
new file mode 100644
index 0000000000..5a30692d84
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/exception/readstatus/DuplicateReadStatusException.java
@@ -0,0 +1,18 @@
+package com.sprint.mission.discodeit.exception.readstatus;
+
+import com.sprint.mission.discodeit.exception.ErrorCode;
+
+import java.util.UUID;
+
+public class DuplicateReadStatusException extends ReadStatusException {
+ public DuplicateReadStatusException() {
+ super(ErrorCode.DUPLICATE_READ_STATUS);
+ }
+
+ public static DuplicateReadStatusException withUserIdAndChannelId(UUID userId, UUID channelId) {
+ DuplicateReadStatusException exception = new DuplicateReadStatusException();
+ exception.addDetail("userId", userId);
+ exception.addDetail("channelId", channelId);
+ return exception;
+ }
+}
\ No newline at end of file
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 0000000000..3860caf2e8
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/exception/readstatus/ReadStatusException.java
@@ -0,0 +1,14 @@
+package com.sprint.mission.discodeit.exception.readstatus;
+
+import com.sprint.mission.discodeit.exception.DiscodeitException;
+import com.sprint.mission.discodeit.exception.ErrorCode;
+
+public class ReadStatusException extends DiscodeitException {
+ public ReadStatusException(ErrorCode errorCode) {
+ super(errorCode);
+ }
+
+ public ReadStatusException(ErrorCode errorCode, Throwable cause) {
+ super(errorCode, cause);
+ }
+}
\ No newline at end of file
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 0000000000..86b9fde75c
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/exception/readstatus/ReadStatusNotFoundException.java
@@ -0,0 +1,17 @@
+package com.sprint.mission.discodeit.exception.readstatus;
+
+import com.sprint.mission.discodeit.exception.ErrorCode;
+
+import java.util.UUID;
+
+public class ReadStatusNotFoundException extends ReadStatusException {
+ public ReadStatusNotFoundException() {
+ super(ErrorCode.READ_STATUS_NOT_FOUND);
+ }
+
+ public static ReadStatusNotFoundException withId(UUID readStatusId) {
+ ReadStatusNotFoundException exception = new ReadStatusNotFoundException();
+ exception.addDetail("readStatusId", readStatusId);
+ return exception;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/sprint/mission/discodeit/exception/user/InvalidCredentialsException.java b/src/main/java/com/sprint/mission/discodeit/exception/user/InvalidCredentialsException.java
new file mode 100644
index 0000000000..d75576fdfe
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/exception/user/InvalidCredentialsException.java
@@ -0,0 +1,14 @@
+package com.sprint.mission.discodeit.exception.user;
+
+import com.sprint.mission.discodeit.exception.ErrorCode;
+
+public class InvalidCredentialsException extends UserException {
+ public InvalidCredentialsException() {
+ super(ErrorCode.INVALID_USER_CREDENTIALS);
+ }
+
+ public static InvalidCredentialsException wrongPassword() {
+ InvalidCredentialsException exception = new InvalidCredentialsException();
+ return exception;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/sprint/mission/discodeit/exception/user/UserAlreadyExistsException.java b/src/main/java/com/sprint/mission/discodeit/exception/user/UserAlreadyExistsException.java
new file mode 100644
index 0000000000..9d0b3b3d1e
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/exception/user/UserAlreadyExistsException.java
@@ -0,0 +1,21 @@
+package com.sprint.mission.discodeit.exception.user;
+
+import com.sprint.mission.discodeit.exception.ErrorCode;
+
+public class UserAlreadyExistsException extends UserException {
+ public UserAlreadyExistsException() {
+ super(ErrorCode.DUPLICATE_USER);
+ }
+
+ public static UserAlreadyExistsException withEmail(String email) {
+ UserAlreadyExistsException exception = new UserAlreadyExistsException();
+ exception.addDetail("email", email);
+ return exception;
+ }
+
+ public static UserAlreadyExistsException withUsername(String username) {
+ UserAlreadyExistsException exception = new UserAlreadyExistsException();
+ exception.addDetail("username", username);
+ return exception;
+ }
+}
\ No newline at end of file
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 0000000000..f48629706a
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/exception/user/UserException.java
@@ -0,0 +1,14 @@
+package com.sprint.mission.discodeit.exception.user;
+
+import com.sprint.mission.discodeit.exception.DiscodeitException;
+import com.sprint.mission.discodeit.exception.ErrorCode;
+
+public class UserException extends DiscodeitException {
+ public UserException(ErrorCode errorCode) {
+ super(errorCode);
+ }
+
+ public UserException(ErrorCode errorCode, Throwable cause) {
+ super(errorCode, cause);
+ }
+}
\ No newline at end of file
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 0000000000..bd76dfa9eb
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/exception/user/UserNotFoundException.java
@@ -0,0 +1,23 @@
+package com.sprint.mission.discodeit.exception.user;
+
+import java.util.UUID;
+
+import com.sprint.mission.discodeit.exception.ErrorCode;
+
+public class UserNotFoundException extends UserException {
+ public UserNotFoundException() {
+ super(ErrorCode.USER_NOT_FOUND);
+ }
+
+ public static UserNotFoundException withId(UUID userId) {
+ UserNotFoundException exception = new UserNotFoundException();
+ exception.addDetail("userId", userId);
+ return exception;
+ }
+
+ public static UserNotFoundException withUsername(String username) {
+ UserNotFoundException exception = new UserNotFoundException();
+ exception.addDetail("username", username);
+ return exception;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/sprint/mission/discodeit/exception/userstatus/DuplicateUserStatusException.java b/src/main/java/com/sprint/mission/discodeit/exception/userstatus/DuplicateUserStatusException.java
new file mode 100644
index 0000000000..04978a2e2c
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/exception/userstatus/DuplicateUserStatusException.java
@@ -0,0 +1,17 @@
+package com.sprint.mission.discodeit.exception.userstatus;
+
+import com.sprint.mission.discodeit.exception.ErrorCode;
+
+import java.util.UUID;
+
+public class DuplicateUserStatusException extends UserStatusException {
+ public DuplicateUserStatusException() {
+ super(ErrorCode.DUPLICATE_USER_STATUS);
+ }
+
+ public static DuplicateUserStatusException withUserId(UUID userId) {
+ DuplicateUserStatusException exception = new DuplicateUserStatusException();
+ exception.addDetail("userId", userId);
+ return exception;
+ }
+}
\ No newline at end of file
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 0000000000..1a45a3d087
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/exception/userstatus/UserStatusException.java
@@ -0,0 +1,14 @@
+package com.sprint.mission.discodeit.exception.userstatus;
+
+import com.sprint.mission.discodeit.exception.DiscodeitException;
+import com.sprint.mission.discodeit.exception.ErrorCode;
+
+public class UserStatusException extends DiscodeitException {
+ public UserStatusException(ErrorCode errorCode) {
+ super(errorCode);
+ }
+
+ public UserStatusException(ErrorCode errorCode, Throwable cause) {
+ super(errorCode, cause);
+ }
+}
\ No newline at end of file
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 0000000000..199fca7958
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/exception/userstatus/UserStatusNotFoundException.java
@@ -0,0 +1,23 @@
+package com.sprint.mission.discodeit.exception.userstatus;
+
+import com.sprint.mission.discodeit.exception.ErrorCode;
+
+import java.util.UUID;
+
+public class UserStatusNotFoundException extends UserStatusException {
+ public UserStatusNotFoundException() {
+ super(ErrorCode.USER_STATUS_NOT_FOUND);
+ }
+
+ public static UserStatusNotFoundException withId(UUID userStatusId) {
+ UserStatusNotFoundException exception = new UserStatusNotFoundException();
+ exception.addDetail("userStatusId", userStatusId);
+ return exception;
+ }
+
+ public static UserStatusNotFoundException withUserId(UUID userId) {
+ UserStatusNotFoundException exception = new UserStatusNotFoundException();
+ exception.addDetail("userId", userId);
+ return exception;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/sprint/mission/discodeit/mapper/BinaryContentMapper.java b/src/main/java/com/sprint/mission/discodeit/mapper/BinaryContentMapper.java
new file mode 100644
index 0000000000..d3ea1f1377
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/mapper/BinaryContentMapper.java
@@ -0,0 +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;
+
+@Mapper(componentModel = "spring")
+public interface BinaryContentMapper {
+
+ 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
new file mode 100644
index 0000000000..f39a5809c9
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/mapper/ChannelMapper.java
@@ -0,0 +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.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.ArrayList;
+import java.util.List;
+import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
+import org.springframework.beans.factory.annotation.Autowired;
+
+@Mapper(componentModel = "spring", uses = {UserMapper.class})
+public abstract class ChannelMapper {
+
+ @Autowired
+ private MessageRepository messageRepository;
+ @Autowired
+ private ReadStatusRepository readStatusRepository;
+ @Autowired
+ private UserMapper userMapper;
+
+ @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
new file mode 100644
index 0000000000..e0301ac089
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/mapper/MessageMapper.java
@@ -0,0 +1,13 @@
+package com.sprint.mission.discodeit.mapper;
+
+import com.sprint.mission.discodeit.dto.data.MessageDto;
+import com.sprint.mission.discodeit.entity.Message;
+import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
+
+@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
new file mode 100644
index 0000000000..108a9b59d0
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/mapper/PageResponseMapper.java
@@ -0,0 +1,30 @@
+package com.sprint.mission.discodeit.mapper;
+
+import com.sprint.mission.discodeit.dto.response.PageResponse;
+import org.mapstruct.Mapper;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Slice;
+
+@Mapper(componentModel = "spring")
+public interface PageResponseMapper {
+
+ default PageResponse fromSlice(Slice slice, Object nextCursor) {
+ return new PageResponse<>(
+ slice.getContent(),
+ nextCursor,
+ slice.getSize(),
+ slice.hasNext(),
+ null
+ );
+ }
+
+ 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
new file mode 100644
index 0000000000..af9b852791
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/mapper/ReadStatusMapper.java
@@ -0,0 +1,14 @@
+package com.sprint.mission.discodeit.mapper;
+
+import com.sprint.mission.discodeit.dto.data.ReadStatusDto;
+import com.sprint.mission.discodeit.entity.ReadStatus;
+import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
+
+@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
new file mode 100644
index 0000000000..c040a2edb4
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/mapper/UserMapper.java
@@ -0,0 +1,13 @@
+package com.sprint.mission.discodeit.mapper;
+
+import com.sprint.mission.discodeit.dto.data.UserDto;
+import com.sprint.mission.discodeit.entity.User;
+import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
+
+@Mapper(componentModel = "spring", uses = {BinaryContentMapper.class, UserStatusMapper.class})
+public interface UserMapper {
+
+ @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
new file mode 100644
index 0000000000..202e56a180
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/mapper/UserStatusMapper.java
@@ -0,0 +1,13 @@
+package com.sprint.mission.discodeit.mapper;
+
+import com.sprint.mission.discodeit.dto.data.UserStatusDto;
+import com.sprint.mission.discodeit.entity.UserStatus;
+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
new file mode 100644
index 0000000000..cbd8c79cfd
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/repository/BinaryContentRepository.java
@@ -0,0 +1,9 @@
+package com.sprint.mission.discodeit.repository;
+
+import com.sprint.mission.discodeit.entity.BinaryContent;
+import java.util.UUID;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface BinaryContentRepository extends JpaRepository {
+
+}
diff --git a/src/main/java/com/sprint/mission/discodeit/repository/ChannelRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/ChannelRepository.java
new file mode 100644
index 0000000000..e4b1fd2353
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/repository/ChannelRepository.java
@@ -0,0 +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.UUID;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface ChannelRepository extends JpaRepository {
+
+ 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
new file mode 100644
index 0000000000..ac649b75fd
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/repository/MessageRepository.java
@@ -0,0 +1,32 @@
+package com.sprint.mission.discodeit.repository;
+
+import com.sprint.mission.discodeit.entity.Message;
+import java.time.Instant;
+import java.util.Optional;
+import java.util.UUID;
+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 {
+
+ @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);
+
+
+ @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);
+}
diff --git a/src/main/java/com/sprint/mission/discodeit/repository/ReadStatusRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/ReadStatusRepository.java
new file mode 100644
index 0000000000..f1d469af14
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/repository/ReadStatusRepository.java
@@ -0,0 +1,25 @@
+package com.sprint.mission.discodeit.repository;
+
+import com.sprint.mission.discodeit.entity.ReadStatus;
+import java.util.List;
+import java.util.UUID;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+public interface ReadStatusRepository extends JpaRepository {
+
+
+ List findAllByUserId(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);
+
+ 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
new file mode 100644
index 0000000000..f7103705f9
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/repository/UserRepository.java
@@ -0,0 +1,22 @@
+package com.sprint.mission.discodeit.repository;
+
+import com.sprint.mission.discodeit.entity.User;
+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 UserRepository extends JpaRepository {
+
+ Optional findByUsername(String username);
+
+ 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
new file mode 100644
index 0000000000..46102abf57
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/repository/UserStatusRepository.java
@@ -0,0 +1,11 @@
+package com.sprint.mission.discodeit.repository;
+
+import com.sprint.mission.discodeit.entity.UserStatus;
+import java.util.Optional;
+import java.util.UUID;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface UserStatusRepository extends JpaRepository {
+
+ Optional findByUserId(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
new file mode 100644
index 0000000000..a1caf1d2d9
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/service/AuthService.java
@@ -0,0 +1,9 @@
+package com.sprint.mission.discodeit.service;
+
+import com.sprint.mission.discodeit.dto.data.UserDto;
+import com.sprint.mission.discodeit.dto.request.LoginRequest;
+
+public interface AuthService {
+
+ UserDto login(LoginRequest loginRequest);
+}
diff --git a/src/main/java/com/sprint/mission/discodeit/service/BinaryContentService.java b/src/main/java/com/sprint/mission/discodeit/service/BinaryContentService.java
new file mode 100644
index 0000000000..23836a4469
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/service/BinaryContentService.java
@@ -0,0 +1,17 @@
+package com.sprint.mission.discodeit.service;
+
+import com.sprint.mission.discodeit.dto.data.BinaryContentDto;
+import com.sprint.mission.discodeit.dto.request.BinaryContentCreateRequest;
+import java.util.List;
+import java.util.UUID;
+
+public interface BinaryContentService {
+
+ BinaryContentDto create(BinaryContentCreateRequest request);
+
+ BinaryContentDto find(UUID binaryContentId);
+
+ List findAllByIdIn(List binaryContentIds);
+
+ void delete(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
new file mode 100644
index 0000000000..a082c9ff98
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/service/ChannelService.java
@@ -0,0 +1,23 @@
+package com.sprint.mission.discodeit.service;
+
+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 java.util.List;
+import java.util.UUID;
+
+public interface ChannelService {
+
+ ChannelDto create(PublicChannelCreateRequest request);
+
+ ChannelDto create(PrivateChannelCreateRequest request);
+
+ ChannelDto find(UUID channelId);
+
+ List findAllByUserId(UUID userId);
+
+ ChannelDto update(UUID channelId, PublicChannelUpdateRequest request);
+
+ void delete(UUID channelId);
+}
\ No newline at end of file
diff --git a/src/main/java/com/sprint/mission/discodeit/service/MessageService.java b/src/main/java/com/sprint/mission/discodeit/service/MessageService.java
new file mode 100644
index 0000000000..8ac5ee9247
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/service/MessageService.java
@@ -0,0 +1,25 @@
+package com.sprint.mission.discodeit.service;
+
+import com.sprint.mission.discodeit.dto.data.MessageDto;
+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.response.PageResponse;
+import java.time.Instant;
+import java.util.List;
+import java.util.UUID;
+import org.springframework.data.domain.Pageable;
+
+public interface MessageService {
+
+ MessageDto create(MessageCreateRequest messageCreateRequest,
+ List binaryContentCreateRequests);
+
+ MessageDto find(UUID messageId);
+
+ PageResponse findAllByChannelId(UUID channelId, Instant createdAt, Pageable pageable);
+
+ MessageDto update(UUID messageId, MessageUpdateRequest request);
+
+ void delete(UUID messageId);
+}
diff --git a/src/main/java/com/sprint/mission/discodeit/service/ReadStatusService.java b/src/main/java/com/sprint/mission/discodeit/service/ReadStatusService.java
new file mode 100644
index 0000000000..8b0c80a319
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/service/ReadStatusService.java
@@ -0,0 +1,20 @@
+package com.sprint.mission.discodeit.service;
+
+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 java.util.List;
+import java.util.UUID;
+
+public interface ReadStatusService {
+
+ ReadStatusDto create(ReadStatusCreateRequest request);
+
+ ReadStatusDto find(UUID readStatusId);
+
+ List findAllByUserId(UUID userId);
+
+ ReadStatusDto update(UUID readStatusId, ReadStatusUpdateRequest request);
+
+ void delete(UUID readStatusId);
+}
diff --git a/src/main/java/com/sprint/mission/discodeit/service/UserService.java b/src/main/java/com/sprint/mission/discodeit/service/UserService.java
new file mode 100644
index 0000000000..4441187804
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/service/UserService.java
@@ -0,0 +1,24 @@
+package com.sprint.mission.discodeit.service;
+
+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 java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+public interface UserService {
+
+ UserDto create(UserCreateRequest userCreateRequest,
+ Optional profileCreateRequest);
+
+ UserDto find(UUID userId);
+
+ List findAll();
+
+ UserDto update(UUID userId, UserUpdateRequest userUpdateRequest,
+ Optional profileCreateRequest);
+
+ void delete(UUID userId);
+}
diff --git a/src/main/java/com/sprint/mission/discodeit/service/UserStatusService.java b/src/main/java/com/sprint/mission/discodeit/service/UserStatusService.java
new file mode 100644
index 0000000000..3c5c55e6e4
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/service/UserStatusService.java
@@ -0,0 +1,22 @@
+package com.sprint.mission.discodeit.service;
+
+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 java.util.List;
+import java.util.UUID;
+
+public interface UserStatusService {
+
+ UserStatusDto create(UserStatusCreateRequest request);
+
+ UserStatusDto find(UUID userStatusId);
+
+ List findAll();
+
+ UserStatusDto update(UUID userStatusId, UserStatusUpdateRequest request);
+
+ UserStatusDto updateByUserId(UUID userId, UserStatusUpdateRequest request);
+
+ void delete(UUID userStatusId);
+}
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
new file mode 100644
index 0000000000..6785cff2f6
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicAuthService.java
@@ -0,0 +1,42 @@
+package com.sprint.mission.discodeit.service.basic;
+
+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.user.InvalidCredentialsException;
+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 lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+@RequiredArgsConstructor
+@Service
+public class BasicAuthService implements AuthService {
+
+ private final UserRepository userRepository;
+ private final UserMapper userMapper;
+
+ @Transactional(readOnly = true)
+ @Override
+ public UserDto login(LoginRequest loginRequest) {
+ log.debug("로그인 시도: username={}", loginRequest.username());
+
+ String username = loginRequest.username();
+ String password = loginRequest.password();
+
+ User user = userRepository.findByUsername(username)
+ .orElseThrow(() -> UserNotFoundException.withUsername(username));
+
+ if (!user.getPassword().equals(password)) {
+ throw InvalidCredentialsException.wrongPassword();
+ }
+
+ log.info("로그인 성공: userId={}, username={}", user.getId(), username);
+ 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
new file mode 100644
index 0000000000..bd50ce57df
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicBinaryContentService.java
@@ -0,0 +1,80 @@
+package com.sprint.mission.discodeit.service.basic;
+
+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.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.util.List;
+import java.util.UUID;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Slf4j
+@RequiredArgsConstructor
+@Service
+public class BasicBinaryContentService implements BinaryContentService {
+
+ private final BinaryContentRepository binaryContentRepository;
+ private final BinaryContentMapper binaryContentMapper;
+ private final BinaryContentStorage binaryContentStorage;
+
+ @Transactional
+ @Override
+ public BinaryContentDto create(BinaryContentCreateRequest request) {
+ log.debug("바이너리 컨텐츠 생성 시작: fileName={}, size={}, contentType={}",
+ request.fileName(), request.bytes().length, request.contentType());
+
+ 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);
+
+ log.info("바이너리 컨텐츠 생성 완료: id={}, fileName={}, size={}",
+ binaryContent.getId(), fileName, bytes.length);
+ return binaryContentMapper.toDto(binaryContent);
+ }
+
+ @Override
+ public BinaryContentDto find(UUID binaryContentId) {
+ log.debug("바이너리 컨텐츠 조회 시작: id={}", binaryContentId);
+ BinaryContentDto dto = binaryContentRepository.findById(binaryContentId)
+ .map(binaryContentMapper::toDto)
+ .orElseThrow(() -> BinaryContentNotFoundException.withId(binaryContentId));
+ log.info("바이너리 컨텐츠 조회 완료: id={}, fileName={}",
+ dto.id(), dto.fileName());
+ return dto;
+ }
+
+ @Override
+ public List findAllByIdIn(List binaryContentIds) {
+ log.debug("바이너리 컨텐츠 목록 조회 시작: ids={}", binaryContentIds);
+ List dtos = binaryContentRepository.findAllById(binaryContentIds).stream()
+ .map(binaryContentMapper::toDto)
+ .toList();
+ log.info("바이너리 컨텐츠 목록 조회 완료: 조회된 항목 수={}", dtos.size());
+ return dtos;
+ }
+
+ @Transactional
+ @Override
+ public void delete(UUID binaryContentId) {
+ log.debug("바이너리 컨텐츠 삭제 시작: id={}", binaryContentId);
+ if (!binaryContentRepository.existsById(binaryContentId)) {
+ throw BinaryContentNotFoundException.withId(binaryContentId);
+ }
+ binaryContentRepository.deleteById(binaryContentId);
+ log.info("바이너리 컨텐츠 삭제 완료: id={}", binaryContentId);
+ }
+}
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
new file mode 100644
index 0000000000..00ab040874
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicChannelService.java
@@ -0,0 +1,118 @@
+package com.sprint.mission.discodeit.service.basic;
+
+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.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.util.List;
+import java.util.UUID;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class BasicChannelService implements ChannelService {
+
+ 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) {
+ log.debug("채널 생성 시작: {}", request);
+ String name = request.name();
+ String description = request.description();
+ Channel channel = new Channel(ChannelType.PUBLIC, name, description);
+
+ channelRepository.save(channel);
+ log.info("채널 생성 완료: id={}, name={}", channel.getId(), channel.getName());
+ return channelMapper.toDto(channel);
+ }
+
+ @Transactional
+ @Override
+ public ChannelDto create(PrivateChannelCreateRequest request) {
+ log.debug("채널 생성 시작: {}", request);
+ Channel channel = new Channel(ChannelType.PRIVATE, null, null);
+ channelRepository.save(channel);
+
+ List readStatuses = userRepository.findAllById(request.participantIds()).stream()
+ .map(user -> new ReadStatus(user, channel, channel.getCreatedAt()))
+ .toList();
+ readStatusRepository.saveAll(readStatuses);
+
+ log.info("채널 생성 완료: id={}, name={}", channel.getId(), channel.getName());
+ return channelMapper.toDto(channel);
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public ChannelDto find(UUID channelId) {
+ return channelRepository.findById(channelId)
+ .map(channelMapper::toDto)
+ .orElseThrow(() -> ChannelNotFoundException.withId(channelId));
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List findAllByUserId(UUID userId) {
+ List mySubscribedChannelIds = readStatusRepository.findAllByUserId(userId).stream()
+ .map(ReadStatus::getChannel)
+ .map(Channel::getId)
+ .toList();
+
+ return channelRepository.findAllByTypeOrIdIn(ChannelType.PUBLIC, mySubscribedChannelIds)
+ .stream()
+ .map(channelMapper::toDto)
+ .toList();
+ }
+
+ @Transactional
+ @Override
+ public ChannelDto update(UUID channelId, PublicChannelUpdateRequest request) {
+ log.debug("채널 수정 시작: id={}, request={}", channelId, request);
+ String newName = request.newName();
+ String newDescription = request.newDescription();
+ Channel channel = channelRepository.findById(channelId)
+ .orElseThrow(() -> ChannelNotFoundException.withId(channelId));
+ if (channel.getType().equals(ChannelType.PRIVATE)) {
+ throw PrivateChannelUpdateException.forChannel(channelId);
+ }
+ channel.update(newName, newDescription);
+ log.info("채널 수정 완료: id={}, name={}", channelId, channel.getName());
+ return channelMapper.toDto(channel);
+ }
+
+ @Transactional
+ @Override
+ public void delete(UUID channelId) {
+ log.debug("채널 삭제 시작: id={}", channelId);
+ if (!channelRepository.existsById(channelId)) {
+ throw ChannelNotFoundException.withId(channelId);
+ }
+
+ messageRepository.deleteAllByChannelId(channelId);
+ readStatusRepository.deleteAllByChannelId(channelId);
+
+ channelRepository.deleteById(channelId);
+ log.info("채널 삭제 완료: id={}", 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
new file mode 100644
index 0000000000..5516ac5189
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicMessageService.java
@@ -0,0 +1,135 @@
+package com.sprint.mission.discodeit.service.basic;
+
+import com.sprint.mission.discodeit.dto.data.MessageDto;
+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.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.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;
+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.List;
+import java.util.Optional;
+import java.util.UUID;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Slice;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class BasicMessageService implements MessageService {
+
+ 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) {
+ log.debug("메시지 생성 시작: request={}", messageCreateRequest);
+ UUID channelId = messageCreateRequest.channelId();
+ UUID authorId = messageCreateRequest.authorId();
+
+ Channel channel = channelRepository.findById(channelId)
+ .orElseThrow(() -> ChannelNotFoundException.withId(channelId));
+ User author = userRepository.findById(authorId)
+ .orElseThrow(() -> UserNotFoundException.withId(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);
+ log.info("메시지 생성 완료: id={}, channelId={}", message.getId(), channelId);
+ return messageMapper.toDto(message);
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public MessageDto find(UUID messageId) {
+ return messageRepository.findById(messageId)
+ .map(messageMapper::toDto)
+ .orElseThrow(() -> MessageNotFoundException.withId(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);
+ }
+
+ @Transactional
+ @Override
+ public MessageDto update(UUID messageId, MessageUpdateRequest request) {
+ log.debug("메시지 수정 시작: id={}, request={}", messageId, request);
+ Message message = messageRepository.findById(messageId)
+ .orElseThrow(() -> MessageNotFoundException.withId(messageId));
+
+ message.update(request.newContent());
+ log.info("메시지 수정 완료: id={}, channelId={}", messageId, message.getChannel().getId());
+ return messageMapper.toDto(message);
+ }
+
+ @Transactional
+ @Override
+ public void delete(UUID messageId) {
+ log.debug("메시지 삭제 시작: id={}", messageId);
+ if (!messageRepository.existsById(messageId)) {
+ throw MessageNotFoundException.withId(messageId);
+ }
+ messageRepository.deleteById(messageId);
+ log.info("메시지 삭제 완료: id={}", 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
new file mode 100644
index 0000000000..41d998fbe5
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicReadStatusService.java
@@ -0,0 +1,105 @@
+package com.sprint.mission.discodeit.service.basic;
+
+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.ReadStatus;
+import com.sprint.mission.discodeit.entity.User;
+import com.sprint.mission.discodeit.exception.channel.ChannelNotFoundException;
+import com.sprint.mission.discodeit.exception.readstatus.DuplicateReadStatusException;
+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 com.sprint.mission.discodeit.service.ReadStatusService;
+import java.time.Instant;
+import java.util.List;
+import java.util.UUID;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+@RequiredArgsConstructor
+@Service
+public class BasicReadStatusService implements ReadStatusService {
+
+ private final ReadStatusRepository readStatusRepository;
+ private final UserRepository userRepository;
+ private final ChannelRepository channelRepository;
+ private final ReadStatusMapper readStatusMapper;
+
+ @Transactional
+ @Override
+ public ReadStatusDto create(ReadStatusCreateRequest request) {
+ log.debug("읽음 상태 생성 시작: userId={}, channelId={}", request.userId(), request.channelId());
+
+ UUID userId = request.userId();
+ UUID channelId = request.channelId();
+
+ User user = userRepository.findById(userId)
+ .orElseThrow(() -> UserNotFoundException.withId(userId));
+ Channel channel = channelRepository.findById(channelId)
+ .orElseThrow(() -> ChannelNotFoundException.withId(channelId));
+
+ if (readStatusRepository.existsByUserIdAndChannelId(user.getId(), channel.getId())) {
+ throw DuplicateReadStatusException.withUserIdAndChannelId(userId, channelId);
+ }
+
+ Instant lastReadAt = request.lastReadAt();
+ ReadStatus readStatus = new ReadStatus(user, channel, lastReadAt);
+ readStatusRepository.save(readStatus);
+
+ log.info("읽음 상태 생성 완료: id={}, userId={}, channelId={}",
+ readStatus.getId(), userId, channelId);
+ return readStatusMapper.toDto(readStatus);
+ }
+
+ @Override
+ public ReadStatusDto find(UUID readStatusId) {
+ log.debug("읽음 상태 조회 시작: id={}", readStatusId);
+ ReadStatusDto dto = readStatusRepository.findById(readStatusId)
+ .map(readStatusMapper::toDto)
+ .orElseThrow(() -> ReadStatusNotFoundException.withId(readStatusId));
+ log.info("읽음 상태 조회 완료: id={}", readStatusId);
+ return dto;
+ }
+
+ @Override
+ public List findAllByUserId(UUID userId) {
+ log.debug("사용자별 읽음 상태 목록 조회 시작: userId={}", userId);
+ List dtos = readStatusRepository.findAllByUserId(userId).stream()
+ .map(readStatusMapper::toDto)
+ .toList();
+ log.info("사용자별 읽음 상태 목록 조회 완료: userId={}, 조회된 항목 수={}", userId, dtos.size());
+ return dtos;
+ }
+
+ @Transactional
+ @Override
+ public ReadStatusDto update(UUID readStatusId, ReadStatusUpdateRequest request) {
+ log.debug("읽음 상태 수정 시작: id={}, newLastReadAt={}", readStatusId, request.newLastReadAt());
+
+ ReadStatus readStatus = readStatusRepository.findById(readStatusId)
+ .orElseThrow(() -> ReadStatusNotFoundException.withId(readStatusId));
+ readStatus.update(request.newLastReadAt());
+
+ log.info("읽음 상태 수정 완료: id={}", readStatusId);
+ return readStatusMapper.toDto(readStatus);
+ }
+
+ @Transactional
+ @Override
+ public void delete(UUID readStatusId) {
+ log.debug("읽음 상태 삭제 시작: id={}", readStatusId);
+ if (!readStatusRepository.existsById(readStatusId)) {
+ throw ReadStatusNotFoundException.withId(readStatusId);
+ }
+ readStatusRepository.deleteById(readStatusId);
+ log.info("읽음 상태 삭제 완료: id={}", 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
new file mode 100644
index 0000000000..5883f21070
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserService.java
@@ -0,0 +1,154 @@
+package com.sprint.mission.discodeit.service.basic;
+
+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.BinaryContent;
+import com.sprint.mission.discodeit.entity.User;
+import com.sprint.mission.discodeit.entity.UserStatus;
+import com.sprint.mission.discodeit.exception.user.UserAlreadyExistsException;
+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.time.Instant;
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+@RequiredArgsConstructor
+@Service
+public class BasicUserService implements UserService {
+
+ 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) {
+ log.debug("사용자 생성 시작: {}", userCreateRequest);
+
+ String username = userCreateRequest.username();
+ String email = userCreateRequest.email();
+
+ if (userRepository.existsByEmail(email)) {
+ throw UserAlreadyExistsException.withEmail(email);
+ }
+ if (userRepository.existsByUsername(username)) {
+ throw UserAlreadyExistsException.withUsername(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);
+ log.info("사용자 생성 완료: id={}, username={}", user.getId(), username);
+ return userMapper.toDto(user);
+ }
+
+ @Override
+ public UserDto find(UUID userId) {
+ log.debug("사용자 조회 시작: id={}", userId);
+ UserDto userDto = userRepository.findById(userId)
+ .map(userMapper::toDto)
+ .orElseThrow(() -> UserNotFoundException.withId(userId));
+ log.info("사용자 조회 완료: id={}", userId);
+ return userDto;
+ }
+
+ @Override
+ public List findAll() {
+ log.debug("모든 사용자 조회 시작");
+ List userDtos = userRepository.findAllWithProfileAndStatus()
+ .stream()
+ .map(userMapper::toDto)
+ .toList();
+ log.info("모든 사용자 조회 완료: 총 {}명", userDtos.size());
+ return userDtos;
+ }
+
+ @Transactional
+ @Override
+ public UserDto update(UUID userId, UserUpdateRequest userUpdateRequest,
+ Optional optionalProfileCreateRequest) {
+ log.debug("사용자 수정 시작: id={}, request={}", userId, userUpdateRequest);
+
+ User user = userRepository.findById(userId)
+ .orElseThrow(() -> {
+ UserNotFoundException exception = UserNotFoundException.withId(userId);
+ return exception;
+ });
+
+ String newUsername = userUpdateRequest.newUsername();
+ String newEmail = userUpdateRequest.newEmail();
+
+ if (userRepository.existsByEmail(newEmail)) {
+ throw UserAlreadyExistsException.withEmail(newEmail);
+ }
+
+ if (userRepository.existsByUsername(newUsername)) {
+ throw UserAlreadyExistsException.withUsername(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);
+
+ log.info("사용자 수정 완료: id={}", userId);
+ return userMapper.toDto(user);
+ }
+
+ @Transactional
+ @Override
+ public void delete(UUID userId) {
+ log.debug("사용자 삭제 시작: id={}", userId);
+
+ if (!userRepository.existsById(userId)) {
+ throw UserNotFoundException.withId(userId);
+ }
+
+ userRepository.deleteById(userId);
+ log.info("사용자 삭제 완료: id={}", 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
new file mode 100644
index 0000000000..0ae0e4ac76
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserStatusService.java
@@ -0,0 +1,115 @@
+package com.sprint.mission.discodeit.service.basic;
+
+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.DuplicateUserStatusException;
+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.Optional;
+import java.util.UUID;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+@RequiredArgsConstructor
+@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) {
+ log.debug("사용자 상태 생성 시작: userId={}", request.userId());
+
+ UUID userId = request.userId();
+ User user = userRepository.findById(userId)
+ .orElseThrow(() -> UserNotFoundException.withId(userId));
+
+ Optional.ofNullable(user.getStatus())
+ .ifPresent(status -> {
+ throw DuplicateUserStatusException.withUserId(userId);
+ });
+
+ Instant lastActiveAt = request.lastActiveAt();
+ UserStatus userStatus = new UserStatus(user, lastActiveAt);
+ userStatusRepository.save(userStatus);
+
+ log.info("사용자 상태 생성 완료: id={}, userId={}", userStatus.getId(), userId);
+ return userStatusMapper.toDto(userStatus);
+ }
+
+ @Override
+ public UserStatusDto find(UUID userStatusId) {
+ log.debug("사용자 상태 조회 시작: id={}", userStatusId);
+ UserStatusDto dto = userStatusRepository.findById(userStatusId)
+ .map(userStatusMapper::toDto)
+ .orElseThrow(() -> UserStatusNotFoundException.withId(userStatusId));
+ log.info("사용자 상태 조회 완료: id={}", userStatusId);
+ return dto;
+ }
+
+ @Override
+ public List findAll() {
+ log.debug("전체 사용자 상태 목록 조회 시작");
+ List dtos = userStatusRepository.findAll().stream()
+ .map(userStatusMapper::toDto)
+ .toList();
+ log.info("전체 사용자 상태 목록 조회 완료: 조회된 항목 수={}", dtos.size());
+ return dtos;
+ }
+
+ @Transactional
+ @Override
+ public UserStatusDto update(UUID userStatusId, UserStatusUpdateRequest request) {
+ Instant newLastActiveAt = request.newLastActiveAt();
+ log.debug("사용자 상태 수정 시작: id={}, newLastActiveAt={}",
+ userStatusId, newLastActiveAt);
+
+ UserStatus userStatus = userStatusRepository.findById(userStatusId)
+ .orElseThrow(() -> UserStatusNotFoundException.withId(userStatusId));
+ userStatus.update(newLastActiveAt);
+
+ log.info("사용자 상태 수정 완료: id={}", userStatusId);
+ return userStatusMapper.toDto(userStatus);
+ }
+
+ @Transactional
+ @Override
+ public UserStatusDto updateByUserId(UUID userId, UserStatusUpdateRequest request) {
+ Instant newLastActiveAt = request.newLastActiveAt();
+ log.debug("사용자 ID로 상태 수정 시작: userId={}, newLastActiveAt={}",
+ userId, newLastActiveAt);
+
+ UserStatus userStatus = userStatusRepository.findByUserId(userId)
+ .orElseThrow(() -> UserStatusNotFoundException.withUserId(userId));
+ userStatus.update(newLastActiveAt);
+
+ log.info("사용자 ID로 상태 수정 완료: userId={}", userId);
+ return userStatusMapper.toDto(userStatus);
+ }
+
+ @Transactional
+ @Override
+ public void delete(UUID userStatusId) {
+ log.debug("사용자 상태 삭제 시작: id={}", userStatusId);
+ if (!userStatusRepository.existsById(userStatusId)) {
+ throw UserStatusNotFoundException.withId(userStatusId);
+ }
+ userStatusRepository.deleteById(userStatusId);
+ log.info("사용자 상태 삭제 완료: id={}", 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
new file mode 100644
index 0000000000..f00216c40b
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/storage/BinaryContentStorage.java
@@ -0,0 +1,15 @@
+package com.sprint.mission.discodeit.storage;
+
+import com.sprint.mission.discodeit.dto.data.BinaryContentDto;
+import java.io.InputStream;
+import java.util.UUID;
+import org.springframework.http.ResponseEntity;
+
+public interface BinaryContentStorage {
+
+ UUID put(UUID binaryContentId, byte[] bytes);
+
+ InputStream get(UUID binaryContentId);
+
+ ResponseEntity> download(BinaryContentDto metaData);
+}
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 0000000000..8922903c07
--- /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/java/com/sprint/mission/discodeit/storage/s3/AWSS3Test.java b/src/main/java/com/sprint/mission/discodeit/storage/s3/AWSS3Test.java
new file mode 100644
index 0000000000..05215d1962
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/storage/s3/AWSS3Test.java
@@ -0,0 +1,189 @@
+package com.sprint.mission.discodeit.storage.s3;
+
+
+import java.io.ByteArrayInputStream;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.time.Duration;
+import java.util.Properties;
+import org.springframework.web.multipart.MultipartFile;
+import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
+import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
+import software.amazon.awssdk.awscore.presigner.PresignRequest;
+import software.amazon.awssdk.core.ResponseInputStream;
+import software.amazon.awssdk.core.sync.RequestBody;
+import software.amazon.awssdk.regions.Region;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.model.GetObjectRequest;
+import software.amazon.awssdk.services.s3.model.GetObjectResponse;
+import software.amazon.awssdk.services.s3.model.PutObjectRequest;
+import software.amazon.awssdk.services.s3.presigner.S3Presigner;
+import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
+import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest;
+
+public class AWSS3Test {
+
+ //AWS
+ private static Properties props = new Properties();
+ private static String accessKey;
+ private static String secretKey;
+ private static String region;
+ private static String bucket;
+
+ //S3
+ private static S3Client s3Client;
+ private static S3Presigner s3Presigner;
+
+ //.env 파일에 있는 AWS 정보 업로드
+ private static void loadProperties() {
+ try (FileInputStream fis = new FileInputStream(".env")) {
+ props.load(fis);
+ accessKey = props.getProperty("AWS_S3_ACCESS_KEY");
+ secretKey = props.getProperty("AWS_S3_SECRET_KEY");
+ region = props.getProperty("AWS_S3_REGION");
+ bucket = props.getProperty("AWS_S3_BUCKET");
+ System.out.println("AWS 설정 정보가 성공적으로 로드되었습니다.");
+ } catch (IOException e) {
+ System.out.println("AWS 설정 정보 로드 중 에러 발생: " + e.getMessage());
+ }
+ }
+
+
+ //업로드
+ public String upload(MultipartFile file) throws IOException {
+ //파일 이름 생성
+ String originalName = file.getOriginalFilename();
+ String uniqueFileName = System.currentTimeMillis() + "_" + originalName;
+
+ //PutObjectRequest 생성
+ PutObjectRequest putObjectRequest = PutObjectRequest.builder()
+ .bucket(bucket)
+ .key(uniqueFileName)
+ .build();
+
+ //객체 업로드
+ s3Client.putObject(putObjectRequest, RequestBody.fromBytes(file.getBytes()));
+ System.out.println("파일 업로드 완료. 파일 키: " + uniqueFileName);
+ return uniqueFileName;
+ }
+
+ //다운로드
+ public byte[] download(String fileKey) {
+ GetObjectRequest getObjectRequest = GetObjectRequest.builder()
+ .bucket(bucket)
+ .key(fileKey)
+ .build();
+
+ try (ResponseInputStream objectData = s3Client.getObject(getObjectRequest)) {
+ byte[] data = objectData.readAllBytes();
+ System.out.println("파일 다운로드 완료. 파일 크기: " + data.length + " 바이트");
+ return data;
+ } catch (IOException e) {
+ throw new RuntimeException("S3에서 파일 다운로드 실패", e);
+ }
+ }
+
+ //Presigned URL
+ public String generatePresignedUrl(String objectKey) {
+ // 다운로드 요청 생성 (리전 값을 .env 파일에서 읽은 값으로 설정)
+ GetObjectPresignRequest getObjectPresignRequest = GetObjectPresignRequest.builder()
+ .signatureDuration(Duration.ofHours(1)) // URL 유효 시간 1시간
+ .getObjectRequest(GetObjectRequest.builder()
+ .bucket(bucket)
+ .key(objectKey)
+ .build())
+ .build();
+
+ PresignedGetObjectRequest presignedRequest = s3Presigner.presignGetObject(
+ getObjectPresignRequest);
+ String url = presignedRequest.url().toString();
+ System.out.println("Presigned URL 생성 완료: " + url);
+ return url;
+ }
+
+ // 테스트용 MultipartFile 구현
+ private static class TestMultipartFile implements MultipartFile {
+
+ private final String originalFilename;
+ private final byte[] content;
+
+ public TestMultipartFile(String originalFilename, byte[] content) {
+ this.originalFilename = originalFilename;
+ this.content = content;
+ }
+
+ @Override
+ public String getName() {
+ return originalFilename;
+ }
+
+ @Override
+ public String getOriginalFilename() {
+ return originalFilename;
+ }
+
+ @Override
+ public String getContentType() {
+ return "text/plain";
+ }
+
+ @Override
+ public boolean isEmpty() {
+ return content == null || content.length == 0;
+ }
+
+ @Override
+ public long getSize() {
+ return content.length;
+ }
+
+ @Override
+ public byte[] getBytes() throws IOException {
+ return content;
+ }
+
+ @Override
+ public java.io.InputStream getInputStream() throws IOException {
+ return new ByteArrayInputStream(content);
+ }
+
+ @Override
+ public void transferTo(java.io.File dest) throws IOException, IllegalStateException {
+ java.nio.file.Files.write(dest.toPath(), content);
+ }
+ }
+
+ public static void main(String[] args) {
+ // 1. .env 파일로부터 AWS 정보 로드
+ loadProperties();
+
+ // 2. S3Client와 S3Presigner 초기화 (설정 정보를 활용)
+ s3Client = S3Client.builder()
+ .region(Region.of(region))
+ .credentialsProvider(StaticCredentialsProvider.create(
+ AwsBasicCredentials.create(accessKey, secretKey)))
+ .build();
+
+ s3Presigner = S3Presigner.builder()
+ .region(Region.of(region))
+ .credentialsProvider(StaticCredentialsProvider.create(
+ AwsBasicCredentials.create(accessKey, secretKey)))
+ .build();
+
+ // 3. S3 API 테스트
+ AWSS3Test awsS3Test = new AWSS3Test();
+ try {
+ String fileKey = awsS3Test.upload(new TestMultipartFile("testFile.txt", "test".getBytes()));
+
+ awsS3Test.download(fileKey);
+
+ awsS3Test.generatePresignedUrl(fileKey);
+ } catch (IOException e) {
+ e.printStackTrace();
+ } finally {
+ // 4. 리소스 해제
+ s3Client.close();
+ s3Presigner.close();
+ }
+ }
+}
diff --git a/src/main/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorage.java b/src/main/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorage.java
new file mode 100644
index 0000000000..af74400faa
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorage.java
@@ -0,0 +1,115 @@
+package com.sprint.mission.discodeit.storage.s3;
+
+import com.sprint.mission.discodeit.dto.data.BinaryContentDto;
+import com.sprint.mission.discodeit.storage.BinaryContentStorage;
+import java.io.InputStream;
+import java.time.Duration;
+import java.util.UUID;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.actuate.autoconfigure.observation.ObservationProperties.Http;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
+import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
+import software.amazon.awssdk.core.ResponseInputStream;
+import software.amazon.awssdk.core.sync.RequestBody;
+import software.amazon.awssdk.regions.Region;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.model.GetObjectRequest;
+import software.amazon.awssdk.services.s3.model.PutObjectRequest;
+import software.amazon.awssdk.services.s3.presigner.S3Presigner;
+import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
+import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest;
+
+@Slf4j
+public class S3BinaryContentStorage implements BinaryContentStorage {
+
+ private final String accessKey;
+ private final String secretKey;
+ private final String region;
+ private final String bucket;
+
+ @Getter
+ private final S3Client s3Client;
+
+ public S3BinaryContentStorage(String accessKey, String secretKey, String region, String bucket) {
+ this.accessKey = accessKey;
+ this.secretKey = secretKey;
+ this.region = region;
+ this.bucket = bucket;
+ this.s3Client = S3Client.builder()
+ .region(Region.of(region))
+ .credentialsProvider(
+ StaticCredentialsProvider.create(
+ AwsBasicCredentials.create(this.accessKey, this.secretKey)
+ )
+ )
+ .build();
+ }
+
+ @Override
+ public UUID put(UUID binaryContentId, byte[] bytes) {
+ String key = binaryContentId.toString();
+
+ PutObjectRequest putObjectRequest = PutObjectRequest.builder()
+ .bucket(bucket)
+ .key(key)
+ .build();
+
+ s3Client.putObject(putObjectRequest, RequestBody.fromBytes(bytes));
+ log.info("파일 업로드 완료");
+ return binaryContentId;
+ }
+
+ @Override
+ public InputStream get(UUID binaryContentId) {
+ String key = binaryContentId.toString();
+
+ GetObjectRequest getObjectRequest = GetObjectRequest.builder()
+ .bucket(bucket)
+ .key(key)
+ .build();
+
+ ResponseInputStream> s3Object = s3Client.getObject(getObjectRequest);
+ return s3Object;
+ }
+
+ @Override
+ public ResponseEntity> download(BinaryContentDto metaData) {
+ String key = metaData.id().toString();
+ String contentType = metaData.contentType();
+ String presignedUrl = generatedPresignedUrl(key, contentType);
+
+ HttpHeaders headers = new HttpHeaders();
+ headers.set(HttpHeaders.LOCATION, presignedUrl);
+ return new ResponseEntity<>(headers, HttpStatus.FOUND);
+ }
+
+ public String generatedPresignedUrl(String key, String contentType) {
+ try (S3Presigner presigner = S3Presigner.builder()
+ .region(Region.of(region))
+ .credentialsProvider(
+ StaticCredentialsProvider.create(
+ AwsBasicCredentials.create(accessKey, secretKey)
+ )
+ ).build()) {
+
+ GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder()
+ .signatureDuration(Duration.ofHours(1))
+ .getObjectRequest(GetObjectRequest.builder()
+ .bucket(bucket)
+ .key(key)
+ .responseContentType(contentType)
+ .build()
+ ).build();
+
+ PresignedGetObjectRequest presignedGetObjectRequest = presigner.presignGetObject(
+ presignRequest);
+ log.info("Presigned URL 생성 완료");
+ return presignedGetObjectRequest.url().toString();
+ }
+ }
+}
diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml
new file mode 100644
index 0000000000..22ae092e68
--- /dev/null
+++ b/src/main/resources/application-dev.yaml
@@ -0,0 +1,26 @@
+server:
+ port: 8080
+
+spring:
+ datasource:
+ url: jdbc:postgresql://localhost:5432/discodeit
+ username: discodeit_user
+ password: discodeit1234
+ jpa:
+ properties:
+ hibernate:
+ format_sql: true
+
+logging:
+ level:
+ com.sprint.mission.discodeit: debug
+ org.hibernate.SQL: debug
+ org.hibernate.orm.jdbc.bind: trace
+
+management:
+ endpoint:
+ health:
+ show-details: always
+ info:
+ env:
+ enabled: true
\ 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 0000000000..3074885d3c
--- /dev/null
+++ b/src/main/resources/application-prod.yaml
@@ -0,0 +1,25 @@
+server:
+ port: 80
+
+spring:
+ datasource:
+ url: ${SPRING_DATASOURCE_URL}
+ username: ${SPRING_DATASOURCE_USERNAME}
+ password: ${SPRING_DATASOURCE_PASSWORD}
+ jpa:
+ properties:
+ hibernate:
+ format_sql: false
+
+logging:
+ level:
+ com.sprint.mission.discodeit: info
+ org.hibernate.SQL: info
+
+management:
+ endpoint:
+ health:
+ show-details: never
+ info:
+ env:
+ enabled: false
\ No newline at end of file
diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml
new file mode 100644
index 0000000000..17c26e8add
--- /dev/null
+++ b/src/main/resources/application.yaml
@@ -0,0 +1,61 @@
+spring:
+ application:
+ name: discodeit
+ servlet:
+ multipart:
+ maxFileSize: 10MB # 파일 하나의 최대 크기
+ maxRequestSize: 30MB # 한 번에 최대 업로드 가능 용량
+ datasource:
+ driver-class-name: org.postgresql.Driver
+ jpa:
+ hibernate:
+ ddl-auto: validate
+ open-in-view: false
+ profiles:
+ active:
+ - dev
+
+management:
+ endpoints:
+ web:
+ exposure:
+ include: health,info,metrics,loggers
+ endpoint:
+ health:
+ show-details: always
+
+info:
+ name: Discodeit
+ version: 1.7.0
+ java:
+ version: 17
+ spring-boot:
+ version: 3.4.0
+ config:
+ datasource:
+ url: ${spring.datasource.url}
+ driver-class-name: ${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.maxFileSize}
+ max-request-size: ${spring.servlet.multipart.maxRequestSize}
+
+discodeit:
+ storage:
+ type: ${STORAGE_TYPE:local}
+ local:
+ root-path: ${STORAGE_LOCAL_ROOT_PATH:.discodeit/storage}
+ s3:
+ access-key: ${AWS_S3_ACCESS_KEY}
+ secret-key: ${AWS_S3_SECRET_KEY}
+ region: ${AWS_S3_REGION}
+ bucket: ${AWS_S3_BUCKET}
+ presigned-url-expiration: ${AWS_S3_PRESIGNED_URL_EXPIRATION:600} # (기본값: 10분)
+
+logging:
+ level:
+ root: info
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 0000000000..852a0869c0
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 0000000000..36d84a7297
--- /dev/null
+++ b/src/main/resources/logback-spring.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ ${LOG_PATTERN}
+
+
+
+
+
+ ${LOG_FILE_PATH}/${LOG_FILE_NAME}.log
+
+ ${LOG_PATTERN}
+
+
+ ${LOG_FILE_PATH}/${LOG_FILE_NAME}.%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
new file mode 100644
index 0000000000..c658649cdc
--- /dev/null
+++ b/src/main/resources/schema.sql
@@ -0,0 +1,126 @@
+-- 테이블
+-- User
+CREATE TABLE users
+(
+ id uuid PRIMARY KEY,
+ 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
+);
+
+-- UserStatus
+CREATE TABLE user_statuses
+(
+ id uuid PRIMARY KEY,
+ 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 timestamp with time zone NOT NULL,
+ updated_at timestamp with time zone,
+ name varchar(100),
+ description varchar(500),
+ type varchar(10) NOT NULL
+);
+
+-- Message
+CREATE TABLE messages
+(
+ 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
+);
+
+-- Message.attachments
+CREATE TABLE message_attachments
+(
+ message_id uuid,
+ attachment_id uuid,
+ PRIMARY KEY (message_id, attachment_id)
+);
+
+-- ReadStatus
+CREATE TABLE read_statuses
+(
+ id uuid PRIMARY KEY,
+ 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)
+);
+
+
+-- 제약 조건
+-- 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/main/resources/static/assets/index-BdLer33P.js b/src/main/resources/static/assets/index-BdLer33P.js
new file mode 100644
index 0000000000..397601bcf9
--- /dev/null
+++ b/src/main/resources/static/assets/index-BdLer33P.js
@@ -0,0 +1,1015 @@
+var og=Object.defineProperty;var ig=(r,i,s)=>i in r?og(r,i,{enumerable:!0,configurable:!0,writable:!0,value:s}):r[i]=s;var ed=(r,i,s)=>ig(r,typeof i!="symbol"?i+"":i,s);(function(){const i=document.createElement("link").relList;if(i&&i.supports&&i.supports("modulepreload"))return;for(const c of document.querySelectorAll('link[rel="modulepreload"]'))l(c);new MutationObserver(c=>{for(const f of c)if(f.type==="childList")for(const p of f.addedNodes)p.tagName==="LINK"&&p.rel==="modulepreload"&&l(p)}).observe(document,{childList:!0,subtree:!0});function s(c){const f={};return c.integrity&&(f.integrity=c.integrity),c.referrerPolicy&&(f.referrerPolicy=c.referrerPolicy),c.crossOrigin==="use-credentials"?f.credentials="include":c.crossOrigin==="anonymous"?f.credentials="omit":f.credentials="same-origin",f}function l(c){if(c.ep)return;c.ep=!0;const f=s(c);fetch(c.href,f)}})();function sg(r){return r&&r.__esModule&&Object.prototype.hasOwnProperty.call(r,"default")?r.default:r}var mu={exports:{}},yo={},gu={exports:{}},fe={};/**
+ * @license React
+ * react.production.min.js
+ *
+ * Copyright (c) Facebook, Inc. and its affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */var td;function lg(){if(td)return fe;td=1;var r=Symbol.for("react.element"),i=Symbol.for("react.portal"),s=Symbol.for("react.fragment"),l=Symbol.for("react.strict_mode"),c=Symbol.for("react.profiler"),f=Symbol.for("react.provider"),p=Symbol.for("react.context"),g=Symbol.for("react.forward_ref"),x=Symbol.for("react.suspense"),v=Symbol.for("react.memo"),S=Symbol.for("react.lazy"),A=Symbol.iterator;function T(E){return E===null||typeof E!="object"?null:(E=A&&E[A]||E["@@iterator"],typeof E=="function"?E:null)}var I={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},R=Object.assign,C={};function N(E,D,se){this.props=E,this.context=D,this.refs=C,this.updater=se||I}N.prototype.isReactComponent={},N.prototype.setState=function(E,D){if(typeof E!="object"&&typeof E!="function"&&E!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,E,D,"setState")},N.prototype.forceUpdate=function(E){this.updater.enqueueForceUpdate(this,E,"forceUpdate")};function b(){}b.prototype=N.prototype;function U(E,D,se){this.props=E,this.context=D,this.refs=C,this.updater=se||I}var V=U.prototype=new b;V.constructor=U,R(V,N.prototype),V.isPureReactComponent=!0;var Q=Array.isArray,$=Object.prototype.hasOwnProperty,L={current:null},B={key:!0,ref:!0,__self:!0,__source:!0};function ie(E,D,se){var ue,de={},ce=null,ve=null;if(D!=null)for(ue in D.ref!==void 0&&(ve=D.ref),D.key!==void 0&&(ce=""+D.key),D)$.call(D,ue)&&!B.hasOwnProperty(ue)&&(de[ue]=D[ue]);var pe=arguments.length-2;if(pe===1)de.children=se;else if(1>>1,D=W[E];if(0>>1;Ec(de,Y))cec(ve,de)?(W[E]=ve,W[ce]=Y,E=ce):(W[E]=de,W[ue]=Y,E=ue);else if(cec(ve,Y))W[E]=ve,W[ce]=Y,E=ce;else break e}}return Z}function c(W,Z){var Y=W.sortIndex-Z.sortIndex;return Y!==0?Y:W.id-Z.id}if(typeof performance=="object"&&typeof performance.now=="function"){var f=performance;r.unstable_now=function(){return f.now()}}else{var p=Date,g=p.now();r.unstable_now=function(){return p.now()-g}}var x=[],v=[],S=1,A=null,T=3,I=!1,R=!1,C=!1,N=typeof setTimeout=="function"?setTimeout:null,b=typeof clearTimeout=="function"?clearTimeout:null,U=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function V(W){for(var Z=s(v);Z!==null;){if(Z.callback===null)l(v);else if(Z.startTime<=W)l(v),Z.sortIndex=Z.expirationTime,i(x,Z);else break;Z=s(v)}}function Q(W){if(C=!1,V(W),!R)if(s(x)!==null)R=!0,Ve($);else{var Z=s(v);Z!==null&&Se(Q,Z.startTime-W)}}function $(W,Z){R=!1,C&&(C=!1,b(ie),ie=-1),I=!0;var Y=T;try{for(V(Z),A=s(x);A!==null&&(!(A.expirationTime>Z)||W&&!Wt());){var E=A.callback;if(typeof E=="function"){A.callback=null,T=A.priorityLevel;var D=E(A.expirationTime<=Z);Z=r.unstable_now(),typeof D=="function"?A.callback=D:A===s(x)&&l(x),V(Z)}else l(x);A=s(x)}if(A!==null)var se=!0;else{var ue=s(v);ue!==null&&Se(Q,ue.startTime-Z),se=!1}return se}finally{A=null,T=Y,I=!1}}var L=!1,B=null,ie=-1,ye=5,Je=-1;function Wt(){return!(r.unstable_now()-JeW||125E?(W.sortIndex=Y,i(v,W),s(x)===null&&W===s(v)&&(C?(b(ie),ie=-1):C=!0,Se(Q,Y-E))):(W.sortIndex=D,i(x,W),R||I||(R=!0,Ve($))),W},r.unstable_shouldYield=Wt,r.unstable_wrapCallback=function(W){var Z=T;return function(){var Y=T;T=Z;try{return W.apply(this,arguments)}finally{T=Y}}}}(wu)),wu}var sd;function fg(){return sd||(sd=1,vu.exports=cg()),vu.exports}/**
+ * @license React
+ * react-dom.production.min.js
+ *
+ * Copyright (c) Facebook, Inc. and its affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */var ld;function dg(){if(ld)return lt;ld=1;var r=Ku(),i=fg();function s(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,n=1;n"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),x=Object.prototype.hasOwnProperty,v=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,S={},A={};function T(e){return x.call(A,e)?!0:x.call(S,e)?!1:v.test(e)?A[e]=!0:(S[e]=!0,!1)}function I(e,t,n,o){if(n!==null&&n.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return o?!1:n!==null?!n.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function R(e,t,n,o){if(t===null||typeof t>"u"||I(e,t,n,o))return!0;if(o)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function C(e,t,n,o,u,a,d){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=o,this.attributeNamespace=u,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=a,this.removeEmptyString=d}var N={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){N[e]=new C(e,0,!1,e,null,!1,!1)}),[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];N[t]=new C(t,1,!1,e[1],null,!1,!1)}),["contentEditable","draggable","spellCheck","value"].forEach(function(e){N[e]=new C(e,2,!1,e.toLowerCase(),null,!1,!1)}),["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){N[e]=new C(e,2,!1,e,null,!1,!1)}),"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){N[e]=new C(e,3,!1,e.toLowerCase(),null,!1,!1)}),["checked","multiple","muted","selected"].forEach(function(e){N[e]=new C(e,3,!0,e,null,!1,!1)}),["capture","download"].forEach(function(e){N[e]=new C(e,4,!1,e,null,!1,!1)}),["cols","rows","size","span"].forEach(function(e){N[e]=new C(e,6,!1,e,null,!1,!1)}),["rowSpan","start"].forEach(function(e){N[e]=new C(e,5,!1,e.toLowerCase(),null,!1,!1)});var b=/[\-:]([a-z])/g;function U(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(b,U);N[t]=new C(t,1,!1,e,null,!1,!1)}),"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(b,U);N[t]=new C(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)}),["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(b,U);N[t]=new C(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)}),["tabIndex","crossOrigin"].forEach(function(e){N[e]=new C(e,1,!1,e.toLowerCase(),null,!1,!1)}),N.xlinkHref=new C("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1),["src","href","action","formAction"].forEach(function(e){N[e]=new C(e,1,!1,e.toLowerCase(),null,!0,!0)});function V(e,t,n,o){var u=N.hasOwnProperty(t)?N[t]:null;(u!==null?u.type!==0:o||!(2m||u[d]!==a[m]){var y=`
+`+u[d].replace(" at new "," at ");return e.displayName&&y.includes("")&&(y=y.replace("",e.displayName)),y}while(1<=d&&0<=m);break}}}finally{se=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:"")?D(e):""}function de(e){switch(e.tag){case 5:return D(e.type);case 16:return D("Lazy");case 13:return D("Suspense");case 19:return D("SuspenseList");case 0:case 2:case 15:return e=ue(e.type,!1),e;case 11:return e=ue(e.type.render,!1),e;case 1:return e=ue(e.type,!0),e;default:return""}}function ce(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case B:return"Fragment";case L:return"Portal";case ye:return"Profiler";case ie:return"StrictMode";case Ze:return"Suspense";case at:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case Wt:return(e.displayName||"Context")+".Consumer";case Je:return(e._context.displayName||"Context")+".Provider";case vt:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case wt:return t=e.displayName||null,t!==null?t:ce(e.type)||"Memo";case Ve:t=e._payload,e=e._init;try{return ce(e(t))}catch{}}return null}function ve(e){var t=e.type;switch(e.tag){case 24:return"Cache";case 9:return(t.displayName||"Context")+".Consumer";case 10:return(t._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=t.render,e=e.displayName||e.name||"",t.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return ce(t);case 8:return t===ie?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t=="function")return t.displayName||t.name||null;if(typeof t=="string")return t}return null}function pe(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function me(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function $e(e){var t=me(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),o=""+e[t];if(!e.hasOwnProperty(t)&&typeof n<"u"&&typeof n.get=="function"&&typeof n.set=="function"){var u=n.get,a=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return u.call(this)},set:function(d){o=""+d,a.call(this,d)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return o},setValue:function(d){o=""+d},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function Yt(e){e._valueTracker||(e._valueTracker=$e(e))}function Rt(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),o="";return e&&(o=me(e)?e.checked?"true":"false":e.value),e=o,e!==n?(t.setValue(e),!0):!1}function No(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function Es(e,t){var n=t.checked;return Y({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function sa(e,t){var n=t.defaultValue==null?"":t.defaultValue,o=t.checked!=null?t.checked:t.defaultChecked;n=pe(t.value!=null?t.value:n),e._wrapperState={initialChecked:o,initialValue:n,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function la(e,t){t=t.checked,t!=null&&V(e,"checked",t,!1)}function Cs(e,t){la(e,t);var n=pe(t.value),o=t.type;if(n!=null)o==="number"?(n===0&&e.value===""||e.value!=n)&&(e.value=""+n):e.value!==""+n&&(e.value=""+n);else if(o==="submit"||o==="reset"){e.removeAttribute("value");return}t.hasOwnProperty("value")?ks(e,t.type,n):t.hasOwnProperty("defaultValue")&&ks(e,t.type,pe(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function ua(e,t,n){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var o=t.type;if(!(o!=="submit"&&o!=="reset"||t.value!==void 0&&t.value!==null))return;t=""+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,n!==""&&(e.name=n)}function ks(e,t,n){(t!=="number"||No(e.ownerDocument)!==e)&&(n==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+n&&(e.defaultValue=""+n))}var Ir=Array.isArray;function qn(e,t,n,o){if(e=e.options,t){t={};for(var u=0;u"+t.valueOf().toString()+"",t=Oo.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function Nr(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var Or={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},ah=["Webkit","ms","Moz","O"];Object.keys(Or).forEach(function(e){ah.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),Or[t]=Or[e]})});function ha(e,t,n){return t==null||typeof t=="boolean"||t===""?"":n||typeof t!="number"||t===0||Or.hasOwnProperty(e)&&Or[e]?(""+t).trim():t+"px"}function ma(e,t){e=e.style;for(var n in t)if(t.hasOwnProperty(n)){var o=n.indexOf("--")===0,u=ha(n,t[n],o);n==="float"&&(n="cssFloat"),o?e.setProperty(n,u):e[n]=u}}var ch=Y({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function Rs(e,t){if(t){if(ch[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(s(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(s(60));if(typeof t.dangerouslySetInnerHTML!="object"||!("__html"in t.dangerouslySetInnerHTML))throw Error(s(61))}if(t.style!=null&&typeof t.style!="object")throw Error(s(62))}}function Ps(e,t){if(e.indexOf("-")===-1)return typeof t.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var _s=null;function Ts(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var Is=null,Qn=null,Gn=null;function ga(e){if(e=to(e)){if(typeof Is!="function")throw Error(s(280));var t=e.stateNode;t&&(t=ni(t),Is(e.stateNode,e.type,t))}}function ya(e){Qn?Gn?Gn.push(e):Gn=[e]:Qn=e}function va(){if(Qn){var e=Qn,t=Gn;if(Gn=Qn=null,ga(e),t)for(e=0;e>>=0,e===0?32:31-(Sh(e)/Eh|0)|0}var Uo=64,Fo=4194304;function zr(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function Bo(e,t){var n=e.pendingLanes;if(n===0)return 0;var o=0,u=e.suspendedLanes,a=e.pingedLanes,d=n&268435455;if(d!==0){var m=d&~u;m!==0?o=zr(m):(a&=d,a!==0&&(o=zr(a)))}else d=n&~u,d!==0?o=zr(d):a!==0&&(o=zr(a));if(o===0)return 0;if(t!==0&&t!==o&&!(t&u)&&(u=o&-o,a=t&-t,u>=a||u===16&&(a&4194240)!==0))return t;if(o&4&&(o|=n&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=o;0n;n++)t.push(e);return t}function Ur(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-Pt(t),e[t]=n}function Ah(e,t){var n=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var o=e.eventTimes;for(e=e.expirationTimes;0=Yr),Ya=" ",qa=!1;function Qa(e,t){switch(e){case"keyup":return em.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Ga(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var Jn=!1;function nm(e,t){switch(e){case"compositionend":return Ga(t);case"keypress":return t.which!==32?null:(qa=!0,Ya);case"textInput":return e=t.data,e===Ya&&qa?null:e;default:return null}}function rm(e,t){if(Jn)return e==="compositionend"||!Gs&&Qa(e,t)?(e=Ba(),Wo=bs=an=null,Jn=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=o}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=nc(n)}}function oc(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?oc(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function ic(){for(var e=window,t=No();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href=="string"}catch{n=!1}if(n)e=t.contentWindow;else break;t=No(e.document)}return t}function Js(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}function dm(e){var t=ic(),n=e.focusedElem,o=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&oc(n.ownerDocument.documentElement,n)){if(o!==null&&Js(n)){if(t=o.start,e=o.end,e===void 0&&(e=t),"selectionStart"in n)n.selectionStart=t,n.selectionEnd=Math.min(e,n.value.length);else if(e=(t=n.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var u=n.textContent.length,a=Math.min(o.start,u);o=o.end===void 0?a:Math.min(o.end,u),!e.extend&&a>o&&(u=o,o=a,a=u),u=rc(n,a);var d=rc(n,o);u&&d&&(e.rangeCount!==1||e.anchorNode!==u.node||e.anchorOffset!==u.offset||e.focusNode!==d.node||e.focusOffset!==d.offset)&&(t=t.createRange(),t.setStart(u.node,u.offset),e.removeAllRanges(),a>o?(e.addRange(t),e.extend(d.node,d.offset)):(t.setEnd(d.node,d.offset),e.addRange(t)))}}for(t=[],e=n;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof n.focus=="function"&&n.focus(),n=0;n=document.documentMode,Zn=null,Zs=null,Kr=null,el=!1;function sc(e,t,n){var o=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;el||Zn==null||Zn!==No(o)||(o=Zn,"selectionStart"in o&&Js(o)?o={start:o.selectionStart,end:o.selectionEnd}:(o=(o.ownerDocument&&o.ownerDocument.defaultView||window).getSelection(),o={anchorNode:o.anchorNode,anchorOffset:o.anchorOffset,focusNode:o.focusNode,focusOffset:o.focusOffset}),Kr&&Gr(Kr,o)||(Kr=o,o=Zo(Zs,"onSelect"),0or||(e.current=dl[or],dl[or]=null,or--)}function Ee(e,t){or++,dl[or]=e.current,e.current=t}var pn={},We=dn(pn),nt=dn(!1),Rn=pn;function ir(e,t){var n=e.type.contextTypes;if(!n)return pn;var o=e.stateNode;if(o&&o.__reactInternalMemoizedUnmaskedChildContext===t)return o.__reactInternalMemoizedMaskedChildContext;var u={},a;for(a in n)u[a]=t[a];return o&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=u),u}function rt(e){return e=e.childContextTypes,e!=null}function ri(){ke(nt),ke(We)}function Sc(e,t,n){if(We.current!==pn)throw Error(s(168));Ee(We,t),Ee(nt,n)}function Ec(e,t,n){var o=e.stateNode;if(t=t.childContextTypes,typeof o.getChildContext!="function")return n;o=o.getChildContext();for(var u in o)if(!(u in t))throw Error(s(108,ve(e)||"Unknown",u));return Y({},n,o)}function oi(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||pn,Rn=We.current,Ee(We,e),Ee(nt,nt.current),!0}function Cc(e,t,n){var o=e.stateNode;if(!o)throw Error(s(169));n?(e=Ec(e,t,Rn),o.__reactInternalMemoizedMergedChildContext=e,ke(nt),ke(We),Ee(We,e)):ke(nt),Ee(nt,n)}var Qt=null,ii=!1,pl=!1;function kc(e){Qt===null?Qt=[e]:Qt.push(e)}function km(e){ii=!0,kc(e)}function hn(){if(!pl&&Qt!==null){pl=!0;var e=0,t=xe;try{var n=Qt;for(xe=1;e>=d,u-=d,Gt=1<<32-Pt(t)+u|n<re?(Fe=ne,ne=null):Fe=ne.sibling;var ge=M(k,ne,j[re],H);if(ge===null){ne===null&&(ne=Fe);break}e&&ne&&ge.alternate===null&&t(k,ne),w=a(ge,w,re),te===null?J=ge:te.sibling=ge,te=ge,ne=Fe}if(re===j.length)return n(k,ne),Ae&&_n(k,re),J;if(ne===null){for(;rere?(Fe=ne,ne=null):Fe=ne.sibling;var Cn=M(k,ne,ge.value,H);if(Cn===null){ne===null&&(ne=Fe);break}e&&ne&&Cn.alternate===null&&t(k,ne),w=a(Cn,w,re),te===null?J=Cn:te.sibling=Cn,te=Cn,ne=Fe}if(ge.done)return n(k,ne),Ae&&_n(k,re),J;if(ne===null){for(;!ge.done;re++,ge=j.next())ge=F(k,ge.value,H),ge!==null&&(w=a(ge,w,re),te===null?J=ge:te.sibling=ge,te=ge);return Ae&&_n(k,re),J}for(ne=o(k,ne);!ge.done;re++,ge=j.next())ge=q(ne,k,re,ge.value,H),ge!==null&&(e&&ge.alternate!==null&&ne.delete(ge.key===null?re:ge.key),w=a(ge,w,re),te===null?J=ge:te.sibling=ge,te=ge);return e&&ne.forEach(function(rg){return t(k,rg)}),Ae&&_n(k,re),J}function Ie(k,w,j,H){if(typeof j=="object"&&j!==null&&j.type===B&&j.key===null&&(j=j.props.children),typeof j=="object"&&j!==null){switch(j.$$typeof){case $:e:{for(var J=j.key,te=w;te!==null;){if(te.key===J){if(J=j.type,J===B){if(te.tag===7){n(k,te.sibling),w=u(te,j.props.children),w.return=k,k=w;break e}}else if(te.elementType===J||typeof J=="object"&&J!==null&&J.$$typeof===Ve&&Tc(J)===te.type){n(k,te.sibling),w=u(te,j.props),w.ref=no(k,te,j),w.return=k,k=w;break e}n(k,te);break}else t(k,te);te=te.sibling}j.type===B?(w=zn(j.props.children,k.mode,H,j.key),w.return=k,k=w):(H=Oi(j.type,j.key,j.props,null,k.mode,H),H.ref=no(k,w,j),H.return=k,k=H)}return d(k);case L:e:{for(te=j.key;w!==null;){if(w.key===te)if(w.tag===4&&w.stateNode.containerInfo===j.containerInfo&&w.stateNode.implementation===j.implementation){n(k,w.sibling),w=u(w,j.children||[]),w.return=k,k=w;break e}else{n(k,w);break}else t(k,w);w=w.sibling}w=cu(j,k.mode,H),w.return=k,k=w}return d(k);case Ve:return te=j._init,Ie(k,w,te(j._payload),H)}if(Ir(j))return K(k,w,j,H);if(Z(j))return X(k,w,j,H);ai(k,j)}return typeof j=="string"&&j!==""||typeof j=="number"?(j=""+j,w!==null&&w.tag===6?(n(k,w.sibling),w=u(w,j),w.return=k,k=w):(n(k,w),w=au(j,k.mode,H),w.return=k,k=w),d(k)):n(k,w)}return Ie}var ar=Ic(!0),Nc=Ic(!1),ci=dn(null),fi=null,cr=null,wl=null;function xl(){wl=cr=fi=null}function Sl(e){var t=ci.current;ke(ci),e._currentValue=t}function El(e,t,n){for(;e!==null;){var o=e.alternate;if((e.childLanes&t)!==t?(e.childLanes|=t,o!==null&&(o.childLanes|=t)):o!==null&&(o.childLanes&t)!==t&&(o.childLanes|=t),e===n)break;e=e.return}}function fr(e,t){fi=e,wl=cr=null,e=e.dependencies,e!==null&&e.firstContext!==null&&(e.lanes&t&&(ot=!0),e.firstContext=null)}function Et(e){var t=e._currentValue;if(wl!==e)if(e={context:e,memoizedValue:t,next:null},cr===null){if(fi===null)throw Error(s(308));cr=e,fi.dependencies={lanes:0,firstContext:e}}else cr=cr.next=e;return t}var Tn=null;function Cl(e){Tn===null?Tn=[e]:Tn.push(e)}function Oc(e,t,n,o){var u=t.interleaved;return u===null?(n.next=n,Cl(t)):(n.next=u.next,u.next=n),t.interleaved=n,Xt(e,o)}function Xt(e,t){e.lanes|=t;var n=e.alternate;for(n!==null&&(n.lanes|=t),n=e,e=e.return;e!==null;)e.childLanes|=t,n=e.alternate,n!==null&&(n.childLanes|=t),n=e,e=e.return;return n.tag===3?n.stateNode:null}var mn=!1;function kl(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function Lc(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function Jt(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function gn(e,t,n){var o=e.updateQueue;if(o===null)return null;if(o=o.shared,he&2){var u=o.pending;return u===null?t.next=t:(t.next=u.next,u.next=t),o.pending=t,Xt(e,n)}return u=o.interleaved,u===null?(t.next=t,Cl(o)):(t.next=u.next,u.next=t),o.interleaved=t,Xt(e,n)}function di(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,(n&4194240)!==0)){var o=t.lanes;o&=e.pendingLanes,n|=o,t.lanes=n,Us(e,n)}}function Dc(e,t){var n=e.updateQueue,o=e.alternate;if(o!==null&&(o=o.updateQueue,n===o)){var u=null,a=null;if(n=n.firstBaseUpdate,n!==null){do{var d={eventTime:n.eventTime,lane:n.lane,tag:n.tag,payload:n.payload,callback:n.callback,next:null};a===null?u=a=d:a=a.next=d,n=n.next}while(n!==null);a===null?u=a=t:a=a.next=t}else u=a=t;n={baseState:o.baseState,firstBaseUpdate:u,lastBaseUpdate:a,shared:o.shared,effects:o.effects},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}function pi(e,t,n,o){var u=e.updateQueue;mn=!1;var a=u.firstBaseUpdate,d=u.lastBaseUpdate,m=u.shared.pending;if(m!==null){u.shared.pending=null;var y=m,P=y.next;y.next=null,d===null?a=P:d.next=P,d=y;var z=e.alternate;z!==null&&(z=z.updateQueue,m=z.lastBaseUpdate,m!==d&&(m===null?z.firstBaseUpdate=P:m.next=P,z.lastBaseUpdate=y))}if(a!==null){var F=u.baseState;d=0,z=P=y=null,m=a;do{var M=m.lane,q=m.eventTime;if((o&M)===M){z!==null&&(z=z.next={eventTime:q,lane:0,tag:m.tag,payload:m.payload,callback:m.callback,next:null});e:{var K=e,X=m;switch(M=t,q=n,X.tag){case 1:if(K=X.payload,typeof K=="function"){F=K.call(q,F,M);break e}F=K;break e;case 3:K.flags=K.flags&-65537|128;case 0:if(K=X.payload,M=typeof K=="function"?K.call(q,F,M):K,M==null)break e;F=Y({},F,M);break e;case 2:mn=!0}}m.callback!==null&&m.lane!==0&&(e.flags|=64,M=u.effects,M===null?u.effects=[m]:M.push(m))}else q={eventTime:q,lane:M,tag:m.tag,payload:m.payload,callback:m.callback,next:null},z===null?(P=z=q,y=F):z=z.next=q,d|=M;if(m=m.next,m===null){if(m=u.shared.pending,m===null)break;M=m,m=M.next,M.next=null,u.lastBaseUpdate=M,u.shared.pending=null}}while(!0);if(z===null&&(y=F),u.baseState=y,u.firstBaseUpdate=P,u.lastBaseUpdate=z,t=u.shared.interleaved,t!==null){u=t;do d|=u.lane,u=u.next;while(u!==t)}else a===null&&(u.shared.lanes=0);On|=d,e.lanes=d,e.memoizedState=F}}function Mc(e,t,n){if(e=t.effects,t.effects=null,e!==null)for(t=0;tn?n:4,e(!0);var o=_l.transition;_l.transition={};try{e(!1),t()}finally{xe=n,_l.transition=o}}function tf(){return Ct().memoizedState}function Pm(e,t,n){var o=xn(e);if(n={lane:o,action:n,hasEagerState:!1,eagerState:null,next:null},nf(e))rf(t,n);else if(n=Oc(e,t,n,o),n!==null){var u=tt();Lt(n,e,o,u),of(n,t,o)}}function _m(e,t,n){var o=xn(e),u={lane:o,action:n,hasEagerState:!1,eagerState:null,next:null};if(nf(e))rf(t,u);else{var a=e.alternate;if(e.lanes===0&&(a===null||a.lanes===0)&&(a=t.lastRenderedReducer,a!==null))try{var d=t.lastRenderedState,m=a(d,n);if(u.hasEagerState=!0,u.eagerState=m,_t(m,d)){var y=t.interleaved;y===null?(u.next=u,Cl(t)):(u.next=y.next,y.next=u),t.interleaved=u;return}}catch{}finally{}n=Oc(e,t,u,o),n!==null&&(u=tt(),Lt(n,e,o,u),of(n,t,o))}}function nf(e){var t=e.alternate;return e===Pe||t!==null&&t===Pe}function rf(e,t){so=gi=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function of(e,t,n){if(n&4194240){var o=t.lanes;o&=e.pendingLanes,n|=o,t.lanes=n,Us(e,n)}}var wi={readContext:Et,useCallback:Ye,useContext:Ye,useEffect:Ye,useImperativeHandle:Ye,useInsertionEffect:Ye,useLayoutEffect:Ye,useMemo:Ye,useReducer:Ye,useRef:Ye,useState:Ye,useDebugValue:Ye,useDeferredValue:Ye,useTransition:Ye,useMutableSource:Ye,useSyncExternalStore:Ye,useId:Ye,unstable_isNewReconciler:!1},Tm={readContext:Et,useCallback:function(e,t){return $t().memoizedState=[e,t===void 0?null:t],e},useContext:Et,useEffect:qc,useImperativeHandle:function(e,t,n){return n=n!=null?n.concat([e]):null,yi(4194308,4,Kc.bind(null,t,e),n)},useLayoutEffect:function(e,t){return yi(4194308,4,e,t)},useInsertionEffect:function(e,t){return yi(4,2,e,t)},useMemo:function(e,t){var n=$t();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var o=$t();return t=n!==void 0?n(t):t,o.memoizedState=o.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},o.queue=e,e=e.dispatch=Pm.bind(null,Pe,e),[o.memoizedState,e]},useRef:function(e){var t=$t();return e={current:e},t.memoizedState=e},useState:Wc,useDebugValue:Ml,useDeferredValue:function(e){return $t().memoizedState=e},useTransition:function(){var e=Wc(!1),t=e[0];return e=Rm.bind(null,e[1]),$t().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var o=Pe,u=$t();if(Ae){if(n===void 0)throw Error(s(407));n=n()}else{if(n=t(),Ue===null)throw Error(s(349));Nn&30||Bc(o,t,n)}u.memoizedState=n;var a={value:n,getSnapshot:t};return u.queue=a,qc(Hc.bind(null,o,a,e),[e]),o.flags|=2048,ao(9,$c.bind(null,o,a,n,t),void 0,null),n},useId:function(){var e=$t(),t=Ue.identifierPrefix;if(Ae){var n=Kt,o=Gt;n=(o&~(1<<32-Pt(o)-1)).toString(32)+n,t=":"+t+"R"+n,n=lo++,0<\/script>",e=e.removeChild(e.firstChild)):typeof o.is=="string"?e=d.createElement(n,{is:o.is}):(e=d.createElement(n),n==="select"&&(d=e,o.multiple?d.multiple=!0:o.size&&(d.size=o.size))):e=d.createElementNS(e,n),e[Ft]=t,e[eo]=o,jf(e,t,!1,!1),t.stateNode=e;e:{switch(d=Ps(n,o),n){case"dialog":Ce("cancel",e),Ce("close",e),u=o;break;case"iframe":case"object":case"embed":Ce("load",e),u=o;break;case"video":case"audio":for(u=0;ugr&&(t.flags|=128,o=!0,co(a,!1),t.lanes=4194304)}else{if(!o)if(e=hi(d),e!==null){if(t.flags|=128,o=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),co(a,!0),a.tail===null&&a.tailMode==="hidden"&&!d.alternate&&!Ae)return qe(t),null}else 2*Te()-a.renderingStartTime>gr&&n!==1073741824&&(t.flags|=128,o=!0,co(a,!1),t.lanes=4194304);a.isBackwards?(d.sibling=t.child,t.child=d):(n=a.last,n!==null?n.sibling=d:t.child=d,a.last=d)}return a.tail!==null?(t=a.tail,a.rendering=t,a.tail=t.sibling,a.renderingStartTime=Te(),t.sibling=null,n=Re.current,Ee(Re,o?n&1|2:n&1),t):(qe(t),null);case 22:case 23:return su(),o=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==o&&(t.flags|=8192),o&&t.mode&1?pt&1073741824&&(qe(t),t.subtreeFlags&6&&(t.flags|=8192)):qe(t),null;case 24:return null;case 25:return null}throw Error(s(156,t.tag))}function Um(e,t){switch(ml(t),t.tag){case 1:return rt(t.type)&&ri(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return dr(),ke(nt),ke(We),Pl(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 5:return Al(t),null;case 13:if(ke(Re),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(s(340));ur()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return ke(Re),null;case 4:return dr(),null;case 10:return Sl(t.type._context),null;case 22:case 23:return su(),null;case 24:return null;default:return null}}var Ci=!1,Qe=!1,Fm=typeof WeakSet=="function"?WeakSet:Set,G=null;function hr(e,t){var n=e.ref;if(n!==null)if(typeof n=="function")try{n(null)}catch(o){_e(e,t,o)}else n.current=null}function Ql(e,t,n){try{n()}catch(o){_e(e,t,o)}}var Pf=!1;function Bm(e,t){if(sl=bo,e=ic(),Js(e)){if("selectionStart"in e)var n={start:e.selectionStart,end:e.selectionEnd};else e:{n=(n=e.ownerDocument)&&n.defaultView||window;var o=n.getSelection&&n.getSelection();if(o&&o.rangeCount!==0){n=o.anchorNode;var u=o.anchorOffset,a=o.focusNode;o=o.focusOffset;try{n.nodeType,a.nodeType}catch{n=null;break e}var d=0,m=-1,y=-1,P=0,z=0,F=e,M=null;t:for(;;){for(var q;F!==n||u!==0&&F.nodeType!==3||(m=d+u),F!==a||o!==0&&F.nodeType!==3||(y=d+o),F.nodeType===3&&(d+=F.nodeValue.length),(q=F.firstChild)!==null;)M=F,F=q;for(;;){if(F===e)break t;if(M===n&&++P===u&&(m=d),M===a&&++z===o&&(y=d),(q=F.nextSibling)!==null)break;F=M,M=F.parentNode}F=q}n=m===-1||y===-1?null:{start:m,end:y}}else n=null}n=n||{start:0,end:0}}else n=null;for(ll={focusedElem:e,selectionRange:n},bo=!1,G=t;G!==null;)if(t=G,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,G=e;else for(;G!==null;){t=G;try{var K=t.alternate;if(t.flags&1024)switch(t.tag){case 0:case 11:case 15:break;case 1:if(K!==null){var X=K.memoizedProps,Ie=K.memoizedState,k=t.stateNode,w=k.getSnapshotBeforeUpdate(t.elementType===t.type?X:It(t.type,X),Ie);k.__reactInternalSnapshotBeforeUpdate=w}break;case 3:var j=t.stateNode.containerInfo;j.nodeType===1?j.textContent="":j.nodeType===9&&j.documentElement&&j.removeChild(j.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(s(163))}}catch(H){_e(t,t.return,H)}if(e=t.sibling,e!==null){e.return=t.return,G=e;break}G=t.return}return K=Pf,Pf=!1,K}function fo(e,t,n){var o=t.updateQueue;if(o=o!==null?o.lastEffect:null,o!==null){var u=o=o.next;do{if((u.tag&e)===e){var a=u.destroy;u.destroy=void 0,a!==void 0&&Ql(t,n,a)}u=u.next}while(u!==o)}}function ki(e,t){if(t=t.updateQueue,t=t!==null?t.lastEffect:null,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var o=n.create;n.destroy=o()}n=n.next}while(n!==t)}}function Gl(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t=="function"?t(e):t.current=e}}function _f(e){var t=e.alternate;t!==null&&(e.alternate=null,_f(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[Ft],delete t[eo],delete t[fl],delete t[Em],delete t[Cm])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function Tf(e){return e.tag===5||e.tag===3||e.tag===4}function If(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||Tf(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Kl(e,t,n){var o=e.tag;if(o===5||o===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=ti));else if(o!==4&&(e=e.child,e!==null))for(Kl(e,t,n),e=e.sibling;e!==null;)Kl(e,t,n),e=e.sibling}function Xl(e,t,n){var o=e.tag;if(o===5||o===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(o!==4&&(e=e.child,e!==null))for(Xl(e,t,n),e=e.sibling;e!==null;)Xl(e,t,n),e=e.sibling}var He=null,Nt=!1;function yn(e,t,n){for(n=n.child;n!==null;)Nf(e,t,n),n=n.sibling}function Nf(e,t,n){if(Ut&&typeof Ut.onCommitFiberUnmount=="function")try{Ut.onCommitFiberUnmount(zo,n)}catch{}switch(n.tag){case 5:Qe||hr(n,t);case 6:var o=He,u=Nt;He=null,yn(e,t,n),He=o,Nt=u,He!==null&&(Nt?(e=He,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):He.removeChild(n.stateNode));break;case 18:He!==null&&(Nt?(e=He,n=n.stateNode,e.nodeType===8?cl(e.parentNode,n):e.nodeType===1&&cl(e,n),br(e)):cl(He,n.stateNode));break;case 4:o=He,u=Nt,He=n.stateNode.containerInfo,Nt=!0,yn(e,t,n),He=o,Nt=u;break;case 0:case 11:case 14:case 15:if(!Qe&&(o=n.updateQueue,o!==null&&(o=o.lastEffect,o!==null))){u=o=o.next;do{var a=u,d=a.destroy;a=a.tag,d!==void 0&&(a&2||a&4)&&Ql(n,t,d),u=u.next}while(u!==o)}yn(e,t,n);break;case 1:if(!Qe&&(hr(n,t),o=n.stateNode,typeof o.componentWillUnmount=="function"))try{o.props=n.memoizedProps,o.state=n.memoizedState,o.componentWillUnmount()}catch(m){_e(n,t,m)}yn(e,t,n);break;case 21:yn(e,t,n);break;case 22:n.mode&1?(Qe=(o=Qe)||n.memoizedState!==null,yn(e,t,n),Qe=o):yn(e,t,n);break;default:yn(e,t,n)}}function Of(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new Fm),t.forEach(function(o){var u=Gm.bind(null,e,o);n.has(o)||(n.add(o),o.then(u,u))})}}function Ot(e,t){var n=t.deletions;if(n!==null)for(var o=0;ou&&(u=d),o&=~a}if(o=u,o=Te()-o,o=(120>o?120:480>o?480:1080>o?1080:1920>o?1920:3e3>o?3e3:4320>o?4320:1960*Hm(o/1960))-o,10e?16:e,wn===null)var o=!1;else{if(e=wn,wn=null,_i=0,he&6)throw Error(s(331));var u=he;for(he|=4,G=e.current;G!==null;){var a=G,d=a.child;if(G.flags&16){var m=a.deletions;if(m!==null){for(var y=0;yTe()-eu?Dn(e,0):Zl|=n),st(e,t)}function Yf(e,t){t===0&&(e.mode&1?(t=Fo,Fo<<=1,!(Fo&130023424)&&(Fo=4194304)):t=1);var n=tt();e=Xt(e,t),e!==null&&(Ur(e,t,n),st(e,n))}function Qm(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),Yf(e,n)}function Gm(e,t){var n=0;switch(e.tag){case 13:var o=e.stateNode,u=e.memoizedState;u!==null&&(n=u.retryLane);break;case 19:o=e.stateNode;break;default:throw Error(s(314))}o!==null&&o.delete(t),Yf(e,n)}var qf;qf=function(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps||nt.current)ot=!0;else{if(!(e.lanes&n)&&!(t.flags&128))return ot=!1,Mm(e,t,n);ot=!!(e.flags&131072)}else ot=!1,Ae&&t.flags&1048576&&jc(t,li,t.index);switch(t.lanes=0,t.tag){case 2:var o=t.type;Ei(e,t),e=t.pendingProps;var u=ir(t,We.current);fr(t,n),u=Il(null,t,o,e,u,n);var a=Nl();return t.flags|=1,typeof u=="object"&&u!==null&&typeof u.render=="function"&&u.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,rt(o)?(a=!0,oi(t)):a=!1,t.memoizedState=u.state!==null&&u.state!==void 0?u.state:null,kl(t),u.updater=xi,t.stateNode=u,u._reactInternals=t,Ul(t,o,e,n),t=Hl(null,t,o,!0,a,n)):(t.tag=0,Ae&&a&&hl(t),et(null,t,u,n),t=t.child),t;case 16:o=t.elementType;e:{switch(Ei(e,t),e=t.pendingProps,u=o._init,o=u(o._payload),t.type=o,u=t.tag=Xm(o),e=It(o,e),u){case 0:t=$l(null,t,o,e,n);break e;case 1:t=wf(null,t,o,e,n);break e;case 11:t=hf(null,t,o,e,n);break e;case 14:t=mf(null,t,o,It(o.type,e),n);break e}throw Error(s(306,o,""))}return t;case 0:return o=t.type,u=t.pendingProps,u=t.elementType===o?u:It(o,u),$l(e,t,o,u,n);case 1:return o=t.type,u=t.pendingProps,u=t.elementType===o?u:It(o,u),wf(e,t,o,u,n);case 3:e:{if(xf(t),e===null)throw Error(s(387));o=t.pendingProps,a=t.memoizedState,u=a.element,Lc(e,t),pi(t,o,null,n);var d=t.memoizedState;if(o=d.element,a.isDehydrated)if(a={element:o,isDehydrated:!1,cache:d.cache,pendingSuspenseBoundaries:d.pendingSuspenseBoundaries,transitions:d.transitions},t.updateQueue.baseState=a,t.memoizedState=a,t.flags&256){u=pr(Error(s(423)),t),t=Sf(e,t,o,n,u);break e}else if(o!==u){u=pr(Error(s(424)),t),t=Sf(e,t,o,n,u);break e}else for(dt=fn(t.stateNode.containerInfo.firstChild),ft=t,Ae=!0,Tt=null,n=Nc(t,null,o,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling;else{if(ur(),o===u){t=Zt(e,t,n);break e}et(e,t,o,n)}t=t.child}return t;case 5:return zc(t),e===null&&yl(t),o=t.type,u=t.pendingProps,a=e!==null?e.memoizedProps:null,d=u.children,ul(o,u)?d=null:a!==null&&ul(o,a)&&(t.flags|=32),vf(e,t),et(e,t,d,n),t.child;case 6:return e===null&&yl(t),null;case 13:return Ef(e,t,n);case 4:return jl(t,t.stateNode.containerInfo),o=t.pendingProps,e===null?t.child=ar(t,null,o,n):et(e,t,o,n),t.child;case 11:return o=t.type,u=t.pendingProps,u=t.elementType===o?u:It(o,u),hf(e,t,o,u,n);case 7:return et(e,t,t.pendingProps,n),t.child;case 8:return et(e,t,t.pendingProps.children,n),t.child;case 12:return et(e,t,t.pendingProps.children,n),t.child;case 10:e:{if(o=t.type._context,u=t.pendingProps,a=t.memoizedProps,d=u.value,Ee(ci,o._currentValue),o._currentValue=d,a!==null)if(_t(a.value,d)){if(a.children===u.children&&!nt.current){t=Zt(e,t,n);break e}}else for(a=t.child,a!==null&&(a.return=t);a!==null;){var m=a.dependencies;if(m!==null){d=a.child;for(var y=m.firstContext;y!==null;){if(y.context===o){if(a.tag===1){y=Jt(-1,n&-n),y.tag=2;var P=a.updateQueue;if(P!==null){P=P.shared;var z=P.pending;z===null?y.next=y:(y.next=z.next,z.next=y),P.pending=y}}a.lanes|=n,y=a.alternate,y!==null&&(y.lanes|=n),El(a.return,n,t),m.lanes|=n;break}y=y.next}}else if(a.tag===10)d=a.type===t.type?null:a.child;else if(a.tag===18){if(d=a.return,d===null)throw Error(s(341));d.lanes|=n,m=d.alternate,m!==null&&(m.lanes|=n),El(d,n,t),d=a.sibling}else d=a.child;if(d!==null)d.return=a;else for(d=a;d!==null;){if(d===t){d=null;break}if(a=d.sibling,a!==null){a.return=d.return,d=a;break}d=d.return}a=d}et(e,t,u.children,n),t=t.child}return t;case 9:return u=t.type,o=t.pendingProps.children,fr(t,n),u=Et(u),o=o(u),t.flags|=1,et(e,t,o,n),t.child;case 14:return o=t.type,u=It(o,t.pendingProps),u=It(o.type,u),mf(e,t,o,u,n);case 15:return gf(e,t,t.type,t.pendingProps,n);case 17:return o=t.type,u=t.pendingProps,u=t.elementType===o?u:It(o,u),Ei(e,t),t.tag=1,rt(o)?(e=!0,oi(t)):e=!1,fr(t,n),lf(t,o,u),Ul(t,o,u,n),Hl(null,t,o,!0,e,n);case 19:return kf(e,t,n);case 22:return yf(e,t,n)}throw Error(s(156,t.tag))};function Qf(e,t){return Aa(e,t)}function Km(e,t,n,o){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=o,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function jt(e,t,n,o){return new Km(e,t,n,o)}function uu(e){return e=e.prototype,!(!e||!e.isReactComponent)}function Xm(e){if(typeof e=="function")return uu(e)?1:0;if(e!=null){if(e=e.$$typeof,e===vt)return 11;if(e===wt)return 14}return 2}function En(e,t){var n=e.alternate;return n===null?(n=jt(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function Oi(e,t,n,o,u,a){var d=2;if(o=e,typeof e=="function")uu(e)&&(d=1);else if(typeof e=="string")d=5;else e:switch(e){case B:return zn(n.children,u,a,t);case ie:d=8,u|=8;break;case ye:return e=jt(12,n,t,u|2),e.elementType=ye,e.lanes=a,e;case Ze:return e=jt(13,n,t,u),e.elementType=Ze,e.lanes=a,e;case at:return e=jt(19,n,t,u),e.elementType=at,e.lanes=a,e;case Se:return Li(n,u,a,t);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case Je:d=10;break e;case Wt:d=9;break e;case vt:d=11;break e;case wt:d=14;break e;case Ve:d=16,o=null;break e}throw Error(s(130,e==null?e:typeof e,""))}return t=jt(d,n,t,u),t.elementType=e,t.type=o,t.lanes=a,t}function zn(e,t,n,o){return e=jt(7,e,o,t),e.lanes=n,e}function Li(e,t,n,o){return e=jt(22,e,o,t),e.elementType=Se,e.lanes=n,e.stateNode={isHidden:!1},e}function au(e,t,n){return e=jt(6,e,null,t),e.lanes=n,e}function cu(e,t,n){return t=jt(4,e.children!==null?e.children:[],e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function Jm(e,t,n,o,u){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=zs(0),this.expirationTimes=zs(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=zs(0),this.identifierPrefix=o,this.onRecoverableError=u,this.mutableSourceEagerHydrationData=null}function fu(e,t,n,o,u,a,d,m,y){return e=new Jm(e,t,n,m,y),t===1?(t=1,a===!0&&(t|=8)):t=0,a=jt(3,null,null,t),e.current=a,a.stateNode=e,a.memoizedState={element:o,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},kl(a),e}function Zm(e,t,n){var o=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(r)}catch(i){console.error(i)}}return r(),yu.exports=dg(),yu.exports}var ad;function hg(){if(ad)return $i;ad=1;var r=pg();return $i.createRoot=r.createRoot,$i.hydrateRoot=r.hydrateRoot,$i}var mg=hg(),Ke=function(){return Ke=Object.assign||function(i){for(var s,l=1,c=arguments.length;l0?Be(Ar,--At):0,Cr--,Oe===10&&(Cr=1,fs--),Oe}function Dt(){return Oe=At2||Lu(Oe)>3?"":" "}function jg(r,i){for(;--i&&Dt()&&!(Oe<48||Oe>102||Oe>57&&Oe<65||Oe>70&&Oe<97););return ps(r,Gi()+(i<6&&Bn()==32&&Dt()==32))}function Du(r){for(;Dt();)switch(Oe){case r:return At;case 34:case 39:r!==34&&r!==39&&Du(Oe);break;case 40:r===41&&Du(r);break;case 92:Dt();break}return At}function Ag(r,i){for(;Dt()&&r+Oe!==57;)if(r+Oe===84&&Bn()===47)break;return"/*"+ps(i,At-1)+"*"+Ju(r===47?r:Dt())}function Rg(r){for(;!Lu(Bn());)Dt();return ps(r,At)}function Pg(r){return Cg(Ki("",null,null,null,[""],r=Eg(r),0,[0],r))}function Ki(r,i,s,l,c,f,p,g,x){for(var v=0,S=0,A=p,T=0,I=0,R=0,C=1,N=1,b=1,U=0,V="",Q=c,$=f,L=l,B=V;N;)switch(R=U,U=Dt()){case 40:if(R!=108&&Be(B,A-1)==58){Qi(B+=ae(xu(U),"&","&\f"),"&\f",up(v?g[v-1]:0))!=-1&&(b=-1);break}case 34:case 39:case 91:B+=xu(U);break;case 9:case 10:case 13:case 32:B+=kg(R);break;case 92:B+=jg(Gi()-1,7);continue;case 47:switch(Bn()){case 42:case 47:Eo(_g(Ag(Dt(),Gi()),i,s,x),x);break;default:B+="/"}break;case 123*C:g[v++]=Vt(B)*b;case 125*C:case 59:case 0:switch(U){case 0:case 125:N=0;case 59+S:b==-1&&(B=ae(B,/\f/g,"")),I>0&&Vt(B)-A&&Eo(I>32?dd(B+";",l,s,A-1,x):dd(ae(B," ","")+";",l,s,A-2,x),x);break;case 59:B+=";";default:if(Eo(L=fd(B,i,s,v,S,c,g,V,Q=[],$=[],A,f),f),U===123)if(S===0)Ki(B,i,L,L,Q,f,A,g,$);else switch(T===99&&Be(B,3)===110?100:T){case 100:case 108:case 109:case 115:Ki(r,L,L,l&&Eo(fd(r,L,L,0,0,c,g,V,c,Q=[],A,$),$),c,$,A,g,l?Q:$);break;default:Ki(B,L,L,L,[""],$,0,g,$)}}v=S=I=0,C=b=1,V=B="",A=p;break;case 58:A=1+Vt(B),I=R;default:if(C<1){if(U==123)--C;else if(U==125&&C++==0&&Sg()==125)continue}switch(B+=Ju(U),U*C){case 38:b=S>0?1:(B+="\f",-1);break;case 44:g[v++]=(Vt(B)-1)*b,b=1;break;case 64:Bn()===45&&(B+=xu(Dt())),T=Bn(),S=A=Vt(V=B+=Rg(Gi())),U++;break;case 45:R===45&&Vt(B)==2&&(C=0)}}return f}function fd(r,i,s,l,c,f,p,g,x,v,S,A){for(var T=c-1,I=c===0?f:[""],R=cp(I),C=0,N=0,b=0;C0?I[U]+" "+V:ae(V,/&\f/g,I[U])))&&(x[b++]=Q);return ds(r,i,s,c===0?cs:g,x,v,S,A)}function _g(r,i,s,l){return ds(r,i,s,sp,Ju(xg()),Er(r,2,-2),0,l)}function dd(r,i,s,l,c){return ds(r,i,s,Xu,Er(r,0,l),Er(r,l+1,-1),l,c)}function dp(r,i,s){switch(vg(r,i)){case 5103:return we+"print-"+r+r;case 5737:case 4201:case 3177:case 3433:case 1641:case 4457:case 2921:case 5572:case 6356:case 5844:case 3191:case 6645:case 3005:case 6391:case 5879:case 5623:case 6135:case 4599:case 4855:case 4215:case 6389:case 5109:case 5365:case 5621:case 3829:return we+r+r;case 4789:return Co+r+r;case 5349:case 4246:case 4810:case 6968:case 2756:return we+r+Co+r+je+r+r;case 5936:switch(Be(r,i+11)){case 114:return we+r+je+ae(r,/[svh]\w+-[tblr]{2}/,"tb")+r;case 108:return we+r+je+ae(r,/[svh]\w+-[tblr]{2}/,"tb-rl")+r;case 45:return we+r+je+ae(r,/[svh]\w+-[tblr]{2}/,"lr")+r}case 6828:case 4268:case 2903:return we+r+je+r+r;case 6165:return we+r+je+"flex-"+r+r;case 5187:return we+r+ae(r,/(\w+).+(:[^]+)/,we+"box-$1$2"+je+"flex-$1$2")+r;case 5443:return we+r+je+"flex-item-"+ae(r,/flex-|-self/g,"")+(tn(r,/flex-|baseline/)?"":je+"grid-row-"+ae(r,/flex-|-self/g,""))+r;case 4675:return we+r+je+"flex-line-pack"+ae(r,/align-content|flex-|-self/g,"")+r;case 5548:return we+r+je+ae(r,"shrink","negative")+r;case 5292:return we+r+je+ae(r,"basis","preferred-size")+r;case 6060:return we+"box-"+ae(r,"-grow","")+we+r+je+ae(r,"grow","positive")+r;case 4554:return we+ae(r,/([^-])(transform)/g,"$1"+we+"$2")+r;case 6187:return ae(ae(ae(r,/(zoom-|grab)/,we+"$1"),/(image-set)/,we+"$1"),r,"")+r;case 5495:case 3959:return ae(r,/(image-set\([^]*)/,we+"$1$`$1");case 4968:return ae(ae(r,/(.+:)(flex-)?(.*)/,we+"box-pack:$3"+je+"flex-pack:$3"),/s.+-b[^;]+/,"justify")+we+r+r;case 4200:if(!tn(r,/flex-|baseline/))return je+"grid-column-align"+Er(r,i)+r;break;case 2592:case 3360:return je+ae(r,"template-","")+r;case 4384:case 3616:return s&&s.some(function(l,c){return i=c,tn(l.props,/grid-\w+-end/)})?~Qi(r+(s=s[i].value),"span",0)?r:je+ae(r,"-start","")+r+je+"grid-row-span:"+(~Qi(s,"span",0)?tn(s,/\d+/):+tn(s,/\d+/)-+tn(r,/\d+/))+";":je+ae(r,"-start","")+r;case 4896:case 4128:return s&&s.some(function(l){return tn(l.props,/grid-\w+-start/)})?r:je+ae(ae(r,"-end","-span"),"span ","")+r;case 4095:case 3583:case 4068:case 2532:return ae(r,/(.+)-inline(.+)/,we+"$1$2")+r;case 8116:case 7059:case 5753:case 5535:case 5445:case 5701:case 4933:case 4677:case 5533:case 5789:case 5021:case 4765:if(Vt(r)-1-i>6)switch(Be(r,i+1)){case 109:if(Be(r,i+4)!==45)break;case 102:return ae(r,/(.+:)(.+)-([^]+)/,"$1"+we+"$2-$3$1"+Co+(Be(r,i+3)==108?"$3":"$2-$3"))+r;case 115:return~Qi(r,"stretch",0)?dp(ae(r,"stretch","fill-available"),i,s)+r:r}break;case 5152:case 5920:return ae(r,/(.+?):(\d+)(\s*\/\s*(span)?\s*(\d+))?(.*)/,function(l,c,f,p,g,x,v){return je+c+":"+f+v+(p?je+c+"-span:"+(g?x:+x-+f)+v:"")+r});case 4949:if(Be(r,i+6)===121)return ae(r,":",":"+we)+r;break;case 6444:switch(Be(r,Be(r,14)===45?18:11)){case 120:return ae(r,/(.+:)([^;\s!]+)(;|(\s+)?!.+)?/,"$1"+we+(Be(r,14)===45?"inline-":"")+"box$3$1"+we+"$2$3$1"+je+"$2box$3")+r;case 100:return ae(r,":",":"+je)+r}break;case 5719:case 2647:case 2135:case 3927:case 2391:return ae(r,"scroll-","scroll-snap-")+r}return r}function rs(r,i){for(var s="",l=0;l-1&&!r.return)switch(r.type){case Xu:r.return=dp(r.value,r.length,s);return;case lp:return rs([kn(r,{value:ae(r.value,"@","@"+we)})],l);case cs:if(r.length)return wg(s=r.props,function(c){switch(tn(c,l=/(::plac\w+|:read-\w+)/)){case":read-only":case":read-write":vr(kn(r,{props:[ae(c,/:(read-\w+)/,":"+Co+"$1")]})),vr(kn(r,{props:[c]})),Ou(r,{props:cd(s,l)});break;case"::placeholder":vr(kn(r,{props:[ae(c,/:(plac\w+)/,":"+we+"input-$1")]})),vr(kn(r,{props:[ae(c,/:(plac\w+)/,":"+Co+"$1")]})),vr(kn(r,{props:[ae(c,/:(plac\w+)/,je+"input-$1")]})),vr(kn(r,{props:[c]})),Ou(r,{props:cd(s,l)});break}return""})}}var Lg={animationIterationCount:1,aspectRatio:1,borderImageOutset:1,borderImageSlice:1,borderImageWidth:1,boxFlex:1,boxFlexGroup:1,boxOrdinalGroup:1,columnCount:1,columns:1,flex:1,flexGrow:1,flexPositive:1,flexShrink:1,flexNegative:1,flexOrder:1,gridRow:1,gridRowEnd:1,gridRowSpan:1,gridRowStart:1,gridColumn:1,gridColumnEnd:1,gridColumnSpan:1,gridColumnStart:1,msGridRow:1,msGridRowSpan:1,msGridColumn:1,msGridColumnSpan:1,fontWeight:1,lineHeight:1,opacity:1,order:1,orphans:1,tabSize:1,widows:1,zIndex:1,zoom:1,WebkitLineClamp:1,fillOpacity:1,floodOpacity:1,stopOpacity:1,strokeDasharray:1,strokeDashoffset:1,strokeMiterlimit:1,strokeOpacity:1,strokeWidth:1},ht={},kr=typeof process<"u"&&ht!==void 0&&(ht.REACT_APP_SC_ATTR||ht.SC_ATTR)||"data-styled",pp="active",hp="data-styled-version",hs="6.1.14",Zu=`/*!sc*/
+`,os=typeof window<"u"&&"HTMLElement"in window,Dg=!!(typeof SC_DISABLE_SPEEDY=="boolean"?SC_DISABLE_SPEEDY:typeof process<"u"&&ht!==void 0&&ht.REACT_APP_SC_DISABLE_SPEEDY!==void 0&&ht.REACT_APP_SC_DISABLE_SPEEDY!==""?ht.REACT_APP_SC_DISABLE_SPEEDY!=="false"&&ht.REACT_APP_SC_DISABLE_SPEEDY:typeof process<"u"&&ht!==void 0&&ht.SC_DISABLE_SPEEDY!==void 0&&ht.SC_DISABLE_SPEEDY!==""&&ht.SC_DISABLE_SPEEDY!=="false"&&ht.SC_DISABLE_SPEEDY),ms=Object.freeze([]),jr=Object.freeze({});function Mg(r,i,s){return s===void 0&&(s=jr),r.theme!==s.theme&&r.theme||i||s.theme}var mp=new Set(["a","abbr","address","area","article","aside","audio","b","base","bdi","bdo","big","blockquote","body","br","button","canvas","caption","cite","code","col","colgroup","data","datalist","dd","del","details","dfn","dialog","div","dl","dt","em","embed","fieldset","figcaption","figure","footer","form","h1","h2","h3","h4","h5","h6","header","hgroup","hr","html","i","iframe","img","input","ins","kbd","keygen","label","legend","li","link","main","map","mark","menu","menuitem","meta","meter","nav","noscript","object","ol","optgroup","option","output","p","param","picture","pre","progress","q","rp","rt","ruby","s","samp","script","section","select","small","source","span","strong","style","sub","summary","sup","table","tbody","td","textarea","tfoot","th","thead","time","tr","track","u","ul","use","var","video","wbr","circle","clipPath","defs","ellipse","foreignObject","g","image","line","linearGradient","marker","mask","path","pattern","polygon","polyline","radialGradient","rect","stop","svg","text","tspan"]),zg=/[!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~-]+/g,Ug=/(^-|-$)/g;function pd(r){return r.replace(zg,"-").replace(Ug,"")}var Fg=/(a)(d)/gi,Hi=52,hd=function(r){return String.fromCharCode(r+(r>25?39:97))};function Mu(r){var i,s="";for(i=Math.abs(r);i>Hi;i=i/Hi|0)s=hd(i%Hi)+s;return(hd(i%Hi)+s).replace(Fg,"$1-$2")}var Su,gp=5381,wr=function(r,i){for(var s=i.length;s;)r=33*r^i.charCodeAt(--s);return r},yp=function(r){return wr(gp,r)};function Bg(r){return Mu(yp(r)>>>0)}function $g(r){return r.displayName||r.name||"Component"}function Eu(r){return typeof r=="string"&&!0}var vp=typeof Symbol=="function"&&Symbol.for,wp=vp?Symbol.for("react.memo"):60115,Hg=vp?Symbol.for("react.forward_ref"):60112,bg={childContextTypes:!0,contextType:!0,contextTypes:!0,defaultProps:!0,displayName:!0,getDefaultProps:!0,getDerivedStateFromError:!0,getDerivedStateFromProps:!0,mixins:!0,propTypes:!0,type:!0},Vg={name:!0,length:!0,prototype:!0,caller:!0,callee:!0,arguments:!0,arity:!0},xp={$$typeof:!0,compare:!0,defaultProps:!0,displayName:!0,propTypes:!0,type:!0},Wg=((Su={})[Hg]={$$typeof:!0,render:!0,defaultProps:!0,displayName:!0,propTypes:!0},Su[wp]=xp,Su);function md(r){return("type"in(i=r)&&i.type.$$typeof)===wp?xp:"$$typeof"in r?Wg[r.$$typeof]:bg;var i}var Yg=Object.defineProperty,qg=Object.getOwnPropertyNames,gd=Object.getOwnPropertySymbols,Qg=Object.getOwnPropertyDescriptor,Gg=Object.getPrototypeOf,yd=Object.prototype;function Sp(r,i,s){if(typeof i!="string"){if(yd){var l=Gg(i);l&&l!==yd&&Sp(r,l,s)}var c=qg(i);gd&&(c=c.concat(gd(i)));for(var f=md(r),p=md(i),g=0;g0?" Args: ".concat(i.join(", ")):""))}var Kg=function(){function r(i){this.groupSizes=new Uint32Array(512),this.length=512,this.tag=i}return r.prototype.indexOfGroup=function(i){for(var s=0,l=0;l=this.groupSizes.length){for(var l=this.groupSizes,c=l.length,f=c;i>=f;)if((f<<=1)<0)throw Vn(16,"".concat(i));this.groupSizes=new Uint32Array(f),this.groupSizes.set(l),this.length=f;for(var p=c;p=this.length||this.groupSizes[i]===0)return s;for(var l=this.groupSizes[i],c=this.indexOfGroup(i),f=c+l,p=c;p=0){var l=document.createTextNode(s);return this.element.insertBefore(l,this.nodes[i]||null),this.length++,!0}return!1},r.prototype.deleteRule=function(i){this.element.removeChild(this.nodes[i]),this.length--},r.prototype.getRule=function(i){return i0&&(N+="".concat(b,","))}),x+="".concat(R).concat(C,'{content:"').concat(N,'"}').concat(Zu)},S=0;S0?".".concat(i):T},S=x.slice();S.push(function(T){T.type===cs&&T.value.includes("&")&&(T.props[0]=T.props[0].replace(ly,s).replace(l,v))}),p.prefix&&S.push(Og),S.push(Tg);var A=function(T,I,R,C){I===void 0&&(I=""),R===void 0&&(R=""),C===void 0&&(C="&"),i=C,s=I,l=new RegExp("\\".concat(s,"\\b"),"g");var N=T.replace(uy,""),b=Pg(R||I?"".concat(R," ").concat(I," { ").concat(N," }"):N);p.namespace&&(b=kp(b,p.namespace));var U=[];return rs(b,Ig(S.concat(Ng(function(V){return U.push(V)})))),U};return A.hash=x.length?x.reduce(function(T,I){return I.name||Vn(15),wr(T,I.name)},gp).toString():"",A}var cy=new Cp,Uu=ay(),jp=mt.createContext({shouldForwardProp:void 0,styleSheet:cy,stylis:Uu});jp.Consumer;mt.createContext(void 0);function Sd(){return oe.useContext(jp)}var fy=function(){function r(i,s){var l=this;this.inject=function(c,f){f===void 0&&(f=Uu);var p=l.name+f.hash;c.hasNameForId(l.id,p)||c.insertRules(l.id,p,f(l.rules,p,"@keyframes"))},this.name=i,this.id="sc-keyframes-".concat(i),this.rules=s,ta(this,function(){throw Vn(12,String(l.name))})}return r.prototype.getName=function(i){return i===void 0&&(i=Uu),this.name+i.hash},r}(),dy=function(r){return r>="A"&&r<="Z"};function Ed(r){for(var i="",s=0;s>>0);if(!s.hasNameForId(this.componentId,p)){var g=l(f,".".concat(p),void 0,this.componentId);s.insertRules(this.componentId,p,g)}c=Un(c,p),this.staticRulesId=p}else{for(var x=wr(this.baseHash,l.hash),v="",S=0;S>>0);s.hasNameForId(this.componentId,I)||s.insertRules(this.componentId,I,l(v,".".concat(I),void 0,this.componentId)),c=Un(c,I)}}return c},r}(),ss=mt.createContext(void 0);ss.Consumer;function Cd(r){var i=mt.useContext(ss),s=oe.useMemo(function(){return function(l,c){if(!l)throw Vn(14);if(bn(l)){var f=l(c);return f}if(Array.isArray(l)||typeof l!="object")throw Vn(8);return c?Ke(Ke({},c),l):l}(r.theme,i)},[r.theme,i]);return r.children?mt.createElement(ss.Provider,{value:s},r.children):null}var Cu={};function gy(r,i,s){var l=ea(r),c=r,f=!Eu(r),p=i.attrs,g=p===void 0?ms:p,x=i.componentId,v=x===void 0?function(Q,$){var L=typeof Q!="string"?"sc":pd(Q);Cu[L]=(Cu[L]||0)+1;var B="".concat(L,"-").concat(Bg(hs+L+Cu[L]));return $?"".concat($,"-").concat(B):B}(i.displayName,i.parentComponentId):x,S=i.displayName,A=S===void 0?function(Q){return Eu(Q)?"styled.".concat(Q):"Styled(".concat($g(Q),")")}(r):S,T=i.displayName&&i.componentId?"".concat(pd(i.displayName),"-").concat(i.componentId):i.componentId||v,I=l&&c.attrs?c.attrs.concat(g).filter(Boolean):g,R=i.shouldForwardProp;if(l&&c.shouldForwardProp){var C=c.shouldForwardProp;if(i.shouldForwardProp){var N=i.shouldForwardProp;R=function(Q,$){return C(Q,$)&&N(Q,$)}}else R=C}var b=new my(s,T,l?c.componentStyle:void 0);function U(Q,$){return function(L,B,ie){var ye=L.attrs,Je=L.componentStyle,Wt=L.defaultProps,vt=L.foldedComponentIds,Ze=L.styledComponentId,at=L.target,wt=mt.useContext(ss),Ve=Sd(),Se=L.shouldForwardProp||Ve.shouldForwardProp,W=Mg(B,wt,Wt)||jr,Z=function(de,ce,ve){for(var pe,me=Ke(Ke({},ce),{className:void 0,theme:ve}),$e=0;$ei=>{const s=vy.call(i);return r[s]||(r[s]=s.slice(8,-1).toLowerCase())})(Object.create(null)),zt=r=>(r=r.toLowerCase(),i=>gs(i)===r),ys=r=>i=>typeof i===r,{isArray:Rr}=Array,Po=ys("undefined");function wy(r){return r!==null&&!Po(r)&&r.constructor!==null&&!Po(r.constructor)&>(r.constructor.isBuffer)&&r.constructor.isBuffer(r)}const Tp=zt("ArrayBuffer");function xy(r){let i;return typeof ArrayBuffer<"u"&&ArrayBuffer.isView?i=ArrayBuffer.isView(r):i=r&&r.buffer&&Tp(r.buffer),i}const Sy=ys("string"),gt=ys("function"),Ip=ys("number"),vs=r=>r!==null&&typeof r=="object",Ey=r=>r===!0||r===!1,Zi=r=>{if(gs(r)!=="object")return!1;const i=na(r);return(i===null||i===Object.prototype||Object.getPrototypeOf(i)===null)&&!(Symbol.toStringTag in r)&&!(Symbol.iterator in r)},Cy=zt("Date"),ky=zt("File"),jy=zt("Blob"),Ay=zt("FileList"),Ry=r=>vs(r)&>(r.pipe),Py=r=>{let i;return r&&(typeof FormData=="function"&&r instanceof FormData||gt(r.append)&&((i=gs(r))==="formdata"||i==="object"&>(r.toString)&&r.toString()==="[object FormData]"))},_y=zt("URLSearchParams"),[Ty,Iy,Ny,Oy]=["ReadableStream","Request","Response","Headers"].map(zt),Ly=r=>r.trim?r.trim():r.replace(/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,"");function _o(r,i,{allOwnKeys:s=!1}={}){if(r===null||typeof r>"u")return;let l,c;if(typeof r!="object"&&(r=[r]),Rr(r))for(l=0,c=r.length;l0;)if(c=s[l],i===c.toLowerCase())return c;return null}const Fn=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:global,Op=r=>!Po(r)&&r!==Fn;function Bu(){const{caseless:r}=Op(this)&&this||{},i={},s=(l,c)=>{const f=r&&Np(i,c)||c;Zi(i[f])&&Zi(l)?i[f]=Bu(i[f],l):Zi(l)?i[f]=Bu({},l):Rr(l)?i[f]=l.slice():i[f]=l};for(let l=0,c=arguments.length;l(_o(i,(c,f)=>{s&>(c)?r[f]=_p(c,s):r[f]=c},{allOwnKeys:l}),r),My=r=>(r.charCodeAt(0)===65279&&(r=r.slice(1)),r),zy=(r,i,s,l)=>{r.prototype=Object.create(i.prototype,l),r.prototype.constructor=r,Object.defineProperty(r,"super",{value:i.prototype}),s&&Object.assign(r.prototype,s)},Uy=(r,i,s,l)=>{let c,f,p;const g={};if(i=i||{},r==null)return i;do{for(c=Object.getOwnPropertyNames(r),f=c.length;f-- >0;)p=c[f],(!l||l(p,r,i))&&!g[p]&&(i[p]=r[p],g[p]=!0);r=s!==!1&&na(r)}while(r&&(!s||s(r,i))&&r!==Object.prototype);return i},Fy=(r,i,s)=>{r=String(r),(s===void 0||s>r.length)&&(s=r.length),s-=i.length;const l=r.indexOf(i,s);return l!==-1&&l===s},By=r=>{if(!r)return null;if(Rr(r))return r;let i=r.length;if(!Ip(i))return null;const s=new Array(i);for(;i-- >0;)s[i]=r[i];return s},$y=(r=>i=>r&&i instanceof r)(typeof Uint8Array<"u"&&na(Uint8Array)),Hy=(r,i)=>{const l=(r&&r[Symbol.iterator]).call(r);let c;for(;(c=l.next())&&!c.done;){const f=c.value;i.call(r,f[0],f[1])}},by=(r,i)=>{let s;const l=[];for(;(s=r.exec(i))!==null;)l.push(s);return l},Vy=zt("HTMLFormElement"),Wy=r=>r.toLowerCase().replace(/[-_\s]([a-z\d])(\w*)/g,function(s,l,c){return l.toUpperCase()+c}),Ad=(({hasOwnProperty:r})=>(i,s)=>r.call(i,s))(Object.prototype),Yy=zt("RegExp"),Lp=(r,i)=>{const s=Object.getOwnPropertyDescriptors(r),l={};_o(s,(c,f)=>{let p;(p=i(c,f,r))!==!1&&(l[f]=p||c)}),Object.defineProperties(r,l)},qy=r=>{Lp(r,(i,s)=>{if(gt(r)&&["arguments","caller","callee"].indexOf(s)!==-1)return!1;const l=r[s];if(gt(l)){if(i.enumerable=!1,"writable"in i){i.writable=!1;return}i.set||(i.set=()=>{throw Error("Can not rewrite read-only method '"+s+"'")})}})},Qy=(r,i)=>{const s={},l=c=>{c.forEach(f=>{s[f]=!0})};return Rr(r)?l(r):l(String(r).split(i)),s},Gy=()=>{},Ky=(r,i)=>r!=null&&Number.isFinite(r=+r)?r:i,ku="abcdefghijklmnopqrstuvwxyz",Rd="0123456789",Dp={DIGIT:Rd,ALPHA:ku,ALPHA_DIGIT:ku+ku.toUpperCase()+Rd},Xy=(r=16,i=Dp.ALPHA_DIGIT)=>{let s="";const{length:l}=i;for(;r--;)s+=i[Math.random()*l|0];return s};function Jy(r){return!!(r&>(r.append)&&r[Symbol.toStringTag]==="FormData"&&r[Symbol.iterator])}const Zy=r=>{const i=new Array(10),s=(l,c)=>{if(vs(l)){if(i.indexOf(l)>=0)return;if(!("toJSON"in l)){i[c]=l;const f=Rr(l)?[]:{};return _o(l,(p,g)=>{const x=s(p,c+1);!Po(x)&&(f[g]=x)}),i[c]=void 0,f}}return l};return s(r,0)},ev=zt("AsyncFunction"),tv=r=>r&&(vs(r)||gt(r))&>(r.then)&>(r.catch),Mp=((r,i)=>r?setImmediate:i?((s,l)=>(Fn.addEventListener("message",({source:c,data:f})=>{c===Fn&&f===s&&l.length&&l.shift()()},!1),c=>{l.push(c),Fn.postMessage(s,"*")}))(`axios@${Math.random()}`,[]):s=>setTimeout(s))(typeof setImmediate=="function",gt(Fn.postMessage)),nv=typeof queueMicrotask<"u"?queueMicrotask.bind(Fn):typeof process<"u"&&process.nextTick||Mp,O={isArray:Rr,isArrayBuffer:Tp,isBuffer:wy,isFormData:Py,isArrayBufferView:xy,isString:Sy,isNumber:Ip,isBoolean:Ey,isObject:vs,isPlainObject:Zi,isReadableStream:Ty,isRequest:Iy,isResponse:Ny,isHeaders:Oy,isUndefined:Po,isDate:Cy,isFile:ky,isBlob:jy,isRegExp:Yy,isFunction:gt,isStream:Ry,isURLSearchParams:_y,isTypedArray:$y,isFileList:Ay,forEach:_o,merge:Bu,extend:Dy,trim:Ly,stripBOM:My,inherits:zy,toFlatObject:Uy,kindOf:gs,kindOfTest:zt,endsWith:Fy,toArray:By,forEachEntry:Hy,matchAll:by,isHTMLForm:Vy,hasOwnProperty:Ad,hasOwnProp:Ad,reduceDescriptors:Lp,freezeMethods:qy,toObjectSet:Qy,toCamelCase:Wy,noop:Gy,toFiniteNumber:Ky,findKey:Np,global:Fn,isContextDefined:Op,ALPHABET:Dp,generateString:Xy,isSpecCompliantForm:Jy,toJSONObject:Zy,isAsyncFn:ev,isThenable:tv,setImmediate:Mp,asap:nv};function le(r,i,s,l,c){Error.call(this),Error.captureStackTrace?Error.captureStackTrace(this,this.constructor):this.stack=new Error().stack,this.message=r,this.name="AxiosError",i&&(this.code=i),s&&(this.config=s),l&&(this.request=l),c&&(this.response=c,this.status=c.status?c.status:null)}O.inherits(le,Error,{toJSON:function(){return{message:this.message,name:this.name,description:this.description,number:this.number,fileName:this.fileName,lineNumber:this.lineNumber,columnNumber:this.columnNumber,stack:this.stack,config:O.toJSONObject(this.config),code:this.code,status:this.status}}});const zp=le.prototype,Up={};["ERR_BAD_OPTION_VALUE","ERR_BAD_OPTION","ECONNABORTED","ETIMEDOUT","ERR_NETWORK","ERR_FR_TOO_MANY_REDIRECTS","ERR_DEPRECATED","ERR_BAD_RESPONSE","ERR_BAD_REQUEST","ERR_CANCELED","ERR_NOT_SUPPORT","ERR_INVALID_URL"].forEach(r=>{Up[r]={value:r}});Object.defineProperties(le,Up);Object.defineProperty(zp,"isAxiosError",{value:!0});le.from=(r,i,s,l,c,f)=>{const p=Object.create(zp);return O.toFlatObject(r,p,function(x){return x!==Error.prototype},g=>g!=="isAxiosError"),le.call(p,r.message,i,s,l,c),p.cause=r,p.name=r.name,f&&Object.assign(p,f),p};const rv=null;function $u(r){return O.isPlainObject(r)||O.isArray(r)}function Fp(r){return O.endsWith(r,"[]")?r.slice(0,-2):r}function Pd(r,i,s){return r?r.concat(i).map(function(c,f){return c=Fp(c),!s&&f?"["+c+"]":c}).join(s?".":""):i}function ov(r){return O.isArray(r)&&!r.some($u)}const iv=O.toFlatObject(O,{},null,function(i){return/^is[A-Z]/.test(i)});function ws(r,i,s){if(!O.isObject(r))throw new TypeError("target must be an object");i=i||new FormData,s=O.toFlatObject(s,{metaTokens:!0,dots:!1,indexes:!1},!1,function(C,N){return!O.isUndefined(N[C])});const l=s.metaTokens,c=s.visitor||S,f=s.dots,p=s.indexes,x=(s.Blob||typeof Blob<"u"&&Blob)&&O.isSpecCompliantForm(i);if(!O.isFunction(c))throw new TypeError("visitor must be a function");function v(R){if(R===null)return"";if(O.isDate(R))return R.toISOString();if(!x&&O.isBlob(R))throw new le("Blob is not supported. Use a Buffer instead.");return O.isArrayBuffer(R)||O.isTypedArray(R)?x&&typeof Blob=="function"?new Blob([R]):Buffer.from(R):R}function S(R,C,N){let b=R;if(R&&!N&&typeof R=="object"){if(O.endsWith(C,"{}"))C=l?C:C.slice(0,-2),R=JSON.stringify(R);else if(O.isArray(R)&&ov(R)||(O.isFileList(R)||O.endsWith(C,"[]"))&&(b=O.toArray(R)))return C=Fp(C),b.forEach(function(V,Q){!(O.isUndefined(V)||V===null)&&i.append(p===!0?Pd([C],Q,f):p===null?C:C+"[]",v(V))}),!1}return $u(R)?!0:(i.append(Pd(N,C,f),v(R)),!1)}const A=[],T=Object.assign(iv,{defaultVisitor:S,convertValue:v,isVisitable:$u});function I(R,C){if(!O.isUndefined(R)){if(A.indexOf(R)!==-1)throw Error("Circular reference detected in "+C.join("."));A.push(R),O.forEach(R,function(b,U){(!(O.isUndefined(b)||b===null)&&c.call(i,b,O.isString(U)?U.trim():U,C,T))===!0&&I(b,C?C.concat(U):[U])}),A.pop()}}if(!O.isObject(r))throw new TypeError("data must be an object");return I(r),i}function _d(r){const i={"!":"%21","'":"%27","(":"%28",")":"%29","~":"%7E","%20":"+","%00":"\0"};return encodeURIComponent(r).replace(/[!'()~]|%20|%00/g,function(l){return i[l]})}function ra(r,i){this._pairs=[],r&&ws(r,this,i)}const Bp=ra.prototype;Bp.append=function(i,s){this._pairs.push([i,s])};Bp.toString=function(i){const s=i?function(l){return i.call(this,l,_d)}:_d;return this._pairs.map(function(c){return s(c[0])+"="+s(c[1])},"").join("&")};function sv(r){return encodeURIComponent(r).replace(/%3A/gi,":").replace(/%24/g,"$").replace(/%2C/gi,",").replace(/%20/g,"+").replace(/%5B/gi,"[").replace(/%5D/gi,"]")}function $p(r,i,s){if(!i)return r;const l=s&&s.encode||sv;O.isFunction(s)&&(s={serialize:s});const c=s&&s.serialize;let f;if(c?f=c(i,s):f=O.isURLSearchParams(i)?i.toString():new ra(i,s).toString(l),f){const p=r.indexOf("#");p!==-1&&(r=r.slice(0,p)),r+=(r.indexOf("?")===-1?"?":"&")+f}return r}class Td{constructor(){this.handlers=[]}use(i,s,l){return this.handlers.push({fulfilled:i,rejected:s,synchronous:l?l.synchronous:!1,runWhen:l?l.runWhen:null}),this.handlers.length-1}eject(i){this.handlers[i]&&(this.handlers[i]=null)}clear(){this.handlers&&(this.handlers=[])}forEach(i){O.forEach(this.handlers,function(l){l!==null&&i(l)})}}const Hp={silentJSONParsing:!0,forcedJSONParsing:!0,clarifyTimeoutError:!1},lv=typeof URLSearchParams<"u"?URLSearchParams:ra,uv=typeof FormData<"u"?FormData:null,av=typeof Blob<"u"?Blob:null,cv={isBrowser:!0,classes:{URLSearchParams:lv,FormData:uv,Blob:av},protocols:["http","https","file","blob","url","data"]},oa=typeof window<"u"&&typeof document<"u",Hu=typeof navigator=="object"&&navigator||void 0,fv=oa&&(!Hu||["ReactNative","NativeScript","NS"].indexOf(Hu.product)<0),dv=typeof WorkerGlobalScope<"u"&&self instanceof WorkerGlobalScope&&typeof self.importScripts=="function",pv=oa&&window.location.href||"http://localhost",hv=Object.freeze(Object.defineProperty({__proto__:null,hasBrowserEnv:oa,hasStandardBrowserEnv:fv,hasStandardBrowserWebWorkerEnv:dv,navigator:Hu,origin:pv},Symbol.toStringTag,{value:"Module"})),Ge={...hv,...cv};function mv(r,i){return ws(r,new Ge.classes.URLSearchParams,Object.assign({visitor:function(s,l,c,f){return Ge.isNode&&O.isBuffer(s)?(this.append(l,s.toString("base64")),!1):f.defaultVisitor.apply(this,arguments)}},i))}function gv(r){return O.matchAll(/\w+|\[(\w*)]/g,r).map(i=>i[0]==="[]"?"":i[1]||i[0])}function yv(r){const i={},s=Object.keys(r);let l;const c=s.length;let f;for(l=0;l=s.length;return p=!p&&O.isArray(c)?c.length:p,x?(O.hasOwnProp(c,p)?c[p]=[c[p],l]:c[p]=l,!g):((!c[p]||!O.isObject(c[p]))&&(c[p]=[]),i(s,l,c[p],f)&&O.isArray(c[p])&&(c[p]=yv(c[p])),!g)}if(O.isFormData(r)&&O.isFunction(r.entries)){const s={};return O.forEachEntry(r,(l,c)=>{i(gv(l),c,s,0)}),s}return null}function vv(r,i,s){if(O.isString(r))try{return(i||JSON.parse)(r),O.trim(r)}catch(l){if(l.name!=="SyntaxError")throw l}return(0,JSON.stringify)(r)}const To={transitional:Hp,adapter:["xhr","http","fetch"],transformRequest:[function(i,s){const l=s.getContentType()||"",c=l.indexOf("application/json")>-1,f=O.isObject(i);if(f&&O.isHTMLForm(i)&&(i=new FormData(i)),O.isFormData(i))return c?JSON.stringify(bp(i)):i;if(O.isArrayBuffer(i)||O.isBuffer(i)||O.isStream(i)||O.isFile(i)||O.isBlob(i)||O.isReadableStream(i))return i;if(O.isArrayBufferView(i))return i.buffer;if(O.isURLSearchParams(i))return s.setContentType("application/x-www-form-urlencoded;charset=utf-8",!1),i.toString();let g;if(f){if(l.indexOf("application/x-www-form-urlencoded")>-1)return mv(i,this.formSerializer).toString();if((g=O.isFileList(i))||l.indexOf("multipart/form-data")>-1){const x=this.env&&this.env.FormData;return ws(g?{"files[]":i}:i,x&&new x,this.formSerializer)}}return f||c?(s.setContentType("application/json",!1),vv(i)):i}],transformResponse:[function(i){const s=this.transitional||To.transitional,l=s&&s.forcedJSONParsing,c=this.responseType==="json";if(O.isResponse(i)||O.isReadableStream(i))return i;if(i&&O.isString(i)&&(l&&!this.responseType||c)){const p=!(s&&s.silentJSONParsing)&&c;try{return JSON.parse(i)}catch(g){if(p)throw g.name==="SyntaxError"?le.from(g,le.ERR_BAD_RESPONSE,this,null,this.response):g}}return i}],timeout:0,xsrfCookieName:"XSRF-TOKEN",xsrfHeaderName:"X-XSRF-TOKEN",maxContentLength:-1,maxBodyLength:-1,env:{FormData:Ge.classes.FormData,Blob:Ge.classes.Blob},validateStatus:function(i){return i>=200&&i<300},headers:{common:{Accept:"application/json, text/plain, */*","Content-Type":void 0}}};O.forEach(["delete","get","head","post","put","patch"],r=>{To.headers[r]={}});const wv=O.toObjectSet(["age","authorization","content-length","content-type","etag","expires","from","host","if-modified-since","if-unmodified-since","last-modified","location","max-forwards","proxy-authorization","referer","retry-after","user-agent"]),xv=r=>{const i={};let s,l,c;return r&&r.split(`
+`).forEach(function(p){c=p.indexOf(":"),s=p.substring(0,c).trim().toLowerCase(),l=p.substring(c+1).trim(),!(!s||i[s]&&wv[s])&&(s==="set-cookie"?i[s]?i[s].push(l):i[s]=[l]:i[s]=i[s]?i[s]+", "+l:l)}),i},Id=Symbol("internals");function vo(r){return r&&String(r).trim().toLowerCase()}function es(r){return r===!1||r==null?r:O.isArray(r)?r.map(es):String(r)}function Sv(r){const i=Object.create(null),s=/([^\s,;=]+)\s*(?:=\s*([^,;]+))?/g;let l;for(;l=s.exec(r);)i[l[1]]=l[2];return i}const Ev=r=>/^[-_a-zA-Z0-9^`|~,!#$%&'*+.]+$/.test(r.trim());function ju(r,i,s,l,c){if(O.isFunction(l))return l.call(this,i,s);if(c&&(i=s),!!O.isString(i)){if(O.isString(l))return i.indexOf(l)!==-1;if(O.isRegExp(l))return l.test(i)}}function Cv(r){return r.trim().toLowerCase().replace(/([a-z\d])(\w*)/g,(i,s,l)=>s.toUpperCase()+l)}function kv(r,i){const s=O.toCamelCase(" "+i);["get","set","has"].forEach(l=>{Object.defineProperty(r,l+s,{value:function(c,f,p){return this[l].call(this,i,c,f,p)},configurable:!0})})}class ut{constructor(i){i&&this.set(i)}set(i,s,l){const c=this;function f(g,x,v){const S=vo(x);if(!S)throw new Error("header name must be a non-empty string");const A=O.findKey(c,S);(!A||c[A]===void 0||v===!0||v===void 0&&c[A]!==!1)&&(c[A||x]=es(g))}const p=(g,x)=>O.forEach(g,(v,S)=>f(v,S,x));if(O.isPlainObject(i)||i instanceof this.constructor)p(i,s);else if(O.isString(i)&&(i=i.trim())&&!Ev(i))p(xv(i),s);else if(O.isHeaders(i))for(const[g,x]of i.entries())f(x,g,l);else i!=null&&f(s,i,l);return this}get(i,s){if(i=vo(i),i){const l=O.findKey(this,i);if(l){const c=this[l];if(!s)return c;if(s===!0)return Sv(c);if(O.isFunction(s))return s.call(this,c,l);if(O.isRegExp(s))return s.exec(c);throw new TypeError("parser must be boolean|regexp|function")}}}has(i,s){if(i=vo(i),i){const l=O.findKey(this,i);return!!(l&&this[l]!==void 0&&(!s||ju(this,this[l],l,s)))}return!1}delete(i,s){const l=this;let c=!1;function f(p){if(p=vo(p),p){const g=O.findKey(l,p);g&&(!s||ju(l,l[g],g,s))&&(delete l[g],c=!0)}}return O.isArray(i)?i.forEach(f):f(i),c}clear(i){const s=Object.keys(this);let l=s.length,c=!1;for(;l--;){const f=s[l];(!i||ju(this,this[f],f,i,!0))&&(delete this[f],c=!0)}return c}normalize(i){const s=this,l={};return O.forEach(this,(c,f)=>{const p=O.findKey(l,f);if(p){s[p]=es(c),delete s[f];return}const g=i?Cv(f):String(f).trim();g!==f&&delete s[f],s[g]=es(c),l[g]=!0}),this}concat(...i){return this.constructor.concat(this,...i)}toJSON(i){const s=Object.create(null);return O.forEach(this,(l,c)=>{l!=null&&l!==!1&&(s[c]=i&&O.isArray(l)?l.join(", "):l)}),s}[Symbol.iterator](){return Object.entries(this.toJSON())[Symbol.iterator]()}toString(){return Object.entries(this.toJSON()).map(([i,s])=>i+": "+s).join(`
+`)}get[Symbol.toStringTag](){return"AxiosHeaders"}static from(i){return i instanceof this?i:new this(i)}static concat(i,...s){const l=new this(i);return s.forEach(c=>l.set(c)),l}static accessor(i){const l=(this[Id]=this[Id]={accessors:{}}).accessors,c=this.prototype;function f(p){const g=vo(p);l[g]||(kv(c,p),l[g]=!0)}return O.isArray(i)?i.forEach(f):f(i),this}}ut.accessor(["Content-Type","Content-Length","Accept","Accept-Encoding","User-Agent","Authorization"]);O.reduceDescriptors(ut.prototype,({value:r},i)=>{let s=i[0].toUpperCase()+i.slice(1);return{get:()=>r,set(l){this[s]=l}}});O.freezeMethods(ut);function Au(r,i){const s=this||To,l=i||s,c=ut.from(l.headers);let f=l.data;return O.forEach(r,function(g){f=g.call(s,f,c.normalize(),i?i.status:void 0)}),c.normalize(),f}function Vp(r){return!!(r&&r.__CANCEL__)}function Pr(r,i,s){le.call(this,r??"canceled",le.ERR_CANCELED,i,s),this.name="CanceledError"}O.inherits(Pr,le,{__CANCEL__:!0});function Wp(r,i,s){const l=s.config.validateStatus;!s.status||!l||l(s.status)?r(s):i(new le("Request failed with status code "+s.status,[le.ERR_BAD_REQUEST,le.ERR_BAD_RESPONSE][Math.floor(s.status/100)-4],s.config,s.request,s))}function jv(r){const i=/^([-+\w]{1,25})(:?\/\/|:)/.exec(r);return i&&i[1]||""}function Av(r,i){r=r||10;const s=new Array(r),l=new Array(r);let c=0,f=0,p;return i=i!==void 0?i:1e3,function(x){const v=Date.now(),S=l[f];p||(p=v),s[c]=x,l[c]=v;let A=f,T=0;for(;A!==c;)T+=s[A++],A=A%r;if(c=(c+1)%r,c===f&&(f=(f+1)%r),v-p{s=S,c=null,f&&(clearTimeout(f),f=null),r.apply(null,v)};return[(...v)=>{const S=Date.now(),A=S-s;A>=l?p(v,S):(c=v,f||(f=setTimeout(()=>{f=null,p(c)},l-A)))},()=>c&&p(c)]}const ls=(r,i,s=3)=>{let l=0;const c=Av(50,250);return Rv(f=>{const p=f.loaded,g=f.lengthComputable?f.total:void 0,x=p-l,v=c(x),S=p<=g;l=p;const A={loaded:p,total:g,progress:g?p/g:void 0,bytes:x,rate:v||void 0,estimated:v&&g&&S?(g-p)/v:void 0,event:f,lengthComputable:g!=null,[i?"download":"upload"]:!0};r(A)},s)},Nd=(r,i)=>{const s=r!=null;return[l=>i[0]({lengthComputable:s,total:r,loaded:l}),i[1]]},Od=r=>(...i)=>O.asap(()=>r(...i)),Pv=Ge.hasStandardBrowserEnv?((r,i)=>s=>(s=new URL(s,Ge.origin),r.protocol===s.protocol&&r.host===s.host&&(i||r.port===s.port)))(new URL(Ge.origin),Ge.navigator&&/(msie|trident)/i.test(Ge.navigator.userAgent)):()=>!0,_v=Ge.hasStandardBrowserEnv?{write(r,i,s,l,c,f){const p=[r+"="+encodeURIComponent(i)];O.isNumber(s)&&p.push("expires="+new Date(s).toGMTString()),O.isString(l)&&p.push("path="+l),O.isString(c)&&p.push("domain="+c),f===!0&&p.push("secure"),document.cookie=p.join("; ")},read(r){const i=document.cookie.match(new RegExp("(^|;\\s*)("+r+")=([^;]*)"));return i?decodeURIComponent(i[3]):null},remove(r){this.write(r,"",Date.now()-864e5)}}:{write(){},read(){return null},remove(){}};function Tv(r){return/^([a-z][a-z\d+\-.]*:)?\/\//i.test(r)}function Iv(r,i){return i?r.replace(/\/?\/$/,"")+"/"+i.replace(/^\/+/,""):r}function Yp(r,i){return r&&!Tv(i)?Iv(r,i):i}const Ld=r=>r instanceof ut?{...r}:r;function Wn(r,i){i=i||{};const s={};function l(v,S,A,T){return O.isPlainObject(v)&&O.isPlainObject(S)?O.merge.call({caseless:T},v,S):O.isPlainObject(S)?O.merge({},S):O.isArray(S)?S.slice():S}function c(v,S,A,T){if(O.isUndefined(S)){if(!O.isUndefined(v))return l(void 0,v,A,T)}else return l(v,S,A,T)}function f(v,S){if(!O.isUndefined(S))return l(void 0,S)}function p(v,S){if(O.isUndefined(S)){if(!O.isUndefined(v))return l(void 0,v)}else return l(void 0,S)}function g(v,S,A){if(A in i)return l(v,S);if(A in r)return l(void 0,v)}const x={url:f,method:f,data:f,baseURL:p,transformRequest:p,transformResponse:p,paramsSerializer:p,timeout:p,timeoutMessage:p,withCredentials:p,withXSRFToken:p,adapter:p,responseType:p,xsrfCookieName:p,xsrfHeaderName:p,onUploadProgress:p,onDownloadProgress:p,decompress:p,maxContentLength:p,maxBodyLength:p,beforeRedirect:p,transport:p,httpAgent:p,httpsAgent:p,cancelToken:p,socketPath:p,responseEncoding:p,validateStatus:g,headers:(v,S,A)=>c(Ld(v),Ld(S),A,!0)};return O.forEach(Object.keys(Object.assign({},r,i)),function(S){const A=x[S]||c,T=A(r[S],i[S],S);O.isUndefined(T)&&A!==g||(s[S]=T)}),s}const qp=r=>{const i=Wn({},r);let{data:s,withXSRFToken:l,xsrfHeaderName:c,xsrfCookieName:f,headers:p,auth:g}=i;i.headers=p=ut.from(p),i.url=$p(Yp(i.baseURL,i.url),r.params,r.paramsSerializer),g&&p.set("Authorization","Basic "+btoa((g.username||"")+":"+(g.password?unescape(encodeURIComponent(g.password)):"")));let x;if(O.isFormData(s)){if(Ge.hasStandardBrowserEnv||Ge.hasStandardBrowserWebWorkerEnv)p.setContentType(void 0);else if((x=p.getContentType())!==!1){const[v,...S]=x?x.split(";").map(A=>A.trim()).filter(Boolean):[];p.setContentType([v||"multipart/form-data",...S].join("; "))}}if(Ge.hasStandardBrowserEnv&&(l&&O.isFunction(l)&&(l=l(i)),l||l!==!1&&Pv(i.url))){const v=c&&f&&_v.read(f);v&&p.set(c,v)}return i},Nv=typeof XMLHttpRequest<"u",Ov=Nv&&function(r){return new Promise(function(s,l){const c=qp(r);let f=c.data;const p=ut.from(c.headers).normalize();let{responseType:g,onUploadProgress:x,onDownloadProgress:v}=c,S,A,T,I,R;function C(){I&&I(),R&&R(),c.cancelToken&&c.cancelToken.unsubscribe(S),c.signal&&c.signal.removeEventListener("abort",S)}let N=new XMLHttpRequest;N.open(c.method.toUpperCase(),c.url,!0),N.timeout=c.timeout;function b(){if(!N)return;const V=ut.from("getAllResponseHeaders"in N&&N.getAllResponseHeaders()),$={data:!g||g==="text"||g==="json"?N.responseText:N.response,status:N.status,statusText:N.statusText,headers:V,config:r,request:N};Wp(function(B){s(B),C()},function(B){l(B),C()},$),N=null}"onloadend"in N?N.onloadend=b:N.onreadystatechange=function(){!N||N.readyState!==4||N.status===0&&!(N.responseURL&&N.responseURL.indexOf("file:")===0)||setTimeout(b)},N.onabort=function(){N&&(l(new le("Request aborted",le.ECONNABORTED,r,N)),N=null)},N.onerror=function(){l(new le("Network Error",le.ERR_NETWORK,r,N)),N=null},N.ontimeout=function(){let Q=c.timeout?"timeout of "+c.timeout+"ms exceeded":"timeout exceeded";const $=c.transitional||Hp;c.timeoutErrorMessage&&(Q=c.timeoutErrorMessage),l(new le(Q,$.clarifyTimeoutError?le.ETIMEDOUT:le.ECONNABORTED,r,N)),N=null},f===void 0&&p.setContentType(null),"setRequestHeader"in N&&O.forEach(p.toJSON(),function(Q,$){N.setRequestHeader($,Q)}),O.isUndefined(c.withCredentials)||(N.withCredentials=!!c.withCredentials),g&&g!=="json"&&(N.responseType=c.responseType),v&&([T,R]=ls(v,!0),N.addEventListener("progress",T)),x&&N.upload&&([A,I]=ls(x),N.upload.addEventListener("progress",A),N.upload.addEventListener("loadend",I)),(c.cancelToken||c.signal)&&(S=V=>{N&&(l(!V||V.type?new Pr(null,r,N):V),N.abort(),N=null)},c.cancelToken&&c.cancelToken.subscribe(S),c.signal&&(c.signal.aborted?S():c.signal.addEventListener("abort",S)));const U=jv(c.url);if(U&&Ge.protocols.indexOf(U)===-1){l(new le("Unsupported protocol "+U+":",le.ERR_BAD_REQUEST,r));return}N.send(f||null)})},Lv=(r,i)=>{const{length:s}=r=r?r.filter(Boolean):[];if(i||s){let l=new AbortController,c;const f=function(v){if(!c){c=!0,g();const S=v instanceof Error?v:this.reason;l.abort(S instanceof le?S:new Pr(S instanceof Error?S.message:S))}};let p=i&&setTimeout(()=>{p=null,f(new le(`timeout ${i} of ms exceeded`,le.ETIMEDOUT))},i);const g=()=>{r&&(p&&clearTimeout(p),p=null,r.forEach(v=>{v.unsubscribe?v.unsubscribe(f):v.removeEventListener("abort",f)}),r=null)};r.forEach(v=>v.addEventListener("abort",f));const{signal:x}=l;return x.unsubscribe=()=>O.asap(g),x}},Dv=function*(r,i){let s=r.byteLength;if(s{const c=Mv(r,i);let f=0,p,g=x=>{p||(p=!0,l&&l(x))};return new ReadableStream({async pull(x){try{const{done:v,value:S}=await c.next();if(v){g(),x.close();return}let A=S.byteLength;if(s){let T=f+=A;s(T)}x.enqueue(new Uint8Array(S))}catch(v){throw g(v),v}},cancel(x){return g(x),c.return()}},{highWaterMark:2})},xs=typeof fetch=="function"&&typeof Request=="function"&&typeof Response=="function",Qp=xs&&typeof ReadableStream=="function",Uv=xs&&(typeof TextEncoder=="function"?(r=>i=>r.encode(i))(new TextEncoder):async r=>new Uint8Array(await new Response(r).arrayBuffer())),Gp=(r,...i)=>{try{return!!r(...i)}catch{return!1}},Fv=Qp&&Gp(()=>{let r=!1;const i=new Request(Ge.origin,{body:new ReadableStream,method:"POST",get duplex(){return r=!0,"half"}}).headers.has("Content-Type");return r&&!i}),Md=64*1024,bu=Qp&&Gp(()=>O.isReadableStream(new Response("").body)),us={stream:bu&&(r=>r.body)};xs&&(r=>{["text","arrayBuffer","blob","formData","stream"].forEach(i=>{!us[i]&&(us[i]=O.isFunction(r[i])?s=>s[i]():(s,l)=>{throw new le(`Response type '${i}' is not supported`,le.ERR_NOT_SUPPORT,l)})})})(new Response);const Bv=async r=>{if(r==null)return 0;if(O.isBlob(r))return r.size;if(O.isSpecCompliantForm(r))return(await new Request(Ge.origin,{method:"POST",body:r}).arrayBuffer()).byteLength;if(O.isArrayBufferView(r)||O.isArrayBuffer(r))return r.byteLength;if(O.isURLSearchParams(r)&&(r=r+""),O.isString(r))return(await Uv(r)).byteLength},$v=async(r,i)=>{const s=O.toFiniteNumber(r.getContentLength());return s??Bv(i)},Hv=xs&&(async r=>{let{url:i,method:s,data:l,signal:c,cancelToken:f,timeout:p,onDownloadProgress:g,onUploadProgress:x,responseType:v,headers:S,withCredentials:A="same-origin",fetchOptions:T}=qp(r);v=v?(v+"").toLowerCase():"text";let I=Lv([c,f&&f.toAbortSignal()],p),R;const C=I&&I.unsubscribe&&(()=>{I.unsubscribe()});let N;try{if(x&&Fv&&s!=="get"&&s!=="head"&&(N=await $v(S,l))!==0){let $=new Request(i,{method:"POST",body:l,duplex:"half"}),L;if(O.isFormData(l)&&(L=$.headers.get("content-type"))&&S.setContentType(L),$.body){const[B,ie]=Nd(N,ls(Od(x)));l=Dd($.body,Md,B,ie)}}O.isString(A)||(A=A?"include":"omit");const b="credentials"in Request.prototype;R=new Request(i,{...T,signal:I,method:s.toUpperCase(),headers:S.normalize().toJSON(),body:l,duplex:"half",credentials:b?A:void 0});let U=await fetch(R);const V=bu&&(v==="stream"||v==="response");if(bu&&(g||V&&C)){const $={};["status","statusText","headers"].forEach(ye=>{$[ye]=U[ye]});const L=O.toFiniteNumber(U.headers.get("content-length")),[B,ie]=g&&Nd(L,ls(Od(g),!0))||[];U=new Response(Dd(U.body,Md,B,()=>{ie&&ie(),C&&C()}),$)}v=v||"text";let Q=await us[O.findKey(us,v)||"text"](U,r);return!V&&C&&C(),await new Promise(($,L)=>{Wp($,L,{data:Q,headers:ut.from(U.headers),status:U.status,statusText:U.statusText,config:r,request:R})})}catch(b){throw C&&C(),b&&b.name==="TypeError"&&/fetch/i.test(b.message)?Object.assign(new le("Network Error",le.ERR_NETWORK,r,R),{cause:b.cause||b}):le.from(b,b&&b.code,r,R)}}),Vu={http:rv,xhr:Ov,fetch:Hv};O.forEach(Vu,(r,i)=>{if(r){try{Object.defineProperty(r,"name",{value:i})}catch{}Object.defineProperty(r,"adapterName",{value:i})}});const zd=r=>`- ${r}`,bv=r=>O.isFunction(r)||r===null||r===!1,Kp={getAdapter:r=>{r=O.isArray(r)?r:[r];const{length:i}=r;let s,l;const c={};for(let f=0;f`adapter ${g} `+(x===!1?"is not supported by the environment":"is not available in the build"));let p=i?f.length>1?`since :
+`+f.map(zd).join(`
+`):" "+zd(f[0]):"as no adapter specified";throw new le("There is no suitable adapter to dispatch the request "+p,"ERR_NOT_SUPPORT")}return l},adapters:Vu};function Ru(r){if(r.cancelToken&&r.cancelToken.throwIfRequested(),r.signal&&r.signal.aborted)throw new Pr(null,r)}function Ud(r){return Ru(r),r.headers=ut.from(r.headers),r.data=Au.call(r,r.transformRequest),["post","put","patch"].indexOf(r.method)!==-1&&r.headers.setContentType("application/x-www-form-urlencoded",!1),Kp.getAdapter(r.adapter||To.adapter)(r).then(function(l){return Ru(r),l.data=Au.call(r,r.transformResponse,l),l.headers=ut.from(l.headers),l},function(l){return Vp(l)||(Ru(r),l&&l.response&&(l.response.data=Au.call(r,r.transformResponse,l.response),l.response.headers=ut.from(l.response.headers))),Promise.reject(l)})}const Xp="1.7.9",Ss={};["object","boolean","number","function","string","symbol"].forEach((r,i)=>{Ss[r]=function(l){return typeof l===r||"a"+(i<1?"n ":" ")+r}});const Fd={};Ss.transitional=function(i,s,l){function c(f,p){return"[Axios v"+Xp+"] Transitional option '"+f+"'"+p+(l?". "+l:"")}return(f,p,g)=>{if(i===!1)throw new le(c(p," has been removed"+(s?" in "+s:"")),le.ERR_DEPRECATED);return s&&!Fd[p]&&(Fd[p]=!0,console.warn(c(p," has been deprecated since v"+s+" and will be removed in the near future"))),i?i(f,p,g):!0}};Ss.spelling=function(i){return(s,l)=>(console.warn(`${l} is likely a misspelling of ${i}`),!0)};function Vv(r,i,s){if(typeof r!="object")throw new le("options must be an object",le.ERR_BAD_OPTION_VALUE);const l=Object.keys(r);let c=l.length;for(;c-- >0;){const f=l[c],p=i[f];if(p){const g=r[f],x=g===void 0||p(g,f,r);if(x!==!0)throw new le("option "+f+" must be "+x,le.ERR_BAD_OPTION_VALUE);continue}if(s!==!0)throw new le("Unknown option "+f,le.ERR_BAD_OPTION)}}const ts={assertOptions:Vv,validators:Ss},bt=ts.validators;class Hn{constructor(i){this.defaults=i,this.interceptors={request:new Td,response:new Td}}async request(i,s){try{return await this._request(i,s)}catch(l){if(l instanceof Error){let c={};Error.captureStackTrace?Error.captureStackTrace(c):c=new Error;const f=c.stack?c.stack.replace(/^.+\n/,""):"";try{l.stack?f&&!String(l.stack).endsWith(f.replace(/^.+\n.+\n/,""))&&(l.stack+=`
+`+f):l.stack=f}catch{}}throw l}}_request(i,s){typeof i=="string"?(s=s||{},s.url=i):s=i||{},s=Wn(this.defaults,s);const{transitional:l,paramsSerializer:c,headers:f}=s;l!==void 0&&ts.assertOptions(l,{silentJSONParsing:bt.transitional(bt.boolean),forcedJSONParsing:bt.transitional(bt.boolean),clarifyTimeoutError:bt.transitional(bt.boolean)},!1),c!=null&&(O.isFunction(c)?s.paramsSerializer={serialize:c}:ts.assertOptions(c,{encode:bt.function,serialize:bt.function},!0)),ts.assertOptions(s,{baseUrl:bt.spelling("baseURL"),withXsrfToken:bt.spelling("withXSRFToken")},!0),s.method=(s.method||this.defaults.method||"get").toLowerCase();let p=f&&O.merge(f.common,f[s.method]);f&&O.forEach(["delete","get","head","post","put","patch","common"],R=>{delete f[R]}),s.headers=ut.concat(p,f);const g=[];let x=!0;this.interceptors.request.forEach(function(C){typeof C.runWhen=="function"&&C.runWhen(s)===!1||(x=x&&C.synchronous,g.unshift(C.fulfilled,C.rejected))});const v=[];this.interceptors.response.forEach(function(C){v.push(C.fulfilled,C.rejected)});let S,A=0,T;if(!x){const R=[Ud.bind(this),void 0];for(R.unshift.apply(R,g),R.push.apply(R,v),T=R.length,S=Promise.resolve(s);A{if(!l._listeners)return;let f=l._listeners.length;for(;f-- >0;)l._listeners[f](c);l._listeners=null}),this.promise.then=c=>{let f;const p=new Promise(g=>{l.subscribe(g),f=g}).then(c);return p.cancel=function(){l.unsubscribe(f)},p},i(function(f,p,g){l.reason||(l.reason=new Pr(f,p,g),s(l.reason))})}throwIfRequested(){if(this.reason)throw this.reason}subscribe(i){if(this.reason){i(this.reason);return}this._listeners?this._listeners.push(i):this._listeners=[i]}unsubscribe(i){if(!this._listeners)return;const s=this._listeners.indexOf(i);s!==-1&&this._listeners.splice(s,1)}toAbortSignal(){const i=new AbortController,s=l=>{i.abort(l)};return this.subscribe(s),i.signal.unsubscribe=()=>this.unsubscribe(s),i.signal}static source(){let i;return{token:new ia(function(c){i=c}),cancel:i}}}function Wv(r){return function(s){return r.apply(null,s)}}function Yv(r){return O.isObject(r)&&r.isAxiosError===!0}const Wu={Continue:100,SwitchingProtocols:101,Processing:102,EarlyHints:103,Ok:200,Created:201,Accepted:202,NonAuthoritativeInformation:203,NoContent:204,ResetContent:205,PartialContent:206,MultiStatus:207,AlreadyReported:208,ImUsed:226,MultipleChoices:300,MovedPermanently:301,Found:302,SeeOther:303,NotModified:304,UseProxy:305,Unused:306,TemporaryRedirect:307,PermanentRedirect:308,BadRequest:400,Unauthorized:401,PaymentRequired:402,Forbidden:403,NotFound:404,MethodNotAllowed:405,NotAcceptable:406,ProxyAuthenticationRequired:407,RequestTimeout:408,Conflict:409,Gone:410,LengthRequired:411,PreconditionFailed:412,PayloadTooLarge:413,UriTooLong:414,UnsupportedMediaType:415,RangeNotSatisfiable:416,ExpectationFailed:417,ImATeapot:418,MisdirectedRequest:421,UnprocessableEntity:422,Locked:423,FailedDependency:424,TooEarly:425,UpgradeRequired:426,PreconditionRequired:428,TooManyRequests:429,RequestHeaderFieldsTooLarge:431,UnavailableForLegalReasons:451,InternalServerError:500,NotImplemented:501,BadGateway:502,ServiceUnavailable:503,GatewayTimeout:504,HttpVersionNotSupported:505,VariantAlsoNegotiates:506,InsufficientStorage:507,LoopDetected:508,NotExtended:510,NetworkAuthenticationRequired:511};Object.entries(Wu).forEach(([r,i])=>{Wu[i]=r});function Jp(r){const i=new Hn(r),s=_p(Hn.prototype.request,i);return O.extend(s,Hn.prototype,i,{allOwnKeys:!0}),O.extend(s,i,null,{allOwnKeys:!0}),s.create=function(c){return Jp(Wn(r,c))},s}const Le=Jp(To);Le.Axios=Hn;Le.CanceledError=Pr;Le.CancelToken=ia;Le.isCancel=Vp;Le.VERSION=Xp;Le.toFormData=ws;Le.AxiosError=le;Le.Cancel=Le.CanceledError;Le.all=function(i){return Promise.all(i)};Le.spread=Wv;Le.isAxiosError=Yv;Le.mergeConfig=Wn;Le.AxiosHeaders=ut;Le.formToJSON=r=>bp(O.isHTMLForm(r)?new FormData(r):r);Le.getAdapter=Kp.getAdapter;Le.HttpStatusCode=Wu;Le.default=Le;const qv={apiBaseUrl:"/api"};class Qv{constructor(){ed(this,"events",{})}on(i,s){return this.events[i]||(this.events[i]=[]),this.events[i].push(s),()=>this.off(i,s)}off(i,s){this.events[i]&&(this.events[i]=this.events[i].filter(l=>l!==s))}emit(i,...s){this.events[i]&&this.events[i].forEach(l=>{l(...s)})}}const as=new Qv,Xe=Le.create({baseURL:qv.apiBaseUrl,headers:{"Content-Type":"application/json"}});Xe.interceptors.response.use(r=>r,r=>{var s,l,c;const i=(s=r.response)==null?void 0:s.data;if(i){const f=(c=(l=r.response)==null?void 0:l.headers)==null?void 0:c["discodeit-request-id"];f&&(i.requestId=f),r.response.data=i}return as.emit("api-error",r),r.response&&r.response.status===401&&as.emit("auth-error"),Promise.reject(r)});const Gv=()=>Xe.defaults.baseURL,Kv=async(r,i)=>{const s={username:r,password:i};return(await Xe.post("/auth/login",s)).data},Xv=async r=>(await Xe.post("/users",r,{headers:{"Content-Type":"multipart/form-data"}})).data,Bd=r=>{let i;const s=new Set,l=(v,S)=>{const A=typeof v=="function"?v(i):v;if(!Object.is(A,i)){const T=i;i=S??(typeof A!="object"||A===null)?A:Object.assign({},i,A),s.forEach(I=>I(i,T))}},c=()=>i,g={setState:l,getState:c,getInitialState:()=>x,subscribe:v=>(s.add(v),()=>s.delete(v))},x=i=r(l,c,g);return g},Jv=r=>r?Bd(r):Bd,Zv=r=>r;function e0(r,i=Zv){const s=mt.useSyncExternalStore(r.subscribe,()=>i(r.getState()),()=>i(r.getInitialState()));return mt.useDebugValue(s),s}const $d=r=>{const i=Jv(r),s=l=>e0(i,l);return Object.assign(s,i),s},_r=r=>r?$d(r):$d,Zp=async(r,i)=>(await Xe.patch(`/users/${r}`,i,{headers:{"Content-Type":"multipart/form-data"}})).data,t0=async()=>(await Xe.get("/users")).data,n0=async r=>(await Xe.patch(`/users/${r}/userStatus`,{newLastActiveAt:new Date().toISOString()})).data,nn=_r(r=>({users:[],fetchUsers:async()=>{try{const i=await t0();r({users:i})}catch(i){console.error("사용자 목록 조회 실패:",i)}},updateUserStatus:async i=>{try{await n0(i)}catch(s){console.error("사용자 상태 업데이트 실패:",s)}}}));function eh(r,i){let s;try{s=r()}catch{return}return{getItem:c=>{var f;const p=x=>x===null?null:JSON.parse(x,void 0),g=(f=s.getItem(c))!=null?f:null;return g instanceof Promise?g.then(p):p(g)},setItem:(c,f)=>s.setItem(c,JSON.stringify(f,void 0)),removeItem:c=>s.removeItem(c)}}const Yu=r=>i=>{try{const s=r(i);return s instanceof Promise?s:{then(l){return Yu(l)(s)},catch(l){return this}}}catch(s){return{then(l){return this},catch(l){return Yu(l)(s)}}}},r0=(r,i)=>(s,l,c)=>{let f={storage:eh(()=>localStorage),partialize:C=>C,version:0,merge:(C,N)=>({...N,...C}),...i},p=!1;const g=new Set,x=new Set;let v=f.storage;if(!v)return r((...C)=>{console.warn(`[zustand persist middleware] Unable to update item '${f.name}', the given storage is currently unavailable.`),s(...C)},l,c);const S=()=>{const C=f.partialize({...l()});return v.setItem(f.name,{state:C,version:f.version})},A=c.setState;c.setState=(C,N)=>{A(C,N),S()};const T=r((...C)=>{s(...C),S()},l,c);c.getInitialState=()=>T;let I;const R=()=>{var C,N;if(!v)return;p=!1,g.forEach(U=>{var V;return U((V=l())!=null?V:T)});const b=((N=f.onRehydrateStorage)==null?void 0:N.call(f,(C=l())!=null?C:T))||void 0;return Yu(v.getItem.bind(v))(f.name).then(U=>{if(U)if(typeof U.version=="number"&&U.version!==f.version){if(f.migrate){const V=f.migrate(U.state,U.version);return V instanceof Promise?V.then(Q=>[!0,Q]):[!0,V]}console.error("State loaded from storage couldn't be migrated since no migrate function was provided")}else return[!1,U.state];return[!1,void 0]}).then(U=>{var V;const[Q,$]=U;if(I=f.merge($,(V=l())!=null?V:T),s(I,!0),Q)return S()}).then(()=>{b==null||b(I,void 0),I=l(),p=!0,x.forEach(U=>U(I))}).catch(U=>{b==null||b(void 0,U)})};return c.persist={setOptions:C=>{f={...f,...C},C.storage&&(v=C.storage)},clearStorage:()=>{v==null||v.removeItem(f.name)},getOptions:()=>f,rehydrate:()=>R(),hasHydrated:()=>p,onHydrate:C=>(g.add(C),()=>{g.delete(C)}),onFinishHydration:C=>(x.add(C),()=>{x.delete(C)})},f.skipHydration||R(),I||T},o0=r0,yt=_r()(o0(r=>({currentUserId:null,setCurrentUser:i=>r({currentUserId:i.id}),logout:()=>{const i=yt.getState().currentUserId;i&&nn.getState().updateUserStatus(i),r({currentUserId:null})},updateUser:async(i,s)=>{try{const l=await Zp(i,s);return await nn.getState().fetchUsers(),l}catch(l){throw console.error("사용자 정보 수정 실패:",l),l}}}),{name:"user-storage",storage:eh(()=>sessionStorage)})),ee={colors:{brand:{primary:"#5865F2",hover:"#4752C4"},background:{primary:"#1a1a1a",secondary:"#2a2a2a",tertiary:"#333333",input:"#40444B",hover:"rgba(255, 255, 255, 0.1)"},text:{primary:"#ffffff",secondary:"#cccccc",muted:"#999999"},status:{online:"#43b581",idle:"#faa61a",dnd:"#f04747",offline:"#747f8d",error:"#ED4245"},border:{primary:"#404040"}}},th=_.div`
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: rgba(0, 0, 0, 0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+`,nh=_.div`
+ background: ${ee.colors.background.primary};
+ padding: 32px;
+ border-radius: 8px;
+ width: 440px;
+
+ h2 {
+ color: ${ee.colors.text.primary};
+ margin-bottom: 24px;
+ font-size: 24px;
+ font-weight: bold;
+ }
+
+ form {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ }
+`,ko=_.input`
+ width: 100%;
+ padding: 10px;
+ border-radius: 4px;
+ background: ${ee.colors.background.input};
+ border: none;
+ color: ${ee.colors.text.primary};
+ font-size: 16px;
+
+ &::placeholder {
+ color: ${ee.colors.text.muted};
+ }
+
+ &:focus {
+ outline: none;
+ }
+`,rh=_.button`
+ width: 100%;
+ padding: 12px;
+ border-radius: 4px;
+ background: ${ee.colors.brand.primary};
+ color: white;
+ font-size: 16px;
+ font-weight: 500;
+ border: none;
+ cursor: pointer;
+ transition: background-color 0.2s;
+
+ &:hover {
+ background: ${ee.colors.brand.hover};
+ }
+`,oh=_.div`
+ color: ${ee.colors.status.error};
+ font-size: 14px;
+ text-align: center;
+`,i0=_.p`
+ text-align: center;
+ margin-top: 16px;
+ color: ${({theme:r})=>r.colors.text.muted};
+ font-size: 14px;
+`,s0=_.span`
+ color: ${({theme:r})=>r.colors.brand.primary};
+ cursor: pointer;
+
+ &:hover {
+ text-decoration: underline;
+ }
+`,Vi=_.div`
+ margin-bottom: 20px;
+`,Wi=_.label`
+ display: block;
+ color: ${({theme:r})=>r.colors.text.muted};
+ font-size: 12px;
+ font-weight: 700;
+ margin-bottom: 8px;
+`,Pu=_.span`
+ color: ${({theme:r})=>r.colors.status.error};
+`,l0=_.div`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ margin: 10px 0;
+`,u0=_.img`
+ width: 80px;
+ height: 80px;
+ border-radius: 50%;
+ margin-bottom: 10px;
+ object-fit: cover;
+`,a0=_.input`
+ display: none;
+`,c0=_.label`
+ color: ${({theme:r})=>r.colors.brand.primary};
+ cursor: pointer;
+ font-size: 14px;
+
+ &:hover {
+ text-decoration: underline;
+ }
+`,f0=_.span`
+ color: ${({theme:r})=>r.colors.brand.primary};
+ cursor: pointer;
+
+ &:hover {
+ text-decoration: underline;
+ }
+`,d0=_(f0)`
+ display: block;
+ text-align: center;
+ margin-top: 16px;
+`,Mt="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAPAAAADwCAYAAAA+VemSAAAACXBIWXMAACE4AAAhOAFFljFgAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAw2SURBVHgB7d3PT1XpHcfxBy5g6hipSMolGViACThxJDbVRZ2FXejKlf9h/4GmC1fTRdkwC8fE0JgyJuICFkCjEA04GeZe6P0cPC0698I95zzPc57v5f1K6DSto3A8n/v9nufXGfrr338+dgBMGnYAzCLAgGEEGDCMAAOGEWDAMAIMGEaAAcMIMGAYAQYMI8CAYQQYMIwAA4YRYMAwAgwYRoABwwgwYBgBBgwjwIBhBBgwjAADhhFgwDACDBhGgAHDCDBgGAEGDCPAgGEEGDCMAAOGEWDAMAIMGEaAAcMIMGAYAQYMI8CAYQQYMIwAA4YRYMAwAgwYRoABwwgwYBgBBgwjwIBhBBgwjAADhhFgwDACDBhGgAHDCDBgGAEGDCPAgGEEGDCMAAOGEWDAMAIMGEaAAcMIMGAYAQYMI8CAYQQYMIwAA4YRYMAwAgwYRoABwwgwYBgBBgwbcTDvyuWh//33w1/1dexwMRBgYxTW5vVh9/vxYTcxPpR9jY0OffZrdt8fu82ttlvfbLv9j4R5kBHgxCmcE1eH3NfTDTc7PfxZte3lJNgjbmlxxK3+1HKrr1oOg4kAJ0pVdnG+4ZqTw7+psEUoxF91Qv/Di1+db/q+ZpvD7g+T6gb04XLyv6mF3//osuqvTmDn3RGdQCAEOCG6+W/ONdzNTnCrhPZLN2Yb2T99hVhdwOLcSOf37f7hknUN4yedgLoGeb3Rdv/qdAIE2S8CnIDzAuGDQrzXeTZee1OtndaHy9LCSOHvU3++vv693nLPX9LS+0KAa6QQLC2o4sb5a1A7rYGtMqPU+l7v3hpx85+qeVnfdH7W2c7z/Pcrh1RjD5gHromq2JOHY9HCK2Ojzk1dL1fhH90fqxzenDoO/X79DMjhbAQ4Mg1OPXl4KauGodrls6j6FaXKq+dZn/IQ13ENBgkBjiRvQR99V2/lmZos9lc+PxOuxdd1uL3gp6pfVDwDR6Ab9cG9Me9VLAZ1CiHpmXhz6yibakJxVODAZpoN9/iBzfCq+sboFkJ/SAwyrlxAujE1WJWSIiO/sYKlxSpTnbEBqnBxVOBA9LybWnjloM8An6ysitc1NCe5FcvgqgVw/85o1OmhItY32n39uqnJuC3/FAEuhavmmcLra77UN7XP2322qRNX494aqvgojqvmUcrhFa1+6tdXkae6tMiEhR3FEWBPNOCTcni1rZCli4OHAHuQ4mjzaewJHlxMI1Wked5Uw7v99ijbwqd/FnVQQ7WmQyiOAFegZ7a736ZzCU820h+7nbfHbnO7XSq4p3+vmHbfMwdcBgGuoO4dNQrZxtaR+08nqNueT73Y2D7qTIW5aLRXGcUR4JL03FtHeBXa9Y2jyhX2PHudiqg/K9ZuoY3t/uan8TkCXIKCG/u5V2Fae9N2a+vtKO2tjqfVnxfj5zw5O4sWugwCXIJa51hiB/e0tfVWdkZX6CrMCHl5BLigWDt0RCc6rrxo1XZQu6rw6qt2tq47FD0G9Lu8E79FgAvIWucIO3QU2B9ftpK4sVWFZ5rDQTYbqHUOcdztRcJCjgLUToauvrqpny4fJlWVlp/5P4BOH1IcbFcdAe6Tght6h5FeiaLwpnZTq5VW2HzN1eYfUoS3OgLcp9sL4cOrkKT6YrI8dFUHnDQYR3j94Rm4D9kLxQLuV009vKdpXbXae00vFdm8UWVZJ3ojwH3QcS+hnn1VifSMaemVoPqeVzqDT6rG2oivQS5dH33l70ZS262w7n04yhae8MrTMAhwH0KNPFsfyNH3vd+pxkwD1Ydn4HOodQ5VfTXHyrMgqiDA55ibCbNJX1VLc6xAFQT4HCEGr9Q6s3wQPhDgM4RqnzWVQusMHwjwGTS66puCS/WFLwT4DCHOKia88IkA96BjTkOcVbzDQgZ4RIB7CBFejTzz7AufCHAPWn3lGwse4BsB7uGa5wqcLS3k7XvwjAD3cOWy84pnX4RAgHvw/QzMLhyEQIC7CLF4Y4+DyxEAAe4iRIB3PzD6DP8IcBejnncPagCL/bAIgQB34fsc5P2PtM8IgwBHcMjJqQiEAHfBm+JhBQGO4IDlkwiEAHdx2PIbuFhv+MPFQ4C7ODx0Xo2OOiAIAhwBz9QIhQB34XvOlhYaoRDgLg5+dl7pcACqMEIgwF2EWDV1bZwAwz8C3IVOzfAd4omrXGr4x13Vg++jb6YmudTwj7uqh733fgOsM6YZzIJvBLiH3Q/+NyDMB3pNCy4u3k7Yw+57/wNZM9PDbu2NGwjqJiauDrmvpxufXiv6+f+v63fw8SjrZDgLLBwC3INO0NBAls+2V220jurZNXw6h8K6ODfibsye/UjQnNR/nnQcGk/IX/DNsbp+EeAetAVQVaQ56fe5dXGu4X54YTPASwsj7uZ8o/CHmkJ/Y7aRfb3eaBNkj3gGPsNOgNZPN7G1RR36fh8/uJS96LxqR6Kf/9H9MRa2eEKAz7C5FaZS3l6w0/goaArchMeFKPkHwrVxbr+quIJn0LNqiFZPVSjEmx98U7UNVS016PWXe6NU4ooI8DnWN8O8DuX+H0eTnxdeWgjb7uv3/vMd9lpWQYDPEep9Rrp5by+kOy+s7+/mfPhWXyPzFrqRVHHlzpFPgYTwTScg87NphjhmZdTgGMohwH1YexPupdx3b40mN5ij6tuMuHabKlweV60PGo0OdTB7ioM5WjEWW5PNHqVw1fq09ibcu33zqZpUQjzTjN/Ws1urHK5an9bWW0Ffj5JSiOv4HiaYEy6Fq9YnLa1cfRWuCku+wOHmXL2DOnUEmGOHyiHABagKh17Dqxv57rcj7k+3RpKfJ0b9CHBBKy/ivOhIU0yPH4xdqD3EV37HB1ZRBLignc6c8MZW2FY6p5ZSK7b0bNyMOM3CTiE7CHAJz1+2or7vV1Msj74by4IcoyKHOMygH4fhptsHFgEuQRXqx5fx7zYFWRX5ycNL2UqpUFV5512cDuNLvAS9ONawlaQ10jpSJsZ64S+d3iCvm3777XGntW9nx9fsfqh+JK5+Nq0Qi43WvTgCXMHqq5abma53g75Gqmen9fX/alz1CBtNmenfj7k6yvIxQ3Wiha5AN/r3K4fJtX55hVarvVTy8AB9OMV0GGdwf+AQ4IpU4f75LN27Tzt9HtwbKzynrNF2zXvHsvOWClwGAfZAN18dg1r9UnuthSFF6WeK1doS4HIIsCeqVrHbziLUUpdZornc6S5iDC5p8A3FEWCPVn9KO8RlTpVUeJ8u/xLsUAPR780UUjkE2LOUQ6x11jPN4n/l+WDdaqDznEOdO3YREOAAFOJUn4mrTA3p51KQNU/sM8g8/5bHPHAgeibWAND9O2mdtlF147yCm2/o0IeBXlyuAwDKfjDotBMWcJRHBQ5IlUUVa1Bv0O1squnkVSllvd5kAXQVBDiwfBAo5pyqFbo2od5+cVEQ4Ag0CKRnYrWedVfjlLqBlEfsrSDAEWnwJx8Eqsve+zQCrA+SOq/DoCDAkeWDQE+X63k23txKIzRUXz8IcE00Qv23f/wSta3Odim9q/+Zc6Pz3Ev19YNppJrpRtaXXrGinUMhp5zUvqfg+Uu2HvlCgBORB1nzqYtzDTc77ffoHC3CSGEAS4N5zPv6Q4ATo7lVfV253MoWXegMrKob6xWaFKax9PzNdJpfBDhRqlL7n6qy2mqFWeuY9QaDfttsfRCoXd1NYOS5rnPEBh0BNuB0mGVifOgk1Ncb2VJGbVLIdxnp12qqaHO7HXQHURH6ngZ5RVqdCLBBqqj62jCwiknbBJefEd5QCDCCUWgV3hRa+EFFgBEEbXMcBBjeabR55UWLUzYiIMDwRoHVK1iZKoqHAMMLqm49CDAqyxefID42MwCGEWDAMAIMGEaAAcMIMGAYAQYMI8CAYQQYMIwAA4YRYMAwAgwYRoABwwgwYBgBBgwjwIBhBBgwjAADhhFgwDACDBhGgAHDCDBgGAEGDCPAgGEEGDCMAAOGEWDAMAIMGEaAAcMIMGAYAQYMI8CAYQQYMIwAA4YRYMAwAgwYRoABwwgwYBgBBgwjwIBhBBgwjAADhhFgwDACDBhGgAHDCDBgGAEGDCPAgGEEGDCMAAOGEWDAMAIMGEaAAcMIMGAYAQYMI8CAYQQYMIwAA4YRYMAwAgwYRoABwwgwYBgBBgwjwIBhBBgwjAADhv0XZkN9IbEGbp4AAAAASUVORK5CYII=",p0=({isOpen:r,onClose:i})=>{const[s,l]=oe.useState(""),[c,f]=oe.useState(""),[p,g]=oe.useState(""),[x,v]=oe.useState(null),[S,A]=oe.useState(null),[T,I]=oe.useState(""),R=yt(b=>b.setCurrentUser),C=b=>{var V;const U=(V=b.target.files)==null?void 0:V[0];if(U){v(U);const Q=new FileReader;Q.onloadend=()=>{A(Q.result)},Q.readAsDataURL(U)}},N=async b=>{b.preventDefault(),I("");try{const U=new FormData;U.append("userCreateRequest",new Blob([JSON.stringify({email:s,username:c,password:p})],{type:"application/json"})),x&&U.append("profile",x);const V=await Xv(U);R(V),i()}catch{I("회원가입에 실패했습니다.")}};return r?h.jsx(th,{children:h.jsxs(nh,{children:[h.jsx("h2",{children:"계정 만들기"}),h.jsxs("form",{onSubmit:N,children:[h.jsxs(Vi,{children:[h.jsxs(Wi,{children:["이메일 ",h.jsx(Pu,{children:"*"})]}),h.jsx(ko,{type:"email",value:s,onChange:b=>l(b.target.value),required:!0})]}),h.jsxs(Vi,{children:[h.jsxs(Wi,{children:["사용자명 ",h.jsx(Pu,{children:"*"})]}),h.jsx(ko,{type:"text",value:c,onChange:b=>f(b.target.value),required:!0})]}),h.jsxs(Vi,{children:[h.jsxs(Wi,{children:["비밀번호 ",h.jsx(Pu,{children:"*"})]}),h.jsx(ko,{type:"password",value:p,onChange:b=>g(b.target.value),required:!0})]}),h.jsxs(Vi,{children:[h.jsx(Wi,{children:"프로필 이미지"}),h.jsxs(l0,{children:[h.jsx(u0,{src:S||Mt,alt:"profile"}),h.jsx(a0,{type:"file",accept:"image/*",onChange:C,id:"profile-image"}),h.jsx(c0,{htmlFor:"profile-image",children:"이미지 변경"})]})]}),T&&h.jsx(oh,{children:T}),h.jsx(rh,{type:"submit",children:"계속하기"}),h.jsx(d0,{onClick:i,children:"이미 계정이 있으신가요?"})]})]})}):null},h0=({isOpen:r,onClose:i})=>{const[s,l]=oe.useState(""),[c,f]=oe.useState(""),[p,g]=oe.useState(""),[x,v]=oe.useState(!1),S=yt(I=>I.setCurrentUser),{fetchUsers:A}=nn(),T=async()=>{var I;try{const R=await Kv(s,c);await A(),S(R),g(""),i()}catch(R){console.error("로그인 에러:",R),((I=R.response)==null?void 0:I.status)===401?g("아이디 또는 비밀번호가 올바르지 않습니다."):g("로그인에 실패했습니다.")}};return r?h.jsxs(h.Fragment,{children:[h.jsx(th,{children:h.jsxs(nh,{children:[h.jsx("h2",{children:"돌아오신 것을 환영해요!"}),h.jsxs("form",{onSubmit:I=>{I.preventDefault(),T()},children:[h.jsx(ko,{type:"text",placeholder:"사용자 이름",value:s,onChange:I=>l(I.target.value)}),h.jsx(ko,{type:"password",placeholder:"비밀번호",value:c,onChange:I=>f(I.target.value)}),p&&h.jsx(oh,{children:p}),h.jsx(rh,{type:"submit",children:"로그인"})]}),h.jsxs(i0,{children:["계정이 필요한가요? ",h.jsx(s0,{onClick:()=>v(!0),children:"가입하기"})]})]})}),h.jsx(p0,{isOpen:x,onClose:()=>v(!1)})]}):null},m0=async r=>(await Xe.get(`/channels?userId=${r}`)).data,g0=async r=>(await Xe.post("/channels/public",r)).data,y0=async r=>{const i={participantIds:r};return(await Xe.post("/channels/private",i)).data},v0=async r=>(await Xe.get("/readStatuses",{params:{userId:r}})).data,w0=async(r,i)=>{const s={newLastReadAt:i};return(await Xe.patch(`/readStatuses/${r}`,s)).data},x0=async(r,i,s)=>{const l={userId:r,channelId:i,lastReadAt:s};return(await Xe.post("/readStatuses",l)).data},jo=_r((r,i)=>({readStatuses:{},fetchReadStatuses:async()=>{try{const s=yt.getState().currentUserId;if(!s)return;const c=(await v0(s)).reduce((f,p)=>(f[p.channelId]={id:p.id,lastReadAt:p.lastReadAt},f),{});r({readStatuses:c})}catch(s){console.error("읽음 상태 조회 실패:",s)}},updateReadStatus:async s=>{try{const l=yt.getState().currentUserId;if(!l)return;const c=i().readStatuses[s];let f;c?f=await w0(c.id,new Date().toISOString()):f=await x0(l,s,new Date().toISOString()),r(p=>({readStatuses:{...p.readStatuses,[s]:{id:f.id,lastReadAt:f.lastReadAt}}}))}catch(l){console.error("읽음 상태 업데이트 실패:",l)}},hasUnreadMessages:(s,l)=>{const c=i().readStatuses[s],f=c==null?void 0:c.lastReadAt;return!f||new Date(l)>new Date(f)}})),xr=_r((r,i)=>({channels:[],pollingInterval:null,loading:!1,error:null,fetchChannels:async s=>{r({loading:!0,error:null});try{const l=await m0(s);r(f=>{const p=new Set(f.channels.map(S=>S.id)),g=l.filter(S=>!p.has(S.id));return{channels:[...f.channels.filter(S=>l.some(A=>A.id===S.id)),...g],loading:!1}});const{fetchReadStatuses:c}=jo.getState();return c(),l}catch(l){return r({error:l,loading:!1}),[]}},startPolling:s=>{const l=i().pollingInterval;l&&clearInterval(l);const c=setInterval(()=>{i().fetchChannels(s)},3e3);r({pollingInterval:c})},stopPolling:()=>{const s=i().pollingInterval;s&&(clearInterval(s),r({pollingInterval:null}))},createPublicChannel:async s=>{try{const l=await g0(s);return r(c=>c.channels.some(p=>p.id===l.id)?c:{channels:[...c.channels,{...l,participantIds:[],lastMessageAt:new Date().toISOString()}]}),l}catch(l){throw console.error("공개 채널 생성 실패:",l),l}},createPrivateChannel:async s=>{try{const l=await y0(s);return r(c=>c.channels.some(p=>p.id===l.id)?c:{channels:[...c.channels,{...l,participantIds:s,lastMessageAt:new Date().toISOString()}]}),l}catch(l){throw console.error("비공개 채널 생성 실패:",l),l}}})),S0=async r=>(await Xe.get(`/binaryContents/${r}`)).data,E0=r=>`${Gv()}/binaryContents/${r}/download`,Yn=_r((r,i)=>({binaryContents:{},fetchBinaryContent:async s=>{if(i().binaryContents[s])return i().binaryContents[s];try{const l=await S0(s),{contentType:c,fileName:f,size:p}=l,x={url:E0(s),contentType:c,fileName:f,size:p};return r(v=>({binaryContents:{...v.binaryContents,[s]:x}})),x}catch(l){return console.error("첨부파일 정보 조회 실패:",l),null}}})),Io=_.div`
+ position: absolute;
+ bottom: -3px;
+ right: -3px;
+ width: 16px;
+ height: 16px;
+ border-radius: 50%;
+ background: ${r=>r.$online?ee.colors.status.online:ee.colors.status.offline};
+ border: 4px solid ${r=>r.$background||ee.colors.background.secondary};
+`;_.div`
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ margin-right: 8px;
+ background: ${r=>ee.colors.status[r.status||"offline"]||ee.colors.status.offline};
+`;const Tr=_.div`
+ position: relative;
+ width: ${r=>r.$size||"32px"};
+ height: ${r=>r.$size||"32px"};
+ flex-shrink: 0;
+ margin: ${r=>r.$margin||"0"};
+`,rn=_.img`
+ width: 100%;
+ height: 100%;
+ border-radius: 50%;
+ object-fit: cover;
+ border: ${r=>r.$border||"none"};
+`;function C0({isOpen:r,onClose:i,user:s}){var $,L;const[l,c]=oe.useState(s.username),[f,p]=oe.useState(s.email),[g,x]=oe.useState(""),[v,S]=oe.useState(null),[A,T]=oe.useState(""),[I,R]=oe.useState(null),{binaryContents:C,fetchBinaryContent:N}=Yn(),b=yt(B=>B.logout);oe.useEffect(()=>{var B;(B=s.profile)!=null&&B.id&&!C[s.profile.id]&&N(s.profile.id)},[s.profile,C,N]);const U=()=>{c(s.username),p(s.email),x(""),S(null),R(null),T(""),i()},V=B=>{var ye;const ie=(ye=B.target.files)==null?void 0:ye[0];if(ie){S(ie);const Je=new FileReader;Je.onloadend=()=>{R(Je.result)},Je.readAsDataURL(ie)}},Q=async B=>{B.preventDefault(),T("");try{const ie=new FormData,ye={};l!==s.username&&(ye.newUsername=l),f!==s.email&&(ye.newEmail=f),g&&(ye.newPassword=g),(Object.keys(ye).length>0||v)&&(ie.append("userUpdateRequest",new Blob([JSON.stringify(ye)],{type:"application/json"})),v&&ie.append("profile",v),await Zp(s.id,ie)),i()}catch{T("사용자 정보 수정에 실패했습니다.")}};return r?h.jsx(k0,{children:h.jsxs(j0,{children:[h.jsx("h2",{children:"프로필 수정"}),h.jsxs("form",{onSubmit:Q,children:[h.jsxs(Yi,{children:[h.jsx(qi,{children:"프로필 이미지"}),h.jsxs(R0,{children:[h.jsx(P0,{src:I||(($=s.profile)!=null&&$.id?(L=C[s.profile.id])==null?void 0:L.url:void 0)||Mt,alt:"profile"}),h.jsx(_0,{type:"file",accept:"image/*",onChange:V,id:"profile-image"}),h.jsx(T0,{htmlFor:"profile-image",children:"이미지 변경"})]})]}),h.jsxs(Yi,{children:[h.jsxs(qi,{children:["사용자명 ",h.jsx(bd,{children:"*"})]}),h.jsx(_u,{type:"text",value:l,onChange:B=>c(B.target.value),required:!0})]}),h.jsxs(Yi,{children:[h.jsxs(qi,{children:["이메일 ",h.jsx(bd,{children:"*"})]}),h.jsx(_u,{type:"email",value:f,onChange:B=>p(B.target.value),required:!0})]}),h.jsxs(Yi,{children:[h.jsx(qi,{children:"새 비밀번호"}),h.jsx(_u,{type:"password",placeholder:"변경하지 않으려면 비워두세요",value:g,onChange:B=>x(B.target.value)})]}),A&&h.jsx(A0,{children:A}),h.jsxs(I0,{children:[h.jsx(Hd,{type:"button",onClick:U,$secondary:!0,children:"취소"}),h.jsx(Hd,{type:"submit",children:"저장"})]})]}),h.jsx(N0,{onClick:b,children:"로그아웃"})]})}):null}const k0=_.div`
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: rgba(0, 0, 0, 0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+`,j0=_.div`
+ background: ${({theme:r})=>r.colors.background.secondary};
+ padding: 32px;
+ border-radius: 5px;
+ width: 100%;
+ max-width: 480px;
+
+ h2 {
+ color: ${({theme:r})=>r.colors.text.primary};
+ margin-bottom: 24px;
+ text-align: center;
+ font-size: 24px;
+ }
+`,_u=_.input`
+ width: 100%;
+ padding: 10px;
+ margin-bottom: 10px;
+ border: none;
+ border-radius: 4px;
+ background: ${({theme:r})=>r.colors.background.input};
+ color: ${({theme:r})=>r.colors.text.primary};
+
+ &::placeholder {
+ color: ${({theme:r})=>r.colors.text.muted};
+ }
+
+ &:focus {
+ outline: none;
+ box-shadow: 0 0 0 2px ${({theme:r})=>r.colors.brand.primary};
+ }
+`,Hd=_.button`
+ width: 100%;
+ padding: 10px;
+ border: none;
+ border-radius: 4px;
+ background: ${({$secondary:r,theme:i})=>r?"transparent":i.colors.brand.primary};
+ color: ${({theme:r})=>r.colors.text.primary};
+ cursor: pointer;
+ font-weight: 500;
+
+ &:hover {
+ background: ${({$secondary:r,theme:i})=>r?i.colors.background.hover:i.colors.brand.hover};
+ }
+`,A0=_.div`
+ color: ${({theme:r})=>r.colors.status.error};
+ font-size: 14px;
+ margin-bottom: 10px;
+`,R0=_.div`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ margin-bottom: 20px;
+`,P0=_.img`
+ width: 100px;
+ height: 100px;
+ border-radius: 50%;
+ margin-bottom: 10px;
+ object-fit: cover;
+`,_0=_.input`
+ display: none;
+`,T0=_.label`
+ color: ${({theme:r})=>r.colors.brand.primary};
+ cursor: pointer;
+ font-size: 14px;
+
+ &:hover {
+ text-decoration: underline;
+ }
+`,I0=_.div`
+ display: flex;
+ gap: 10px;
+ margin-top: 20px;
+`,N0=_.button`
+ width: 100%;
+ padding: 10px;
+ margin-top: 16px;
+ border: none;
+ border-radius: 4px;
+ background: transparent;
+ color: ${({theme:r})=>r.colors.status.error};
+ cursor: pointer;
+ font-weight: 500;
+
+ &:hover {
+ background: ${({theme:r})=>r.colors.status.error}20;
+ }
+`,Yi=_.div`
+ margin-bottom: 20px;
+`,qi=_.label`
+ display: block;
+ color: ${({theme:r})=>r.colors.text.muted};
+ font-size: 12px;
+ font-weight: 700;
+ margin-bottom: 8px;
+`,bd=_.span`
+ color: ${({theme:r})=>r.colors.status.error};
+`,O0=_.div`
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 0.5rem 0.75rem;
+ background-color: ${({theme:r})=>r.colors.background.tertiary};
+ width: 100%;
+ height: 52px;
+`,L0=_(Tr)``;_(rn)``;const D0=_.div`
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+`,M0=_.div`
+ font-weight: 500;
+ color: ${({theme:r})=>r.colors.text.primary};
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ font-size: 0.875rem;
+ line-height: 1.2;
+`,z0=_.div`
+ font-size: 0.75rem;
+ color: ${({theme:r})=>r.colors.text.secondary};
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ line-height: 1.2;
+`,U0=_.div`
+ display: flex;
+ align-items: center;
+ flex-shrink: 0;
+`,F0=_.button`
+ background: none;
+ border: none;
+ padding: 0.25rem;
+ cursor: pointer;
+ color: ${({theme:r})=>r.colors.text.secondary};
+ font-size: 18px;
+
+ &:hover {
+ color: ${({theme:r})=>r.colors.text.primary};
+ }
+`;function B0({user:r}){var f,p;const[i,s]=oe.useState(!1),{binaryContents:l,fetchBinaryContent:c}=Yn();return oe.useEffect(()=>{var g;(g=r.profile)!=null&&g.id&&!l[r.profile.id]&&c(r.profile.id)},[r.profile,l,c]),h.jsxs(h.Fragment,{children:[h.jsxs(O0,{children:[h.jsxs(L0,{children:[h.jsx(rn,{src:(f=r.profile)!=null&&f.id?(p=l[r.profile.id])==null?void 0:p.url:Mt,alt:r.username}),h.jsx(Io,{$online:!0})]}),h.jsxs(D0,{children:[h.jsx(M0,{children:r.username}),h.jsx(z0,{children:"온라인"})]}),h.jsx(U0,{children:h.jsx(F0,{onClick:()=>s(!0),children:"⚙️"})})]}),h.jsx(C0,{isOpen:i,onClose:()=>s(!1),user:r})]})}const $0=_.div`
+ width: 240px;
+ background: ${ee.colors.background.secondary};
+ border-right: 1px solid ${ee.colors.border.primary};
+ display: flex;
+ flex-direction: column;
+`,H0=_.div`
+ flex: 1;
+ overflow-y: auto;
+`,b0=_.div`
+ padding: 16px;
+ font-size: 16px;
+ font-weight: bold;
+ color: ${ee.colors.text.primary};
+`,ih=_.div`
+ height: 34px;
+ padding: 0 8px;
+ margin: 1px 8px;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ color: ${r=>r.$hasUnread?r.theme.colors.text.primary:r.theme.colors.text.muted};
+ font-weight: ${r=>r.$hasUnread?"600":"normal"};
+ cursor: pointer;
+ background: ${r=>r.$isActive?r.theme.colors.background.hover:"transparent"};
+ border-radius: 4px;
+
+ &:hover {
+ background: ${r=>r.theme.colors.background.hover};
+ color: ${r=>r.theme.colors.text.primary};
+ }
+`,Vd=_.div`
+ margin-bottom: 8px;
+`,qu=_.div`
+ padding: 8px 16px;
+ display: flex;
+ align-items: center;
+ color: ${ee.colors.text.muted};
+ text-transform: uppercase;
+ font-size: 12px;
+ font-weight: 600;
+ cursor: pointer;
+ user-select: none;
+
+ & > span:nth-child(2) {
+ flex: 1;
+ margin-right: auto;
+ }
+
+ &:hover {
+ color: ${ee.colors.text.primary};
+ }
+`,Wd=_.span`
+ margin-right: 4px;
+ font-size: 10px;
+ transition: transform 0.2s;
+ transform: rotate(${r=>r.$folded?"-90deg":"0deg"});
+`,Yd=_.div`
+ display: ${r=>r.$folded?"none":"block"};
+`,qd=_(ih)`
+ height: ${r=>r.hasSubtext?"42px":"34px"};
+`,V0=_(Tr)`
+ width: 32px;
+ height: 32px;
+ margin: 0 8px;
+`,Qd=_.div`
+ font-size: 16px;
+ line-height: 18px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ color: ${r=>r.$isActive||r.$hasUnread?r.theme.colors.text.primary:r.theme.colors.text.muted};
+ font-weight: ${r=>r.$hasUnread?"600":"normal"};
+`;_(Io)`
+ border-color: ${ee.colors.background.primary};
+`;const Gd=_.button`
+ background: none;
+ border: none;
+ color: ${ee.colors.text.muted};
+ font-size: 18px;
+ padding: 0;
+ cursor: pointer;
+ width: 16px;
+ height: 16px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ opacity: 0;
+ transition: opacity 0.2s, color 0.2s;
+
+ ${qu}:hover & {
+ opacity: 1;
+ }
+
+ &:hover {
+ color: ${ee.colors.text.primary};
+ }
+`,W0=_(Tr)`
+ width: 40px;
+ height: 24px;
+ margin: 0 8px;
+`,Y0=_.div`
+ font-size: 12px;
+ line-height: 13px;
+ color: ${ee.colors.text.muted};
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+`,Kd=_.div`
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ gap: 2px;
+`,q0=_.div`
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: rgba(0, 0, 0, 0.85);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+`,Q0=_.div`
+ background: ${ee.colors.background.primary};
+ border-radius: 4px;
+ width: 440px;
+ max-width: 90%;
+`,G0=_.div`
+ padding: 16px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+`,K0=_.h2`
+ color: ${ee.colors.text.primary};
+ font-size: 20px;
+ font-weight: 600;
+ margin: 0;
+`,X0=_.div`
+ padding: 0 16px 16px;
+`,J0=_.form`
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+`,Tu=_.div`
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+`,Iu=_.label`
+ color: ${ee.colors.text.primary};
+ font-size: 12px;
+ font-weight: 600;
+ text-transform: uppercase;
+`,Z0=_.p`
+ color: ${ee.colors.text.muted};
+ font-size: 14px;
+ margin: -4px 0 0;
+`,Qu=_.input`
+ padding: 10px;
+ background: ${ee.colors.background.tertiary};
+ border: none;
+ border-radius: 3px;
+ color: ${ee.colors.text.primary};
+ font-size: 16px;
+
+ &:focus {
+ outline: none;
+ box-shadow: 0 0 0 2px ${ee.colors.status.online};
+ }
+
+ &::placeholder {
+ color: ${ee.colors.text.muted};
+ }
+`,e1=_.button`
+ margin-top: 8px;
+ padding: 12px;
+ background: ${ee.colors.status.online};
+ color: white;
+ border: none;
+ border-radius: 3px;
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: background 0.2s;
+
+ &:hover {
+ background: #3ca374;
+ }
+`,t1=_.button`
+ background: none;
+ border: none;
+ color: ${ee.colors.text.muted};
+ font-size: 24px;
+ cursor: pointer;
+ padding: 4px;
+ line-height: 1;
+
+ &:hover {
+ color: ${ee.colors.text.primary};
+ }
+`,n1=_(Qu)`
+ margin-bottom: 8px;
+`,r1=_.div`
+ max-height: 300px;
+ overflow-y: auto;
+ background: ${ee.colors.background.tertiary};
+ border-radius: 4px;
+`,o1=_.div`
+ display: flex;
+ align-items: center;
+ padding: 8px 12px;
+ cursor: pointer;
+ transition: background 0.2s;
+
+ &:hover {
+ background: ${ee.colors.background.hover};
+ }
+
+ & + & {
+ border-top: 1px solid ${ee.colors.border.primary};
+ }
+`,i1=_.input`
+ margin-right: 12px;
+ width: 16px;
+ height: 16px;
+ cursor: pointer;
+`,Xd=_.img`
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ margin-right: 12px;
+`,s1=_.div`
+ flex: 1;
+ min-width: 0;
+`,l1=_.div`
+ color: ${ee.colors.text.primary};
+ font-size: 14px;
+ font-weight: 500;
+`,u1=_.div`
+ color: ${ee.colors.text.muted};
+ font-size: 12px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+`,a1=_.div`
+ padding: 16px;
+ text-align: center;
+ color: ${ee.colors.text.muted};
+`,c1=_.div`
+ color: ${ee.colors.status.error};
+ font-size: 14px;
+ padding: 8px 0;
+ text-align: center;
+ background-color: ${({theme:r})=>r.colors.background.tertiary};
+ border-radius: 4px;
+ margin-bottom: 8px;
+`;function f1(){return h.jsx(b0,{children:"채널 목록"})}function Jd({channel:r,isActive:i,onClick:s,hasUnread:l}){var x;const c=yt(v=>v.currentUserId),{binaryContents:f}=Yn();if(r.type==="PUBLIC")return h.jsxs(ih,{$isActive:i,onClick:s,$hasUnread:l,children:["# ",r.name]});const p=r.participants;if(p.length>2){const v=p.filter(S=>S.id!==c).map(S=>S.username).join(", ");return h.jsxs(qd,{$isActive:i,onClick:s,children:[h.jsx(W0,{children:p.filter(S=>S.id!==c).slice(0,2).map((S,A)=>{var T;return h.jsx(rn,{src:S.profile?(T=f[S.profile.id])==null?void 0:T.url:Mt,style:{position:"absolute",left:A*16,zIndex:2-A,width:"24px",height:"24px",border:"2px solid #2a2a2a"}},S.id)})}),h.jsxs(Kd,{children:[h.jsx(Qd,{$hasUnread:l,children:v}),h.jsxs(Y0,{children:["멤버 ",p.length,"명"]})]})]})}const g=p.filter(v=>v.id!==c)[0];return g&&h.jsxs(qd,{$isActive:i,onClick:s,children:[h.jsxs(V0,{children:[h.jsx(rn,{src:g.profile?(x=f[g.profile.id])==null?void 0:x.url:Mt,alt:"profile"}),h.jsx(Io,{$online:g.online})]}),h.jsx(Kd,{children:h.jsx(Qd,{$hasUnread:l,children:g.username})})]})}function d1({isOpen:r,type:i,onClose:s,onCreateSuccess:l}){const[c,f]=oe.useState({name:"",description:""}),[p,g]=oe.useState(""),[x,v]=oe.useState([]),[S,A]=oe.useState(""),T=nn($=>$.users),I=Yn($=>$.binaryContents),R=yt($=>$.currentUserId),C=oe.useMemo(()=>T.filter($=>$.id!==R).filter($=>$.username.toLowerCase().includes(p.toLowerCase())||$.email.toLowerCase().includes(p.toLowerCase())),[p,T,R]),N=xr($=>$.createPublicChannel),b=xr($=>$.createPrivateChannel),U=$=>{const{name:L,value:B}=$.target;f(ie=>({...ie,[L]:B}))},V=$=>{v(L=>L.includes($)?L.filter(B=>B!==$):[...L,$])},Q=async $=>{var L,B;$.preventDefault(),A("");try{let ie;if(i==="PUBLIC"){if(!c.name.trim()){A("채널 이름을 입력해주세요.");return}const ye={name:c.name,description:c.description};ie=await N(ye)}else{if(x.length===0){A("대화 상대를 선택해주세요.");return}const ye=R&&[...x,R]||x;ie=await b(ye)}l(ie)}catch(ie){console.error("채널 생성 실패:",ie),A(((B=(L=ie.response)==null?void 0:L.data)==null?void 0:B.message)||"채널 생성에 실패했습니다. 다시 시도해주세요.")}};return r?h.jsx(q0,{onClick:s,children:h.jsxs(Q0,{onClick:$=>$.stopPropagation(),children:[h.jsxs(G0,{children:[h.jsx(K0,{children:i==="PUBLIC"?"채널 만들기":"개인 메시지 시작하기"}),h.jsx(t1,{onClick:s,children:"×"})]}),h.jsx(X0,{children:h.jsxs(J0,{onSubmit:Q,children:[S&&h.jsx(c1,{children:S}),i==="PUBLIC"?h.jsxs(h.Fragment,{children:[h.jsxs(Tu,{children:[h.jsx(Iu,{children:"채널 이름"}),h.jsx(Qu,{name:"name",value:c.name,onChange:U,placeholder:"새로운-채널",required:!0})]}),h.jsxs(Tu,{children:[h.jsx(Iu,{children:"채널 설명"}),h.jsx(Z0,{children:"이 채널의 주제를 설명해주세요."}),h.jsx(Qu,{name:"description",value:c.description,onChange:U,placeholder:"채널 설명을 입력하세요"})]})]}):h.jsxs(Tu,{children:[h.jsx(Iu,{children:"사용자 검색"}),h.jsx(n1,{type:"text",value:p,onChange:$=>g($.target.value),placeholder:"사용자명 또는 이메일로 검색"}),h.jsx(r1,{children:C.length>0?C.map($=>h.jsxs(o1,{children:[h.jsx(i1,{type:"checkbox",checked:x.includes($.id),onChange:()=>V($.id)}),$.profile?h.jsx(Xd,{src:I[$.profile.id].url}):h.jsx(Xd,{src:Mt}),h.jsxs(s1,{children:[h.jsx(l1,{children:$.username}),h.jsx(u1,{children:$.email})]})]},$.id)):h.jsx(a1,{children:"검색 결과가 없습니다."})})]}),h.jsx(e1,{type:"submit",children:i==="PUBLIC"?"채널 만들기":"대화 시작하기"})]})})]})}):null}function p1({currentUser:r,activeChannel:i,onChannelSelect:s}){var Q,$;const[l,c]=oe.useState({PUBLIC:!1,PRIVATE:!1}),[f,p]=oe.useState({isOpen:!1,type:null}),g=xr(L=>L.channels),x=xr(L=>L.fetchChannels),v=xr(L=>L.startPolling),S=xr(L=>L.stopPolling),A=jo(L=>L.fetchReadStatuses),T=jo(L=>L.updateReadStatus),I=jo(L=>L.hasUnreadMessages);oe.useEffect(()=>{if(r)return x(r.id),A(),v(r.id),()=>{S()}},[r,x,A,v,S]);const R=L=>{c(B=>({...B,[L]:!B[L]}))},C=(L,B)=>{B.stopPropagation(),p({isOpen:!0,type:L})},N=()=>{p({isOpen:!1,type:null})},b=async L=>{try{const ie=(await x(r.id)).find(ye=>ye.id===L.id);ie&&s(ie),N()}catch(B){console.error("채널 생성 실패:",B)}},U=L=>{s(L),T(L.id)},V=g.reduce((L,B)=>(L[B.type]||(L[B.type]=[]),L[B.type].push(B),L),{});return h.jsxs($0,{children:[h.jsx(f1,{}),h.jsxs(H0,{children:[h.jsxs(Vd,{children:[h.jsxs(qu,{onClick:()=>R("PUBLIC"),children:[h.jsx(Wd,{$folded:l.PUBLIC,children:"▼"}),h.jsx("span",{children:"일반 채널"}),h.jsx(Gd,{onClick:L=>C("PUBLIC",L),children:"+"})]}),h.jsx(Yd,{$folded:l.PUBLIC,children:(Q=V.PUBLIC)==null?void 0:Q.map(L=>h.jsx(Jd,{channel:L,isActive:(i==null?void 0:i.id)===L.id,hasUnread:I(L.id,L.lastMessageAt),onClick:()=>U(L)},L.id))})]}),h.jsxs(Vd,{children:[h.jsxs(qu,{onClick:()=>R("PRIVATE"),children:[h.jsx(Wd,{$folded:l.PRIVATE,children:"▼"}),h.jsx("span",{children:"개인 메시지"}),h.jsx(Gd,{onClick:L=>C("PRIVATE",L),children:"+"})]}),h.jsx(Yd,{$folded:l.PRIVATE,children:($=V.PRIVATE)==null?void 0:$.map(L=>h.jsx(Jd,{channel:L,isActive:(i==null?void 0:i.id)===L.id,hasUnread:I(L.id,L.lastMessageAt),onClick:()=>U(L)},L.id))})]})]}),h.jsx(h1,{children:h.jsx(B0,{user:r})}),h.jsx(d1,{isOpen:f.isOpen,type:f.type,onClose:N,onCreateSuccess:b})]})}const h1=_.div`
+ margin-top: auto;
+ border-top: 1px solid ${({theme:r})=>r.colors.border.primary};
+ background-color: ${({theme:r})=>r.colors.background.tertiary};
+`,m1=_.div`
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ background: ${({theme:r})=>r.colors.background.primary};
+`,g1=_.div`
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ background: ${({theme:r})=>r.colors.background.primary};
+`,y1=_(g1)`
+ justify-content: center;
+ align-items: center;
+ flex: 1;
+ padding: 0 20px;
+`,v1=_.div`
+ text-align: center;
+ max-width: 400px;
+ padding: 20px;
+ margin-bottom: 80px;
+`,w1=_.div`
+ font-size: 48px;
+ margin-bottom: 16px;
+ animation: wave 2s infinite;
+ transform-origin: 70% 70%;
+
+ @keyframes wave {
+ 0% { transform: rotate(0deg); }
+ 10% { transform: rotate(14deg); }
+ 20% { transform: rotate(-8deg); }
+ 30% { transform: rotate(14deg); }
+ 40% { transform: rotate(-4deg); }
+ 50% { transform: rotate(10deg); }
+ 60% { transform: rotate(0deg); }
+ 100% { transform: rotate(0deg); }
+ }
+`,x1=_.h2`
+ color: ${({theme:r})=>r.colors.text.primary};
+ font-size: 28px;
+ font-weight: 700;
+ margin-bottom: 16px;
+`,S1=_.p`
+ color: ${({theme:r})=>r.colors.text.muted};
+ font-size: 16px;
+ line-height: 1.6;
+ word-break: keep-all;
+`,Zd=_.div`
+ height: 48px;
+ padding: 0 16px;
+ background: ${ee.colors.background.primary};
+ border-bottom: 1px solid ${ee.colors.border.primary};
+ display: flex;
+ align-items: center;
+`,ep=_.div`
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ height: 100%;
+`,E1=_.div`
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ height: 100%;
+`,C1=_(Tr)`
+ width: 24px;
+ height: 24px;
+`;_.img`
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+`;const k1=_.div`
+ position: relative;
+ width: 40px;
+ height: 24px;
+ flex-shrink: 0;
+`,j1=_(Io)`
+ border-color: ${ee.colors.background.primary};
+ bottom: -3px;
+ right: -3px;
+`,A1=_.div`
+ font-size: 12px;
+ color: ${ee.colors.text.muted};
+ line-height: 13px;
+`,tp=_.div`
+ font-weight: bold;
+ color: ${ee.colors.text.primary};
+ line-height: 20px;
+ font-size: 16px;
+`,R1=_.div`
+ flex: 1;
+ display: flex;
+ flex-direction: column-reverse;
+ overflow-y: auto;
+`,P1=_.div`
+ padding: 16px;
+ display: flex;
+ flex-direction: column;
+`,_1=_.div`
+ margin-bottom: 16px;
+ display: flex;
+ align-items: flex-start;
+`,T1=_(Tr)`
+ margin-right: 16px;
+ width: 40px;
+ height: 40px;
+`;_.img`
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+`;const I1=_.div`
+ display: flex;
+ align-items: center;
+ margin-bottom: 4px;
+`,N1=_.span`
+ font-weight: bold;
+ color: ${ee.colors.text.primary};
+ margin-right: 8px;
+`,O1=_.span`
+ font-size: 0.75rem;
+ color: ${ee.colors.text.muted};
+`,L1=_.div`
+ color: ${ee.colors.text.secondary};
+ margin-top: 4px;
+`,D1=_.form`
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 16px;
+ background: ${({theme:r})=>r.colors.background.secondary};
+`,M1=_.textarea`
+ flex: 1;
+ padding: 12px;
+ background: ${({theme:r})=>r.colors.background.tertiary};
+ border: none;
+ border-radius: 4px;
+ color: ${({theme:r})=>r.colors.text.primary};
+ font-size: 14px;
+ resize: none;
+ min-height: 44px;
+ max-height: 144px;
+
+ &:focus {
+ outline: none;
+ }
+
+ &::placeholder {
+ color: ${({theme:r})=>r.colors.text.muted};
+ }
+`,z1=_.button`
+ background: none;
+ border: none;
+ color: ${({theme:r})=>r.colors.text.muted};
+ font-size: 24px;
+ cursor: pointer;
+ padding: 4px 8px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ &:hover {
+ color: ${({theme:r})=>r.colors.text.primary};
+ }
+`;_.div`
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: ${ee.colors.text.muted};
+ font-size: 16px;
+ font-weight: 500;
+ padding: 20px;
+ text-align: center;
+`;const np=_.div`
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-top: 8px;
+ width: 100%;
+`,U1=_.a`
+ display: block;
+ border-radius: 4px;
+ overflow: hidden;
+ max-width: 300px;
+
+ img {
+ width: 100%;
+ height: auto;
+ display: block;
+ }
+`,F1=_.a`
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px;
+ background: ${({theme:r})=>r.colors.background.tertiary};
+ border-radius: 8px;
+ text-decoration: none;
+ width: fit-content;
+
+ &:hover {
+ background: ${({theme:r})=>r.colors.background.hover};
+ }
+`,B1=_.div`
+ width: 40px;
+ height: 40px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 40px;
+ color: #0B93F6;
+`,$1=_.div`
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+`,H1=_.span`
+ font-size: 14px;
+ color: #0B93F6;
+ font-weight: 500;
+`,b1=_.span`
+ font-size: 13px;
+ color: ${({theme:r})=>r.colors.text.muted};
+`,V1=_.div`
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ padding: 8px 0;
+`,sh=_.div`
+ position: relative;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 12px;
+ background: ${({theme:r})=>r.colors.background.tertiary};
+ border-radius: 4px;
+ max-width: 300px;
+`,W1=_(sh)`
+ padding: 0;
+ overflow: hidden;
+ width: 200px;
+ height: 120px;
+
+ img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ }
+`,Y1=_.div`
+ color: #0B93F6;
+ font-size: 20px;
+`,q1=_.div`
+ font-size: 13px;
+ color: ${({theme:r})=>r.colors.text.primary};
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+`,rp=_.button`
+ position: absolute;
+ top: -6px;
+ right: -6px;
+ width: 20px;
+ height: 20px;
+ border-radius: 50%;
+ background: ${({theme:r})=>r.colors.background.secondary};
+ border: none;
+ color: ${({theme:r})=>r.colors.text.muted};
+ font-size: 16px;
+ line-height: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ padding: 0;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+
+ &:hover {
+ color: ${({theme:r})=>r.colors.text.primary};
+ }
+`;function Q1({channel:r}){var x;const i=yt(v=>v.currentUserId),s=nn(v=>v.users),l=Yn(v=>v.binaryContents);if(!r)return null;if(r.type==="PUBLIC")return h.jsx(Zd,{children:h.jsx(ep,{children:h.jsxs(tp,{children:["# ",r.name]})})});const c=r.participants.map(v=>s.find(S=>S.id===v.id)).filter(Boolean),f=c.filter(v=>v.id!==i),p=c.length>2,g=c.filter(v=>v.id!==i).map(v=>v.username).join(", ");return h.jsx(Zd,{children:h.jsx(ep,{children:h.jsxs(E1,{children:[p?h.jsx(k1,{children:f.slice(0,2).map((v,S)=>{var A;return h.jsx(rn,{src:v.profile?(A=l[v.profile.id])==null?void 0:A.url:Mt,style:{position:"absolute",left:S*16,zIndex:2-S,width:"24px",height:"24px"}},v.id)})}):h.jsxs(C1,{children:[h.jsx(rn,{src:f[0].profile?(x=l[f[0].profile.id])==null?void 0:x.url:Mt}),h.jsx(j1,{$online:f[0].online})]}),h.jsxs("div",{children:[h.jsx(tp,{children:g}),p&&h.jsxs(A1,{children:["멤버 ",c.length,"명"]})]})]})})})}const G1=async(r,i,s)=>{var c;return(await Xe.get("/messages",{params:{channelId:r,cursor:i,size:s.size,sort:(c=s.sort)==null?void 0:c.join(",")}})).data},K1=async(r,i)=>{const s=new FormData,l={content:r.content,channelId:r.channelId,authorId:r.authorId};return s.append("messageCreateRequest",new Blob([JSON.stringify(l)],{type:"application/json"})),i&&i.length>0&&i.forEach(f=>{s.append("attachments",f)}),(await Xe.post("/messages",s,{headers:{"Content-Type":"multipart/form-data"}})).data},Nu={size:50,sort:["createdAt,desc"]},lh=_r((r,i)=>({messages:[],pollingIntervals:{},lastMessageId:null,pagination:{nextCursor:null,pageSize:50,hasNext:!1},fetchMessages:async(s,l,c=Nu)=>{try{const f=await G1(s,l,c),p=f.content,g=p.length>0?p[0]:null,x=(g==null?void 0:g.id)!==i().lastMessageId;return r(v=>{var C;const S=!l,A=s!==((C=v.messages[0])==null?void 0:C.channelId),T=S&&(v.messages.length===0||A);let I=[],R={...v.pagination};if(T)I=p,R={nextCursor:f.nextCursor,pageSize:f.size,hasNext:f.hasNext};else if(S){const N=new Set(v.messages.map(U=>U.id));I=[...p.filter(U=>!N.has(U.id)&&(v.messages.length===0||U.createdAt>v.messages[0].createdAt)),...v.messages]}else{const N=new Set(v.messages.map(U=>U.id)),b=p.filter(U=>!N.has(U.id));I=[...v.messages,...b],R={nextCursor:f.nextCursor,pageSize:f.size,hasNext:f.hasNext}}return{messages:I,lastMessageId:(g==null?void 0:g.id)||null,pagination:R}}),x}catch(f){return console.error("메시지 목록 조회 실패:",f),!1}},loadMoreMessages:async s=>{const{pagination:l}=i();l.hasNext&&await i().fetchMessages(s,l.nextCursor,{...Nu})},startPolling:s=>{const l=i();if(l.pollingIntervals[s]){const g=l.pollingIntervals[s];typeof g=="number"&&clearTimeout(g)}let c=300;const f=3e3;r(g=>({pollingIntervals:{...g.pollingIntervals,[s]:!0}}));const p=async()=>{const g=i();if(!g.pollingIntervals[s])return;if(await g.fetchMessages(s,null,Nu)?c=300:c=Math.min(c*1.5,f),i().pollingIntervals[s]){const v=setTimeout(p,c);r(S=>({pollingIntervals:{...S.pollingIntervals,[s]:v}}))}};p()},stopPolling:s=>{const{pollingIntervals:l}=i();if(l[s]){const c=l[s];typeof c=="number"&&clearTimeout(c),r(f=>{const p={...f.pollingIntervals};return delete p[s],{pollingIntervals:p}})}},createMessage:async(s,l)=>{try{const c=await K1(s,l),f=jo.getState().updateReadStatus;return await f(s.channelId),r(p=>p.messages.some(x=>x.id===c.id)?p:{messages:[c,...p.messages],lastMessageId:c.id}),c}catch(c){throw console.error("메시지 생성 실패:",c),c}}}));function X1({channel:r}){const[i,s]=oe.useState(""),[l,c]=oe.useState([]),f=lh(T=>T.createMessage),p=yt(T=>T.currentUserId),g=async T=>{if(T.preventDefault(),!(!i.trim()&&l.length===0))try{await f({content:i.trim(),channelId:r.id,authorId:p??""},l),s(""),c([])}catch(I){console.error("메시지 전송 실패:",I)}},x=T=>{const I=Array.from(T.target.files||[]);c(R=>[...R,...I]),T.target.value=""},v=T=>{c(I=>I.filter((R,C)=>C!==T))},S=T=>{if(T.key==="Enter"&&!T.shiftKey){if(console.log("Enter key pressed"),T.preventDefault(),T.nativeEvent.isComposing)return;g(T)}},A=(T,I)=>T.type.startsWith("image/")?h.jsxs(W1,{children:[h.jsx("img",{src:URL.createObjectURL(T),alt:T.name}),h.jsx(rp,{onClick:()=>v(I),children:"×"})]},I):h.jsxs(sh,{children:[h.jsx(Y1,{children:"📎"}),h.jsx(q1,{children:T.name}),h.jsx(rp,{onClick:()=>v(I),children:"×"})]},I);return oe.useEffect(()=>()=>{l.forEach(T=>{T.type.startsWith("image/")&&URL.revokeObjectURL(URL.createObjectURL(T))})},[l]),r?h.jsxs(h.Fragment,{children:[l.length>0&&h.jsx(V1,{children:l.map((T,I)=>A(T,I))}),h.jsxs(D1,{onSubmit:g,children:[h.jsxs(z1,{as:"label",children:["+",h.jsx("input",{type:"file",multiple:!0,onChange:x,style:{display:"none"}})]}),h.jsx(M1,{value:i,onChange:T=>s(T.target.value),onKeyDown:S,placeholder:r.type==="PUBLIC"?`#${r.name}에 메시지 보내기`:"메시지 보내기"})]})]}):null}/*! *****************************************************************************
+Copyright (c) Microsoft Corporation. All rights reserved.
+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 http://www.apache.org/licenses/LICENSE-2.0
+
+THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY IMPLIED
+WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE,
+MERCHANTABLITY OR NON-INFRINGEMENT.
+
+See the Apache Version 2.0 License for specific language governing permissions
+and limitations under the License.
+***************************************************************************** */var Gu=function(r,i){return Gu=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(s,l){s.__proto__=l}||function(s,l){for(var c in l)l.hasOwnProperty(c)&&(s[c]=l[c])},Gu(r,i)};function J1(r,i){Gu(r,i);function s(){this.constructor=r}r.prototype=i===null?Object.create(i):(s.prototype=i.prototype,new s)}var Ao=function(){return Ao=Object.assign||function(i){for(var s,l=1,c=arguments.length;lr?I():i!==!0&&(c=setTimeout(l?R:I,l===void 0?r-A:r))}return v.cancel=x,v}var Sr={Pixel:"Pixel",Percent:"Percent"},op={unit:Sr.Percent,value:.8};function ip(r){return typeof r=="number"?{unit:Sr.Percent,value:r*100}:typeof r=="string"?r.match(/^(\d*(\.\d+)?)px$/)?{unit:Sr.Pixel,value:parseFloat(r)}:r.match(/^(\d*(\.\d+)?)%$/)?{unit:Sr.Percent,value:parseFloat(r)}:(console.warn('scrollThreshold format is invalid. Valid formats: "120px", "50%"...'),op):(console.warn("scrollThreshold should be string or number"),op)}var ew=function(r){J1(i,r);function i(s){var l=r.call(this,s)||this;return l.lastScrollTop=0,l.actionTriggered=!1,l.startY=0,l.currentY=0,l.dragging=!1,l.maxPullDownDistance=0,l.getScrollableTarget=function(){return l.props.scrollableTarget instanceof HTMLElement?l.props.scrollableTarget:typeof l.props.scrollableTarget=="string"?document.getElementById(l.props.scrollableTarget):(l.props.scrollableTarget===null&&console.warn(`You are trying to pass scrollableTarget but it is null. This might
+ happen because the element may not have been added to DOM yet.
+ See https://github.com/ankeetmaini/react-infinite-scroll-component/issues/59 for more info.
+ `),null)},l.onStart=function(c){l.lastScrollTop||(l.dragging=!0,c instanceof MouseEvent?l.startY=c.pageY:c instanceof TouchEvent&&(l.startY=c.touches[0].pageY),l.currentY=l.startY,l._infScroll&&(l._infScroll.style.willChange="transform",l._infScroll.style.transition="transform 0.2s cubic-bezier(0,0,0.31,1)"))},l.onMove=function(c){l.dragging&&(c instanceof MouseEvent?l.currentY=c.pageY:c instanceof TouchEvent&&(l.currentY=c.touches[0].pageY),!(l.currentY=Number(l.props.pullDownToRefreshThreshold)&&l.setState({pullToRefreshThresholdBreached:!0}),!(l.currentY-l.startY>l.maxPullDownDistance*1.5)&&l._infScroll&&(l._infScroll.style.overflow="visible",l._infScroll.style.transform="translate3d(0px, "+(l.currentY-l.startY)+"px, 0px)")))},l.onEnd=function(){l.startY=0,l.currentY=0,l.dragging=!1,l.state.pullToRefreshThresholdBreached&&(l.props.refreshFunction&&l.props.refreshFunction(),l.setState({pullToRefreshThresholdBreached:!1})),requestAnimationFrame(function(){l._infScroll&&(l._infScroll.style.overflow="auto",l._infScroll.style.transform="none",l._infScroll.style.willChange="unset")})},l.onScrollListener=function(c){typeof l.props.onScroll=="function"&&setTimeout(function(){return l.props.onScroll&&l.props.onScroll(c)},0);var f=l.props.height||l._scrollableNode?c.target:document.documentElement.scrollTop?document.documentElement:document.body;if(!l.actionTriggered){var p=l.props.inverse?l.isElementAtTop(f,l.props.scrollThreshold):l.isElementAtBottom(f,l.props.scrollThreshold);p&&l.props.hasMore&&(l.actionTriggered=!0,l.setState({showLoader:!0}),l.props.next&&l.props.next()),l.lastScrollTop=f.scrollTop}},l.state={showLoader:!1,pullToRefreshThresholdBreached:!1,prevDataLength:s.dataLength},l.throttledOnScrollListener=Z1(150,l.onScrollListener).bind(l),l.onStart=l.onStart.bind(l),l.onMove=l.onMove.bind(l),l.onEnd=l.onEnd.bind(l),l}return i.prototype.componentDidMount=function(){if(typeof this.props.dataLength>"u")throw new Error('mandatory prop "dataLength" is missing. The prop is needed when loading more content. Check README.md for usage');if(this._scrollableNode=this.getScrollableTarget(),this.el=this.props.height?this._infScroll:this._scrollableNode||window,this.el&&this.el.addEventListener("scroll",this.throttledOnScrollListener),typeof this.props.initialScrollY=="number"&&this.el&&this.el instanceof HTMLElement&&this.el.scrollHeight>this.props.initialScrollY&&this.el.scrollTo(0,this.props.initialScrollY),this.props.pullDownToRefresh&&this.el&&(this.el.addEventListener("touchstart",this.onStart),this.el.addEventListener("touchmove",this.onMove),this.el.addEventListener("touchend",this.onEnd),this.el.addEventListener("mousedown",this.onStart),this.el.addEventListener("mousemove",this.onMove),this.el.addEventListener("mouseup",this.onEnd),this.maxPullDownDistance=this._pullDown&&this._pullDown.firstChild&&this._pullDown.firstChild.getBoundingClientRect().height||0,this.forceUpdate(),typeof this.props.refreshFunction!="function"))throw new Error(`Mandatory prop "refreshFunction" missing.
+ Pull Down To Refresh functionality will not work
+ as expected. Check README.md for usage'`)},i.prototype.componentWillUnmount=function(){this.el&&(this.el.removeEventListener("scroll",this.throttledOnScrollListener),this.props.pullDownToRefresh&&(this.el.removeEventListener("touchstart",this.onStart),this.el.removeEventListener("touchmove",this.onMove),this.el.removeEventListener("touchend",this.onEnd),this.el.removeEventListener("mousedown",this.onStart),this.el.removeEventListener("mousemove",this.onMove),this.el.removeEventListener("mouseup",this.onEnd)))},i.prototype.componentDidUpdate=function(s){this.props.dataLength!==s.dataLength&&(this.actionTriggered=!1,this.setState({showLoader:!1}))},i.getDerivedStateFromProps=function(s,l){var c=s.dataLength!==l.prevDataLength;return c?Ao(Ao({},l),{prevDataLength:s.dataLength}):null},i.prototype.isElementAtTop=function(s,l){l===void 0&&(l=.8);var c=s===document.body||s===document.documentElement?window.screen.availHeight:s.clientHeight,f=ip(l);return f.unit===Sr.Pixel?s.scrollTop<=f.value+c-s.scrollHeight+1:s.scrollTop<=f.value/100+c-s.scrollHeight+1},i.prototype.isElementAtBottom=function(s,l){l===void 0&&(l=.8);var c=s===document.body||s===document.documentElement?window.screen.availHeight:s.clientHeight,f=ip(l);return f.unit===Sr.Pixel?s.scrollTop+c>=s.scrollHeight-f.value:s.scrollTop+c>=f.value/100*s.scrollHeight},i.prototype.render=function(){var s=this,l=Ao({height:this.props.height||"auto",overflow:"auto",WebkitOverflowScrolling:"touch"},this.props.style),c=this.props.hasChildren||!!(this.props.children&&this.props.children instanceof Array&&this.props.children.length),f=this.props.pullDownToRefresh&&this.props.height?{overflow:"auto"}:{};return mt.createElement("div",{style:f,className:"infinite-scroll-component__outerdiv"},mt.createElement("div",{className:"infinite-scroll-component "+(this.props.className||""),ref:function(p){return s._infScroll=p},style:l},this.props.pullDownToRefresh&&mt.createElement("div",{style:{position:"relative"},ref:function(p){return s._pullDown=p}},mt.createElement("div",{style:{position:"absolute",left:0,right:0,top:-1*this.maxPullDownDistance}},this.state.pullToRefreshThresholdBreached?this.props.releaseToRefreshContent:this.props.pullDownToRefreshContent)),this.props.children,!this.state.showLoader&&!c&&this.props.hasMore&&this.props.loader,this.state.showLoader&&this.props.hasMore&&this.props.loader,!this.props.hasMore&&this.props.endMessage))},i}(oe.Component);const tw=r=>r<1024?r+" B":r<1024*1024?(r/1024).toFixed(2)+" KB":r<1024*1024*1024?(r/(1024*1024)).toFixed(2)+" MB":(r/(1024*1024*1024)).toFixed(2)+" GB";function nw({channel:r}){const{messages:i,fetchMessages:s,loadMoreMessages:l,pagination:c,startPolling:f,stopPolling:p}=lh(),{binaryContents:g,fetchBinaryContent:x}=Yn();oe.useEffect(()=>{if(r!=null&&r.id)return s(r.id,null),f(r.id),()=>{p(r.id)}},[r==null?void 0:r.id,s,f,p]),oe.useEffect(()=>{i.forEach(I=>{var R;(R=I.attachments)==null||R.forEach(C=>{g[C.id]||x(C.id)})})},[i,g,x]);const v=async I=>{try{const{url:R,fileName:C}=I,N=document.createElement("a");N.href=R,N.download=C,N.style.display="none",document.body.appendChild(N);try{const U=await(await window.showSaveFilePicker({suggestedName:I.fileName,types:[{description:"Files",accept:{"*/*":[".txt",".pdf",".doc",".docx",".xls",".xlsx",".jpg",".jpeg",".png",".gif"]}}]})).createWritable(),Q=await(await fetch(R)).blob();await U.write(Q),await U.close()}catch(b){b.name!=="AbortError"&&N.click()}document.body.removeChild(N),window.URL.revokeObjectURL(R)}catch(R){console.error("파일 다운로드 실패:",R)}},S=I=>I!=null&&I.length?I.map(R=>{const C=g[R.id];return C?C.contentType.startsWith("image/")?h.jsx(np,{children:h.jsx(U1,{href:"#",onClick:b=>{b.preventDefault(),v(C)},children:h.jsx("img",{src:C.url,alt:C.fileName})})},C.url):h.jsx(np,{children:h.jsxs(F1,{href:"#",onClick:b=>{b.preventDefault(),v(C)},children:[h.jsx(B1,{children:h.jsxs("svg",{width:"40",height:"40",viewBox:"0 0 40 40",fill:"none",children:[h.jsx("path",{d:"M8 3C8 1.89543 8.89543 1 10 1H22L32 11V37C32 38.1046 31.1046 39 30 39H10C8.89543 39 8 38.1046 8 37V3Z",fill:"#0B93F6",fillOpacity:"0.1"}),h.jsx("path",{d:"M22 1L32 11H24C22.8954 11 22 10.1046 22 9V1Z",fill:"#0B93F6",fillOpacity:"0.3"}),h.jsx("path",{d:"M13 19H27M13 25H27M13 31H27",stroke:"#0B93F6",strokeWidth:"2",strokeLinecap:"round"})]})}),h.jsxs($1,{children:[h.jsx(H1,{children:C.fileName}),h.jsx(b1,{children:tw(C.size)})]})]})},C.url):null}):null,A=I=>new Date(I).toLocaleTimeString(),T=()=>{r!=null&&r.id&&l(r.id)};return h.jsx(R1,{children:h.jsx("div",{id:"scrollableDiv",style:{height:"100%",overflow:"auto",display:"flex",flexDirection:"column-reverse"},children:h.jsx(ew,{dataLength:i.length,next:T,hasMore:c.hasNext,loader:h.jsx("h4",{style:{textAlign:"center"},children:"메시지를 불러오는 중..."}),scrollableTarget:"scrollableDiv",style:{display:"flex",flexDirection:"column-reverse"},inverse:!0,endMessage:h.jsx("p",{style:{textAlign:"center"},children:h.jsx("b",{children:c.nextCursor!==null?"모든 메시지를 불러왔습니다":""})}),children:h.jsx(P1,{children:[...i].reverse().map(I=>{var C;const R=I.author;return h.jsxs(_1,{children:[h.jsx(T1,{children:h.jsx(rn,{src:R&&R.profile?(C=g[R.profile.id])==null?void 0:C.url:Mt,alt:R&&R.username||"알 수 없음"})}),h.jsxs("div",{children:[h.jsxs(I1,{children:[h.jsx(N1,{children:R&&R.username||"알 수 없음"}),h.jsx(O1,{children:A(I.createdAt)})]}),h.jsx(L1,{children:I.content}),S(I.attachments)]})]},I.id)})})})})})}function rw({channel:r}){return r?h.jsxs(m1,{children:[h.jsx(Q1,{channel:r}),h.jsx(nw,{channel:r}),h.jsx(X1,{channel:r})]}):h.jsx(y1,{children:h.jsxs(v1,{children:[h.jsx(w1,{children:"👋"}),h.jsx(x1,{children:"채널을 선택해주세요"}),h.jsxs(S1,{children:["왼쪽의 채널 목록에서 채널을 선택하여",h.jsx("br",{}),"대화를 시작하세요."]})]})})}function ow(r,i="yyyy-MM-dd HH:mm:ss"){if(!r||!(r instanceof Date)||isNaN(r.getTime()))return"";const s=r.getFullYear(),l=String(r.getMonth()+1).padStart(2,"0"),c=String(r.getDate()).padStart(2,"0"),f=String(r.getHours()).padStart(2,"0"),p=String(r.getMinutes()).padStart(2,"0"),g=String(r.getSeconds()).padStart(2,"0");return i.replace("yyyy",s.toString()).replace("MM",l).replace("dd",c).replace("HH",f).replace("mm",p).replace("ss",g)}const iw=_.div`
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: rgba(0, 0, 0, 0.7);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+`,sw=_.div`
+ background: ${({theme:r})=>r.colors.background.primary};
+ border-radius: 8px;
+ width: 500px;
+ max-width: 90%;
+ padding: 24px;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
+`,lw=_.div`
+ display: flex;
+ align-items: center;
+ margin-bottom: 16px;
+`,uw=_.div`
+ color: ${({theme:r})=>r.colors.status.error};
+ font-size: 24px;
+ margin-right: 12px;
+`,aw=_.h3`
+ color: ${({theme:r})=>r.colors.text.primary};
+ margin: 0;
+ font-size: 18px;
+`,cw=_.div`
+ background: ${({theme:r})=>r.colors.background.tertiary};
+ color: ${({theme:r})=>r.colors.text.muted};
+ padding: 2px 8px;
+ border-radius: 4px;
+ font-size: 14px;
+ margin-left: auto;
+`,fw=_.p`
+ color: ${({theme:r})=>r.colors.text.secondary};
+ margin-bottom: 20px;
+ line-height: 1.5;
+ font-weight: 500;
+`,dw=_.div`
+ margin-bottom: 20px;
+ background: ${({theme:r})=>r.colors.background.secondary};
+ border-radius: 6px;
+ padding: 12px;
+`,wo=_.div`
+ display: flex;
+ margin-bottom: 8px;
+ font-size: 14px;
+`,xo=_.span`
+ color: ${({theme:r})=>r.colors.text.muted};
+ min-width: 100px;
+`,So=_.span`
+ color: ${({theme:r})=>r.colors.text.secondary};
+ word-break: break-word;
+`,pw=_.button`
+ background: ${({theme:r})=>r.colors.brand.primary};
+ color: white;
+ border: none;
+ border-radius: 4px;
+ padding: 8px 16px;
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+ width: 100%;
+
+ &:hover {
+ background: ${({theme:r})=>r.colors.brand.hover};
+ }
+`;function hw({isOpen:r,onClose:i,error:s}){var T,I;if(!r)return null;const l=(T=s==null?void 0:s.response)==null?void 0:T.data,c=(l==null?void 0:l.status)||((I=s==null?void 0:s.response)==null?void 0:I.status)||"오류",f=(l==null?void 0:l.code)||"",p=(l==null?void 0:l.message)||(s==null?void 0:s.message)||"알 수 없는 오류가 발생했습니다.",g=l!=null&&l.timestamp?new Date(l.timestamp):new Date,x=ow(g),v=(l==null?void 0:l.exceptionType)||"",S=(l==null?void 0:l.details)||{},A=(l==null?void 0:l.requestId)||"";return h.jsx(iw,{onClick:i,children:h.jsxs(sw,{onClick:R=>R.stopPropagation(),children:[h.jsxs(lw,{children:[h.jsx(uw,{children:"⚠️"}),h.jsx(aw,{children:"오류가 발생했습니다"}),h.jsxs(cw,{children:[c,f?` (${f})`:""]})]}),h.jsx(fw,{children:p}),h.jsxs(dw,{children:[h.jsxs(wo,{children:[h.jsx(xo,{children:"시간:"}),h.jsx(So,{children:x})]}),A&&h.jsxs(wo,{children:[h.jsx(xo,{children:"요청 ID:"}),h.jsx(So,{children:A})]}),f&&h.jsxs(wo,{children:[h.jsx(xo,{children:"에러 코드:"}),h.jsx(So,{children:f})]}),v&&h.jsxs(wo,{children:[h.jsx(xo,{children:"예외 유형:"}),h.jsx(So,{children:v})]}),Object.keys(S).length>0&&h.jsxs(wo,{children:[h.jsx(xo,{children:"상세 정보:"}),h.jsx(So,{children:Object.entries(S).map(([R,C])=>h.jsxs("div",{children:[R,": ",String(C)]},R))})]})]}),h.jsx(pw,{onClick:i,children:"확인"})]})})}const mw=_.div`
+ width: 240px;
+ background: ${ee.colors.background.secondary};
+ border-left: 1px solid ${ee.colors.border.primary};
+`,gw=_.div`
+ padding: 16px;
+ font-size: 14px;
+ font-weight: bold;
+ color: ${ee.colors.text.muted};
+ text-transform: uppercase;
+`,yw=_.div`
+ padding: 8px 16px;
+ display: flex;
+ align-items: center;
+ color: ${ee.colors.text.muted};
+`,vw=_(Tr)`
+ margin-right: 12px;
+`;_(rn)``;const ww=_.div`
+ display: flex;
+ align-items: center;
+`;function xw({member:r}){var l,c,f;const{binaryContents:i,fetchBinaryContent:s}=Yn();return oe.useEffect(()=>{var p;(p=r.profile)!=null&&p.id&&!i[r.profile.id]&&s(r.profile.id)},[(l=r.profile)==null?void 0:l.id,i,s]),h.jsxs(yw,{children:[h.jsxs(vw,{children:[h.jsx(rn,{src:(c=r.profile)!=null&&c.id&&((f=i[r.profile.id])==null?void 0:f.url)||Mt,alt:r.username}),h.jsx(Io,{$online:r.online})]}),h.jsx(ww,{children:r.username})]})}function Sw(){const r=nn(c=>c.users),i=nn(c=>c.fetchUsers),s=yt(c=>c.currentUserId);oe.useEffect(()=>{i()},[i]);const l=[...r].sort((c,f)=>c.id===s?-1:f.id===s?1:c.online&&!f.online?-1:!c.online&&f.online?1:c.username.localeCompare(f.username));return h.jsxs(mw,{children:[h.jsxs(gw,{children:["멤버 목록 - ",r.length]}),l.map(c=>h.jsx(xw,{member:c},c.id))]})}function Ew(){const r=yt(C=>C.currentUserId),i=yt(C=>C.logout),s=nn(C=>C.users),{fetchUsers:l,updateUserStatus:c}=nn(),[f,p]=oe.useState(null),[g,x]=oe.useState(null),[v,S]=oe.useState(!1),[A,T]=oe.useState(!0),I=r?s.find(C=>C.id===r):null;oe.useEffect(()=>{(async()=>{try{if(r)try{await c(r),await l()}catch(N){console.warn("사용자 상태 업데이트 실패. 로그아웃합니다.",N),i()}}catch(N){console.error("초기화 오류:",N)}finally{T(!1)}})()},[r,c,l,i]),oe.useEffect(()=>{const C=V=>{x(V),S(!0)},N=()=>{i()},b=as.on("api-error",C),U=as.on("auth-error",N);return()=>{b("api-error",C),U("auth-error",N)}},[i]),oe.useEffect(()=>{let C;if(r){c(r),C=setInterval(()=>{c(r)},3e4);const N=setInterval(()=>{l()},6e4);return()=>{clearInterval(C),clearInterval(N)}}},[r,l,c]);const R=()=>{S(!1),x(null)};return A?h.jsx(Cd,{theme:ee,children:h.jsx(kw,{children:h.jsx(jw,{})})}):h.jsxs(Cd,{theme:ee,children:[I?h.jsxs(Cw,{children:[h.jsx(p1,{currentUser:I,activeChannel:f,onChannelSelect:p}),h.jsx(rw,{channel:f}),h.jsx(Sw,{})]}):h.jsx(h0,{isOpen:!0,onClose:()=>{}}),h.jsx(hw,{isOpen:v,onClose:R,error:g})]})}const Cw=_.div`
+ display: flex;
+ height: 100vh;
+ width: 100vw;
+ position: relative;
+`,kw=_.div`
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ height: 100vh;
+ width: 100vw;
+ background-color: ${({theme:r})=>r.colors.background.primary};
+`,jw=_.div`
+ width: 40px;
+ height: 40px;
+ border: 4px solid ${({theme:r})=>r.colors.background.tertiary};
+ border-top: 4px solid ${({theme:r})=>r.colors.brand.primary};
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+
+ @keyframes spin {
+ 0% { transform: rotate(0deg); }
+ 100% { transform: rotate(360deg); }
+ }
+`,uh=document.getElementById("root");if(!uh)throw new Error("Root element not found");mg.createRoot(uh).render(h.jsx(oe.StrictMode,{children:h.jsx(Ew,{})}));
diff --git a/src/main/resources/static/assets/index-kQJbKSsj.css b/src/main/resources/static/assets/index-kQJbKSsj.css
new file mode 100644
index 0000000000..096eb41128
--- /dev/null
+++ b/src/main/resources/static/assets/index-kQJbKSsj.css
@@ -0,0 +1 @@
+:root{font-family:Inter,system-ui,Avenir,Helvetica,Arial,sans-serif;line-height:1.5;font-weight:400;color-scheme:light dark;color:#ffffffde;background-color:#242424;font-synthesis:none;text-rendering:optimizeLegibility;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}a{font-weight:500;color:#646cff;text-decoration:inherit}a:hover{color:#535bf2}body{margin:0;display:flex;place-items:center;min-width:320px;min-height:100vh}h1{font-size:3.2em;line-height:1.1}button{border-radius:8px;border:1px solid transparent;padding:.6em 1.2em;font-size:1em;font-weight:500;font-family:inherit;background-color:#1a1a1a;cursor:pointer;transition:border-color .25s}button:hover{border-color:#646cff}button:focus,button:focus-visible{outline:4px auto -webkit-focus-ring-color}@media (prefers-color-scheme: light){:root{color:#213547;background-color:#fff}a:hover{color:#747bff}button{background-color:#f9f9f9}}
diff --git a/src/main/resources/static/favicon.ico b/src/main/resources/static/favicon.ico
new file mode 100644
index 0000000000..479bed6a3d
Binary files /dev/null and b/src/main/resources/static/favicon.ico differ
diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html
new file mode 100644
index 0000000000..3ba308ec18
--- /dev/null
+++ b/src/main/resources/static/index.html
@@ -0,0 +1,26 @@
+
+
+
+
+
+ Discodeit
+
+
+
+
+
+
+
+
diff --git a/src/test/java/com/sprint/mission/discodeit/DiscodeitApplicationTests.java b/src/test/java/com/sprint/mission/discodeit/DiscodeitApplicationTests.java
new file mode 100644
index 0000000000..92985af96c
--- /dev/null
+++ b/src/test/java/com/sprint/mission/discodeit/DiscodeitApplicationTests.java
@@ -0,0 +1,14 @@
+package com.sprint.mission.discodeit;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.ActiveProfiles;
+
+@SpringBootTest
+class DiscodeitApplicationTests {
+
+ @Test
+ void contextLoads() {
+ }
+
+}
diff --git a/src/test/java/com/sprint/mission/discodeit/controller/AuthControllerTest.java b/src/test/java/com/sprint/mission/discodeit/controller/AuthControllerTest.java
new file mode 100644
index 0000000000..d23a59e59c
--- /dev/null
+++ b/src/test/java/com/sprint/mission/discodeit/controller/AuthControllerTest.java
@@ -0,0 +1,121 @@
+package com.sprint.mission.discodeit.controller;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.BDDMockito.willThrow;
+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.UserDto;
+import com.sprint.mission.discodeit.dto.request.LoginRequest;
+import com.sprint.mission.discodeit.exception.user.InvalidCredentialsException;
+import com.sprint.mission.discodeit.exception.user.UserNotFoundException;
+import com.sprint.mission.discodeit.service.AuthService;
+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.bean.override.mockito.MockitoBean;
+import org.springframework.test.web.servlet.MockMvc;
+
+@WebMvcTest(AuthController.class)
+class AuthControllerTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private ObjectMapper objectMapper;
+
+ @MockitoBean
+ private AuthService authService;
+
+ @Test
+ @DisplayName("로그인 성공 테스트")
+ void login_Success() throws Exception {
+ // Given
+ LoginRequest loginRequest = new LoginRequest(
+ "testuser",
+ "Password1!"
+ );
+
+ UUID userId = UUID.randomUUID();
+ UserDto loggedInUser = new UserDto(
+ userId,
+ "testuser",
+ "test@example.com",
+ null,
+ true
+ );
+
+ given(authService.login(any(LoginRequest.class))).willReturn(loggedInUser);
+
+ // When & Then
+ mockMvc.perform(post("/api/auth/login")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(loginRequest)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.id").value(userId.toString()))
+ .andExpect(jsonPath("$.username").value("testuser"))
+ .andExpect(jsonPath("$.email").value("test@example.com"))
+ .andExpect(jsonPath("$.online").value(true));
+ }
+
+ @Test
+ @DisplayName("로그인 실패 테스트 - 존재하지 않는 사용자")
+ void login_Failure_UserNotFound() throws Exception {
+ // Given
+ LoginRequest loginRequest = new LoginRequest(
+ "nonexistentuser",
+ "Password1!"
+ );
+
+ given(authService.login(any(LoginRequest.class)))
+ .willThrow(UserNotFoundException.withUsername("nonexistentuser"));
+
+ // When & Then
+ mockMvc.perform(post("/api/auth/login")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(loginRequest)))
+ .andExpect(status().isNotFound());
+ }
+
+ @Test
+ @DisplayName("로그인 실패 테스트 - 잘못된 비밀번호")
+ void login_Failure_InvalidCredentials() throws Exception {
+ // Given
+ LoginRequest loginRequest = new LoginRequest(
+ "testuser",
+ "WrongPassword1!"
+ );
+
+ given(authService.login(any(LoginRequest.class)))
+ .willThrow(InvalidCredentialsException.wrongPassword());
+
+ // When & Then
+ mockMvc.perform(post("/api/auth/login")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(loginRequest)))
+ .andExpect(status().isUnauthorized());
+ }
+
+ @Test
+ @DisplayName("로그인 실패 테스트 - 유효하지 않은 요청")
+ void login_Failure_InvalidRequest() throws Exception {
+ // Given
+ LoginRequest invalidRequest = new LoginRequest(
+ "", // 사용자 이름 비어있음 (NotBlank 위반)
+ "" // 비밀번호 비어있음 (NotBlank 위반)
+ );
+
+ // When & Then
+ mockMvc.perform(post("/api/auth/login")
+ .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/controller/BinaryContentControllerTest.java b/src/test/java/com/sprint/mission/discodeit/controller/BinaryContentControllerTest.java
new file mode 100644
index 0000000000..a8451d3704
--- /dev/null
+++ b/src/test/java/com/sprint/mission/discodeit/controller/BinaryContentControllerTest.java
@@ -0,0 +1,149 @@
+package com.sprint.mission.discodeit.controller;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.doReturn;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.sprint.mission.discodeit.dto.data.BinaryContentDto;
+import com.sprint.mission.discodeit.exception.binarycontent.BinaryContentNotFoundException;
+import com.sprint.mission.discodeit.service.BinaryContentService;
+import com.sprint.mission.discodeit.storage.BinaryContentStorage;
+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.core.io.ByteArrayResource;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.test.web.servlet.MockMvc;
+
+@WebMvcTest(BinaryContentController.class)
+class BinaryContentControllerTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private ObjectMapper objectMapper;
+
+ @MockitoBean
+ private BinaryContentService binaryContentService;
+
+ @MockitoBean
+ private BinaryContentStorage binaryContentStorage;
+
+ @Test
+ @DisplayName("바이너리 컨텐츠 조회 성공 테스트")
+ void find_Success() throws Exception {
+ // Given
+ UUID binaryContentId = UUID.randomUUID();
+ BinaryContentDto binaryContent = new BinaryContentDto(
+ binaryContentId,
+ "test.jpg",
+ 10240L,
+ MediaType.IMAGE_JPEG_VALUE
+ );
+
+ given(binaryContentService.find(binaryContentId)).willReturn(binaryContent);
+
+ // When & Then
+ mockMvc.perform(get("/api/binaryContents/{binaryContentId}", binaryContentId)
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.id").value(binaryContentId.toString()))
+ .andExpect(jsonPath("$.fileName").value("test.jpg"))
+ .andExpect(jsonPath("$.size").value(10240))
+ .andExpect(jsonPath("$.contentType").value(MediaType.IMAGE_JPEG_VALUE));
+ }
+
+ @Test
+ @DisplayName("바이너리 컨텐츠 조회 실패 테스트 - 존재하지 않는 컨텐츠")
+ void find_Failure_BinaryContentNotFound() throws Exception {
+ // Given
+ UUID nonExistentId = UUID.randomUUID();
+
+ given(binaryContentService.find(nonExistentId))
+ .willThrow(BinaryContentNotFoundException.withId(nonExistentId));
+
+ // When & Then
+ mockMvc.perform(get("/api/binaryContents/{binaryContentId}", nonExistentId)
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isNotFound());
+ }
+
+ @Test
+ @DisplayName("ID 목록으로 바이너리 컨텐츠 조회 성공 테스트")
+ void findAllByIdIn_Success() throws Exception {
+ // Given
+ UUID id1 = UUID.randomUUID();
+ UUID id2 = UUID.randomUUID();
+
+ List binaryContentIds = List.of(id1, id2);
+
+ List binaryContents = List.of(
+ new BinaryContentDto(id1, "test1.jpg", 10240L, MediaType.IMAGE_JPEG_VALUE),
+ new BinaryContentDto(id2, "test2.pdf", 20480L, MediaType.APPLICATION_PDF_VALUE)
+ );
+
+ given(binaryContentService.findAllByIdIn(binaryContentIds)).willReturn(binaryContents);
+
+ // When & Then
+ mockMvc.perform(get("/api/binaryContents")
+ .param("binaryContentIds", id1.toString(), id2.toString())
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$[0].id").value(id1.toString()))
+ .andExpect(jsonPath("$[0].fileName").value("test1.jpg"))
+ .andExpect(jsonPath("$[1].id").value(id2.toString()))
+ .andExpect(jsonPath("$[1].fileName").value("test2.pdf"));
+ }
+
+ @Test
+ @DisplayName("바이너리 컨텐츠 다운로드 성공 테스트")
+ void download_Success() throws Exception {
+ // Given
+ UUID binaryContentId = UUID.randomUUID();
+ BinaryContentDto binaryContent = new BinaryContentDto(
+ binaryContentId,
+ "test.jpg",
+ 10240L,
+ MediaType.IMAGE_JPEG_VALUE
+ );
+
+ given(binaryContentService.find(binaryContentId)).willReturn(binaryContent);
+
+ // doReturn 사용하여 타입 문제 우회
+ ResponseEntity mockResponse = ResponseEntity.ok()
+ .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"test.jpg\"")
+ .header(HttpHeaders.CONTENT_TYPE, MediaType.IMAGE_JPEG_VALUE)
+ .body(new ByteArrayResource("test data".getBytes()));
+
+ doReturn(mockResponse).when(binaryContentStorage).download(any(BinaryContentDto.class));
+
+ // When & Then
+ mockMvc.perform(get("/api/binaryContents/{binaryContentId}/download", binaryContentId))
+ .andExpect(status().isOk());
+ }
+
+ @Test
+ @DisplayName("바이너리 컨텐츠 다운로드 실패 테스트 - 존재하지 않는 컨텐츠")
+ void download_Failure_BinaryContentNotFound() throws Exception {
+ // Given
+ UUID nonExistentId = UUID.randomUUID();
+
+ given(binaryContentService.find(nonExistentId))
+ .willThrow(BinaryContentNotFoundException.withId(nonExistentId));
+
+ // When & Then
+ mockMvc.perform(get("/api/binaryContents/{binaryContentId}/download", nonExistentId))
+ .andExpect(status().isNotFound());
+ }
+}
\ No newline at end of file
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 0000000000..facad5130f
--- /dev/null
+++ b/src/test/java/com/sprint/mission/discodeit/controller/ChannelControllerTest.java
@@ -0,0 +1,274 @@
+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.mockito.BDDMockito.willThrow;
+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.post;
+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.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.exception.channel.ChannelNotFoundException;
+import com.sprint.mission.discodeit.exception.channel.PrivateChannelUpdateException;
+import com.sprint.mission.discodeit.service.ChannelService;
+import java.time.Instant;
+import java.util.ArrayList;
+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.bean.override.mockito.MockitoBean;
+import org.springframework.test.web.servlet.MockMvc;
+
+@WebMvcTest(ChannelController.class)
+class ChannelControllerTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private ObjectMapper objectMapper;
+
+ @MockitoBean
+ private ChannelService channelService;
+
+ @Test
+ @DisplayName("공개 채널 생성 성공 테스트")
+ void createPublicChannel_Success() throws Exception {
+ // Given
+ PublicChannelCreateRequest createRequest = new PublicChannelCreateRequest(
+ "test-channel",
+ "채널 설명입니다."
+ );
+
+ UUID channelId = UUID.randomUUID();
+ ChannelDto createdChannel = new ChannelDto(
+ channelId,
+ ChannelType.PUBLIC,
+ "test-channel",
+ "채널 설명입니다.",
+ new ArrayList<>(),
+ Instant.now()
+ );
+
+ given(channelService.create(any(PublicChannelCreateRequest.class)))
+ .willReturn(createdChannel);
+
+ // When & Then
+ mockMvc.perform(post("/api/channels/public")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(createRequest)))
+ .andExpect(status().isCreated())
+ .andExpect(jsonPath("$.id").value(channelId.toString()))
+ .andExpect(jsonPath("$.type").value("PUBLIC"))
+ .andExpect(jsonPath("$.name").value("test-channel"))
+ .andExpect(jsonPath("$.description").value("채널 설명입니다."));
+ }
+
+ @Test
+ @DisplayName("공개 채널 생성 실패 테스트 - 유효하지 않은 요청")
+ void createPublicChannel_Failure_InvalidRequest() throws Exception {
+ // Given
+ PublicChannelCreateRequest invalidRequest = new PublicChannelCreateRequest(
+ "a", // 최소 길이 위반 (2자 이상이어야 함)
+ "채널 설명은 최대 255자까지 가능합니다.".repeat(10) // 최대 길이 위반
+ );
+
+ // 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
+ List participantIds = List.of(UUID.randomUUID(), UUID.randomUUID());
+ PrivateChannelCreateRequest createRequest = new PrivateChannelCreateRequest(participantIds);
+
+ UUID channelId = UUID.randomUUID();
+ List participants = new ArrayList<>();
+ for (UUID userId : participantIds) {
+ participants.add(new UserDto(userId, "user-" + userId.toString().substring(0, 5),
+ "user" + userId.toString().substring(0, 5) + "@example.com", null, false));
+ }
+
+ ChannelDto createdChannel = new ChannelDto(
+ channelId,
+ ChannelType.PRIVATE,
+ null,
+ null,
+ participants,
+ Instant.now()
+ );
+
+ given(channelService.create(any(PrivateChannelCreateRequest.class)))
+ .willReturn(createdChannel);
+
+ // When & Then
+ mockMvc.perform(post("/api/channels/private")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(createRequest)))
+ .andExpect(status().isCreated())
+ .andExpect(jsonPath("$.id").value(channelId.toString()))
+ .andExpect(jsonPath("$.type").value("PRIVATE"))
+ .andExpect(jsonPath("$.participants").isArray())
+ .andExpect(jsonPath("$.participants.length()").value(2));
+ }
+
+ @Test
+ @DisplayName("공개 채널 업데이트 성공 테스트")
+ void updateChannel_Success() throws Exception {
+ // Given
+ UUID channelId = UUID.randomUUID();
+ PublicChannelUpdateRequest updateRequest = new PublicChannelUpdateRequest(
+ "updated-channel",
+ "업데이트된 채널 설명입니다."
+ );
+
+ ChannelDto updatedChannel = new ChannelDto(
+ channelId,
+ ChannelType.PUBLIC,
+ "updated-channel",
+ "업데이트된 채널 설명입니다.",
+ new ArrayList<>(),
+ Instant.now()
+ );
+
+ given(channelService.update(eq(channelId), any(PublicChannelUpdateRequest.class)))
+ .willReturn(updatedChannel);
+
+ // When & Then
+ mockMvc.perform(patch("/api/channels/{channelId}", channelId)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(updateRequest)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.id").value(channelId.toString()))
+ .andExpect(jsonPath("$.name").value("updated-channel"))
+ .andExpect(jsonPath("$.description").value("업데이트된 채널 설명입니다."));
+ }
+
+ @Test
+ @DisplayName("채널 업데이트 실패 테스트 - 존재하지 않는 채널")
+ void updateChannel_Failure_ChannelNotFound() throws Exception {
+ // Given
+ UUID nonExistentChannelId = UUID.randomUUID();
+ PublicChannelUpdateRequest updateRequest = new PublicChannelUpdateRequest(
+ "updated-channel",
+ "업데이트된 채널 설명입니다."
+ );
+
+ given(channelService.update(eq(nonExistentChannelId), any(PublicChannelUpdateRequest.class)))
+ .willThrow(ChannelNotFoundException.withId(nonExistentChannelId));
+
+ // When & Then
+ mockMvc.perform(patch("/api/channels/{channelId}", nonExistentChannelId)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(updateRequest)))
+ .andExpect(status().isNotFound());
+ }
+
+ @Test
+ @DisplayName("채널 업데이트 실패 테스트 - 비공개 채널 업데이트 시도")
+ void updateChannel_Failure_PrivateChannelUpdate() throws Exception {
+ // Given
+ UUID privateChannelId = UUID.randomUUID();
+ PublicChannelUpdateRequest updateRequest = new PublicChannelUpdateRequest(
+ "updated-channel",
+ "업데이트된 채널 설명입니다."
+ );
+
+ given(channelService.update(eq(privateChannelId), any(PublicChannelUpdateRequest.class)))
+ .willThrow(PrivateChannelUpdateException.forChannel(privateChannelId));
+
+ // When & Then
+ mockMvc.perform(patch("/api/channels/{channelId}", privateChannelId)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(updateRequest)))
+ .andExpect(status().isBadRequest());
+ }
+
+ @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)
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isNoContent());
+ }
+
+ @Test
+ @DisplayName("채널 삭제 실패 테스트 - 존재하지 않는 채널")
+ void deleteChannel_Failure_ChannelNotFound() throws Exception {
+ // Given
+ UUID nonExistentChannelId = UUID.randomUUID();
+ willThrow(ChannelNotFoundException.withId(nonExistentChannelId))
+ .given(channelService).delete(nonExistentChannelId);
+
+ // When & Then
+ mockMvc.perform(delete("/api/channels/{channelId}", nonExistentChannelId)
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isNotFound());
+ }
+
+ @Test
+ @DisplayName("사용자별 채널 목록 조회 성공 테스트")
+ void findAllByUserId_Success() throws Exception {
+ // Given
+ UUID userId = UUID.randomUUID();
+ UUID channelId1 = UUID.randomUUID();
+ UUID channelId2 = UUID.randomUUID();
+
+ List channels = List.of(
+ new ChannelDto(
+ channelId1,
+ ChannelType.PUBLIC,
+ "public-channel",
+ "공개 채널 설명",
+ new ArrayList<>(),
+ Instant.now()
+ ),
+ new ChannelDto(
+ channelId2,
+ ChannelType.PRIVATE,
+ null,
+ null,
+ List.of(new UserDto(userId, "user1", "user1@example.com", null, true)),
+ Instant.now().minusSeconds(3600)
+ )
+ );
+
+ given(channelService.findAllByUserId(userId)).willReturn(channels);
+
+ // When & Then
+ mockMvc.perform(get("/api/channels")
+ .param("userId", userId.toString())
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$[0].id").value(channelId1.toString()))
+ .andExpect(jsonPath("$[0].type").value("PUBLIC"))
+ .andExpect(jsonPath("$[0].name").value("public-channel"))
+ .andExpect(jsonPath("$[1].id").value(channelId2.toString()))
+ .andExpect(jsonPath("$[1].type").value("PRIVATE"));
+ }
+}
\ 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 0000000000..3330e6b084
--- /dev/null
+++ b/src/test/java/com/sprint/mission/discodeit/controller/MessageControllerTest.java
@@ -0,0 +1,304 @@
+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.mockito.BDDMockito.willThrow;
+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.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.dto.response.PageResponse;
+import com.sprint.mission.discodeit.exception.message.MessageNotFoundException;
+import com.sprint.mission.discodeit.service.MessageService;
+import java.time.Instant;
+import java.util.ArrayList;
+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.PageRequest;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Sort;
+import org.springframework.http.MediaType;
+import org.springframework.mock.web.MockMultipartFile;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.test.web.servlet.MockMvc;
+
+@WebMvcTest(MessageController.class)
+class MessageControllerTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private ObjectMapper objectMapper;
+
+ @MockitoBean
+ private MessageService messageService;
+
+ @Test
+ @DisplayName("메시지 생성 성공 테스트")
+ void createMessage_Success() throws Exception {
+ // Given
+ UUID channelId = UUID.randomUUID();
+ UUID authorId = UUID.randomUUID();
+ MessageCreateRequest createRequest = new MessageCreateRequest(
+ "안녕하세요, 테스트 메시지입니다.",
+ channelId,
+ authorId
+ );
+
+ MockMultipartFile messageCreateRequestPart = new MockMultipartFile(
+ "messageCreateRequest",
+ "",
+ MediaType.APPLICATION_JSON_VALUE,
+ objectMapper.writeValueAsBytes(createRequest)
+ );
+
+ MockMultipartFile attachment = new MockMultipartFile(
+ "attachments",
+ "test.jpg",
+ MediaType.IMAGE_JPEG_VALUE,
+ "test-image".getBytes()
+ );
+
+ UUID messageId = UUID.randomUUID();
+ Instant now = Instant.now();
+
+ UserDto author = new UserDto(
+ authorId,
+ "testuser",
+ "test@example.com",
+ null,
+ true
+ );
+
+ BinaryContentDto attachmentDto = new BinaryContentDto(
+ UUID.randomUUID(),
+ "test.jpg",
+ 10L,
+ MediaType.IMAGE_JPEG_VALUE
+ );
+
+ MessageDto createdMessage = new MessageDto(
+ messageId,
+ now,
+ now,
+ "안녕하세요, 테스트 메시지입니다.",
+ channelId,
+ author,
+ List.of(attachmentDto)
+ );
+
+ given(messageService.create(any(MessageCreateRequest.class), any(List.class)))
+ .willReturn(createdMessage);
+
+ // When & Then
+ mockMvc.perform(multipart("/api/messages")
+ .file(messageCreateRequestPart)
+ .file(attachment)
+ .contentType(MediaType.MULTIPART_FORM_DATA_VALUE))
+ .andExpect(status().isCreated())
+ .andExpect(jsonPath("$.id").value(messageId.toString()))
+ .andExpect(jsonPath("$.content").value("안녕하세요, 테스트 메시지입니다."))
+ .andExpect(jsonPath("$.channelId").value(channelId.toString()))
+ .andExpect(jsonPath("$.author.id").value(authorId.toString()))
+ .andExpect(jsonPath("$.attachments[0].fileName").value("test.jpg"));
+ }
+
+ @Test
+ @DisplayName("메시지 생성 실패 테스트 - 유효하지 않은 요청")
+ void createMessage_Failure_InvalidRequest() throws Exception {
+ // Given
+ MessageCreateRequest invalidRequest = new MessageCreateRequest(
+ "", // 내용이 비어있음 (NotBlank 위반)
+ null, // 채널 ID가 비어있음 (NotNull 위반)
+ null // 작성자 ID가 비어있음 (NotNull 위반)
+ );
+
+ MockMultipartFile messageCreateRequestPart = new MockMultipartFile(
+ "messageCreateRequest",
+ "",
+ MediaType.APPLICATION_JSON_VALUE,
+ objectMapper.writeValueAsBytes(invalidRequest)
+ );
+
+ // When & Then
+ mockMvc.perform(multipart("/api/messages")
+ .file(messageCreateRequestPart)
+ .contentType(MediaType.MULTIPART_FORM_DATA_VALUE))
+ .andExpect(status().isBadRequest());
+ }
+
+ @Test
+ @DisplayName("메시지 업데이트 성공 테스트")
+ void updateMessage_Success() throws Exception {
+ // Given
+ UUID messageId = UUID.randomUUID();
+ UUID channelId = UUID.randomUUID();
+ UUID authorId = UUID.randomUUID();
+
+ MessageUpdateRequest updateRequest = new MessageUpdateRequest(
+ "수정된 메시지 내용입니다."
+ );
+
+ Instant now = Instant.now();
+
+ UserDto author = new UserDto(
+ authorId,
+ "testuser",
+ "test@example.com",
+ null,
+ true
+ );
+
+ MessageDto updatedMessage = new MessageDto(
+ messageId,
+ now.minusSeconds(60),
+ now,
+ "수정된 메시지 내용입니다.",
+ channelId,
+ author,
+ new ArrayList<>()
+ );
+
+ given(messageService.update(eq(messageId), any(MessageUpdateRequest.class)))
+ .willReturn(updatedMessage);
+
+ // When & Then
+ mockMvc.perform(patch("/api/messages/{messageId}", messageId)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(updateRequest)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.id").value(messageId.toString()))
+ .andExpect(jsonPath("$.content").value("수정된 메시지 내용입니다."))
+ .andExpect(jsonPath("$.channelId").value(channelId.toString()))
+ .andExpect(jsonPath("$.author.id").value(authorId.toString()));
+ }
+
+ @Test
+ @DisplayName("메시지 업데이트 실패 테스트 - 존재하지 않는 메시지")
+ void updateMessage_Failure_MessageNotFound() throws Exception {
+ // Given
+ UUID nonExistentMessageId = UUID.randomUUID();
+
+ MessageUpdateRequest updateRequest = new MessageUpdateRequest(
+ "수정된 메시지 내용입니다."
+ );
+
+ given(messageService.update(eq(nonExistentMessageId), any(MessageUpdateRequest.class)))
+ .willThrow(MessageNotFoundException.withId(nonExistentMessageId));
+
+ // When & Then
+ mockMvc.perform(patch("/api/messages/{messageId}", nonExistentMessageId)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(updateRequest)))
+ .andExpect(status().isNotFound());
+ }
+
+ @Test
+ @DisplayName("메시지 삭제 성공 테스트")
+ void deleteMessage_Success() throws Exception {
+ // Given
+ UUID messageId = UUID.randomUUID();
+ willDoNothing().given(messageService).delete(messageId);
+
+ // When & Then
+ mockMvc.perform(delete("/api/messages/{messageId}", messageId)
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isNoContent());
+ }
+
+ @Test
+ @DisplayName("메시지 삭제 실패 테스트 - 존재하지 않는 메시지")
+ void deleteMessage_Failure_MessageNotFound() throws Exception {
+ // Given
+ UUID nonExistentMessageId = UUID.randomUUID();
+ willThrow(MessageNotFoundException.withId(nonExistentMessageId))
+ .given(messageService).delete(nonExistentMessageId);
+
+ // When & Then
+ mockMvc.perform(delete("/api/messages/{messageId}", nonExistentMessageId)
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isNotFound());
+ }
+
+ @Test
+ @DisplayName("채널별 메시지 목록 조회 성공 테스트")
+ void findAllByChannelId_Success() throws Exception {
+ // Given
+ UUID channelId = UUID.randomUUID();
+ UUID authorId = UUID.randomUUID();
+ Instant cursor = Instant.now();
+ Pageable pageable = PageRequest.of(0, 50, Sort.Direction.DESC, "createdAt");
+
+ UserDto author = new UserDto(
+ authorId,
+ "testuser",
+ "test@example.com",
+ null,
+ true
+ );
+
+ List messages = List.of(
+ new MessageDto(
+ UUID.randomUUID(),
+ cursor.minusSeconds(10),
+ cursor.minusSeconds(10),
+ "첫 번째 메시지",
+ channelId,
+ author,
+ new ArrayList<>()
+ ),
+ new MessageDto(
+ UUID.randomUUID(),
+ cursor.minusSeconds(20),
+ cursor.minusSeconds(20),
+ "두 번째 메시지",
+ channelId,
+ author,
+ new ArrayList<>()
+ )
+ );
+
+ PageResponse pageResponse = new PageResponse<>(
+ messages,
+ cursor.minusSeconds(30), // nextCursor 값
+ pageable.getPageSize(),
+ true, // hasNext
+ (long) messages.size() // totalElements
+ );
+
+ 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())
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.content").isArray())
+ .andExpect(jsonPath("$.content.length()").value(2))
+ .andExpect(jsonPath("$.content[0].content").value("첫 번째 메시지"))
+ .andExpect(jsonPath("$.content[1].content").value("두 번째 메시지"))
+ .andExpect(jsonPath("$.nextCursor").exists())
+ .andExpect(jsonPath("$.size").value(50))
+ .andExpect(jsonPath("$.hasNext").value(true))
+ .andExpect(jsonPath("$.totalElements").value(2));
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/sprint/mission/discodeit/controller/ReadStatusControllerTest.java b/src/test/java/com/sprint/mission/discodeit/controller/ReadStatusControllerTest.java
new file mode 100644
index 0000000000..91772c33c9
--- /dev/null
+++ b/src/test/java/com/sprint/mission/discodeit/controller/ReadStatusControllerTest.java
@@ -0,0 +1,172 @@
+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.willThrow;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.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.ReadStatusDto;
+import com.sprint.mission.discodeit.dto.request.ReadStatusCreateRequest;
+import com.sprint.mission.discodeit.dto.request.ReadStatusUpdateRequest;
+import com.sprint.mission.discodeit.exception.readstatus.ReadStatusNotFoundException;
+import com.sprint.mission.discodeit.service.ReadStatusService;
+import java.time.Instant;
+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.bean.override.mockito.MockitoBean;
+import org.springframework.test.web.servlet.MockMvc;
+
+@WebMvcTest(ReadStatusController.class)
+class ReadStatusControllerTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private ObjectMapper objectMapper;
+
+ @MockitoBean
+ private ReadStatusService readStatusService;
+
+ @Test
+ @DisplayName("읽음 상태 생성 성공 테스트")
+ void create_Success() throws Exception {
+ // Given
+ UUID userId = UUID.randomUUID();
+ UUID channelId = UUID.randomUUID();
+ Instant lastReadAt = Instant.now();
+
+ ReadStatusCreateRequest createRequest = new ReadStatusCreateRequest(
+ userId,
+ channelId,
+ lastReadAt
+ );
+
+ UUID readStatusId = UUID.randomUUID();
+ ReadStatusDto createdReadStatus = new ReadStatusDto(
+ readStatusId,
+ userId,
+ channelId,
+ lastReadAt
+ );
+
+ given(readStatusService.create(any(ReadStatusCreateRequest.class)))
+ .willReturn(createdReadStatus);
+
+ // When & Then
+ mockMvc.perform(post("/api/readStatuses")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(createRequest)))
+ .andExpect(status().isCreated())
+ .andExpect(jsonPath("$.id").value(readStatusId.toString()))
+ .andExpect(jsonPath("$.userId").value(userId.toString()))
+ .andExpect(jsonPath("$.channelId").value(channelId.toString()))
+ .andExpect(jsonPath("$.lastReadAt").exists());
+ }
+
+ @Test
+ @DisplayName("읽음 상태 생성 실패 테스트 - 유효하지 않은 요청")
+ void create_Failure_InvalidRequest() throws Exception {
+ // Given
+ ReadStatusCreateRequest invalidRequest = new ReadStatusCreateRequest(
+ null, // userId가 null (NotNull 위반)
+ null, // channelId가 null (NotNull 위반)
+ null // lastReadAt이 null (NotNull 위반)
+ );
+
+ // When & Then
+ mockMvc.perform(post("/api/readStatuses")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(invalidRequest)))
+ .andExpect(status().isBadRequest());
+ }
+
+ @Test
+ @DisplayName("읽음 상태 업데이트 성공 테스트")
+ void update_Success() throws Exception {
+ // Given
+ UUID readStatusId = UUID.randomUUID();
+ UUID userId = UUID.randomUUID();
+ UUID channelId = UUID.randomUUID();
+ Instant newLastReadAt = Instant.now();
+
+ ReadStatusUpdateRequest updateRequest = new ReadStatusUpdateRequest(newLastReadAt);
+
+ ReadStatusDto updatedReadStatus = new ReadStatusDto(
+ readStatusId,
+ userId,
+ channelId,
+ newLastReadAt
+ );
+
+ given(readStatusService.update(eq(readStatusId), any(ReadStatusUpdateRequest.class)))
+ .willReturn(updatedReadStatus);
+
+ // When & Then
+ mockMvc.perform(patch("/api/readStatuses/{readStatusId}", readStatusId)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(updateRequest)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.id").value(readStatusId.toString()))
+ .andExpect(jsonPath("$.userId").value(userId.toString()))
+ .andExpect(jsonPath("$.channelId").value(channelId.toString()))
+ .andExpect(jsonPath("$.lastReadAt").exists());
+ }
+
+ @Test
+ @DisplayName("읽음 상태 업데이트 실패 테스트 - 존재하지 않는 읽음 상태")
+ void update_Failure_ReadStatusNotFound() throws Exception {
+ // Given
+ UUID nonExistentId = UUID.randomUUID();
+ Instant newLastReadAt = Instant.now();
+
+ ReadStatusUpdateRequest updateRequest = new ReadStatusUpdateRequest(newLastReadAt);
+
+ given(readStatusService.update(eq(nonExistentId), any(ReadStatusUpdateRequest.class)))
+ .willThrow(ReadStatusNotFoundException.withId(nonExistentId));
+
+ // When & Then
+ mockMvc.perform(patch("/api/readStatuses/{readStatusId}", nonExistentId)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(updateRequest)))
+ .andExpect(status().isNotFound());
+ }
+
+ @Test
+ @DisplayName("사용자별 읽음 상태 목록 조회 성공 테스트")
+ void findAllByUserId_Success() throws Exception {
+ // Given
+ UUID userId = UUID.randomUUID();
+ UUID channelId1 = UUID.randomUUID();
+ UUID channelId2 = UUID.randomUUID();
+ Instant now = Instant.now();
+
+ List readStatuses = List.of(
+ new ReadStatusDto(UUID.randomUUID(), userId, channelId1, now.minusSeconds(60)),
+ new ReadStatusDto(UUID.randomUUID(), userId, channelId2, now)
+ );
+
+ given(readStatusService.findAllByUserId(userId)).willReturn(readStatuses);
+
+ // When & Then
+ mockMvc.perform(get("/api/readStatuses")
+ .param("userId", userId.toString())
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$[0].userId").value(userId.toString()))
+ .andExpect(jsonPath("$[0].channelId").value(channelId1.toString()))
+ .andExpect(jsonPath("$[1].userId").value(userId.toString()))
+ .andExpect(jsonPath("$[1].channelId").value(channelId2.toString()));
+ }
+}
\ No newline at end of file
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 0000000000..d376362d85
--- /dev/null
+++ b/src/test/java/com/sprint/mission/discodeit/controller/UserControllerTest.java
@@ -0,0 +1,343 @@
+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.mockito.BDDMockito.willThrow;
+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.content;
+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.exception.user.UserNotFoundException;
+import com.sprint.mission.discodeit.service.UserService;
+import com.sprint.mission.discodeit.service.UserStatusService;
+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.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.http.MediaType;
+import org.springframework.mock.web.MockMultipartFile;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.test.web.servlet.MockMvc;
+
+@WebMvcTest(UserController.class)
+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 createRequest = new UserCreateRequest(
+ "testuser",
+ "test@example.com",
+ "Password1!"
+ );
+
+ MockMultipartFile userCreateRequestPart = new MockMultipartFile(
+ "userCreateRequest",
+ "",
+ MediaType.APPLICATION_JSON_VALUE,
+ objectMapper.writeValueAsBytes(createRequest)
+ );
+
+ MockMultipartFile profilePart = new MockMultipartFile(
+ "profile",
+ "profile.jpg",
+ MediaType.IMAGE_JPEG_VALUE,
+ "test-image".getBytes()
+ );
+
+ UUID userId = UUID.randomUUID();
+ BinaryContentDto profileDto = new BinaryContentDto(
+ UUID.randomUUID(),
+ "profile.jpg",
+ 12L,
+ MediaType.IMAGE_JPEG_VALUE
+ );
+
+ UserDto createdUser = new UserDto(
+ userId,
+ "testuser",
+ "test@example.com",
+ profileDto,
+ false
+ );
+
+ given(userService.create(any(UserCreateRequest.class), any(Optional.class)))
+ .willReturn(createdUser);
+
+ // When & Then
+ mockMvc.perform(multipart("/api/users")
+ .file(userCreateRequestPart)
+ .file(profilePart)
+ .contentType(MediaType.MULTIPART_FORM_DATA_VALUE))
+ .andExpect(status().isCreated())
+ .andExpect(jsonPath("$.id").value(userId.toString()))
+ .andExpect(jsonPath("$.username").value("testuser"))
+ .andExpect(jsonPath("$.email").value("test@example.com"))
+ .andExpect(jsonPath("$.profile.fileName").value("profile.jpg"))
+ .andExpect(jsonPath("$.online").value(false));
+ }
+
+ @Test
+ @DisplayName("사용자 생성 실패 테스트 - 유효하지 않은 요청")
+ void createUser_Failure_InvalidRequest() throws Exception {
+ // Given
+ UserCreateRequest invalidRequest = new UserCreateRequest(
+ "t", // 최소 길이 위반
+ "invalid-email", // 이메일 형식 위반
+ "short" // 비밀번호 정책 위반
+ );
+
+ MockMultipartFile userCreateRequestPart = new MockMultipartFile(
+ "userCreateRequest",
+ "",
+ MediaType.APPLICATION_JSON_VALUE,
+ objectMapper.writeValueAsBytes(invalidRequest)
+ );
+
+ // When & Then
+ mockMvc.perform(multipart("/api/users")
+ .file(userCreateRequestPart)
+ .contentType(MediaType.MULTIPART_FORM_DATA_VALUE))
+ .andExpect(status().isBadRequest());
+ }
+
+ @Test
+ @DisplayName("사용자 조회 성공 테스트")
+ void findAllUsers_Success() throws Exception {
+ // Given
+ UUID userId1 = UUID.randomUUID();
+ UUID userId2 = UUID.randomUUID();
+
+ UserDto user1 = new UserDto(
+ userId1,
+ "user1",
+ "user1@example.com",
+ null,
+ true
+ );
+
+ UserDto user2 = new UserDto(
+ userId2,
+ "user2",
+ "user2@example.com",
+ null,
+ false
+ );
+
+ List users = List.of(user1, user2);
+
+ given(userService.findAll()).willReturn(users);
+
+ // When & Then
+ mockMvc.perform(get("/api/users")
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$[0].id").value(userId1.toString()))
+ .andExpect(jsonPath("$[0].username").value("user1"))
+ .andExpect(jsonPath("$[0].online").value(true))
+ .andExpect(jsonPath("$[1].id").value(userId2.toString()))
+ .andExpect(jsonPath("$[1].username").value("user2"))
+ .andExpect(jsonPath("$[1].online").value(false));
+ }
+
+ @Test
+ @DisplayName("사용자 업데이트 성공 테스트")
+ void updateUser_Success() throws Exception {
+ // Given
+ UUID userId = UUID.randomUUID();
+ UserUpdateRequest updateRequest = new UserUpdateRequest(
+ "updateduser",
+ "updated@example.com",
+ "UpdatedPassword1!"
+ );
+
+ MockMultipartFile userUpdateRequestPart = new MockMultipartFile(
+ "userUpdateRequest",
+ "",
+ MediaType.APPLICATION_JSON_VALUE,
+ objectMapper.writeValueAsBytes(updateRequest)
+ );
+
+ MockMultipartFile profilePart = new MockMultipartFile(
+ "profile",
+ "updated-profile.jpg",
+ MediaType.IMAGE_JPEG_VALUE,
+ "updated-image".getBytes()
+ );
+
+ BinaryContentDto profileDto = new BinaryContentDto(
+ UUID.randomUUID(),
+ "updated-profile.jpg",
+ 14L,
+ MediaType.IMAGE_JPEG_VALUE
+ );
+
+ UserDto updatedUser = new UserDto(
+ userId,
+ "updateduser",
+ "updated@example.com",
+ profileDto,
+ true
+ );
+
+ given(userService.update(eq(userId), any(UserUpdateRequest.class), any(Optional.class)))
+ .willReturn(updatedUser);
+
+ // When & Then
+ mockMvc.perform(multipart("/api/users/{userId}", userId)
+ .file(userUpdateRequestPart)
+ .file(profilePart)
+ .contentType(MediaType.MULTIPART_FORM_DATA_VALUE)
+ .with(request -> {
+ request.setMethod("PATCH");
+ return request;
+ }))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.id").value(userId.toString()))
+ .andExpect(jsonPath("$.username").value("updateduser"))
+ .andExpect(jsonPath("$.email").value("updated@example.com"))
+ .andExpect(jsonPath("$.profile.fileName").value("updated-profile.jpg"))
+ .andExpect(jsonPath("$.online").value(true));
+ }
+
+ @Test
+ @DisplayName("사용자 업데이트 실패 테스트 - 존재하지 않는 사용자")
+ void updateUser_Failure_UserNotFound() throws Exception {
+ // Given
+ UUID nonExistentUserId = UUID.randomUUID();
+ UserUpdateRequest updateRequest = new UserUpdateRequest(
+ "updateduser",
+ "updated@example.com",
+ "UpdatedPassword1!"
+ );
+
+ MockMultipartFile userUpdateRequestPart = new MockMultipartFile(
+ "userUpdateRequest",
+ "",
+ MediaType.APPLICATION_JSON_VALUE,
+ objectMapper.writeValueAsBytes(updateRequest)
+ );
+
+ MockMultipartFile profilePart = new MockMultipartFile(
+ "profile",
+ "updated-profile.jpg",
+ MediaType.IMAGE_JPEG_VALUE,
+ "updated-image".getBytes()
+ );
+
+ given(userService.update(eq(nonExistentUserId), any(UserUpdateRequest.class),
+ any(Optional.class)))
+ .willThrow(UserNotFoundException.withId(nonExistentUserId));
+
+ // When & Then
+ mockMvc.perform(multipart("/api/users/{userId}", nonExistentUserId)
+ .file(userUpdateRequestPart)
+ .file(profilePart)
+ .contentType(MediaType.MULTIPART_FORM_DATA_VALUE)
+ .with(request -> {
+ request.setMethod("PATCH");
+ return request;
+ }))
+ .andExpect(status().isNotFound());
+ }
+
+ @Test
+ @DisplayName("사용자 삭제 성공 테스트")
+ void deleteUser_Success() throws Exception {
+ // Given
+ UUID userId = UUID.randomUUID();
+ willDoNothing().given(userService).delete(userId);
+
+ // When & Then
+ mockMvc.perform(delete("/api/users/{userId}", userId)
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isNoContent());
+ }
+
+ @Test
+ @DisplayName("사용자 삭제 실패 테스트 - 존재하지 않는 사용자")
+ void deleteUser_Failure_UserNotFound() throws Exception {
+ // Given
+ UUID nonExistentUserId = UUID.randomUUID();
+ willThrow(UserNotFoundException.withId(nonExistentUserId))
+ .given(userService).delete(nonExistentUserId);
+
+ // When & Then
+ mockMvc.perform(delete("/api/users/{userId}", nonExistentUserId)
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isNotFound());
+ }
+
+ @Test
+ @DisplayName("사용자 상태 업데이트 성공 테스트")
+ void updateUserStatus_Success() throws Exception {
+ // Given
+ UUID userId = UUID.randomUUID();
+ UUID statusId = UUID.randomUUID();
+ Instant lastActiveAt = Instant.now();
+
+ UserStatusUpdateRequest updateRequest = new UserStatusUpdateRequest(lastActiveAt);
+ UserStatusDto updatedStatus = new UserStatusDto(statusId, userId, lastActiveAt);
+
+ given(userStatusService.updateByUserId(eq(userId), any(UserStatusUpdateRequest.class)))
+ .willReturn(updatedStatus);
+
+ // When & Then
+ mockMvc.perform(patch("/api/users/{userId}/userStatus", userId)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(updateRequest)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.id").value(statusId.toString()))
+ .andExpect(jsonPath("$.userId").value(userId.toString()))
+ .andExpect(content().json(objectMapper.writeValueAsString(updatedStatus)));
+ }
+
+ @Test
+ @DisplayName("사용자 상태 업데이트 실패 테스트 - 존재하지 않는 사용자 상태")
+ void updateUserStatus_Failure_UserStatusNotFound() throws Exception {
+ // Given
+ UUID userId = UUID.randomUUID();
+ Instant lastActiveAt = Instant.now();
+
+ UserStatusUpdateRequest updateRequest = new UserStatusUpdateRequest(lastActiveAt);
+
+ given(userStatusService.updateByUserId(eq(userId), any(UserStatusUpdateRequest.class)))
+ .willThrow(UserNotFoundException.withId(userId));
+
+ // When & Then
+ mockMvc.perform(patch("/api/users/{userId}/userStatus", userId)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(updateRequest)))
+ .andExpect(status().isNotFound());
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/sprint/mission/discodeit/integration/AuthApiIntegrationTest.java b/src/test/java/com/sprint/mission/discodeit/integration/AuthApiIntegrationTest.java
new file mode 100644
index 0000000000..2dfed05bc7
--- /dev/null
+++ b/src/test/java/com/sprint/mission/discodeit/integration/AuthApiIntegrationTest.java
@@ -0,0 +1,133 @@
+package com.sprint.mission.discodeit.integration;
+
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.notNullValue;
+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.request.LoginRequest;
+import com.sprint.mission.discodeit.dto.request.UserCreateRequest;
+import com.sprint.mission.discodeit.service.UserService;
+import java.util.Optional;
+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.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.MediaType;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.transaction.annotation.Transactional;
+
+@SpringBootTest
+@AutoConfigureMockMvc
+@ActiveProfiles("test")
+@Transactional
+class AuthApiIntegrationTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private ObjectMapper objectMapper;
+
+ @Autowired
+ private UserService userService;
+
+ @Test
+ @DisplayName("로그인 API 통합 테스트 - 성공")
+ void login_Success() throws Exception {
+ // Given
+ // 테스트 사용자 생성
+ UserCreateRequest userRequest = new UserCreateRequest(
+ "loginuser",
+ "login@example.com",
+ "Password1!"
+ );
+
+ userService.create(userRequest, Optional.empty());
+
+ // 로그인 요청
+ LoginRequest loginRequest = new LoginRequest(
+ "loginuser",
+ "Password1!"
+ );
+
+ String requestBody = objectMapper.writeValueAsString(loginRequest);
+
+ // When & Then
+ mockMvc.perform(post("/api/auth/login")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(requestBody))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.id", notNullValue()))
+ .andExpect(jsonPath("$.username", is("loginuser")))
+ .andExpect(jsonPath("$.email", is("login@example.com")));
+ }
+
+ @Test
+ @DisplayName("로그인 API 통합 테스트 - 실패 (존재하지 않는 사용자)")
+ void login_Failure_UserNotFound() throws Exception {
+ // Given
+ LoginRequest loginRequest = new LoginRequest(
+ "nonexistentuser",
+ "Password1!"
+ );
+
+ String requestBody = objectMapper.writeValueAsString(loginRequest);
+
+ // When & Then
+ mockMvc.perform(post("/api/auth/login")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(requestBody))
+ .andExpect(status().isNotFound());
+ }
+
+ @Test
+ @DisplayName("로그인 API 통합 테스트 - 실패 (잘못된 비밀번호)")
+ void login_Failure_InvalidCredentials() throws Exception {
+ // Given
+ // 테스트 사용자 생성
+ UserCreateRequest userRequest = new UserCreateRequest(
+ "loginuser2",
+ "login2@example.com",
+ "Password1!"
+ );
+
+ userService.create(userRequest, Optional.empty());
+
+ // 잘못된 비밀번호로 로그인 시도
+ LoginRequest loginRequest = new LoginRequest(
+ "loginuser2",
+ "WrongPassword1!"
+ );
+
+ String requestBody = objectMapper.writeValueAsString(loginRequest);
+
+ // When & Then
+ mockMvc.perform(post("/api/auth/login")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(requestBody))
+ .andExpect(status().isUnauthorized());
+ }
+
+ @Test
+ @DisplayName("로그인 API 통합 테스트 - 실패 (유효하지 않은 요청)")
+ void login_Failure_InvalidRequest() throws Exception {
+ // Given
+ LoginRequest invalidRequest = new LoginRequest(
+ "", // 사용자 이름 비어있음 (NotBlank 위반)
+ "" // 비밀번호 비어있음 (NotBlank 위반)
+ );
+
+ String requestBody = objectMapper.writeValueAsString(invalidRequest);
+
+ // When & Then
+ mockMvc.perform(post("/api/auth/login")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(requestBody))
+ .andExpect(status().isBadRequest());
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/sprint/mission/discodeit/integration/BinaryContentApiIntegrationTest.java b/src/test/java/com/sprint/mission/discodeit/integration/BinaryContentApiIntegrationTest.java
new file mode 100644
index 0000000000..871296eaa6
--- /dev/null
+++ b/src/test/java/com/sprint/mission/discodeit/integration/BinaryContentApiIntegrationTest.java
@@ -0,0 +1,209 @@
+package com.sprint.mission.discodeit.integration;
+
+import static org.hamcrest.Matchers.hasItems;
+import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.is;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
+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.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.PublicChannelCreateRequest;
+import com.sprint.mission.discodeit.dto.request.UserCreateRequest;
+import com.sprint.mission.discodeit.service.BinaryContentService;
+import com.sprint.mission.discodeit.service.ChannelService;
+import com.sprint.mission.discodeit.service.MessageService;
+import com.sprint.mission.discodeit.service.UserService;
+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.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.MediaType;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.transaction.annotation.Transactional;
+
+@SpringBootTest
+@AutoConfigureMockMvc
+@ActiveProfiles("test")
+@Transactional
+class BinaryContentApiIntegrationTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private ObjectMapper objectMapper;
+
+ @Autowired
+ private BinaryContentService binaryContentService;
+
+ @Autowired
+ private UserService userService;
+
+ @Autowired
+ private ChannelService channelService;
+
+ @Autowired
+ private MessageService messageService;
+
+ @Test
+ @DisplayName("바이너리 컨텐츠 조회 API 통합 테스트")
+ void findBinaryContent_Success() throws Exception {
+ // Given
+ // 테스트 바이너리 컨텐츠 생성 (메시지 첨부파일을 통해 생성)
+ // 사용자 생성
+ UserCreateRequest userRequest = new UserCreateRequest(
+ "contentuser",
+ "content@example.com",
+ "Password1!"
+ );
+ UserDto user = userService.create(userRequest, Optional.empty());
+
+ // 채널 생성
+ PublicChannelCreateRequest channelRequest = new PublicChannelCreateRequest(
+ "테스트 채널",
+ "테스트 채널 설명입니다."
+ );
+ var channel = channelService.create(channelRequest);
+
+ // 첨부파일이 있는 메시지 생성
+ MessageCreateRequest messageRequest = new MessageCreateRequest(
+ "첨부파일이 있는 메시지입니다.",
+ channel.id(),
+ user.id()
+ );
+
+ byte[] fileContent = "테스트 파일 내용입니다.".getBytes();
+ BinaryContentCreateRequest attachmentRequest = new BinaryContentCreateRequest(
+ "test.txt",
+ MediaType.TEXT_PLAIN_VALUE,
+ fileContent
+ );
+
+ MessageDto message = messageService.create(messageRequest, List.of(attachmentRequest));
+ UUID binaryContentId = message.attachments().get(0).id();
+
+ // When & Then
+ mockMvc.perform(get("/api/binaryContents/{binaryContentId}", binaryContentId))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.id", is(binaryContentId.toString())))
+ .andExpect(jsonPath("$.fileName", is("test.txt")))
+ .andExpect(jsonPath("$.contentType", is(MediaType.TEXT_PLAIN_VALUE)))
+ .andExpect(jsonPath("$.size", is(fileContent.length)));
+ }
+
+ @Test
+ @DisplayName("존재하지 않는 바이너리 컨텐츠 조회 API 통합 테스트")
+ void findBinaryContent_Failure_NotFound() throws Exception {
+ // Given
+ UUID nonExistentBinaryContentId = UUID.randomUUID();
+
+ // When & Then
+ mockMvc.perform(get("/api/binaryContents/{binaryContentId}", nonExistentBinaryContentId))
+ .andExpect(status().isNotFound());
+ }
+
+ @Test
+ @DisplayName("여러 바이너리 컨텐츠 조회 API 통합 테스트")
+ void findAllBinaryContentsByIds_Success() throws Exception {
+ // Given
+ // 테스트 바이너리 컨텐츠 생성 (메시지 첨부파일을 통해 생성)
+ UserCreateRequest userRequest = new UserCreateRequest(
+ "contentuser2",
+ "content2@example.com",
+ "Password1!"
+ );
+ UserDto user = userService.create(userRequest, Optional.empty());
+
+ PublicChannelCreateRequest channelRequest = new PublicChannelCreateRequest(
+ "테스트 채널2",
+ "테스트 채널 설명입니다."
+ );
+ var channel = channelService.create(channelRequest);
+
+ MessageCreateRequest messageRequest = new MessageCreateRequest(
+ "첨부파일이 있는 메시지입니다.",
+ channel.id(),
+ user.id()
+ );
+
+ // 첫 번째 첨부파일
+ BinaryContentCreateRequest attachmentRequest1 = new BinaryContentCreateRequest(
+ "test1.txt",
+ MediaType.TEXT_PLAIN_VALUE,
+ "첫 번째 테스트 파일 내용입니다.".getBytes()
+ );
+
+ // 두 번째 첨부파일
+ BinaryContentCreateRequest attachmentRequest2 = new BinaryContentCreateRequest(
+ "test2.txt",
+ MediaType.TEXT_PLAIN_VALUE,
+ "두 번째 테스트 파일 내용입니다.".getBytes()
+ );
+
+ // 첨부파일 두 개를 가진 메시지 생성
+ MessageDto message = messageService.create(
+ messageRequest,
+ List.of(attachmentRequest1, attachmentRequest2)
+ );
+
+ List binaryContentIds = message.attachments().stream()
+ .map(BinaryContentDto::id)
+ .toList();
+
+ // When & Then
+ mockMvc.perform(get("/api/binaryContents")
+ .param("binaryContentIds", binaryContentIds.get(0).toString())
+ .param("binaryContentIds", binaryContentIds.get(1).toString()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$", hasSize(2)))
+ .andExpect(jsonPath("$[*].fileName", hasItems("test1.txt", "test2.txt")));
+ }
+
+ @Test
+ @DisplayName("바이너리 컨텐츠 다운로드 API 통합 테스트")
+ void downloadBinaryContent_Success() throws Exception {
+ // Given
+ String fileContent = "다운로드 테스트 파일 내용입니다.";
+ BinaryContentCreateRequest createRequest = new BinaryContentCreateRequest(
+ "download-test.txt",
+ MediaType.TEXT_PLAIN_VALUE,
+ fileContent.getBytes()
+ );
+
+ BinaryContentDto binaryContent = binaryContentService.create(createRequest);
+ UUID binaryContentId = binaryContent.id();
+
+ // When & Then
+ mockMvc.perform(get("/api/binaryContents/{binaryContentId}/download", binaryContentId))
+ .andExpect(status().isOk())
+ .andExpect(header().string("Content-Disposition",
+ "attachment; filename=\"download-test.txt\""))
+ .andExpect(content().contentType(MediaType.TEXT_PLAIN_VALUE))
+ .andExpect(content().bytes(fileContent.getBytes()));
+ }
+
+ @Test
+ @DisplayName("존재하지 않는 바이너리 컨텐츠 다운로드 API 통합 테스트")
+ void downloadBinaryContent_Failure_NotFound() throws Exception {
+ // Given
+ UUID nonExistentBinaryContentId = UUID.randomUUID();
+
+ // When & Then
+ mockMvc.perform(
+ get("/api/binaryContents/{binaryContentId}/download", nonExistentBinaryContentId))
+ .andExpect(status().isNotFound());
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/sprint/mission/discodeit/integration/ChannelApiIntegrationTest.java b/src/test/java/com/sprint/mission/discodeit/integration/ChannelApiIntegrationTest.java
new file mode 100644
index 0000000000..5917b4d024
--- /dev/null
+++ b/src/test/java/com/sprint/mission/discodeit/integration/ChannelApiIntegrationTest.java
@@ -0,0 +1,269 @@
+package com.sprint.mission.discodeit.integration;
+
+import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.notNullValue;
+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.dto.request.UserCreateRequest;
+import com.sprint.mission.discodeit.entity.ChannelType;
+import com.sprint.mission.discodeit.service.ChannelService;
+import com.sprint.mission.discodeit.service.UserService;
+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.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.MediaType;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.transaction.annotation.Transactional;
+
+@SpringBootTest
+@AutoConfigureMockMvc
+@ActiveProfiles("test")
+@Transactional
+class ChannelApiIntegrationTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private ObjectMapper objectMapper;
+
+ @Autowired
+ private ChannelService channelService;
+
+ @Autowired
+ private UserService userService;
+
+ @Test
+ @DisplayName("공개 채널 생성 API 통합 테스트")
+ void createPublicChannel_Success() throws Exception {
+ // Given
+ PublicChannelCreateRequest createRequest = new PublicChannelCreateRequest(
+ "테스트 채널",
+ "테스트 채널 설명입니다."
+ );
+
+ String requestBody = objectMapper.writeValueAsString(createRequest);
+
+ // When & Then
+ mockMvc.perform(post("/api/channels/public")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(requestBody))
+ .andExpect(status().isCreated())
+ .andExpect(jsonPath("$.id", notNullValue()))
+ .andExpect(jsonPath("$.type", is(ChannelType.PUBLIC.name())))
+ .andExpect(jsonPath("$.name", is("테스트 채널")))
+ .andExpect(jsonPath("$.description", is("테스트 채널 설명입니다.")));
+ }
+
+ @Test
+ @DisplayName("공개 채널 생성 실패 API 통합 테스트 - 유효하지 않은 요청")
+ void createPublicChannel_Failure_InvalidRequest() throws Exception {
+ // Given
+ PublicChannelCreateRequest invalidRequest = new PublicChannelCreateRequest(
+ "a", // 최소 길이 위반
+ "테스트 채널 설명입니다."
+ );
+
+ String requestBody = objectMapper.writeValueAsString(invalidRequest);
+
+ // When & Then
+ mockMvc.perform(post("/api/channels/public")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(requestBody))
+ .andExpect(status().isBadRequest());
+ }
+
+ @Test
+ @DisplayName("비공개 채널 생성 API 통합 테스트")
+ void createPrivateChannel_Success() throws Exception {
+ // Given
+ // 테스트 사용자 생성
+ UserCreateRequest userRequest1 = new UserCreateRequest(
+ "user1",
+ "user1@example.com",
+ "Password1!"
+ );
+
+ UserCreateRequest userRequest2 = new UserCreateRequest(
+ "user2",
+ "user2@example.com",
+ "Password1!"
+ );
+
+ UserDto user1 = userService.create(userRequest1, Optional.empty());
+ UserDto user2 = userService.create(userRequest2, Optional.empty());
+
+ List participantIds = List.of(user1.id(), user2.id());
+ PrivateChannelCreateRequest createRequest = new PrivateChannelCreateRequest(participantIds);
+
+ String requestBody = objectMapper.writeValueAsString(createRequest);
+
+ // When & Then
+ mockMvc.perform(post("/api/channels/private")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(requestBody))
+ .andExpect(status().isCreated())
+ .andExpect(jsonPath("$.id", notNullValue()))
+ .andExpect(jsonPath("$.type", is(ChannelType.PRIVATE.name())))
+ .andExpect(jsonPath("$.participants", hasSize(2)));
+ }
+
+ @Test
+ @DisplayName("사용자별 채널 목록 조회 API 통합 테스트")
+ void findAllChannelsByUserId_Success() throws Exception {
+ // Given
+ // 테스트 사용자 생성
+ UserCreateRequest userRequest = new UserCreateRequest(
+ "channeluser",
+ "channeluser@example.com",
+ "Password1!"
+ );
+
+ UserDto user = userService.create(userRequest, Optional.empty());
+ UUID userId = user.id();
+
+ // 공개 채널 생성
+ PublicChannelCreateRequest publicChannelRequest = new PublicChannelCreateRequest(
+ "공개 채널 1",
+ "공개 채널 설명입니다."
+ );
+
+ channelService.create(publicChannelRequest);
+
+ // 비공개 채널 생성
+ UserCreateRequest otherUserRequest = new UserCreateRequest(
+ "otheruser",
+ "otheruser@example.com",
+ "Password1!"
+ );
+
+ UserDto otherUser = userService.create(otherUserRequest, Optional.empty());
+
+ PrivateChannelCreateRequest privateChannelRequest = new PrivateChannelCreateRequest(
+ List.of(userId, otherUser.id())
+ );
+
+ channelService.create(privateChannelRequest);
+
+ // When & Then
+ mockMvc.perform(get("/api/channels")
+ .param("userId", userId.toString())
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$", hasSize(2)))
+ .andExpect(jsonPath("$[0].type", is(ChannelType.PUBLIC.name())))
+ .andExpect(jsonPath("$[1].type", is(ChannelType.PRIVATE.name())));
+ }
+
+ @Test
+ @DisplayName("채널 업데이트 API 통합 테스트")
+ void updateChannel_Success() throws Exception {
+ // Given
+ // 공개 채널 생성
+ PublicChannelCreateRequest createRequest = new PublicChannelCreateRequest(
+ "원본 채널",
+ "원본 채널 설명입니다."
+ );
+
+ ChannelDto createdChannel = channelService.create(createRequest);
+ UUID channelId = createdChannel.id();
+
+ PublicChannelUpdateRequest updateRequest = new PublicChannelUpdateRequest(
+ "수정된 채널",
+ "수정된 채널 설명입니다."
+ );
+
+ String requestBody = objectMapper.writeValueAsString(updateRequest);
+
+ // When & Then
+ mockMvc.perform(patch("/api/channels/{channelId}", channelId)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(requestBody))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.id", is(channelId.toString())))
+ .andExpect(jsonPath("$.name", is("수정된 채널")))
+ .andExpect(jsonPath("$.description", is("수정된 채널 설명입니다.")));
+ }
+
+ @Test
+ @DisplayName("채널 업데이트 실패 API 통합 테스트 - 존재하지 않는 채널")
+ void updateChannel_Failure_ChannelNotFound() throws Exception {
+ // Given
+ UUID nonExistentChannelId = UUID.randomUUID();
+
+ PublicChannelUpdateRequest updateRequest = new PublicChannelUpdateRequest(
+ "수정된 채널",
+ "수정된 채널 설명입니다."
+ );
+
+ String requestBody = objectMapper.writeValueAsString(updateRequest);
+
+ // When & Then
+ mockMvc.perform(patch("/api/channels/{channelId}", nonExistentChannelId)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(requestBody))
+ .andExpect(status().isNotFound());
+ }
+
+ @Test
+ @DisplayName("채널 삭제 API 통합 테스트")
+ void deleteChannel_Success() throws Exception {
+ // Given
+ // 공개 채널 생성
+ PublicChannelCreateRequest createRequest = new PublicChannelCreateRequest(
+ "삭제할 채널",
+ "삭제할 채널 설명입니다."
+ );
+
+ ChannelDto createdChannel = channelService.create(createRequest);
+ UUID channelId = createdChannel.id();
+
+ // When & Then
+ mockMvc.perform(delete("/api/channels/{channelId}", channelId))
+ .andExpect(status().isNoContent());
+
+ // 삭제 확인 - 사용자로 채널 조회 시 삭제된 채널은 조회되지 않아야 함
+ UserCreateRequest userRequest = new UserCreateRequest(
+ "testuser",
+ "testuser@example.com",
+ "Password1!"
+ );
+
+ UserDto user = userService.create(userRequest, Optional.empty());
+
+ mockMvc.perform(get("/api/channels")
+ .param("userId", user.id().toString())
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$[?(@.id == '" + channelId + "')]").doesNotExist());
+ }
+
+ @Test
+ @DisplayName("채널 삭제 실패 API 통합 테스트 - 존재하지 않는 채널")
+ void deleteChannel_Failure_ChannelNotFound() throws Exception {
+ // Given
+ UUID nonExistentChannelId = UUID.randomUUID();
+
+ // When & Then
+ mockMvc.perform(delete("/api/channels/{channelId}", nonExistentChannelId))
+ .andExpect(status().isNotFound());
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/sprint/mission/discodeit/integration/MessageApiIntegrationTest.java b/src/test/java/com/sprint/mission/discodeit/integration/MessageApiIntegrationTest.java
new file mode 100644
index 0000000000..092575de3e
--- /dev/null
+++ b/src/test/java/com/sprint/mission/discodeit/integration/MessageApiIntegrationTest.java
@@ -0,0 +1,307 @@
+package com.sprint.mission.discodeit.integration;
+
+import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.notNullValue;
+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.ChannelDto;
+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.request.MessageUpdateRequest;
+import com.sprint.mission.discodeit.dto.request.PublicChannelCreateRequest;
+import com.sprint.mission.discodeit.dto.request.UserCreateRequest;
+import com.sprint.mission.discodeit.service.ChannelService;
+import com.sprint.mission.discodeit.service.MessageService;
+import com.sprint.mission.discodeit.service.UserService;
+import java.util.ArrayList;
+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.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.MediaType;
+import org.springframework.mock.web.MockMultipartFile;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.transaction.annotation.Transactional;
+
+@SpringBootTest
+@AutoConfigureMockMvc
+@ActiveProfiles("test")
+@Transactional
+class MessageApiIntegrationTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private ObjectMapper objectMapper;
+
+ @Autowired
+ private MessageService messageService;
+
+ @Autowired
+ private ChannelService channelService;
+
+ @Autowired
+ private UserService userService;
+
+ @Test
+ @DisplayName("메시지 생성 API 통합 테스트")
+ void createMessage_Success() throws Exception {
+ // Given
+ // 테스트 채널 생성
+ PublicChannelCreateRequest channelRequest = new PublicChannelCreateRequest(
+ "테스트 채널",
+ "테스트 채널 설명입니다."
+ );
+
+ ChannelDto channel = channelService.create(channelRequest);
+
+ // 테스트 사용자 생성
+ UserCreateRequest userRequest = new UserCreateRequest(
+ "messageuser",
+ "messageuser@example.com",
+ "Password1!"
+ );
+
+ UserDto user = userService.create(userRequest, Optional.empty());
+
+ // 메시지 생성 요청
+ MessageCreateRequest createRequest = new MessageCreateRequest(
+ "테스트 메시지 내용입니다.",
+ channel.id(),
+ user.id()
+ );
+
+ MockMultipartFile messageCreateRequestPart = new MockMultipartFile(
+ "messageCreateRequest",
+ "",
+ MediaType.APPLICATION_JSON_VALUE,
+ objectMapper.writeValueAsBytes(createRequest)
+ );
+
+ MockMultipartFile attachmentPart = new MockMultipartFile(
+ "attachments",
+ "test.txt",
+ MediaType.TEXT_PLAIN_VALUE,
+ "테스트 첨부 파일 내용".getBytes()
+ );
+
+ // When & Then
+ mockMvc.perform(multipart("/api/messages")
+ .file(messageCreateRequestPart)
+ .file(attachmentPart))
+ .andExpect(status().isCreated())
+ .andExpect(jsonPath("$.id", notNullValue()))
+ .andExpect(jsonPath("$.content", is("테스트 메시지 내용입니다.")))
+ .andExpect(jsonPath("$.channelId", is(channel.id().toString())))
+ .andExpect(jsonPath("$.author.id", is(user.id().toString())))
+ .andExpect(jsonPath("$.attachments", hasSize(1)))
+ .andExpect(jsonPath("$.attachments[0].fileName", is("test.txt")));
+ }
+
+ @Test
+ @DisplayName("메시지 생성 실패 API 통합 테스트 - 유효하지 않은 요청")
+ void createMessage_Failure_InvalidRequest() throws Exception {
+ // Given
+ MessageCreateRequest invalidRequest = new MessageCreateRequest(
+ "", // 내용이 비어있음
+ UUID.randomUUID(),
+ UUID.randomUUID()
+ );
+
+ MockMultipartFile messageCreateRequestPart = new MockMultipartFile(
+ "messageCreateRequest",
+ "",
+ MediaType.APPLICATION_JSON_VALUE,
+ objectMapper.writeValueAsBytes(invalidRequest)
+ );
+
+ // When & Then
+ mockMvc.perform(multipart("/api/messages")
+ .file(messageCreateRequestPart))
+ .andExpect(status().isBadRequest());
+ }
+
+ @Test
+ @DisplayName("채널별 메시지 목록 조회 API 통합 테스트")
+ void findAllMessagesByChannelId_Success() throws Exception {
+ // Given
+ // 테스트 채널 생성
+ PublicChannelCreateRequest channelRequest = new PublicChannelCreateRequest(
+ "테스트 채널",
+ "테스트 채널 설명입니다."
+ );
+
+ ChannelDto channel = channelService.create(channelRequest);
+
+ // 테스트 사용자 생성
+ UserCreateRequest userRequest = new UserCreateRequest(
+ "messageuser",
+ "messageuser@example.com",
+ "Password1!"
+ );
+
+ UserDto user = userService.create(userRequest, Optional.empty());
+
+ // 메시지 생성
+ MessageCreateRequest messageRequest1 = new MessageCreateRequest(
+ "첫 번째 메시지 내용입니다.",
+ channel.id(),
+ user.id()
+ );
+
+ MessageCreateRequest messageRequest2 = new MessageCreateRequest(
+ "두 번째 메시지 내용입니다.",
+ channel.id(),
+ user.id()
+ );
+
+ messageService.create(messageRequest1, new ArrayList<>());
+ messageService.create(messageRequest2, new ArrayList<>());
+
+ // When & Then
+ mockMvc.perform(get("/api/messages")
+ .param("channelId", channel.id().toString())
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.content", hasSize(2)))
+ .andExpect(jsonPath("$.content[0].content", is("두 번째 메시지 내용입니다.")))
+ .andExpect(jsonPath("$.content[1].content", is("첫 번째 메시지 내용입니다.")))
+ .andExpect(jsonPath("$.size").exists())
+ .andExpect(jsonPath("$.hasNext").exists())
+ .andExpect(jsonPath("$.totalElements").isEmpty());
+ }
+
+ @Test
+ @DisplayName("메시지 업데이트 API 통합 테스트")
+ void updateMessage_Success() throws Exception {
+ // Given
+ // 테스트 채널 생성
+ PublicChannelCreateRequest channelRequest = new PublicChannelCreateRequest(
+ "테스트 채널",
+ "테스트 채널 설명입니다."
+ );
+
+ ChannelDto channel = channelService.create(channelRequest);
+
+ // 테스트 사용자 생성
+ UserCreateRequest userRequest = new UserCreateRequest(
+ "messageuser",
+ "messageuser@example.com",
+ "Password1!"
+ );
+
+ UserDto user = userService.create(userRequest, Optional.empty());
+
+ // 메시지 생성
+ MessageCreateRequest createRequest = new MessageCreateRequest(
+ "원본 메시지 내용입니다.",
+ channel.id(),
+ user.id()
+ );
+
+ MessageDto createdMessage = messageService.create(createRequest, new ArrayList<>());
+ UUID messageId = createdMessage.id();
+
+ // 메시지 업데이트 요청
+ MessageUpdateRequest updateRequest = new MessageUpdateRequest(
+ "수정된 메시지 내용입니다."
+ );
+
+ String requestBody = objectMapper.writeValueAsString(updateRequest);
+
+ // When & Then
+ mockMvc.perform(patch("/api/messages/{messageId}", messageId)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(requestBody))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.id", is(messageId.toString())))
+ .andExpect(jsonPath("$.content", is("수정된 메시지 내용입니다.")))
+ .andExpect(jsonPath("$.updatedAt").exists());
+ }
+
+ @Test
+ @DisplayName("메시지 업데이트 실패 API 통합 테스트 - 존재하지 않는 메시지")
+ void updateMessage_Failure_MessageNotFound() throws Exception {
+ // Given
+ UUID nonExistentMessageId = UUID.randomUUID();
+
+ MessageUpdateRequest updateRequest = new MessageUpdateRequest(
+ "수정된 메시지 내용입니다."
+ );
+
+ String requestBody = objectMapper.writeValueAsString(updateRequest);
+
+ // When & Then
+ mockMvc.perform(patch("/api/messages/{messageId}", nonExistentMessageId)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(requestBody))
+ .andExpect(status().isNotFound());
+ }
+
+ @Test
+ @DisplayName("메시지 삭제 API 통합 테스트")
+ void deleteMessage_Success() throws Exception {
+ // Given
+ // 테스트 채널 생성
+ PublicChannelCreateRequest channelRequest = new PublicChannelCreateRequest(
+ "테스트 채널",
+ "테스트 채널 설명입니다."
+ );
+
+ ChannelDto channel = channelService.create(channelRequest);
+
+ // 테스트 사용자 생성
+ UserCreateRequest userRequest = new UserCreateRequest(
+ "messageuser",
+ "messageuser@example.com",
+ "Password1!"
+ );
+
+ UserDto user = userService.create(userRequest, Optional.empty());
+
+ // 메시지 생성
+ MessageCreateRequest createRequest = new MessageCreateRequest(
+ "삭제할 메시지 내용입니다.",
+ channel.id(),
+ user.id()
+ );
+
+ MessageDto createdMessage = messageService.create(createRequest, new ArrayList<>());
+ UUID messageId = createdMessage.id();
+
+ // When & Then
+ mockMvc.perform(delete("/api/messages/{messageId}", messageId))
+ .andExpect(status().isNoContent());
+
+ // 삭제 확인 - 채널의 메시지 목록 조회 시 삭제된 메시지는 조회되지 않아야 함
+ mockMvc.perform(get("/api/messages")
+ .param("channelId", channel.id().toString())
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.content", hasSize(0)));
+ }
+
+ @Test
+ @DisplayName("메시지 삭제 실패 API 통합 테스트 - 존재하지 않는 메시지")
+ void deleteMessage_Failure_MessageNotFound() throws Exception {
+ // Given
+ UUID nonExistentMessageId = UUID.randomUUID();
+
+ // When & Then
+ mockMvc.perform(delete("/api/messages/{messageId}", nonExistentMessageId))
+ .andExpect(status().isNotFound());
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/sprint/mission/discodeit/integration/ReadStatusApiIntegrationTest.java b/src/test/java/com/sprint/mission/discodeit/integration/ReadStatusApiIntegrationTest.java
new file mode 100644
index 0000000000..8a93c8831b
--- /dev/null
+++ b/src/test/java/com/sprint/mission/discodeit/integration/ReadStatusApiIntegrationTest.java
@@ -0,0 +1,266 @@
+package com.sprint.mission.discodeit.integration;
+
+import static org.hamcrest.Matchers.hasItems;
+import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.notNullValue;
+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.ReadStatusDto;
+import com.sprint.mission.discodeit.dto.data.UserDto;
+import com.sprint.mission.discodeit.dto.request.PublicChannelCreateRequest;
+import com.sprint.mission.discodeit.dto.request.ReadStatusCreateRequest;
+import com.sprint.mission.discodeit.dto.request.ReadStatusUpdateRequest;
+import com.sprint.mission.discodeit.dto.request.UserCreateRequest;
+import com.sprint.mission.discodeit.service.ChannelService;
+import com.sprint.mission.discodeit.service.ReadStatusService;
+import com.sprint.mission.discodeit.service.UserService;
+import java.time.Instant;
+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.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.MediaType;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.transaction.annotation.Transactional;
+
+@SpringBootTest
+@AutoConfigureMockMvc
+@ActiveProfiles("test")
+@Transactional
+class ReadStatusApiIntegrationTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private ObjectMapper objectMapper;
+
+ @Autowired
+ private ReadStatusService readStatusService;
+
+ @Autowired
+ private UserService userService;
+
+ @Autowired
+ private ChannelService channelService;
+
+ @Test
+ @DisplayName("읽음 상태 생성 API 통합 테스트")
+ void createReadStatus_Success() throws Exception {
+ // Given
+ // 테스트 사용자 생성
+ UserCreateRequest userRequest = new UserCreateRequest(
+ "readstatususer",
+ "readstatus@example.com",
+ "Password1!"
+ );
+ UserDto user = userService.create(userRequest, Optional.empty());
+
+ // 공개 채널 생성
+ PublicChannelCreateRequest channelRequest = new PublicChannelCreateRequest(
+ "읽음 상태 테스트 채널",
+ "읽음 상태 테스트 채널 설명입니다."
+ );
+ ChannelDto channel = channelService.create(channelRequest);
+
+ // 읽음 상태 생성 요청
+ Instant lastReadAt = Instant.now();
+ ReadStatusCreateRequest createRequest = new ReadStatusCreateRequest(
+ user.id(),
+ channel.id(),
+ lastReadAt
+ );
+
+ String requestBody = objectMapper.writeValueAsString(createRequest);
+
+ // When & Then
+ mockMvc.perform(post("/api/readStatuses")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(requestBody))
+ .andExpect(status().isCreated())
+ .andExpect(jsonPath("$.id", notNullValue()))
+ .andExpect(jsonPath("$.userId", is(user.id().toString())))
+ .andExpect(jsonPath("$.channelId", is(channel.id().toString())))
+ .andExpect(jsonPath("$.lastReadAt", is(lastReadAt.toString())));
+ }
+
+ @Test
+ @DisplayName("읽음 상태 생성 실패 API 통합 테스트 - 중복 생성")
+ void createReadStatus_Failure_Duplicate() throws Exception {
+ // Given
+ // 테스트 사용자 생성
+ UserCreateRequest userRequest = new UserCreateRequest(
+ "duplicateuser",
+ "duplicate@example.com",
+ "Password1!"
+ );
+ UserDto user = userService.create(userRequest, Optional.empty());
+
+ // 공개 채널 생성
+ PublicChannelCreateRequest channelRequest = new PublicChannelCreateRequest(
+ "중복 테스트 채널",
+ "중복 테스트 채널 설명입니다."
+ );
+ ChannelDto channel = channelService.create(channelRequest);
+
+ // 첫 번째 읽음 상태 생성 요청 (성공)
+ Instant lastReadAt = Instant.now();
+ ReadStatusCreateRequest firstCreateRequest = new ReadStatusCreateRequest(
+ user.id(),
+ channel.id(),
+ lastReadAt
+ );
+
+ String firstRequestBody = objectMapper.writeValueAsString(firstCreateRequest);
+ mockMvc.perform(post("/api/readStatuses")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(firstRequestBody))
+ .andExpect(status().isCreated());
+
+ // 두 번째 읽음 상태 생성 요청 (동일 사용자, 동일 채널) - 실패해야 함
+ ReadStatusCreateRequest duplicateCreateRequest = new ReadStatusCreateRequest(
+ user.id(),
+ channel.id(),
+ Instant.now()
+ );
+
+ String duplicateRequestBody = objectMapper.writeValueAsString(duplicateCreateRequest);
+
+ // When & Then
+ mockMvc.perform(post("/api/readStatuses")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(duplicateRequestBody))
+ .andExpect(status().isConflict());
+ }
+
+ @Test
+ @DisplayName("읽음 상태 업데이트 API 통합 테스트")
+ void updateReadStatus_Success() throws Exception {
+ // Given
+ // 테스트 사용자 생성
+ UserCreateRequest userRequest = new UserCreateRequest(
+ "updateuser",
+ "update@example.com",
+ "Password1!"
+ );
+ UserDto user = userService.create(userRequest, Optional.empty());
+
+ // 공개 채널 생성
+ PublicChannelCreateRequest channelRequest = new PublicChannelCreateRequest(
+ "업데이트 테스트 채널",
+ "업데이트 테스트 채널 설명입니다."
+ );
+ ChannelDto channel = channelService.create(channelRequest);
+
+ // 읽음 상태 생성
+ Instant initialLastReadAt = Instant.now().minusSeconds(3600); // 1시간 전
+ ReadStatusCreateRequest createRequest = new ReadStatusCreateRequest(
+ user.id(),
+ channel.id(),
+ initialLastReadAt
+ );
+
+ ReadStatusDto createdReadStatus = readStatusService.create(createRequest);
+ UUID readStatusId = createdReadStatus.id();
+
+ // 읽음 상태 업데이트 요청
+ Instant newLastReadAt = Instant.now();
+ ReadStatusUpdateRequest updateRequest = new ReadStatusUpdateRequest(
+ newLastReadAt
+ );
+
+ String requestBody = objectMapper.writeValueAsString(updateRequest);
+
+ // When & Then
+ mockMvc.perform(patch("/api/readStatuses/{readStatusId}", readStatusId)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(requestBody))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.id", is(readStatusId.toString())))
+ .andExpect(jsonPath("$.userId", is(user.id().toString())))
+ .andExpect(jsonPath("$.channelId", is(channel.id().toString())))
+ .andExpect(jsonPath("$.lastReadAt", is(newLastReadAt.toString())));
+ }
+
+ @Test
+ @DisplayName("읽음 상태 업데이트 실패 API 통합 테스트 - 존재하지 않는 읽음 상태")
+ void updateReadStatus_Failure_NotFound() throws Exception {
+ // Given
+ UUID nonExistentReadStatusId = UUID.randomUUID();
+
+ ReadStatusUpdateRequest updateRequest = new ReadStatusUpdateRequest(
+ Instant.now()
+ );
+
+ String requestBody = objectMapper.writeValueAsString(updateRequest);
+
+ // When & Then
+ mockMvc.perform(patch("/api/readStatuses/{readStatusId}", nonExistentReadStatusId)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(requestBody))
+ .andExpect(status().isNotFound());
+ }
+
+ @Test
+ @DisplayName("사용자별 읽음 상태 목록 조회 API 통합 테스트")
+ void findAllReadStatusesByUserId_Success() throws Exception {
+ // Given
+ // 테스트 사용자 생성
+ UserCreateRequest userRequest = new UserCreateRequest(
+ "listuser",
+ "list@example.com",
+ "Password1!"
+ );
+ UserDto user = userService.create(userRequest, Optional.empty());
+
+ // 여러 채널 생성
+ PublicChannelCreateRequest channelRequest1 = new PublicChannelCreateRequest(
+ "목록 테스트 채널 1",
+ "목록 테스트 채널 설명입니다."
+ );
+
+ PublicChannelCreateRequest channelRequest2 = new PublicChannelCreateRequest(
+ "목록 테스트 채널 2",
+ "목록 테스트 채널 설명입니다."
+ );
+
+ ChannelDto channel1 = channelService.create(channelRequest1);
+ ChannelDto channel2 = channelService.create(channelRequest2);
+
+ // 각 채널에 대한 읽음 상태 생성
+ ReadStatusCreateRequest createRequest1 = new ReadStatusCreateRequest(
+ user.id(),
+ channel1.id(),
+ Instant.now().minusSeconds(3600)
+ );
+
+ ReadStatusCreateRequest createRequest2 = new ReadStatusCreateRequest(
+ user.id(),
+ channel2.id(),
+ Instant.now()
+ );
+
+ readStatusService.create(createRequest1);
+ readStatusService.create(createRequest2);
+
+ // When & Then
+ mockMvc.perform(get("/api/readStatuses")
+ .param("userId", user.id().toString())
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$", hasSize(2)))
+ .andExpect(jsonPath("$[*].channelId",
+ hasItems(channel1.id().toString(), channel2.id().toString())));
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/sprint/mission/discodeit/integration/UserApiIntegrationTest.java b/src/test/java/com/sprint/mission/discodeit/integration/UserApiIntegrationTest.java
new file mode 100644
index 0000000000..61d7895bf1
--- /dev/null
+++ b/src/test/java/com/sprint/mission/discodeit/integration/UserApiIntegrationTest.java
@@ -0,0 +1,299 @@
+package com.sprint.mission.discodeit.integration;
+
+import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.notNullValue;
+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.UserDto;
+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 java.time.Instant;
+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.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.MediaType;
+import org.springframework.mock.web.MockMultipartFile;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.transaction.annotation.Transactional;
+
+@SpringBootTest
+@AutoConfigureMockMvc
+@ActiveProfiles("test")
+@Transactional
+class UserApiIntegrationTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private ObjectMapper objectMapper;
+
+ @Autowired
+ private UserService userService;
+
+
+ @Test
+ @DisplayName("사용자 생성 API 통합 테스트")
+ void createUser_Success() throws Exception {
+ // Given
+ UserCreateRequest createRequest = new UserCreateRequest(
+ "testuser",
+ "test@example.com",
+ "Password1!"
+ );
+
+ MockMultipartFile userCreateRequestPart = new MockMultipartFile(
+ "userCreateRequest",
+ "",
+ MediaType.APPLICATION_JSON_VALUE,
+ objectMapper.writeValueAsBytes(createRequest)
+ );
+
+ MockMultipartFile profilePart = new MockMultipartFile(
+ "profile",
+ "profile.jpg",
+ MediaType.IMAGE_JPEG_VALUE,
+ "test-image".getBytes()
+ );
+
+ // When & Then
+ mockMvc.perform(multipart("/api/users")
+ .file(userCreateRequestPart)
+ .file(profilePart)
+ .contentType(MediaType.MULTIPART_FORM_DATA_VALUE))
+ .andExpect(status().isCreated())
+ .andExpect(jsonPath("$.id", notNullValue()))
+ .andExpect(jsonPath("$.username", is("testuser")))
+ .andExpect(jsonPath("$.email", is("test@example.com")))
+ .andExpect(jsonPath("$.profile.fileName", is("profile.jpg")))
+ .andExpect(jsonPath("$.online", is(true)));
+ }
+
+ @Test
+ @DisplayName("사용자 생성 실패 API 통합 테스트 - 유효하지 않은 요청")
+ void createUser_Failure_InvalidRequest() throws Exception {
+ // Given
+ UserCreateRequest invalidRequest = new UserCreateRequest(
+ "t", // 최소 길이 위반
+ "invalid-email", // 이메일 형식 위반
+ "short" // 비밀번호 정책 위반
+ );
+
+ MockMultipartFile userCreateRequestPart = new MockMultipartFile(
+ "userCreateRequest",
+ "",
+ MediaType.APPLICATION_JSON_VALUE,
+ objectMapper.writeValueAsBytes(invalidRequest)
+ );
+
+ // When & Then
+ mockMvc.perform(multipart("/api/users")
+ .file(userCreateRequestPart)
+ .contentType(MediaType.MULTIPART_FORM_DATA_VALUE))
+ .andExpect(status().isBadRequest());
+ }
+
+ @Test
+ @DisplayName("모든 사용자 조회 API 통합 테스트")
+ void findAllUsers_Success() throws Exception {
+ // Given
+ // 테스트 사용자 생성 - Service를 통해 초기화
+ UserCreateRequest userRequest1 = new UserCreateRequest(
+ "user1",
+ "user1@example.com",
+ "Password1!"
+ );
+
+ UserCreateRequest userRequest2 = new UserCreateRequest(
+ "user2",
+ "user2@example.com",
+ "Password1!"
+ );
+
+ userService.create(userRequest1, Optional.empty());
+ userService.create(userRequest2, Optional.empty());
+
+ // When & Then
+ mockMvc.perform(get("/api/users")
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$", hasSize(2)))
+ .andExpect(jsonPath("$[0].username", is("user1")))
+ .andExpect(jsonPath("$[0].email", is("user1@example.com")))
+ .andExpect(jsonPath("$[1].username", is("user2")))
+ .andExpect(jsonPath("$[1].email", is("user2@example.com")));
+ }
+
+ @Test
+ @DisplayName("사용자 업데이트 API 통합 테스트")
+ void updateUser_Success() throws Exception {
+ // Given
+ // 테스트 사용자 생성 - Service를 통해 초기화
+ UserCreateRequest createRequest = new UserCreateRequest(
+ "originaluser",
+ "original@example.com",
+ "Password1!"
+ );
+
+ UserDto createdUser = userService.create(createRequest, Optional.empty());
+ UUID userId = createdUser.id();
+
+ UserUpdateRequest updateRequest = new UserUpdateRequest(
+ "updateduser",
+ "updated@example.com",
+ "UpdatedPassword1!"
+ );
+
+ MockMultipartFile userUpdateRequestPart = new MockMultipartFile(
+ "userUpdateRequest",
+ "",
+ MediaType.APPLICATION_JSON_VALUE,
+ objectMapper.writeValueAsBytes(updateRequest)
+ );
+
+ MockMultipartFile profilePart = new MockMultipartFile(
+ "profile",
+ "updated-profile.jpg",
+ MediaType.IMAGE_JPEG_VALUE,
+ "updated-image".getBytes()
+ );
+
+ // When & Then
+ mockMvc.perform(multipart("/api/users/{userId}", userId)
+ .file(userUpdateRequestPart)
+ .file(profilePart)
+ .contentType(MediaType.MULTIPART_FORM_DATA_VALUE)
+ .with(request -> {
+ request.setMethod("PATCH");
+ return request;
+ }))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.id", is(userId.toString())))
+ .andExpect(jsonPath("$.username", is("updateduser")))
+ .andExpect(jsonPath("$.email", is("updated@example.com")))
+ .andExpect(jsonPath("$.profile.fileName", is("updated-profile.jpg")));
+ }
+
+ @Test
+ @DisplayName("사용자 업데이트 실패 API 통합 테스트 - 존재하지 않는 사용자")
+ void updateUser_Failure_UserNotFound() throws Exception {
+ // Given
+ UUID nonExistentUserId = UUID.randomUUID();
+ UserUpdateRequest updateRequest = new UserUpdateRequest(
+ "updateduser",
+ "updated@example.com",
+ "UpdatedPassword1!"
+ );
+
+ MockMultipartFile userUpdateRequestPart = new MockMultipartFile(
+ "userUpdateRequest",
+ "",
+ MediaType.APPLICATION_JSON_VALUE,
+ objectMapper.writeValueAsBytes(updateRequest)
+ );
+
+ // When & Then
+ mockMvc.perform(multipart("/api/users/{userId}", nonExistentUserId)
+ .file(userUpdateRequestPart)
+ .contentType(MediaType.MULTIPART_FORM_DATA_VALUE)
+ .with(request -> {
+ request.setMethod("PATCH");
+ return request;
+ }))
+ .andExpect(status().isNotFound());
+ }
+
+ @Test
+ @DisplayName("사용자 삭제 API 통합 테스트")
+ void deleteUser_Success() throws Exception {
+ // Given
+ // 테스트 사용자 생성 - Service를 통해 초기화
+ UserCreateRequest createRequest = new UserCreateRequest(
+ "deleteuser",
+ "delete@example.com",
+ "Password1!"
+ );
+
+ UserDto createdUser = userService.create(createRequest, Optional.empty());
+ UUID userId = createdUser.id();
+
+ // When & Then
+ mockMvc.perform(delete("/api/users/{userId}", userId))
+ .andExpect(status().isNoContent());
+
+ // 삭제 확인
+ mockMvc.perform(get("/api/users"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$[?(@.id == '" + userId + "')]").doesNotExist());
+ }
+
+ @Test
+ @DisplayName("사용자 삭제 실패 API 통합 테스트 - 존재하지 않는 사용자")
+ void deleteUser_Failure_UserNotFound() throws Exception {
+ // Given
+ UUID nonExistentUserId = UUID.randomUUID();
+
+ // When & Then
+ mockMvc.perform(delete("/api/users/{userId}", nonExistentUserId))
+ .andExpect(status().isNotFound());
+ }
+
+ @Test
+ @DisplayName("사용자 상태 업데이트 API 통합 테스트")
+ void updateUserStatus_Success() throws Exception {
+ // Given
+ // 테스트 사용자 생성 - Service를 통해 초기화
+ UserCreateRequest createRequest = new UserCreateRequest(
+ "statususer",
+ "status@example.com",
+ "Password1!"
+ );
+
+ UserDto createdUser = userService.create(createRequest, Optional.empty());
+ UUID userId = createdUser.id();
+
+ Instant newLastActiveAt = Instant.now();
+ UserStatusUpdateRequest statusUpdateRequest = new UserStatusUpdateRequest(
+ newLastActiveAt
+ );
+ String requestBody = objectMapper.writeValueAsString(statusUpdateRequest);
+
+ // When & Then
+ mockMvc.perform(patch("/api/users/{userId}/userStatus", userId)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(requestBody))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.lastActiveAt", is(newLastActiveAt.toString())));
+ }
+
+ @Test
+ @DisplayName("사용자 상태 업데이트 실패 API 통합 테스트 - 존재하지 않는 사용자")
+ void updateUserStatus_Failure_UserNotFound() throws Exception {
+ // Given
+ UUID nonExistentUserId = UUID.randomUUID();
+ UserStatusUpdateRequest statusUpdateRequest = new UserStatusUpdateRequest(
+ Instant.now()
+ );
+ String requestBody = objectMapper.writeValueAsString(statusUpdateRequest);
+
+ // When & Then
+ mockMvc.perform(patch("/api/users/{userId}/userStatus", nonExistentUserId)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(requestBody))
+ .andExpect(status().isNotFound());
+ }
+}
\ No newline at end of file
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 0000000000..6d4563153c
--- /dev/null
+++ b/src/test/java/com/sprint/mission/discodeit/repository/ChannelRepositoryTest.java
@@ -0,0 +1,96 @@
+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.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;
+
+/**
+ * ChannelRepository 슬라이스 테스트
+ */
+@DataJpaTest
+@EnableJpaAuditing
+@ActiveProfiles("test")
+class ChannelRepositoryTest {
+
+ @Autowired
+ private ChannelRepository channelRepository;
+
+ @Autowired
+ private TestEntityManager entityManager;
+
+ /**
+ * TestFixture: 채널 생성용 테스트 픽스처
+ */
+ private Channel createTestChannel(ChannelType type, String name) {
+ Channel channel = new Channel(type, name, "설명: " + name);
+ return channelRepository.save(channel);
+ }
+
+ @Test
+ @DisplayName("타입이 PUBLIC이거나 ID 목록에 포함된 채널을 모두 조회할 수 있다")
+ void findAllByTypeOrIdIn_ReturnsChannels() {
+ // given
+ Channel publicChannel1 = createTestChannel(ChannelType.PUBLIC, "공개채널1");
+ Channel publicChannel2 = createTestChannel(ChannelType.PUBLIC, "공개채널2");
+ Channel privateChannel1 = createTestChannel(ChannelType.PRIVATE, "비공개채널1");
+ Channel privateChannel2 = createTestChannel(ChannelType.PRIVATE, "비공개채널2");
+
+ channelRepository.saveAll(
+ Arrays.asList(publicChannel1, publicChannel2, privateChannel1, privateChannel2));
+
+ // 영속성 컨텍스트 초기화
+ entityManager.flush();
+ entityManager.clear();
+
+ // when
+ List selectedPrivateIds = List.of(privateChannel1.getId());
+ List foundChannels = channelRepository.findAllByTypeOrIdIn(ChannelType.PUBLIC,
+ selectedPrivateIds);
+
+ // then
+ assertThat(foundChannels).hasSize(3); // 공개채널 2개 + 선택된 비공개채널 1개
+
+ // 공개 채널 2개가 모두 포함되어 있는지 확인
+ assertThat(
+ foundChannels.stream().filter(c -> c.getType() == ChannelType.PUBLIC).count()).isEqualTo(2);
+
+ // 선택된 비공개 채널만 포함되어 있는지 확인
+ List privateChannels = foundChannels.stream()
+ .filter(c -> c.getType() == ChannelType.PRIVATE)
+ .toList();
+ assertThat(privateChannels).hasSize(1);
+ assertThat(privateChannels.get(0).getId()).isEqualTo(privateChannel1.getId());
+ }
+
+ @Test
+ @DisplayName("타입이 PUBLIC이 아니고 ID 목록이 비어있으면 비어있는 리스트를 반환한다")
+ void findAllByTypeOrIdIn_EmptyList_ReturnsEmptyList() {
+ // given
+ Channel privateChannel1 = createTestChannel(ChannelType.PRIVATE, "비공개채널1");
+ Channel privateChannel2 = createTestChannel(ChannelType.PRIVATE, "비공개채널2");
+
+ channelRepository.saveAll(Arrays.asList(privateChannel1, privateChannel2));
+
+ // 영속성 컨텍스트 초기화
+ entityManager.flush();
+ entityManager.clear();
+
+ // when
+ List foundChannels = channelRepository.findAllByTypeOrIdIn(ChannelType.PUBLIC,
+ List.of());
+
+ // then
+ assertThat(foundChannels).isEmpty();
+ }
+}
\ No newline at end of file
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 0000000000..3207ebb06f
--- /dev/null
+++ b/src/test/java/com/sprint/mission/discodeit/repository/MessageRepositoryTest.java
@@ -0,0 +1,221 @@
+package com.sprint.mission.discodeit.repository;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.sprint.mission.discodeit.entity.BinaryContent;
+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.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import org.hibernate.Hibernate;
+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.Slice;
+import org.springframework.data.domain.Sort;
+import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.util.ReflectionTestUtils;
+
+/**
+ * MessageRepository 슬라이스 테스트
+ */
+@DataJpaTest
+@EnableJpaAuditing
+@ActiveProfiles("test")
+class MessageRepositoryTest {
+
+ @Autowired
+ private MessageRepository messageRepository;
+
+ @Autowired
+ private ChannelRepository channelRepository;
+
+ @Autowired
+ private UserRepository userRepository;
+
+ @Autowired
+ private TestEntityManager entityManager;
+
+ /**
+ * TestFixture: 테스트용 사용자 생성
+ */
+ private User createTestUser(String username, String email) {
+ BinaryContent profile = new BinaryContent("profile.jpg", 1024L, "image/jpeg");
+ User user = new User(username, email, "password123!@#", profile);
+ // UserStatus 생성 및 연결
+ UserStatus status = new UserStatus(user, Instant.now());
+ return userRepository.save(user);
+ }
+
+ /**
+ * TestFixture: 테스트용 채널 생성
+ */
+ private Channel createTestChannel(ChannelType type, String name) {
+ Channel channel = new Channel(type, name, "설명: " + name);
+ return channelRepository.save(channel);
+ }
+
+ /**
+ * TestFixture: 테스트용 메시지 생성 ReflectionTestUtils를 사용하여 createdAt 필드를 직접 설정
+ */
+ private Message createTestMessage(String content, Channel channel, User author,
+ Instant createdAt) {
+ Message message = new Message(content, channel, author, new ArrayList<>());
+
+ // 생성 시간이 지정된 경우, ReflectionTestUtils로 설정
+ if (createdAt != null) {
+ ReflectionTestUtils.setField(message, "createdAt", createdAt);
+ }
+
+ Message savedMessage = messageRepository.save(message);
+ entityManager.flush();
+
+ return savedMessage;
+ }
+
+ @Test
+ @DisplayName("채널 ID와 생성 시간으로 메시지를 페이징하여 조회할 수 있다")
+ void findAllByChannelIdWithAuthor_ReturnsMessagesWithAuthor() {
+ // given
+ User user = createTestUser("testUser", "test@example.com");
+ Channel channel = createTestChannel(ChannelType.PUBLIC, "테스트채널");
+
+ Instant now = Instant.now();
+ Instant fiveMinutesAgo = now.minus(5, ChronoUnit.MINUTES);
+ Instant tenMinutesAgo = now.minus(10, ChronoUnit.MINUTES);
+
+ // 채널에 세 개의 메시지 생성 (시간 순서대로)
+ Message message1 = createTestMessage("첫 번째 메시지", channel, user, tenMinutesAgo);
+ Message message2 = createTestMessage("두 번째 메시지", channel, user, fiveMinutesAgo);
+ Message message3 = createTestMessage("세 번째 메시지", channel, user, now);
+
+ // 영속성 컨텍스트 초기화
+ entityManager.flush();
+ entityManager.clear();
+
+ // when - 최신 메시지보다 이전 시간으로 조회
+ Slice messages = messageRepository.findAllByChannelIdWithAuthor(
+ channel.getId(),
+ now.plus(1, ChronoUnit.MINUTES), // 현재 시간보다 더 미래
+ PageRequest.of(0, 2, Sort.by(Sort.Direction.DESC, "createdAt"))
+ );
+
+ // then
+ assertThat(messages).isNotNull();
+ assertThat(messages.hasContent()).isTrue();
+ assertThat(messages.getNumberOfElements()).isEqualTo(2); // 페이지 크기 만큼만 반환
+ assertThat(messages.hasNext()).isTrue();
+
+ // 시간 역순(최신순)으로 정렬되어 있는지 확인
+ List content = messages.getContent();
+ assertThat(content.get(0).getCreatedAt()).isAfterOrEqualTo(content.get(1).getCreatedAt());
+
+ // 저자 정보가 함께 로드되었는지 확인 (FETCH JOIN)
+ Message firstMessage = content.get(0);
+ assertThat(Hibernate.isInitialized(firstMessage.getAuthor())).isTrue();
+ assertThat(Hibernate.isInitialized(firstMessage.getAuthor().getStatus())).isTrue();
+ assertThat(Hibernate.isInitialized(firstMessage.getAuthor().getProfile())).isTrue();
+ }
+
+ @Test
+ @DisplayName("채널의 마지막 메시지 시간을 조회할 수 있다")
+ void findLastMessageAtByChannelId_ReturnsLastMessageTime() {
+ // given
+ User user = createTestUser("testUser", "test@example.com");
+ Channel channel = createTestChannel(ChannelType.PUBLIC, "테스트채널");
+
+ Instant now = Instant.now();
+ Instant fiveMinutesAgo = now.minus(5, ChronoUnit.MINUTES);
+ Instant tenMinutesAgo = now.minus(10, ChronoUnit.MINUTES);
+
+ // 채널에 세 개의 메시지 생성 (시간 순서대로)
+ createTestMessage("첫 번째 메시지", channel, user, tenMinutesAgo);
+ createTestMessage("두 번째 메시지", channel, user, fiveMinutesAgo);
+ Message lastMessage = createTestMessage("세 번째 메시지", channel, user, now);
+
+ // 영속성 컨텍스트 초기화
+ entityManager.flush();
+ entityManager.clear();
+
+ // when
+ Optional lastMessageAt = messageRepository.findLastMessageAtByChannelId(
+ channel.getId());
+
+ // then
+ assertThat(lastMessageAt).isPresent();
+ // 마지막 메시지 시간과 일치하는지 확인 (밀리초 단위 이하의 차이는 무시)
+ assertThat(lastMessageAt.get().truncatedTo(ChronoUnit.MILLIS))
+ .isEqualTo(lastMessage.getCreatedAt().truncatedTo(ChronoUnit.MILLIS));
+ }
+
+ @Test
+ @DisplayName("메시지가 없는 채널에서는 마지막 메시지 시간이 없다")
+ void findLastMessageAtByChannelId_NoMessages_ReturnsEmpty() {
+ // given
+ Channel emptyChannel = createTestChannel(ChannelType.PUBLIC, "빈채널");
+
+ // 영속성 컨텍스트 초기화
+ entityManager.flush();
+ entityManager.clear();
+
+ // when
+ Optional lastMessageAt = messageRepository.findLastMessageAtByChannelId(
+ emptyChannel.getId());
+
+ // then
+ assertThat(lastMessageAt).isEmpty();
+ }
+
+ @Test
+ @DisplayName("채널의 모든 메시지를 삭제할 수 있다")
+ void deleteAllByChannelId_DeletesAllMessages() {
+ // given
+ User user = createTestUser("testUser", "test@example.com");
+ Channel channel = createTestChannel(ChannelType.PUBLIC, "테스트채널");
+ Channel otherChannel = createTestChannel(ChannelType.PUBLIC, "다른채널");
+
+ // 테스트 채널에 메시지 3개 생성
+ createTestMessage("첫 번째 메시지", channel, user, null);
+ createTestMessage("두 번째 메시지", channel, user, null);
+ createTestMessage("세 번째 메시지", channel, user, null);
+
+ // 다른 채널에 메시지 1개 생성
+ createTestMessage("다른 채널 메시지", otherChannel, user, null);
+
+ // 영속성 컨텍스트 초기화
+ entityManager.flush();
+ entityManager.clear();
+
+ // when
+ messageRepository.deleteAllByChannelId(channel.getId());
+ entityManager.flush();
+ entityManager.clear();
+
+ // then
+ // 해당 채널의 메시지는 삭제되었는지 확인
+ List channelMessages = messageRepository.findAllByChannelIdWithAuthor(
+ channel.getId(),
+ Instant.now().plus(1, ChronoUnit.DAYS),
+ PageRequest.of(0, 100)
+ ).getContent();
+ assertThat(channelMessages).isEmpty();
+
+ // 다른 채널의 메시지는 그대로인지 확인
+ List otherChannelMessages = messageRepository.findAllByChannelIdWithAuthor(
+ otherChannel.getId(),
+ Instant.now().plus(1, ChronoUnit.DAYS),
+ PageRequest.of(0, 100)
+ ).getContent();
+ assertThat(otherChannelMessages).hasSize(1);
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/sprint/mission/discodeit/repository/ReadStatusRepositoryTest.java b/src/test/java/com/sprint/mission/discodeit/repository/ReadStatusRepositoryTest.java
new file mode 100644
index 0000000000..3dc797ca42
--- /dev/null
+++ b/src/test/java/com/sprint/mission/discodeit/repository/ReadStatusRepositoryTest.java
@@ -0,0 +1,199 @@
+package com.sprint.mission.discodeit.repository;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.sprint.mission.discodeit.entity.BinaryContent;
+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 java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.List;
+import org.hibernate.Hibernate;
+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;
+
+/**
+ * ReadStatusRepository 슬라이스 테스트
+ */
+@DataJpaTest
+@EnableJpaAuditing
+@ActiveProfiles("test")
+class ReadStatusRepositoryTest {
+
+ @Autowired
+ private ReadStatusRepository readStatusRepository;
+
+ @Autowired
+ private UserRepository userRepository;
+
+ @Autowired
+ private ChannelRepository channelRepository;
+
+ @Autowired
+ private TestEntityManager entityManager;
+
+ /**
+ * TestFixture: 테스트용 사용자 생성
+ */
+ private User createTestUser(String username, String email) {
+ BinaryContent profile = new BinaryContent("profile.jpg", 1024L, "image/jpeg");
+ User user = new User(username, email, "password123!@#", profile);
+ // UserStatus 생성 및 연결
+ UserStatus status = new UserStatus(user, Instant.now());
+ return userRepository.save(user);
+ }
+
+ /**
+ * TestFixture: 테스트용 채널 생성
+ */
+ private Channel createTestChannel(ChannelType type, String name) {
+ Channel channel = new Channel(type, name, "설명: " + name);
+ return channelRepository.save(channel);
+ }
+
+ /**
+ * TestFixture: 테스트용 읽음 상태 생성
+ */
+ private ReadStatus createTestReadStatus(User user, Channel channel, Instant lastReadAt) {
+ ReadStatus readStatus = new ReadStatus(user, channel, lastReadAt);
+ return readStatusRepository.save(readStatus);
+ }
+
+ @Test
+ @DisplayName("사용자 ID로 모든 읽음 상태를 조회할 수 있다")
+ void findAllByUserId_ReturnsReadStatuses() {
+ // given
+ User user = createTestUser("testUser", "test@example.com");
+ Channel channel1 = createTestChannel(ChannelType.PUBLIC, "채널1");
+ Channel channel2 = createTestChannel(ChannelType.PRIVATE, "채널2");
+
+ Instant now = Instant.now();
+ ReadStatus readStatus1 = createTestReadStatus(user, channel1, now.minus(1, ChronoUnit.DAYS));
+ ReadStatus readStatus2 = createTestReadStatus(user, channel2, now);
+
+ // 영속성 컨텍스트 초기화
+ entityManager.flush();
+ entityManager.clear();
+
+ // when
+ List readStatuses = readStatusRepository.findAllByUserId(user.getId());
+
+ // then
+ assertThat(readStatuses).hasSize(2);
+ }
+
+ @Test
+ @DisplayName("채널 ID로 모든 읽음 상태를 사용자 정보와 함께 조회할 수 있다")
+ void findAllByChannelIdWithUser_ReturnsReadStatusesWithUser() {
+ // given
+ User user1 = createTestUser("user1", "user1@example.com");
+ User user2 = createTestUser("user2", "user2@example.com");
+ Channel channel = createTestChannel(ChannelType.PUBLIC, "공개채널");
+
+ Instant now = Instant.now();
+ ReadStatus readStatus1 = createTestReadStatus(user1, channel, now.minus(1, ChronoUnit.DAYS));
+ ReadStatus readStatus2 = createTestReadStatus(user2, channel, now);
+
+ // 영속성 컨텍스트 초기화
+ entityManager.flush();
+ entityManager.clear();
+
+ // when
+ List readStatuses = readStatusRepository.findAllByChannelIdWithUser(
+ channel.getId());
+
+ // then
+ assertThat(readStatuses).hasSize(2);
+
+ // 사용자 정보가 함께 로드되었는지 확인 (FETCH JOIN)
+ for (ReadStatus status : readStatuses) {
+ assertThat(Hibernate.isInitialized(status.getUser())).isTrue();
+ assertThat(Hibernate.isInitialized(status.getUser().getStatus())).isTrue();
+ assertThat(Hibernate.isInitialized(status.getUser().getProfile())).isTrue();
+ }
+ }
+
+ @Test
+ @DisplayName("사용자 ID와 채널 ID로 읽음 상태 존재 여부를 확인할 수 있다")
+ void existsByUserIdAndChannelId_ExistingStatus_ReturnsTrue() {
+ // given
+ User user = createTestUser("testUser", "test@example.com");
+ Channel channel = createTestChannel(ChannelType.PUBLIC, "공개채널");
+
+ ReadStatus readStatus = createTestReadStatus(user, channel, Instant.now());
+
+ // 영속성 컨텍스트 초기화
+ entityManager.flush();
+ entityManager.clear();
+
+ // when
+ Boolean exists = readStatusRepository.existsByUserIdAndChannelId(user.getId(), channel.getId());
+
+ // then
+ assertThat(exists).isTrue();
+ }
+
+ @Test
+ @DisplayName("존재하지 않는 읽음 상태에 대해 false를 반환한다")
+ void existsByUserIdAndChannelId_NonExistingStatus_ReturnsFalse() {
+ // given
+ User user = createTestUser("testUser", "test@example.com");
+ Channel channel = createTestChannel(ChannelType.PUBLIC, "공개채널");
+
+ // 영속성 컨텍스트 초기화
+ entityManager.flush();
+ entityManager.clear();
+
+ // 읽음 상태를 생성하지 않음
+
+ // when
+ Boolean exists = readStatusRepository.existsByUserIdAndChannelId(user.getId(), channel.getId());
+
+ // then
+ assertThat(exists).isFalse();
+ }
+
+ @Test
+ @DisplayName("채널의 모든 읽음 상태를 삭제할 수 있다")
+ void deleteAllByChannelId_DeletesAllReadStatuses() {
+ // given
+ User user1 = createTestUser("user1", "user1@example.com");
+ User user2 = createTestUser("user2", "user2@example.com");
+
+ Channel channel = createTestChannel(ChannelType.PUBLIC, "삭제할채널");
+ Channel otherChannel = createTestChannel(ChannelType.PUBLIC, "유지할채널");
+
+ // 삭제할 채널에 읽음 상태 2개 생성
+ createTestReadStatus(user1, channel, Instant.now());
+ createTestReadStatus(user2, channel, Instant.now());
+
+ // 유지할 채널에 읽음 상태 1개 생성
+ createTestReadStatus(user1, otherChannel, Instant.now());
+
+ // 영속성 컨텍스트 초기화
+ entityManager.flush();
+ entityManager.clear();
+
+ // when
+ readStatusRepository.deleteAllByChannelId(channel.getId());
+ entityManager.flush();
+ entityManager.clear();
+
+ // then
+ // 해당 채널의 읽음 상태는 삭제되었는지 확인
+ List channelReadStatuses = readStatusRepository.findAllByChannelIdWithUser(channel.getId());
+ assertThat(channelReadStatuses).isEmpty();
+
+ // 다른 채널의 읽음 상태는 그대로인지 확인
+ List otherChannelReadStatuses = readStatusRepository.findAllByChannelIdWithUser(otherChannel.getId());
+ assertThat(otherChannelReadStatuses).hasSize(1);
+ }
+}
\ No newline at end of file
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 0000000000..84f360a2d7
--- /dev/null
+++ b/src/test/java/com/sprint/mission/discodeit/repository/UserRepositoryTest.java
@@ -0,0 +1,138 @@
+package com.sprint.mission.discodeit.repository;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.sprint.mission.discodeit.entity.BinaryContent;
+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.hibernate.Hibernate;
+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;
+
+/**
+ * UserRepository 슬라이스 테스트
+ */
+@DataJpaTest
+@EnableJpaAuditing
+@ActiveProfiles("test")
+class UserRepositoryTest {
+
+ @Autowired
+ private UserRepository userRepository;
+
+ @Autowired
+ private TestEntityManager entityManager;
+
+ /**
+ * TestFixture: 테스트에서 일관된 상태를 제공하기 위한 고정된 객체 세트 여러 테스트에서 재사용할 수 있는 테스트 데이터를 생성하는 메서드
+ */
+ private User createTestUser(String username, String email) {
+ BinaryContent profile = new BinaryContent("profile.jpg", 1024L, "image/jpeg");
+ User user = new User(username, email, "password123!@#", profile);
+ // UserStatus 생성 및 연결
+ UserStatus status = new UserStatus(user, Instant.now());
+ return user;
+ }
+
+ @Test
+ @DisplayName("사용자 이름으로 사용자를 찾을 수 있다")
+ void findByUsername_ExistingUsername_ReturnsUser() {
+ // given
+ String username = "testUser";
+ User user = createTestUser(username, "test@example.com");
+ userRepository.save(user);
+
+ // 영속성 컨텍스트 초기화 - 1차 캐시 비우기
+ entityManager.flush();
+ entityManager.clear();
+
+ // when
+ Optional foundUser = userRepository.findByUsername(username);
+
+ // then
+ assertThat(foundUser).isPresent();
+ assertThat(foundUser.get().getUsername()).isEqualTo(username);
+ }
+
+ @Test
+ @DisplayName("존재하지 않는 사용자 이름으로 검색하면 빈 Optional을 반환한다")
+ void findByUsername_NonExistingUsername_ReturnsEmptyOptional() {
+ // given
+ String nonExistingUsername = "nonExistingUser";
+
+ // when
+ Optional foundUser = userRepository.findByUsername(nonExistingUsername);
+
+ // then
+ assertThat(foundUser).isEmpty();
+ }
+
+ @Test
+ @DisplayName("이메일로 사용자 존재 여부를 확인할 수 있다")
+ void existsByEmail_ExistingEmail_ReturnsTrue() {
+ // given
+ String email = "test@example.com";
+ User user = createTestUser("testUser", email);
+ userRepository.save(user);
+
+ // when
+ boolean exists = userRepository.existsByEmail(email);
+
+ // then
+ assertThat(exists).isTrue();
+ }
+
+ @Test
+ @DisplayName("존재하지 않는 이메일로 확인하면 false를 반환한다")
+ void existsByEmail_NonExistingEmail_ReturnsFalse() {
+ // given
+ String nonExistingEmail = "nonexisting@example.com";
+
+ // when
+ boolean exists = userRepository.existsByEmail(nonExistingEmail);
+
+ // then
+ assertThat(exists).isFalse();
+ }
+
+ @Test
+ @DisplayName("모든 사용자를 프로필과 상태 정보와 함께 조회할 수 있다")
+ void findAllWithProfileAndStatus_ReturnsUsersWithProfileAndStatus() {
+ // given
+ User user1 = createTestUser("user1", "user1@example.com");
+ User user2 = createTestUser("user2", "user2@example.com");
+
+ userRepository.saveAll(List.of(user1, user2));
+
+ // 영속성 컨텍스트 초기화 - 1차 캐시 비우기
+ entityManager.flush();
+ entityManager.clear();
+
+ // when
+ List users = userRepository.findAllWithProfileAndStatus();
+
+ // then
+ assertThat(users).hasSize(2);
+ assertThat(users).extracting("username").containsExactlyInAnyOrder("user1", "user2");
+
+ // 프로필과 상태 정보가 함께 조회되었는지 확인 - 프록시 초기화 없이도 접근 가능한지 테스트
+ User foundUser1 = users.stream().filter(u -> u.getUsername().equals("user1")).findFirst()
+ .orElseThrow();
+ User foundUser2 = users.stream().filter(u -> u.getUsername().equals("user2")).findFirst()
+ .orElseThrow();
+
+ // 프록시 초기화 여부 확인
+ assertThat(Hibernate.isInitialized(foundUser1.getProfile())).isTrue();
+ assertThat(Hibernate.isInitialized(foundUser1.getStatus())).isTrue();
+ assertThat(Hibernate.isInitialized(foundUser2.getProfile())).isTrue();
+ assertThat(Hibernate.isInitialized(foundUser2.getStatus())).isTrue();
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/sprint/mission/discodeit/repository/UserStatusRepositoryTest.java b/src/test/java/com/sprint/mission/discodeit/repository/UserStatusRepositoryTest.java
new file mode 100644
index 0000000000..4ba3e1839e
--- /dev/null
+++ b/src/test/java/com/sprint/mission/discodeit/repository/UserStatusRepositoryTest.java
@@ -0,0 +1,119 @@
+package com.sprint.mission.discodeit.repository;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.sprint.mission.discodeit.entity.BinaryContent;
+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.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.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;
+
+/**
+ * UserStatusRepository 슬라이스 테스트
+ */
+@DataJpaTest
+@EnableJpaAuditing
+@ActiveProfiles("test")
+class UserStatusRepositoryTest {
+
+ @Autowired
+ private UserStatusRepository userStatusRepository;
+
+ @Autowired
+ private UserRepository userRepository;
+
+ @Autowired
+ private TestEntityManager entityManager;
+
+ /**
+ * TestFixture: 테스트용 사용자와 상태 생성
+ */
+ private User createTestUserWithStatus(String username, String email, Instant lastActiveAt) {
+ BinaryContent profile = new BinaryContent("profile.jpg", 1024L, "image/jpeg");
+ User user = new User(username, email, "password123!@#", profile);
+ UserStatus status = new UserStatus(user, lastActiveAt);
+ return userRepository.save(user);
+ }
+
+ @Test
+ @DisplayName("사용자 ID로 상태 정보를 찾을 수 있다")
+ void findByUserId_ExistingUserId_ReturnsUserStatus() {
+ // given
+ Instant now = Instant.now();
+ User user = createTestUserWithStatus("testUser", "test@example.com", now);
+ UUID userId = user.getId();
+
+ // 영속성 컨텍스트 초기화
+ entityManager.flush();
+ entityManager.clear();
+
+ // when
+ Optional foundStatus = userStatusRepository.findByUserId(userId);
+
+ // then
+ assertThat(foundStatus).isPresent();
+ assertThat(foundStatus.get().getUser().getId()).isEqualTo(userId);
+ assertThat(foundStatus.get().getLastActiveAt().truncatedTo(ChronoUnit.MILLIS))
+ .isEqualTo(now.truncatedTo(ChronoUnit.MILLIS));
+ }
+
+ @Test
+ @DisplayName("존재하지 않는 사용자 ID로 검색하면 빈 Optional을 반환한다")
+ void findByUserId_NonExistingUserId_ReturnsEmptyOptional() {
+ // given
+ UUID nonExistingUserId = UUID.randomUUID();
+
+ // when
+ Optional foundStatus = userStatusRepository.findByUserId(nonExistingUserId);
+
+ // then
+ assertThat(foundStatus).isEmpty();
+ }
+
+ @Test
+ @DisplayName("UserStatus의 isOnline 메서드는 최근 활동 시간이 5분 이내일 때 true를 반환한다")
+ void isOnline_LastActiveWithinFiveMinutes_ReturnsTrue() {
+ // given
+ Instant now = Instant.now();
+ User user = createTestUserWithStatus("testUser", "test@example.com", now);
+
+ // 영속성 컨텍스트 초기화
+ entityManager.flush();
+ entityManager.clear();
+
+ // when
+ Optional foundStatus = userStatusRepository.findByUserId(user.getId());
+
+ // then
+ assertThat(foundStatus).isPresent();
+ assertThat(foundStatus.get().isOnline()).isTrue();
+ }
+
+ @Test
+ @DisplayName("UserStatus의 isOnline 메서드는 최근 활동 시간이 5분보다 이전일 때 false를 반환한다")
+ void isOnline_LastActiveBeforeFiveMinutes_ReturnsFalse() {
+ // given
+ Instant sixMinutesAgo = Instant.now().minus(6, ChronoUnit.MINUTES);
+ User user = createTestUserWithStatus("testUser", "test@example.com", sixMinutesAgo);
+
+ // 영속성 컨텍스트 초기화
+ entityManager.flush();
+ entityManager.clear();
+
+ // when
+ Optional foundStatus = userStatusRepository.findByUserId(user.getId());
+
+ // then
+ assertThat(foundStatus).isPresent();
+ assertThat(foundStatus.get().isOnline()).isFalse();
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/sprint/mission/discodeit/service/basic/BasicBinaryContentServiceTest.java b/src/test/java/com/sprint/mission/discodeit/service/basic/BasicBinaryContentServiceTest.java
new file mode 100644
index 0000000000..fba3f3d659
--- /dev/null
+++ b/src/test/java/com/sprint/mission/discodeit/service/basic/BasicBinaryContentServiceTest.java
@@ -0,0 +1,172 @@
+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.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.verify;
+
+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.binarycontent.BinaryContentNotFoundException;
+import com.sprint.mission.discodeit.mapper.BinaryContentMapper;
+import com.sprint.mission.discodeit.repository.BinaryContentRepository;
+import com.sprint.mission.discodeit.storage.BinaryContentStorage;
+import java.util.Arrays;
+import java.util.List;
+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;
+import org.springframework.test.util.ReflectionTestUtils;
+
+@ExtendWith(MockitoExtension.class)
+class BasicBinaryContentServiceTest {
+
+ @Mock
+ private BinaryContentRepository binaryContentRepository;
+
+ @Mock
+ private BinaryContentMapper binaryContentMapper;
+
+ @Mock
+ private BinaryContentStorage binaryContentStorage;
+
+ @InjectMocks
+ private BasicBinaryContentService binaryContentService;
+
+ private UUID binaryContentId;
+ private String fileName;
+ private String contentType;
+ private byte[] bytes;
+ private BinaryContent binaryContent;
+ private BinaryContentDto binaryContentDto;
+
+ @BeforeEach
+ void setUp() {
+ binaryContentId = UUID.randomUUID();
+ fileName = "test.jpg";
+ contentType = "image/jpeg";
+ bytes = "test data".getBytes();
+
+ binaryContent = new BinaryContent(fileName, (long) bytes.length, contentType);
+ ReflectionTestUtils.setField(binaryContent, "id", binaryContentId);
+
+ binaryContentDto = new BinaryContentDto(
+ binaryContentId,
+ fileName,
+ (long) bytes.length,
+ contentType
+ );
+ }
+
+ @Test
+ @DisplayName("바이너리 콘텐츠 생성 성공")
+ void createBinaryContent_Success() {
+ // given
+ BinaryContentCreateRequest request = new BinaryContentCreateRequest(fileName, contentType,
+ bytes);
+
+ given(binaryContentRepository.save(any(BinaryContent.class))).will(invocation -> {
+ BinaryContent binaryContent = invocation.getArgument(0);
+ ReflectionTestUtils.setField(binaryContent, "id", binaryContentId);
+ return binaryContent;
+ });
+ given(binaryContentMapper.toDto(any(BinaryContent.class))).willReturn(binaryContentDto);
+
+ // when
+ BinaryContentDto result = binaryContentService.create(request);
+
+ // then
+ assertThat(result).isEqualTo(binaryContentDto);
+ verify(binaryContentRepository).save(any(BinaryContent.class));
+ verify(binaryContentStorage).put(binaryContentId, bytes);
+ }
+
+ @Test
+ @DisplayName("바이너리 콘텐츠 조회 성공")
+ void findBinaryContent_Success() {
+ // given
+ given(binaryContentRepository.findById(eq(binaryContentId))).willReturn(
+ Optional.of(binaryContent));
+ given(binaryContentMapper.toDto(eq(binaryContent))).willReturn(binaryContentDto);
+
+ // when
+ BinaryContentDto result = binaryContentService.find(binaryContentId);
+
+ // then
+ assertThat(result).isEqualTo(binaryContentDto);
+ }
+
+ @Test
+ @DisplayName("존재하지 않는 바이너리 콘텐츠 조회 시 예외 발생")
+ void findBinaryContent_WithNonExistentId_ThrowsException() {
+ // given
+ given(binaryContentRepository.findById(eq(binaryContentId))).willReturn(Optional.empty());
+
+ // when & then
+ assertThatThrownBy(() -> binaryContentService.find(binaryContentId))
+ .isInstanceOf(BinaryContentNotFoundException.class);
+ }
+
+ @Test
+ @DisplayName("여러 ID로 바이너리 콘텐츠 목록 조회 성공")
+ void findAllByIdIn_Success() {
+ // given
+ UUID id1 = UUID.randomUUID();
+ UUID id2 = UUID.randomUUID();
+ List ids = Arrays.asList(id1, id2);
+
+ BinaryContent content1 = new BinaryContent("file1.jpg", 100L, "image/jpeg");
+ ReflectionTestUtils.setField(content1, "id", id1);
+
+ BinaryContent content2 = new BinaryContent("file2.jpg", 200L, "image/png");
+ ReflectionTestUtils.setField(content2, "id", id2);
+
+ List contents = Arrays.asList(content1, content2);
+
+ BinaryContentDto dto1 = new BinaryContentDto(id1, "file1.jpg", 100L, "image/jpeg");
+ BinaryContentDto dto2 = new BinaryContentDto(id2, "file2.jpg", 200L, "image/png");
+
+ given(binaryContentRepository.findAllById(eq(ids))).willReturn(contents);
+ given(binaryContentMapper.toDto(eq(content1))).willReturn(dto1);
+ given(binaryContentMapper.toDto(eq(content2))).willReturn(dto2);
+
+ // when
+ List result = binaryContentService.findAllByIdIn(ids);
+
+ // then
+ assertThat(result).containsExactly(dto1, dto2);
+ }
+
+ @Test
+ @DisplayName("바이너리 콘텐츠 삭제 성공")
+ void deleteBinaryContent_Success() {
+ // given
+ given(binaryContentRepository.existsById(binaryContentId)).willReturn(true);
+
+ // when
+ binaryContentService.delete(binaryContentId);
+
+ // then
+ verify(binaryContentRepository).deleteById(binaryContentId);
+ }
+
+ @Test
+ @DisplayName("존재하지 않는 바이너리 콘텐츠 삭제 시 예외 발생")
+ void deleteBinaryContent_WithNonExistentId_ThrowsException() {
+ // given
+ given(binaryContentRepository.existsById(eq(binaryContentId))).willReturn(false);
+
+ // when & then
+ assertThatThrownBy(() -> binaryContentService.delete(binaryContentId))
+ .isInstanceOf(BinaryContentNotFoundException.class);
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/sprint/mission/discodeit/service/basic/BasicChannelServiceTest.java b/src/test/java/com/sprint/mission/discodeit/service/basic/BasicChannelServiceTest.java
new file mode 100644
index 0000000000..da1dd0ca05
--- /dev/null
+++ b/src/test/java/com/sprint/mission/discodeit/service/basic/BasicChannelServiceTest.java
@@ -0,0 +1,228 @@
+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.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.verify;
+
+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.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.List;
+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;
+import org.springframework.test.util.ReflectionTestUtils;
+
+@ExtendWith(MockitoExtension.class)
+class BasicChannelServiceTest {
+
+ @Mock
+ private ChannelRepository channelRepository;
+
+ @Mock
+ private ReadStatusRepository readStatusRepository;
+
+ @Mock
+ private MessageRepository messageRepository;
+
+ @Mock
+ private UserRepository userRepository;
+
+ @Mock
+ private ChannelMapper channelMapper;
+
+ @InjectMocks
+ private BasicChannelService channelService;
+
+ private UUID channelId;
+ private UUID userId;
+ private String channelName;
+ private String channelDescription;
+ private Channel channel;
+ private ChannelDto channelDto;
+ private User user;
+
+ @BeforeEach
+ void setUp() {
+ channelId = UUID.randomUUID();
+ userId = UUID.randomUUID();
+ channelName = "testChannel";
+ channelDescription = "testDescription";
+
+ channel = new Channel(ChannelType.PUBLIC, channelName, channelDescription);
+ ReflectionTestUtils.setField(channel, "id", channelId);
+ channelDto = new ChannelDto(channelId, ChannelType.PUBLIC, channelName, channelDescription,
+ List.of(), Instant.now());
+ user = new User("testUser", "test@example.com", "password", null);
+ }
+
+ @Test
+ @DisplayName("공개 채널 생성 성공")
+ void createPublicChannel_Success() {
+ // given
+ PublicChannelCreateRequest request = new PublicChannelCreateRequest(channelName,
+ channelDescription);
+ given(channelMapper.toDto(any(Channel.class))).willReturn(channelDto);
+
+ // when
+ ChannelDto result = channelService.create(request);
+
+ // then
+ assertThat(result).isEqualTo(channelDto);
+ verify(channelRepository).save(any(Channel.class));
+ }
+
+ @Test
+ @DisplayName("비공개 채널 생성 성공")
+ void createPrivateChannel_Success() {
+ // given
+ List participantIds = List.of(userId);
+ PrivateChannelCreateRequest request = new PrivateChannelCreateRequest(participantIds);
+ given(userRepository.findAllById(eq(participantIds))).willReturn(List.of(user));
+ given(channelMapper.toDto(any(Channel.class))).willReturn(channelDto);
+
+ // when
+ ChannelDto result = channelService.create(request);
+
+ // then
+ assertThat(result).isEqualTo(channelDto);
+ verify(channelRepository).save(any(Channel.class));
+ verify(readStatusRepository).saveAll(anyList());
+ }
+
+ @Test
+ @DisplayName("채널 조회 성공")
+ void findChannel_Success() {
+ // given
+ given(channelRepository.findById(eq(channelId))).willReturn(Optional.of(channel));
+ given(channelMapper.toDto(any(Channel.class))).willReturn(channelDto);
+
+ // when
+ ChannelDto result = channelService.find(channelId);
+
+ // then
+ assertThat(result).isEqualTo(channelDto);
+ }
+
+ @Test
+ @DisplayName("존재하지 않는 채널 조회 시 실패")
+ void findChannel_WithNonExistentId_ThrowsException() {
+ // given
+ given(channelRepository.findById(eq(channelId))).willReturn(Optional.empty());
+
+ // when & then
+ assertThatThrownBy(() -> channelService.find(channelId))
+ .isInstanceOf(ChannelNotFoundException.class);
+ }
+
+ @Test
+ @DisplayName("사용자별 채널 목록 조회 성공")
+ void findAllByUserId_Success() {
+ // given
+ List readStatuses = List.of(new ReadStatus(user, channel, Instant.now()));
+ given(readStatusRepository.findAllByUserId(eq(userId))).willReturn(readStatuses);
+ given(channelRepository.findAllByTypeOrIdIn(eq(ChannelType.PUBLIC), eq(List.of(channel.getId()))))
+ .willReturn(List.of(channel));
+ given(channelMapper.toDto(any(Channel.class))).willReturn(channelDto);
+
+ // when
+ List result = channelService.findAllByUserId(userId);
+
+ // then
+ assertThat(result).containsExactly(channelDto);
+ }
+
+ @Test
+ @DisplayName("공개 채널 수정 성공")
+ void updatePublicChannel_Success() {
+ // given
+ String newName = "newChannelName";
+ String newDescription = "newDescription";
+ PublicChannelUpdateRequest request = new PublicChannelUpdateRequest(newName, newDescription);
+
+ given(channelRepository.findById(eq(channelId))).willReturn(Optional.of(channel));
+ given(channelMapper.toDto(any(Channel.class))).willReturn(channelDto);
+
+ // when
+ ChannelDto result = channelService.update(channelId, request);
+
+ // then
+ assertThat(result).isEqualTo(channelDto);
+ }
+
+ @Test
+ @DisplayName("비공개 채널 수정 시도 시 실패")
+ void updatePrivateChannel_ThrowsException() {
+ // given
+ Channel privateChannel = new Channel(ChannelType.PRIVATE, null, null);
+ PublicChannelUpdateRequest request = new PublicChannelUpdateRequest("newName",
+ "newDescription");
+ given(channelRepository.findById(eq(channelId))).willReturn(Optional.of(privateChannel));
+
+ // when & then
+ assertThatThrownBy(() -> channelService.update(channelId, request))
+ .isInstanceOf(PrivateChannelUpdateException.class);
+ }
+
+ @Test
+ @DisplayName("존재하지 않는 채널 수정 시도 시 실패")
+ void updateChannel_WithNonExistentId_ThrowsException() {
+ // given
+ PublicChannelUpdateRequest request = new PublicChannelUpdateRequest("newName",
+ "newDescription");
+ given(channelRepository.findById(eq(channelId))).willReturn(Optional.empty());
+
+ // when & then
+ assertThatThrownBy(() -> channelService.update(channelId, request))
+ .isInstanceOf(ChannelNotFoundException.class);
+ }
+
+ @Test
+ @DisplayName("채널 삭제 성공")
+ void deleteChannel_Success() {
+ // given
+ given(channelRepository.existsById(eq(channelId))).willReturn(true);
+
+ // when
+ channelService.delete(channelId);
+
+ // then
+ verify(messageRepository).deleteAllByChannelId(eq(channelId));
+ verify(readStatusRepository).deleteAllByChannelId(eq(channelId));
+ verify(channelRepository).deleteById(eq(channelId));
+ }
+
+ @Test
+ @DisplayName("존재하지 않는 채널 삭제 시도 시 실패")
+ void deleteChannel_WithNonExistentId_ThrowsException() {
+ // given
+ given(channelRepository.existsById(eq(channelId))).willReturn(false);
+
+ // when & then
+ assertThatThrownBy(() -> channelService.delete(channelId))
+ .isInstanceOf(ChannelNotFoundException.class);
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/sprint/mission/discodeit/service/basic/BasicMessageServiceTest.java b/src/test/java/com/sprint/mission/discodeit/service/basic/BasicMessageServiceTest.java
new file mode 100644
index 0000000000..c00150babe
--- /dev/null
+++ b/src/test/java/com/sprint/mission/discodeit/service/basic/BasicMessageServiceTest.java
@@ -0,0 +1,365 @@
+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.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.verify;
+
+import com.sprint.mission.discodeit.dto.data.BinaryContentDto;
+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.dto.response.PageResponse;
+import com.sprint.mission.discodeit.entity.BinaryContent;
+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.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.storage.BinaryContentStorage;
+import java.time.Instant;
+import java.util.List;
+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;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.SliceImpl;
+import org.springframework.test.util.ReflectionTestUtils;
+
+@ExtendWith(MockitoExtension.class)
+class BasicMessageServiceTest {
+
+ @Mock
+ private MessageRepository messageRepository;
+
+ @Mock
+ private ChannelRepository channelRepository;
+
+ @Mock
+ private UserRepository userRepository;
+
+ @Mock
+ private MessageMapper messageMapper;
+
+ @Mock
+ private BinaryContentStorage binaryContentStorage;
+
+ @Mock
+ private BinaryContentRepository binaryContentRepository;
+
+ @Mock
+ private PageResponseMapper pageResponseMapper;
+
+ @InjectMocks
+ private BasicMessageService messageService;
+
+ private UUID messageId;
+ private UUID channelId;
+ private UUID authorId;
+ private String content;
+ private Message message;
+ private MessageDto messageDto;
+ private Channel channel;
+ private User author;
+ private BinaryContent attachment;
+ private BinaryContentDto attachmentDto;
+
+ @BeforeEach
+ void setUp() {
+ messageId = UUID.randomUUID();
+ channelId = UUID.randomUUID();
+ authorId = UUID.randomUUID();
+ content = "test message";
+
+ channel = new Channel(ChannelType.PUBLIC, "testChannel", "testDescription");
+ ReflectionTestUtils.setField(channel, "id", channelId);
+
+ author = new User("testUser", "test@example.com", "password", null);
+ ReflectionTestUtils.setField(author, "id", authorId);
+
+ attachment = new BinaryContent("test.txt", 100L, "text/plain");
+ ReflectionTestUtils.setField(attachment, "id", UUID.randomUUID());
+ attachmentDto = new BinaryContentDto(attachment.getId(), "test.txt", 100L, "text/plain");
+
+ message = new Message(content, channel, author, List.of(attachment));
+ ReflectionTestUtils.setField(message, "id", messageId);
+
+ messageDto = new MessageDto(
+ messageId,
+ Instant.now(),
+ Instant.now(),
+ content,
+ channelId,
+ new UserDto(authorId, "testUser", "test@example.com", null, true),
+ List.of(attachmentDto)
+ );
+ }
+
+ @Test
+ @DisplayName("메시지 생성 성공")
+ void createMessage_Success() {
+ // given
+ MessageCreateRequest request = new MessageCreateRequest(content, channelId, authorId);
+ BinaryContentCreateRequest attachmentRequest = new BinaryContentCreateRequest("test.txt", "text/plain", new byte[100]);
+ List attachmentRequests = List.of(attachmentRequest);
+
+ given(channelRepository.findById(eq(channelId))).willReturn(Optional.of(channel));
+ given(userRepository.findById(eq(authorId))).willReturn(Optional.of(author));
+ given(binaryContentRepository.save(any(BinaryContent.class))).will(invocation -> {
+ BinaryContent binaryContent = invocation.getArgument(0);
+ ReflectionTestUtils.setField(binaryContent, "id", attachment.getId());
+ return attachment;
+ });
+ given(messageRepository.save(any(Message.class))).willReturn(message);
+ given(messageMapper.toDto(any(Message.class))).willReturn(messageDto);
+
+ // when
+ MessageDto result = messageService.create(request, attachmentRequests);
+
+ // then
+ assertThat(result).isEqualTo(messageDto);
+ verify(messageRepository).save(any(Message.class));
+ verify(binaryContentStorage).put(eq(attachment.getId()), any(byte[].class));
+ }
+
+ @Test
+ @DisplayName("존재하지 않는 채널에 메시지 생성 시도 시 실패")
+ void createMessage_WithNonExistentChannel_ThrowsException() {
+ // given
+ MessageCreateRequest request = new MessageCreateRequest(content, channelId, authorId);
+ given(channelRepository.findById(eq(channelId))).willReturn(Optional.empty());
+
+ // when & then
+ assertThatThrownBy(() -> messageService.create(request, List.of()))
+ .isInstanceOf(ChannelNotFoundException.class);
+ }
+
+ @Test
+ @DisplayName("존재하지 않는 작성자로 메시지 생성 시도 시 실패")
+ void createMessage_WithNonExistentAuthor_ThrowsException() {
+ // given
+ MessageCreateRequest request = new MessageCreateRequest(content, channelId, authorId);
+ given(channelRepository.findById(eq(channelId))).willReturn(Optional.of(channel));
+ given(userRepository.findById(eq(authorId))).willReturn(Optional.empty());
+
+ // when & then
+ assertThatThrownBy(() -> messageService.create(request, List.of()))
+ .isInstanceOf(UserNotFoundException.class);
+ }
+
+ @Test
+ @DisplayName("메시지 조회 성공")
+ void findMessage_Success() {
+ // given
+ given(messageRepository.findById(eq(messageId))).willReturn(Optional.of(message));
+ given(messageMapper.toDto(eq(message))).willReturn(messageDto);
+
+ // when
+ MessageDto result = messageService.find(messageId);
+
+ // then
+ assertThat(result).isEqualTo(messageDto);
+ }
+
+ @Test
+ @DisplayName("존재하지 않는 메시지 조회 시 실패")
+ void findMessage_WithNonExistentId_ThrowsException() {
+ // given
+ given(messageRepository.findById(eq(messageId))).willReturn(Optional.empty());
+
+ // when & then
+ assertThatThrownBy(() -> messageService.find(messageId))
+ .isInstanceOf(MessageNotFoundException.class);
+ }
+
+ @Test
+ @DisplayName("채널별 메시지 목록 조회 성공")
+ void findAllByChannelId_Success() {
+ // given
+ int pageSize = 2; // 페이지 크기를 2로 설정
+ Instant createdAt = Instant.now();
+ Pageable pageable = PageRequest.of(0, pageSize);
+
+ // 여러 메시지 생성 (페이지 사이즈보다 많게)
+ Message message1 = new Message(content + "1", channel, author, List.of(attachment));
+ Message message2 = new Message(content + "2", channel, author, List.of(attachment));
+ Message message3 = new Message(content + "3", channel, author, List.of(attachment));
+
+ ReflectionTestUtils.setField(message1, "id", UUID.randomUUID());
+ ReflectionTestUtils.setField(message2, "id", UUID.randomUUID());
+ ReflectionTestUtils.setField(message3, "id", UUID.randomUUID());
+
+ // 각 메시지에 해당하는 DTO 생성
+ Instant message1CreatedAt = Instant.now().minusSeconds(30);
+ Instant message2CreatedAt = Instant.now().minusSeconds(20);
+ Instant message3CreatedAt = Instant.now().minusSeconds(10);
+
+ ReflectionTestUtils.setField(message1, "createdAt", message1CreatedAt);
+ ReflectionTestUtils.setField(message2, "createdAt", message2CreatedAt);
+ ReflectionTestUtils.setField(message3, "createdAt", message3CreatedAt);
+
+ MessageDto messageDto1 = new MessageDto(
+ message1.getId(),
+ message1CreatedAt,
+ message1CreatedAt,
+ content + "1",
+ channelId,
+ new UserDto(authorId, "testUser", "test@example.com", null, true),
+ List.of(attachmentDto)
+ );
+
+ MessageDto messageDto2 = new MessageDto(
+ message2.getId(),
+ message2CreatedAt,
+ message2CreatedAt,
+ content + "2",
+ channelId,
+ new UserDto(authorId, "testUser", "test@example.com", null, true),
+ List.of(attachmentDto)
+ );
+
+ // 첫 페이지 결과 세팅 (2개 메시지)
+ List firstPageMessages = List.of(message1, message2);
+ List firstPageDtos = List.of(messageDto1, messageDto2);
+
+ // 첫 페이지는 다음 페이지가 있고, 커서는 message2의 생성 시간이어야 함
+ SliceImpl firstPageSlice = new SliceImpl<>(firstPageMessages, pageable, true);
+ PageResponse firstPageResponse = new PageResponse<>(
+ firstPageDtos,
+ message2CreatedAt,
+ pageSize,
+ true,
+ null
+ );
+
+ // 모의 객체 설정
+ given(messageRepository.findAllByChannelIdWithAuthor(eq(channelId), eq(createdAt), eq(pageable)))
+ .willReturn(firstPageSlice);
+ given(messageMapper.toDto(eq(message1))).willReturn(messageDto1);
+ given(messageMapper.toDto(eq(message2))).willReturn(messageDto2);
+ given(pageResponseMapper.fromSlice(any(), eq(message2CreatedAt)))
+ .willReturn(firstPageResponse);
+
+ // when
+ PageResponse result = messageService.findAllByChannelId(channelId, createdAt,
+ pageable);
+
+ // then
+ assertThat(result).isEqualTo(firstPageResponse);
+ assertThat(result.content()).hasSize(pageSize);
+ assertThat(result.hasNext()).isTrue();
+ assertThat(result.nextCursor()).isEqualTo(message2CreatedAt);
+
+ // 두 번째 페이지 테스트
+ // given
+ List secondPageMessages = List.of(message3);
+ MessageDto messageDto3 = new MessageDto(
+ message3.getId(),
+ message3CreatedAt,
+ message3CreatedAt,
+ content + "3",
+ channelId,
+ new UserDto(authorId, "testUser", "test@example.com", null, true),
+ List.of(attachmentDto)
+ );
+ List secondPageDtos = List.of(messageDto3);
+
+ // 두 번째 페이지는 다음 페이지가 없음
+ SliceImpl secondPageSlice = new SliceImpl<>(secondPageMessages, pageable, false);
+ PageResponse secondPageResponse = new PageResponse<>(
+ secondPageDtos,
+ message3CreatedAt,
+ pageSize,
+ false,
+ null
+ );
+
+ // 두 번째 페이지 모의 객체 설정
+ given(messageRepository.findAllByChannelIdWithAuthor(eq(channelId), eq(message2CreatedAt), eq(pageable)))
+ .willReturn(secondPageSlice);
+ given(messageMapper.toDto(eq(message3))).willReturn(messageDto3);
+ given(pageResponseMapper.fromSlice(any(), eq(message3CreatedAt)))
+ .willReturn(secondPageResponse);
+
+ // when - 두 번째 페이지 요청 (첫 페이지의 커서 사용)
+ PageResponse secondResult = messageService.findAllByChannelId(channelId, message2CreatedAt,
+ pageable);
+
+ // then - 두 번째 페이지 검증
+ assertThat(secondResult).isEqualTo(secondPageResponse);
+ assertThat(secondResult.content()).hasSize(1); // 마지막 페이지는 항목 1개만 있음
+ assertThat(secondResult.hasNext()).isFalse(); // 더 이상 다음 페이지 없음
+ }
+
+ @Test
+ @DisplayName("메시지 수정 성공")
+ void updateMessage_Success() {
+ // given
+ String newContent = "updated content";
+ MessageUpdateRequest request = new MessageUpdateRequest(newContent);
+
+ given(messageRepository.findById(eq(messageId))).willReturn(Optional.of(message));
+ given(messageMapper.toDto(eq(message))).willReturn(messageDto);
+
+ // when
+ MessageDto result = messageService.update(messageId, request);
+
+ // then
+ assertThat(result).isEqualTo(messageDto);
+ }
+
+ @Test
+ @DisplayName("존재하지 않는 메시지 수정 시도 시 실패")
+ void updateMessage_WithNonExistentId_ThrowsException() {
+ // given
+ MessageUpdateRequest request = new MessageUpdateRequest("new content");
+ given(messageRepository.findById(eq(messageId))).willReturn(Optional.empty());
+
+ // when & then
+ assertThatThrownBy(() -> messageService.update(messageId, request))
+ .isInstanceOf(MessageNotFoundException.class);
+ }
+
+ @Test
+ @DisplayName("메시지 삭제 성공")
+ void deleteMessage_Success() {
+ // given
+ given(messageRepository.existsById(eq(messageId))).willReturn(true);
+
+ // when
+ messageService.delete(messageId);
+
+ // then
+ verify(messageRepository).deleteById(eq(messageId));
+ }
+
+ @Test
+ @DisplayName("존재하지 않는 메시지 삭제 시도 시 실패")
+ void deleteMessage_WithNonExistentId_ThrowsException() {
+ // given
+ given(messageRepository.existsById(eq(messageId))).willReturn(false);
+
+ // when & then
+ assertThatThrownBy(() -> messageService.delete(messageId))
+ .isInstanceOf(MessageNotFoundException.class);
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/sprint/mission/discodeit/service/basic/BasicUserServiceTest.java b/src/test/java/com/sprint/mission/discodeit/service/basic/BasicUserServiceTest.java
new file mode 100644
index 0000000000..d165fb7107
--- /dev/null
+++ b/src/test/java/com/sprint/mission/discodeit/service/basic/BasicUserServiceTest.java
@@ -0,0 +1,184 @@
+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.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.verify;
+
+import com.sprint.mission.discodeit.dto.data.UserDto;
+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.UserAlreadyExistsException;
+import com.sprint.mission.discodeit.exception.user.UserNotFoundException;
+import com.sprint.mission.discodeit.mapper.UserMapper;
+import com.sprint.mission.discodeit.repository.UserRepository;
+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;
+import org.springframework.test.util.ReflectionTestUtils;
+
+@ExtendWith(MockitoExtension.class)
+class BasicUserServiceTest {
+
+ @Mock
+ private UserRepository userRepository;
+
+ @Mock
+ private UserMapper userMapper;
+
+ @InjectMocks
+ private BasicUserService userService;
+
+ private UUID userId;
+ private String username;
+ private String email;
+ private String password;
+ private User user;
+ private UserDto userDto;
+
+ @BeforeEach
+ void setUp() {
+ userId = UUID.randomUUID();
+ username = "testUser";
+ email = "test@example.com";
+ password = "password123";
+
+ user = new User(username, email, password, null);
+ ReflectionTestUtils.setField(user, "id", userId);
+ userDto = new UserDto(userId, username, email, null, true);
+ }
+
+ @Test
+ @DisplayName("사용자 생성 성공")
+ void createUser_Success() {
+ // given
+ UserCreateRequest request = new UserCreateRequest(username, email, password);
+ given(userRepository.existsByEmail(eq(email))).willReturn(false);
+ given(userRepository.existsByUsername(eq(username))).willReturn(false);
+ given(userMapper.toDto(any(User.class))).willReturn(userDto);
+
+ // when
+ UserDto result = userService.create(request, Optional.empty());
+
+ // then
+ assertThat(result).isEqualTo(userDto);
+ verify(userRepository).save(any(User.class));
+ }
+
+ @Test
+ @DisplayName("이미 존재하는 이메일로 사용자 생성 시도 시 실패")
+ void createUser_WithExistingEmail_ThrowsException() {
+ // given
+ UserCreateRequest request = new UserCreateRequest(username, email, password);
+ given(userRepository.existsByEmail(eq(email))).willReturn(true);
+
+ // when & then
+ assertThatThrownBy(() -> userService.create(request, Optional.empty()))
+ .isInstanceOf(UserAlreadyExistsException.class);
+ }
+
+ @Test
+ @DisplayName("이미 존재하는 사용자명으로 사용자 생성 시도 시 실패")
+ void createUser_WithExistingUsername_ThrowsException() {
+ // given
+ UserCreateRequest request = new UserCreateRequest(username, email, password);
+ given(userRepository.existsByEmail(eq(email))).willReturn(false);
+ given(userRepository.existsByUsername(eq(username))).willReturn(true);
+
+ // when & then
+ assertThatThrownBy(() -> userService.create(request, Optional.empty()))
+ .isInstanceOf(UserAlreadyExistsException.class);
+ }
+
+ @Test
+ @DisplayName("사용자 조회 성공")
+ void findUser_Success() {
+ // given
+ given(userRepository.findById(eq(userId))).willReturn(Optional.of(user));
+ given(userMapper.toDto(any(User.class))).willReturn(userDto);
+
+ // when
+ UserDto result = userService.find(userId);
+
+ // then
+ assertThat(result).isEqualTo(userDto);
+ }
+
+ @Test
+ @DisplayName("존재하지 않는 사용자 조회 시 실패")
+ void findUser_WithNonExistentId_ThrowsException() {
+ // given
+ given(userRepository.findById(eq(userId))).willReturn(Optional.empty());
+
+ // when & then
+ assertThatThrownBy(() -> userService.find(userId))
+ .isInstanceOf(UserNotFoundException.class);
+ }
+
+ @Test
+ @DisplayName("사용자 수정 성공")
+ void updateUser_Success() {
+ // given
+ String newUsername = "newUsername";
+ String newEmail = "new@example.com";
+ String newPassword = "newPassword";
+ UserUpdateRequest request = new UserUpdateRequest(newUsername, newEmail, newPassword);
+
+ given(userRepository.findById(eq(userId))).willReturn(Optional.of(user));
+ given(userRepository.existsByEmail(eq(newEmail))).willReturn(false);
+ given(userRepository.existsByUsername(eq(newUsername))).willReturn(false);
+ given(userMapper.toDto(any(User.class))).willReturn(userDto);
+
+ // when
+ UserDto result = userService.update(userId, request, Optional.empty());
+
+ // then
+ assertThat(result).isEqualTo(userDto);
+ }
+
+ @Test
+ @DisplayName("존재하지 않는 사용자 수정 시도 시 실패")
+ void updateUser_WithNonExistentId_ThrowsException() {
+ // given
+ UserUpdateRequest request = new UserUpdateRequest("newUsername", "new@example.com",
+ "newPassword");
+ given(userRepository.findById(eq(userId))).willReturn(Optional.empty());
+
+ // when & then
+ assertThatThrownBy(() -> userService.update(userId, request, Optional.empty()))
+ .isInstanceOf(UserNotFoundException.class);
+ }
+
+ @Test
+ @DisplayName("사용자 삭제 성공")
+ void deleteUser_Success() {
+ // given
+ given(userRepository.existsById(eq(userId))).willReturn(true);
+
+ // when
+ userService.delete(userId);
+
+ // then
+ verify(userRepository).deleteById(eq(userId));
+ }
+
+ @Test
+ @DisplayName("존재하지 않는 사용자 삭제 시도 시 실패")
+ void deleteUser_WithNonExistentId_ThrowsException() {
+ // given
+ given(userRepository.existsById(eq(userId))).willReturn(false);
+
+ // when & then
+ assertThatThrownBy(() -> userService.delete(userId))
+ .isInstanceOf(UserNotFoundException.class);
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/sprint/mission/discodeit/service/basic/BasicUserStatusServiceTest.java b/src/test/java/com/sprint/mission/discodeit/service/basic/BasicUserStatusServiceTest.java
new file mode 100644
index 0000000000..1fd57a0e97
--- /dev/null
+++ b/src/test/java/com/sprint/mission/discodeit/service/basic/BasicUserStatusServiceTest.java
@@ -0,0 +1,243 @@
+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.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.verify;
+
+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.DuplicateUserStatusException;
+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.List;
+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;
+import org.springframework.test.util.ReflectionTestUtils;
+
+@ExtendWith(MockitoExtension.class)
+class BasicUserStatusServiceTest {
+
+ @Mock
+ private UserStatusRepository userStatusRepository;
+
+ @Mock
+ private UserRepository userRepository;
+
+ @Mock
+ private UserStatusMapper userStatusMapper;
+
+ @InjectMocks
+ private BasicUserStatusService userStatusService;
+
+ private UUID userStatusId;
+ private UUID userId;
+ private Instant lastActiveAt;
+ private User user;
+ private UserStatus userStatus;
+ private UserStatusDto userStatusDto;
+
+ @BeforeEach
+ void setUp() {
+ userStatusId = UUID.randomUUID();
+ userId = UUID.randomUUID();
+ lastActiveAt = Instant.now();
+
+ user = new User("testUser", "test@example.com", "password", null);
+ ReflectionTestUtils.setField(user, "id", userId);
+
+ userStatus = new UserStatus(user, lastActiveAt);
+ ReflectionTestUtils.setField(userStatus, "id", userStatusId);
+
+ userStatusDto = new UserStatusDto(userStatusId, userId, lastActiveAt);
+ }
+
+ @Test
+ @DisplayName("사용자 상태 생성 성공")
+ void createUserStatus_Success() {
+ // given
+ UserStatusCreateRequest request = new UserStatusCreateRequest(userId, lastActiveAt);
+ given(userRepository.findById(eq(userId))).willReturn(Optional.of(user));
+ given(userStatusMapper.toDto(any(UserStatus.class))).willReturn(userStatusDto);
+
+ // 사용자에게 기존 상태가 없어야 함
+ ReflectionTestUtils.setField(user, "status", null);
+
+ // when
+ UserStatusDto result = userStatusService.create(request);
+
+ // then
+ assertThat(result).isEqualTo(userStatusDto);
+ verify(userStatusRepository).save(any(UserStatus.class));
+ }
+
+ @Test
+ @DisplayName("이미 상태가 있는 사용자에 대한 상태 생성 시도 시 실패")
+ void createUserStatus_WithExistingStatus_ThrowsException() {
+ // given
+ UserStatusCreateRequest request = new UserStatusCreateRequest(userId, lastActiveAt);
+ given(userRepository.findById(eq(userId))).willReturn(Optional.of(user));
+
+ // 사용자에게 이미 상태가 있음
+ ReflectionTestUtils.setField(user, "status", userStatus);
+
+ // when & then
+ assertThatThrownBy(() -> userStatusService.create(request))
+ .isInstanceOf(DuplicateUserStatusException.class);
+ }
+
+ @Test
+ @DisplayName("존재하지 않는 사용자에 대한 상태 생성 시도 시 실패")
+ void createUserStatus_WithNonExistentUser_ThrowsException() {
+ // given
+ UserStatusCreateRequest request = new UserStatusCreateRequest(userId, lastActiveAt);
+ given(userRepository.findById(eq(userId))).willReturn(Optional.empty());
+
+ // when & then
+ assertThatThrownBy(() -> userStatusService.create(request))
+ .isInstanceOf(UserNotFoundException.class);
+ }
+
+ @Test
+ @DisplayName("사용자 상태 조회 성공")
+ void findUserStatus_Success() {
+ // given
+ given(userStatusRepository.findById(eq(userStatusId))).willReturn(Optional.of(userStatus));
+ given(userStatusMapper.toDto(any(UserStatus.class))).willReturn(userStatusDto);
+
+ // when
+ UserStatusDto result = userStatusService.find(userStatusId);
+
+ // then
+ assertThat(result).isEqualTo(userStatusDto);
+ }
+
+ @Test
+ @DisplayName("존재하지 않는 사용자 상태 조회 시 실패")
+ void findUserStatus_WithNonExistentId_ThrowsException() {
+ // given
+ given(userStatusRepository.findById(eq(userStatusId))).willReturn(Optional.empty());
+
+ // when & then
+ assertThatThrownBy(() -> userStatusService.find(userStatusId))
+ .isInstanceOf(UserStatusNotFoundException.class);
+ }
+
+ @Test
+ @DisplayName("전체 사용자 상태 목록 조회 성공")
+ void findAllUserStatuses_Success() {
+ // given
+ given(userStatusRepository.findAll()).willReturn(List.of(userStatus));
+ given(userStatusMapper.toDto(any(UserStatus.class))).willReturn(userStatusDto);
+
+ // when
+ List result = userStatusService.findAll();
+
+ // then
+ assertThat(result).hasSize(1);
+ assertThat(result.get(0)).isEqualTo(userStatusDto);
+ }
+
+ @Test
+ @DisplayName("사용자 상태 수정 성공")
+ void updateUserStatus_Success() {
+ // given
+ Instant newLastActiveAt = Instant.now().plusSeconds(60);
+ UserStatusUpdateRequest request = new UserStatusUpdateRequest(newLastActiveAt);
+
+ given(userStatusRepository.findById(eq(userStatusId))).willReturn(Optional.of(userStatus));
+ given(userStatusMapper.toDto(any(UserStatus.class))).willReturn(userStatusDto);
+
+ // when
+ UserStatusDto result = userStatusService.update(userStatusId, request);
+
+ // then
+ assertThat(result).isEqualTo(userStatusDto);
+ }
+
+ @Test
+ @DisplayName("존재하지 않는 사용자 상태 수정 시도 시 실패")
+ void updateUserStatus_WithNonExistentId_ThrowsException() {
+ // given
+ Instant newLastActiveAt = Instant.now().plusSeconds(60);
+ UserStatusUpdateRequest request = new UserStatusUpdateRequest(newLastActiveAt);
+
+ given(userStatusRepository.findById(eq(userStatusId))).willReturn(Optional.empty());
+
+ // when & then
+ assertThatThrownBy(() -> userStatusService.update(userStatusId, request))
+ .isInstanceOf(UserStatusNotFoundException.class);
+ }
+
+ @Test
+ @DisplayName("사용자 ID로 상태 수정 성공")
+ void updateUserStatusByUserId_Success() {
+ // given
+ Instant newLastActiveAt = Instant.now().plusSeconds(60);
+ UserStatusUpdateRequest request = new UserStatusUpdateRequest(newLastActiveAt);
+
+ given(userStatusRepository.findByUserId(eq(userId))).willReturn(Optional.of(userStatus));
+ given(userStatusMapper.toDto(any(UserStatus.class))).willReturn(userStatusDto);
+
+ // when
+ UserStatusDto result = userStatusService.updateByUserId(userId, request);
+
+ // then
+ assertThat(result).isEqualTo(userStatusDto);
+ }
+
+ @Test
+ @DisplayName("존재하지 않는 사용자 ID로 상태 수정 시도 시 실패")
+ void updateUserStatusByUserId_WithNonExistentUserId_ThrowsException() {
+ // given
+ Instant newLastActiveAt = Instant.now().plusSeconds(60);
+ UserStatusUpdateRequest request = new UserStatusUpdateRequest(newLastActiveAt);
+
+ given(userStatusRepository.findByUserId(eq(userId))).willReturn(Optional.empty());
+
+ // when & then
+ assertThatThrownBy(() -> userStatusService.updateByUserId(userId, request))
+ .isInstanceOf(UserStatusNotFoundException.class);
+ }
+
+ @Test
+ @DisplayName("사용자 상태 삭제 성공")
+ void deleteUserStatus_Success() {
+ // given
+ given(userStatusRepository.existsById(eq(userStatusId))).willReturn(true);
+
+ // when
+ userStatusService.delete(userStatusId);
+
+ // then
+ verify(userStatusRepository).deleteById(eq(userStatusId));
+ }
+
+ @Test
+ @DisplayName("존재하지 않는 사용자 상태 삭제 시도 시 실패")
+ void deleteUserStatus_WithNonExistentId_ThrowsException() {
+ // given
+ given(userStatusRepository.existsById(eq(userStatusId))).willReturn(false);
+
+ // when & then
+ assertThatThrownBy(() -> userStatusService.delete(userStatusId))
+ .isInstanceOf(UserStatusNotFoundException.class);
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorageTest.java b/src/test/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorageTest.java
new file mode 100644
index 0000000000..f1175f69f8
--- /dev/null
+++ b/src/test/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorageTest.java
@@ -0,0 +1,96 @@
+package com.sprint.mission.discodeit.storage.s3;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.sprint.mission.discodeit.dto.data.BinaryContentDto;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Properties;
+import java.util.UUID;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+
+public class S3BinaryContentStorageTest {
+
+ private static Properties props = new Properties();
+ private static String accessKey;
+ private static String secretKey;
+ private static String region;
+ private static String bucket;
+
+ @BeforeAll
+ public static void loadProperties() {
+ try (FileInputStream fis = new FileInputStream(".env")) {
+ props.load(fis);
+ accessKey = props.getProperty("AWS_S3_ACCESS_KEY");
+ secretKey = props.getProperty("AWS_S3_SECRET_KEY");
+ region = props.getProperty("AWS_S3_REGION");
+ bucket = props.getProperty("AWS_S3_BUCKET");
+ System.out.println("AWS 설정 정보가 성공적으로 로드되었습니다.");
+ } catch (IOException e) {
+ System.out.println("AWS 설정 정보 로드 중 에러 발생: " + e.getMessage());
+ }
+ }
+
+ @Test
+ public void testPutAndGet() {
+ S3BinaryContentStorage storage = new S3BinaryContentStorage(accessKey, secretKey, region,
+ bucket);
+
+ //업로드 할 테스트 데이터
+ UUID testId = UUID.randomUUID();
+ String testContent = "test data";
+ byte[] testBytes = testContent.getBytes();
+
+ //put
+ UUID putId = storage.put(testId, testBytes);
+ assertEquals(testId, putId, "테스트 ID와 업로드 ID가 동일해야 함");
+
+ // get() 메서드 테스트
+ try (InputStream inputStream = storage.get(testId)) {
+ byte[] retrievedBytes = inputStream.readAllBytes();
+ String retrievedContent = new String(retrievedBytes, StandardCharsets.UTF_8);
+ assertEquals(testContent, retrievedContent, "S3에서 가져온 콘텐츠가 업로드된 콘텐츠와 일치해야 합니다.");
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+
+ }
+
+ @Test
+ public void testDownload() {
+ S3BinaryContentStorage storage = new S3BinaryContentStorage(accessKey, secretKey, region,
+ bucket);
+
+ //테스트 데이터
+ UUID testId = UUID.randomUUID();
+ String testContent = "test data";
+ byte[] testBytes = testContent.getBytes();
+ storage.put(testId, testBytes);
+
+ //DTO 생성
+ BinaryContentDto dto = new BinaryContentDto(
+ testId, "testdownload.txt", (long) testBytes.length, testContent
+ );
+
+ //download 메서드 테스트
+ ResponseEntity> responseEntity = storage.download(dto);
+ assertEquals(HttpStatus.FOUND, responseEntity.getStatusCode());
+
+ HttpHeaders headers = responseEntity.getHeaders();
+ assertTrue(headers.containsKey(HttpHeaders.LOCATION), "응답 헤더에 Location 정보가 있어야 합니다.");
+ String location = headers.getFirst(HttpHeaders.LOCATION);
+ assertNotNull(location, "Location 헤더 값은 null이 아니어야 합니다.");
+ assertTrue(location.startsWith("https://"), "생성된 Presigned URL은 https://로 시작해야 합니다.");
+
+ // 추가: 콘솔에 URL 출력 (테스트 시 확인 용도)
+ System.out.println("생성된 Presigned URL: " + location);
+ }
+}
diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml
new file mode 100644
index 0000000000..f2cf46a657
--- /dev/null
+++ b/src/test/resources/application-test.yaml
@@ -0,0 +1,20 @@
+spring:
+ datasource:
+ url: jdbc:h2:mem:testdb;MODE=PostgreSQL
+ driver-class-name: org.h2.Driver
+ username: sa
+ password:
+ jpa:
+ hibernate:
+ ddl-auto: create
+ show-sql: true
+ properties:
+ hibernate:
+ format_sql: true
+ dialect: org.hibernate.dialect.H2Dialect
+
+logging:
+ level:
+ com.sprint.mission.discodeit: debug
+ org.hibernate.SQL: debug
+ org.hibernate.orm.jdbc.bind: trace
\ No newline at end of file