diff --git a/build.gradle b/build.gradle
index 832736e2..5b413dc0 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,6 +1,7 @@
plugins {
id 'org.springframework.boot' version '3.2.0'
id 'io.spring.dependency-management' version '1.1.4'
+ id 'org.asciidoctor.jvm.convert' version '4.0.1'
id 'java'
id 'jacoco'
id 'checkstyle'
@@ -24,30 +25,47 @@ repositories {
}
dependencies {
- implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
+ implementation 'org.springframework.boot:spring-boot-starter-web'
+ implementation 'org.springframework.boot:spring-boot-starter-mail'
implementation 'org.springframework.boot:spring-boot-starter-security'
+ implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
+ implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-validation'
- implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.cloud:spring-cloud-starter-openfeign:4.1.0'
- implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
+
implementation "io.minio:minio:8.5.7"
+ implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
+ implementation 'org.apache.commons:commons-lang3'
+
+ runtimeOnly 'com.mysql:mysql-connector-j'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3'
- runtimeOnly 'com.mysql:mysql-connector-j'
+
compileOnly 'org.projectlombok:lombok'
+
annotationProcessor 'org.projectlombok:lombok'
+
+ testImplementation 'net.datafaker:datafaker:2.0.2'
+ testImplementation 'com.icegreen:greenmail-junit5:2.0.1'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
- testImplementation 'net.datafaker:datafaker:2.0.2'
+ testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
+ testImplementation 'org.springframework.restdocs:spring-restdocs-asciidoctor'
}
jacoco {
toolVersion = "0.8.11"
}
+ext {
+ snippetsDir = file('build/generated-snippets')
+}
+
test {
useJUnitPlatform()
+ outputs.dir snippetsDir
+ finalizedBy asciidoctor
finalizedBy jacocoTestReport
}
@@ -94,6 +112,7 @@ jacocoTestCoverageVerification {
}
}
}
+
dependsOn jacocoTestReport
}
@@ -105,3 +124,22 @@ checkstyle {
"org.checkstyle.google.suppressionxpathfilter.config": "suppressions.xml"
]
}
+
+asciidoctor {
+ inputs.dir snippetsDir
+ outputDir 'src/docs/asciidoc'
+ sources {
+ include 'index.adoc'
+ }
+
+ dependsOn test
+}
+
+bootJar {
+ copy {
+ from "${asciidoctor.outputDir}"
+ into 'build/static/docs'
+ }
+
+ dependsOn asciidoctor
+}
diff --git a/docker-compose.yml b/docker-compose.yml
index ce121d40..effba59c 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -8,7 +8,7 @@ services:
ports:
- ${MAIN_MYSQL_PORT}
volumes:
- - "data_volume:/var/lib/mysql-local"
+ - data_volume:/var/lib/mysql-local
environment:
MYSQL_ROOT_PASSWORD: ${MAIN_MYSQL_PASSWORD}
MYSQL_DATABASE: ${MAIN_MYSQL_DATABASE}
@@ -19,7 +19,7 @@ services:
ports:
- ${TEST_MYSQL_PORT}
volumes:
- - "data_volume:/var/lib/mysql-test"
+ - data_volume:/var/lib/mysql-test
environment:
MYSQL_ROOT_PASSWORD: ${TEST_MYSQL_PASSWORD}
MYSQL_DATABASE: ${TEST_MYSQL_DATABASE}
@@ -31,6 +31,18 @@ services:
MINIO_ROOT_USER: ${MINIO_USER}
MINIO_ROOT_PASSWORD: ${MINIO_PASSWORD}
command: server --address ":9099" /data
+ redis-local:
+ image: redis:latest
+ container_name: redis-test
+ ports:
+ - 6379:6379
+ volumes:
+ - data_volume:/data
+ - data_volume:/usr/local/conf/redis.conf
+ labels:
+ - name=redis
+ - mode=standalone
+ command: redis-server /usr/local/conf/redis.conf
volumes:
data_volume:
diff --git a/http/post.http b/http/post.http
deleted file mode 100644
index 388a775c..00000000
--- a/http/post.http
+++ /dev/null
@@ -1,30 +0,0 @@
-### 게시글 생성
-POST http://localhost:8080/api/v1/posts
-Content-Type: application/json
-Accept: application/json
-
-{
- "userId": 1,
- "title": "일교차가 심할 때 좋은 코디",
- "content": "일교차가 심할 땐 목도리 필수! 가디건도 챙기면 좋아요!",
- "image": null,
- "weather": {
- "currentTemperature": 0.0,
- "dayMinTemperature": -899.99,
- "dayMaxTemperature": 899.99,
- "skyCode": 1,
- "ptyCode": 1
- }
-}
-
-
-### 게시글 목록 조회(deafult: created_at 최신순)
-GET http://localhost:8080/api/v1/posts
-Content-Type: application/json
-Accept: application/json
-
-
-### 게시글 단건 조회
-GET http://localhost:8080/api/v1/posts/1
-Content-Type: application/json
-Accept: application/json
diff --git a/mysql/Dockerfile b/mysql/Dockerfile
index 5a81d8c9..dd6b4467 100644
--- a/mysql/Dockerfile
+++ b/mysql/Dockerfile
@@ -1,4 +1,6 @@
FROM mysql:8
+
ENV MYSQL_ROOT_PASSWORD=root
ENV MYSQL_DATABASE=ootw
+
COPY ./ /docker-entrypoint-initdb.d/
diff --git a/mysql/schema.sql b/mysql/schema.sql
index 40d891df..bb422a2e 100644
--- a/mysql/schema.sql
+++ b/mysql/schema.sql
@@ -4,42 +4,56 @@ USE ootw;
CREATE TABLE users
(
- id BIGINT AUTO_INCREMENT,
- email VARCHAR(255) NOT NULL,
- password VARCHAR(255) NOT NULL,
- nickname VARCHAR(255) NOT NULL,
- image VARCHAR(500) NULL,
- created_at DATETIME(6) NULL,
- updated_at DATETIME(6) NULL,
- CONSTRAINT users_pk
- PRIMARY KEY (id),
- CONSTRAINT users_email_index
- UNIQUE (email)
+ id BIGINT AUTO_INCREMENT,
+ email VARCHAR(255) NOT NULL,
+ password VARCHAR(255) NOT NULL,
+ nickname VARCHAR(255) NOT NULL,
+ profile_image_url VARCHAR(500) NULL,
+ certified TINYINT(1) NOT NULL,
+ created_at DATETIME(6) NULL,
+ updated_at DATETIME(6) NULL,
+
+ CONSTRAINT users_pk PRIMARY KEY (id),
+ CONSTRAINT users_email_index UNIQUE (email)
);
CREATE TABLE avatar_items
(
- id BIGINT AUTO_INCREMENT,
- image VARCHAR(500) NOT NULL,
- type VARCHAR(30) NOT NULL,
- sex TINYINT NOT NULL,
- CONSTRAINT avatar_items_pk
- PRIMARY KEY (id)
+ id BIGINT AUTO_INCREMENT,
+ image_url VARCHAR(500) NOT NULL,
+ type VARCHAR(30) NOT NULL,
+ sex VARCHAR(10) NOT NULL,
+
+ CONSTRAINT avatar_items_pk PRIMARY KEY (id)
);
CREATE TABLE posts
+(
+ id BIGINT AUTO_INCREMENT,
+ user_id BIGINT NOT NULL,
+ title VARCHAR(30) NOT NULL,
+ content VARCHAR(255) NOT NULL,
+ image_url VARCHAR(500) NULL,
+ created_at DATETIME(6) NULL,
+ updated_at DATETIME(6) NULL,
+ like_cnt INTEGER NULL,
+ min_temperature DOUBLE NOT NULL,
+ max_temperature DOUBLE NOT NULL,
+
+ CONSTRAINT posts_pk PRIMARY KEY (id),
+ FOREIGN KEY (user_id) REFERENCES users (id)
+);
+
+CREATE TABLE likes
(
id BIGINT AUTO_INCREMENT,
- user_id BIGINT NOT NULL,
- title VARCHAR(30) NOT NULL,
- content VARCHAR(255) NOT NULL,
- image VARCHAR(500) NULL,
- created_at DATETIME(6) NULL,
- updated_at DATETIME(6) NULL,
- min_temperature DOUBLE NOT NULL,
- max_temperature DOUBLE NOT NULL,
- CONSTRAINT posts_pk
- PRIMARY KEY (id),
- FOREIGN KEY (user_id)
- REFERENCES users (id)
+ user_id BIGINT NOT NULL,
+ post_id BIGINT NOT NULL,
+ is_like TINYINT NOT NULL,
+ created_at DATETIME(6) NULL,
+ updated_at DATETIME(6) NULL,
+
+ CONSTRAINT posts_pk PRIMARY KEY (id),
+ FOREIGN KEY (user_id) REFERENCES users (id),
+ FOREIGN KEY (post_id) REFERENCES posts (id) ON DELETE CASCADE
);
diff --git a/src/docs/asciidoc/index.adoc b/src/docs/asciidoc/index.adoc
new file mode 100644
index 00000000..0ee4eedf
--- /dev/null
+++ b/src/docs/asciidoc/index.adoc
@@ -0,0 +1,296 @@
+ifndef::snippets[]
+:snippets: build/generated-snippets
+endif::[]
+
+= #OOTW API Documentation
+Doc Writers - 김현우
+:doctype: book
+:icons: font
+:source-highlighter: highlightjs
+:toc: left
+:toclevels: 2
+:sectlinks:
+
+= User API
+
+'''
+== signup
+
+NOTE: 사용자 회원가입
+
+=== Request
+
+include::{snippets}/user-signup/request-body.adoc[]
+include::{snippets}/user-signup/request-fields.adoc[]
+
+==== Request example
+
+include::{snippets}/user-signup/http-request.adoc[]
+
+=== Response
+
+==== Response example
+
+include::{snippets}/user-signup/http-response.adoc[]
+
+== login
+
+NOTE: 사용자 로그인
+
+=== Request
+
+include::{snippets}/user-login/request-body.adoc[]
+include::{snippets}/user-login/request-fields.adoc[]
+
+==== Request example
+
+include::{snippets}/user-login/http-request.adoc[]
+
+=== Response
+
+include::{snippets}/user-login/response-body.adoc[]
+include::{snippets}/user-login/response-fields.adoc[]
+
+==== Response example
+
+include::{snippets}/user-login/http-response.adoc[]
+
+== certificate
+
+NOTE: 이메일 인증 코드 발송
+
+=== Request
+
+include::{snippets}/certificate/query-parameters.adoc[]
+
+==== Request example
+
+include::{snippets}/certificate/http-request.adoc[]
+
+=== Response
+
+==== Response example
+
+include::{snippets}/certificate/http-response.adoc[]
+
+== certify
+
+NOTE: 이메일 인증
+
+=== Request
+
+include::{snippets}/certify/query-parameters.adoc[]
+
+==== Request example
+
+include::{snippets}/certify/http-request.adoc[]
+
+=== Response
+
+==== Response example
+
+include::{snippets}/certify/http-response.adoc[]
+
+= Avatar Item API
+
+'''
+== upload
+
+NOTE: 아바타 이미지 업로드
+
+=== Request
+
+include::{snippets}/avatar-image-upload/request-headers.adoc[]
+include::{snippets}/avatar-image-upload/request-parts.adoc[]
+include::{snippets}/avatar-image-upload/request-part-request-fields.adoc[]
+
+==== Request example
+
+include::{snippets}/avatar-image-upload/http-request.adoc[]
+
+=== Response
+
+include::{snippets}/avatar-image-upload/response-body.adoc[]
+include::{snippets}/avatar-image-upload/response-fields.adoc[]
+
+==== Response example
+
+include::{snippets}/avatar-image-upload/http-response.adoc[]
+
+== get all
+
+NOTE: 전체 아바타 이미지 조회
+
+=== Request
+
+==== Request example
+
+include::{snippets}/avatar-image-get-all/http-request.adoc[]
+
+=== Response
+
+include::{snippets}/avatar-image-get-all/response-body.adoc[]
+include::{snippets}/avatar-image-get-all/response-fields.adoc[]
+
+==== Response example
+
+include::{snippets}/avatar-image-get-all/http-response.adoc[]
+
+= Post API
+
+'''
+== post create
+
+NOTE: 게시글 생성
+
+=== Request
+
+include::{snippets}/post-create/request-headers.adoc[]
+include::{snippets}/post-create/request-parts.adoc[]
+include::{snippets}/post-create/request-part-request-fields.adoc[]
+
+==== Request example
+
+include::{snippets}/post-create/http-request.adoc[]
+
+=== Response
+
+include::{snippets}/post-create/response-body.adoc[]
+include::{snippets}/post-create/response-fields.adoc[]
+
+==== Response example
+
+include::{snippets}/post-create/http-response.adoc[]
+
+== post read detail
+
+NOTE: 게시글 상세 조회
+
+=== Request
+
+include::{snippets}/post-read-detail/request-headers.adoc[]
+include::{snippets}/post-read-detail/path-parameters.adoc[]
+
+==== Request example
+
+include::{snippets}/post-read-detail/http-request.adoc[]
+
+=== Response
+
+include::{snippets}/post-read-detail/response-body.adoc[]
+include::{snippets}/post-read-detail/response-fields.adoc[]
+
+==== Response example
+
+include::{snippets}/post-read-detail/http-response.adoc[]
+
+== post read all
+
+NOTE: 게시글 전체 조회
+
+=== Request
+
+include::{snippets}/post-read-all/request-headers.adoc[]
+
+==== Request example
+
+include::{snippets}/post-read-all/http-request.adoc[]
+
+=== Response
+
+include::{snippets}/post-read-all/response-body.adoc[]
+include::{snippets}/post-read-all/response-fields.adoc[]
+
+==== Response example
+
+include::{snippets}/post-read-all/http-response.adoc[]
+
+== post update
+
+NOTE: 게시글 변경
+
+=== Request
+
+include::{snippets}/post-update/request-headers.adoc[]
+include::{snippets}/post-update/request-parts.adoc[]
+include::{snippets}/post-update/request-part-request-fields.adoc[]
+
+==== Request example
+
+include::{snippets}/post-update/http-request.adoc[]
+
+=== Response
+
+include::{snippets}/post-update/response-body.adoc[]
+include::{snippets}/post-update/response-fields.adoc[]
+
+==== Response example
+
+include::{snippets}/post-update/http-response.adoc[]
+
+== post delete
+
+NOTE: 게시글 삭제
+
+=== Request
+
+include::{snippets}/post-delete/request-headers.adoc[]
+include::{snippets}/post-delete/path-parameters.adoc[]
+
+==== Request example
+
+include::{snippets}/post-delete/http-request.adoc[]
+
+=== Response
+
+==== Response example
+
+include::{snippets}/post-delete/http-response.adoc[]
+
+== like push
+
+NOTE: 게시글 좋아요
+
+=== Request
+
+include::{snippets}/like-push/request-headers.adoc[]
+include::{snippets}/like-push/request-body.adoc[]
+include::{snippets}/like-push/request-fields.adoc[]
+
+==== Request example
+
+include::{snippets}/like-push/http-request.adoc[]
+
+=== Response
+
+include::{snippets}/like-push/response-body.adoc[]
+include::{snippets}/like-push/response-fields.adoc[]
+
+==== Response example
+
+include::{snippets}/like-push/http-response.adoc[]
+
+= Weather API
+
+'''
+== current weather
+
+NOTE: 현재 날씨 조회
+
+=== Request
+
+include::{snippets}/weather/request-headers.adoc[]
+include::{snippets}/weather/query-parameters.adoc[]
+
+==== Request example
+
+include::{snippets}/weather/http-request.adoc[]
+
+=== Response
+
+include::{snippets}/weather/response-body.adoc[]
+include::{snippets}/weather/response-fields.adoc[]
+
+==== Response example
+
+include::{snippets}/weather/http-response.adoc[]
diff --git a/src/docs/asciidoc/index.html b/src/docs/asciidoc/index.html
new file mode 100644
index 00000000..88a73c93
--- /dev/null
+++ b/src/docs/asciidoc/index.html
@@ -0,0 +1,2542 @@
+
+
+
+
+
+
+
+
+#OOTW API Documentation
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+사용자 회원가입
+ |
+
+
+
+
+
+
+
+
{
+ "email" : "gavin.lakin@hotmail.com",
+ "password" : "BNc@^R!#tM&Prm0WcOIf5Jm1w",
+ "nickname" : "esteban.haag"
+}
+
+
+
+
+
+
+
+
+
+
+Path |
+Type |
+Description |
+
+
+
+
+email
|
+String
|
+Email 주소 |
+
+
+password
|
+String
|
+비밀번호 |
+
+
+nickname
|
+String
|
+별명 |
+
+
+
+
+
+
+
+
POST /api/v1/auth/signup HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Content-Length: 116
+Host: docs.api.com
+
+{
+ "email" : "gavin.lakin@hotmail.com",
+ "password" : "BNc@^R!#tM&Prm0WcOIf5Jm1w",
+ "nickname" : "esteban.haag"
+}
+
+
+
+
+
+
+
+
+
+
+
HTTP/1.1 201 Created
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{
+ "email" : "williams.flatley@yahoo.com",
+ "password" : "Xz#0^3%v8$yk0o%o"
+}
+
+
+
+
+
+
+
+
+
+
+Path |
+Type |
+Description |
+
+
+
+
+email
|
+String
|
+Email 주소 |
+
+
+password
|
+String
|
+비밀번호 |
+
+
+
+
+
+
+
+
POST /api/v1/auth/login HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Content-Length: 79
+Host: docs.api.com
+
+{
+ "email" : "williams.flatley@yahoo.com",
+ "password" : "Xz#0^3%v8$yk0o%o"
+}
+
+
+
+
+
+
+
+
+
{
+ "token" : "a5fbfa1c2fd6a064babe4b7234f141fe57733ee9c53839bb31a0284a7b0a3a49b2894252b81e86078ecc0d9a7e24dc81372376b006d457ede46a8d9d1ea0ff93"
+}
+
+
+
+
+
+
+
+
+
+
+Path |
+Type |
+Description |
+
+
+
+
+token
|
+String
|
+JWT 토큰 |
+
+
+
+
+
+
+
+
HTTP/1.1 201 Created
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Authorization: Bearer a5fbfa1c2fd6a064babe4b7234f141fe57733ee9c53839bb31a0284a7b0a3a49b2894252b81e86078ecc0d9a7e24dc81372376b006d457ede46a8d9d1ea0ff93
+Content-Type: application/json
+Content-Length: 146
+
+{
+ "token" : "a5fbfa1c2fd6a064babe4b7234f141fe57733ee9c53839bb31a0284a7b0a3a49b2894252b81e86078ecc0d9a7e24dc81372376b006d457ede46a8d9d1ea0ff93"
+}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+이메일 인증 코드 발송
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+Parameter |
+Description |
+
+
+
+
+email
|
+Email 주소 |
+
+
+
+
+
+
+
+
PATCH /api/v1/auth/certificate?email=magnolia.becker@yahoo.com HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Host: docs.api.com
+
+
+
+
+
+
+
+
+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Parameter |
+Description |
+
+
+
+
+email
|
+Email 주소 |
+
+
+code
|
+Email 인증 코드 |
+
+
+
+
+
+
+
+
PATCH /api/v1/auth/certify?email=emery.kutch@example.com&code=Q8K940 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Host: docs.api.com
+
+
+
+
+
+
+
+
+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+아바타 이미지 업로드
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Name |
+Description |
+Required |
+
+
+
+
+Authorization
|
+JWT 토큰 |
+true |
+
+
+
+
+
+
+
+
+
+
+Part |
+Description |
+
+
+
+
+file
|
+아바타 이미지 파일 |
+
+
+request
|
+아바타 이미지 상세 정보 |
+
+
+
+
+
+
+
+
+
+
+
+Path |
+Type |
+Description |
+
+
+
+
+type
|
+String
|
+아바타 이미지 타입 |
+
+
+sex
|
+String
|
+아바타 이미지 성별 |
+
+
+
+
+
+
+
+
+
POST /api/v1/avatar-items HTTP/1.1
+Content-Type: multipart/form-data;charset=UTF-8; boundary=6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
+Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJpc3MiOiJvb3R3IiwiaWF0IjoxNzA1NTcwOTgyLCJleHAiOjE3MDU1NzQ1ODIsInVzZXJfaWQiOjUyODI5OTAzMH0.yILk1vXe2vX81teF_UVtLqCh-tqyCgnOYlQLUU0alnzVpxGY-3gd6oUbmHmFeMowDsoCJzGEH0G2nwHW6hCEwQ
+Accept: application/json
+Host: docs.api.com
+
+--6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
+Content-Disposition: form-data; name=file; filename=filename.txt
+Content-Type: image/jpeg
+
+some xml
+--6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
+Content-Disposition: form-data; name=request; filename=filename.txt
+Content-Type: application/json
+
+{"type":"HAIR","sex":"MALE"}
+--6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm--
+
+
+
+
+
+
+
+
+
{
+ "avatarItemId" : 528299030,
+ "type" : "HAIR",
+ "sex" : "MALE",
+ "url" : "https://www.alva-reinger.org:4583/nam/harumofficiis?consectetur=esse&sequi=dolorem"
+}
+
+
+
+
+
+
+
+
+
+
+Path |
+Type |
+Description |
+
+
+
+
+avatarItemId
|
+Number
|
+아바타 이미지 ID |
+
+
+type
|
+String
|
+아바타 이미지 타입 |
+
+
+sex
|
+String
|
+아바타 이미지 성별 |
+
+
+url
|
+String
|
+아바타 이미지 URL |
+
+
+
+
+
+
+
+
HTTP/1.1 201 Created
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json
+Content-Length: 165
+
+{
+ "avatarItemId" : 528299030,
+ "type" : "HAIR",
+ "sex" : "MALE",
+ "url" : "https://www.alva-reinger.org:4583/nam/harumofficiis?consectetur=esse&sequi=dolorem"
+}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+전체 아바타 이미지 조회
+ |
+
+
+
+
+
+
+
+
+
+
GET /api/v1/avatar-items HTTP/1.1
+Accept: application/json
+Host: docs.api.com
+
+
+
+
+
+
+
+
+
[ {
+ "avatarItemId" : 1,
+ "type" : "HAIR",
+ "sex" : "MALE",
+ "url" : "http://www.williams-crooks.biz/"
+}, {
+ "avatarItemId" : 2,
+ "type" : "TOP",
+ "sex" : "FEMALE",
+ "url" : "https://www.elyse-volkman.io/earum/eius?voluptas=rerum&voluptas=exercitationem#vel"
+} ]
+
+
+
+
+
+
+
+
+
+
+Path |
+Type |
+Description |
+
+
+
+
+[].avatarItemId
|
+Number
|
+아바타 이미지 ID |
+
+
+[].type
|
+String
|
+아바타 이미지 타입 |
+
+
+[].sex
|
+String
|
+아바타 이미지 성별 |
+
+
+[].url
|
+String
|
+아바타 이미지 URL |
+
+
+
+
+
+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json
+Content-Length: 270
+
+[ {
+ "avatarItemId" : 1,
+ "type" : "HAIR",
+ "sex" : "MALE",
+ "url" : "http://www.williams-crooks.biz/"
+}, {
+ "avatarItemId" : 2,
+ "type" : "TOP",
+ "sex" : "FEMALE",
+ "url" : "https://www.elyse-volkman.io/earum/eius?voluptas=rerum&voluptas=exercitationem#vel"
+} ]
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Name |
+Description |
+Required |
+
+
+
+
+Authorization
|
+JWT 토큰 |
+true |
+
+
+
+
+
+
+
+
+
+
+Part |
+Description |
+
+
+
+
+request
|
+게시글 생성 요청 정보 |
+
+
+postImg
|
+게시글 이미지 파일 |
+
+
+
+
+
+
+
+
+
+
+
+Path |
+Type |
+Description |
+
+
+
+
+title
|
+String
|
+게시글 제목 |
+
+
+content
|
+String
|
+게시글 내용 |
+
+
+coordinate.nx
|
+Number
|
+사용자 X 좌표 |
+
+
+coordinate.ny
|
+Number
|
+사용자 Y 좌표 |
+
+
+
+
+
+
+
+
+
POST /api/v1/posts HTTP/1.1
+Content-Type: multipart/form-data;charset=UTF-8; boundary=6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
+Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJpc3MiOiJvb3R3IiwiaWF0IjoxNzA1NTcwOTg0LCJleHAiOjE3MDU1NzQ1ODQsInVzZXJfaWQiOjF9.mTcgBjKPrrKkjVtfmEk0MJyDI-8PQyKuXrzwr2w1_I8mwQq5lT6PkW9eMPNfCee2vdtxCj6HF_U2rN_yqcbgRA
+Accept: application/json
+Host: docs.api.com
+
+--6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
+Content-Disposition: form-data; name=request; filename=request.json
+Content-Type: application/json
+
+{"title":"Arms and the Man","content":"Tin","coordinate":{"nx":50,"ny":127}}
+--6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
+Content-Disposition: form-data; name=postImg; filename=image.jpeg
+Content-Type: image/jpeg
+
+content
+--6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm--
+
+
+
+
+
+
+
+
+
{
+ "postId" : 1,
+ "title" : "Arms and the Man",
+ "content" : "Tin",
+ "image" : "https://www.arnulfo-runolfsdottir.info/?architecto=ut&impedit=aperiam",
+ "createdAt" : "2024-01-18T18:43:04.877194",
+ "updatedAt" : "2024-01-18T18:43:04.877208",
+ "temperatureArrange" : {
+ "min" : 0.0,
+ "max" : 15.0
+ }
+}
+
+
+
+
+
+
+
+
+
+
+Path |
+Type |
+Description |
+
+
+
+
+postId
|
+Number
|
+게시글 ID |
+
+
+title
|
+String
|
+게시글 제목 |
+
+
+content
|
+String
|
+게시글 내용 |
+
+
+image
|
+String
|
+게시글 이미지 URL |
+
+
+createdAt
|
+String
|
+게시글 생성 일자 |
+
+
+updatedAt
|
+String
|
+게시글 수정 일자 |
+
+
+temperatureArrange.min
|
+Number
|
+최저 기온 |
+
+
+temperatureArrange.max
|
+Number
|
+최고 기온 |
+
+
+
+
+
+
+
+
HTTP/1.1 201 Created
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Location: /api/v1/posts/1
+Content-Type: application/json
+Content-Length: 314
+
+{
+ "postId" : 1,
+ "title" : "Arms and the Man",
+ "content" : "Tin",
+ "image" : "https://www.arnulfo-runolfsdottir.info/?architecto=ut&impedit=aperiam",
+ "createdAt" : "2024-01-18T18:43:04.877194",
+ "updatedAt" : "2024-01-18T18:43:04.877208",
+ "temperatureArrange" : {
+ "min" : 0.0,
+ "max" : 15.0
+ }
+}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+게시글 상세 조회
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Name |
+Description |
+Required |
+
+
+
+
+Authorization
|
+JWT 토큰 |
+false |
+
+
+
+
+Table 1. /api/v1/posts/{postId}
+
+
+
+
+
+
+Parameter |
+Description |
+
+
+
+
+postId
|
+게시글 ID |
+
+
+
+
+
+
+
+
+
GET /api/v1/posts/1803095596 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJpc3MiOiJvb3R3IiwiaWF0IjoxNzA1NTcwOTg0LCJleHAiOjE3MDU1NzQ1ODQsInVzZXJfaWQiOjF9.mTcgBjKPrrKkjVtfmEk0MJyDI-8PQyKuXrzwr2w1_I8mwQq5lT6PkW9eMPNfCee2vdtxCj6HF_U2rN_yqcbgRA
+Accept: application/json
+Host: docs.api.com
+
+
+
+
+
+
+
+
+
{
+ "postId" : 1803095596,
+ "writer" : {
+ "userId" : 412308604,
+ "nickname" : "jay.grimes",
+ "image" : "http://www.wyatt-terry.biz/?et=incidunt&possimus=saepe#maxime"
+ },
+ "title" : "The Lathe of Heaven",
+ "content" : "Californium",
+ "image" : "https://www.breanne-waelchi.org:43673/ullam/sequiodio#earum",
+ "createdAt" : "2024-01-18T18:43:04.832065",
+ "updatedAt" : "2024-01-18T18:43:04.832079",
+ "temperatureArrange" : {
+ "min" : 0.0,
+ "max" : 15.0
+ },
+ "likeCnt" : 33,
+ "isLike" : 0
+}
+
+
+
+
+
+
+
+
+
+
+Path |
+Type |
+Description |
+
+
+
+
+postId
|
+Number
|
+게시글 ID |
+
+
+writer.userId
|
+Number
|
+게시글 작성자 ID |
+
+
+writer.nickname
|
+String
|
+게시글 작성자 별명 |
+
+
+writer.image
|
+String
|
+게시글 작성자 프로필 이미지 URL |
+
+
+title
|
+String
|
+게시글 제목 |
+
+
+content
|
+String
|
+게시글 내용 |
+
+
+image
|
+String
|
+게시글 이미지 URL |
+
+
+createdAt
|
+String
|
+게시글 생성 일자 |
+
+
+updatedAt
|
+String
|
+게시글 수정 일자 |
+
+
+temperatureArrange.min
|
+Number
|
+최저 기온 |
+
+
+temperatureArrange.max
|
+Number
|
+최고 기온 |
+
+
+likeCnt
|
+Number
|
+좋아요 개수 |
+
+
+isLike
|
+Number
|
+좋아요 여부 |
+
+
+
+
+
+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json
+Content-Length: 513
+
+{
+ "postId" : 1803095596,
+ "writer" : {
+ "userId" : 412308604,
+ "nickname" : "jay.grimes",
+ "image" : "http://www.wyatt-terry.biz/?et=incidunt&possimus=saepe#maxime"
+ },
+ "title" : "The Lathe of Heaven",
+ "content" : "Californium",
+ "image" : "https://www.breanne-waelchi.org:43673/ullam/sequiodio#earum",
+ "createdAt" : "2024-01-18T18:43:04.832065",
+ "updatedAt" : "2024-01-18T18:43:04.832079",
+ "temperatureArrange" : {
+ "min" : 0.0,
+ "max" : 15.0
+ },
+ "likeCnt" : 33,
+ "isLike" : 0
+}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+게시글 전체 조회
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Name |
+Description |
+Required |
+
+
+
+
+Authorization
|
+JWT 토큰 |
+false |
+
+
+
+
+
+
+
+
+
GET /api/v1/posts HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJpc3MiOiJvb3R3IiwiaWF0IjoxNzA1NTcwOTg0LCJleHAiOjE3MDU1NzQ1ODQsInVzZXJfaWQiOjF9.mTcgBjKPrrKkjVtfmEk0MJyDI-8PQyKuXrzwr2w1_I8mwQq5lT6PkW9eMPNfCee2vdtxCj6HF_U2rN_yqcbgRA
+Accept: application/json
+Host: docs.api.com
+
+
+
+
+
+
+
+
+
[ {
+ "postId" : 1908042653,
+ "writer" : {
+ "userId" : 934467639,
+ "nickname" : "alfredo.schmidt",
+ "image" : "http://www.king-huels.com:45991/nemo/et?quas=vero&debitis=consequatur#ut"
+ },
+ "title" : "An Acceptable Time",
+ "content" : "Cobalt",
+ "image" : "https://www.lowell-rogahn.io:60654/#molestias",
+ "createdAt" : "2024-01-18T18:43:04.779531",
+ "updatedAt" : "2024-01-18T18:43:04.779548",
+ "temperatureArrange" : {
+ "min" : 0.0,
+ "max" : 15.0
+ },
+ "likeCnt" : 54,
+ "isLike" : 0
+}, {
+ "postId" : 976739760,
+ "writer" : {
+ "userId" : 1468392497,
+ "nickname" : "georgianne.yundt",
+ "image" : "http://www.sherilyn-pollich.org/dolores?cumque=magnam&dolor=nulla#deleniti"
+ },
+ "title" : "The Little Foxes",
+ "content" : "Helium",
+ "image" : "https://www.georgette-hirthe.net/aperiam/optio?eum=molestiae&aut=officiis#illum",
+ "createdAt" : "2024-01-18T18:43:04.782134",
+ "updatedAt" : "2024-01-18T18:43:04.78215",
+ "temperatureArrange" : {
+ "min" : 0.0,
+ "max" : 15.0
+ },
+ "likeCnt" : 55,
+ "isLike" : 0
+}, {
+ "postId" : 1153411505,
+ "writer" : {
+ "userId" : 682342469,
+ "nickname" : "marlin.nienow",
+ "image" : "https://www.darrick-kozey.io:61868/perspiciatis#quae"
+ },
+ "title" : "Precious Bane",
+ "content" : "Potassium",
+ "image" : "http://www.corrine-west.biz/a/dolorem?sit=dolorem&molestias=aliquid",
+ "createdAt" : "2024-01-18T18:43:04.78255",
+ "updatedAt" : "2024-01-18T18:43:04.782565",
+ "temperatureArrange" : {
+ "min" : 0.0,
+ "max" : 15.0
+ },
+ "likeCnt" : 70,
+ "isLike" : 0
+} ]
+
+
+
+
+
+
+
+
+
+
+Path |
+Type |
+Description |
+
+
+
+
+[]postId
|
+Number
|
+게시글 ID |
+
+
+[]writer.userId
|
+Number
|
+게시글 작성자 ID |
+
+
+[]writer.nickname
|
+String
|
+게시글 작성자 별명 |
+
+
+[]writer.image
|
+String
|
+게시글 작성자 프로필 이미지 URL |
+
+
+[]title
|
+String
|
+게시글 제목 |
+
+
+[]content
|
+String
|
+게시글 내용 |
+
+
+[]image
|
+String
|
+게시글 이미지 URL |
+
+
+[]createdAt
|
+String
|
+게시글 생성 일자 |
+
+
+[]updatedAt
|
+String
|
+게시글 수정 일자 |
+
+
+[]temperatureArrange.min
|
+Number
|
+최저 기온 |
+
+
+[]temperatureArrange.max
|
+Number
|
+최고 기온 |
+
+
+[]likeCnt
|
+Number
|
+좋아요 개수 |
+
+
+[]isLike
|
+Number
|
+좋아요 여부 |
+
+
+
+
+
+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json
+Content-Length: 1566
+
+[ {
+ "postId" : 1908042653,
+ "writer" : {
+ "userId" : 934467639,
+ "nickname" : "alfredo.schmidt",
+ "image" : "http://www.king-huels.com:45991/nemo/et?quas=vero&debitis=consequatur#ut"
+ },
+ "title" : "An Acceptable Time",
+ "content" : "Cobalt",
+ "image" : "https://www.lowell-rogahn.io:60654/#molestias",
+ "createdAt" : "2024-01-18T18:43:04.779531",
+ "updatedAt" : "2024-01-18T18:43:04.779548",
+ "temperatureArrange" : {
+ "min" : 0.0,
+ "max" : 15.0
+ },
+ "likeCnt" : 54,
+ "isLike" : 0
+}, {
+ "postId" : 976739760,
+ "writer" : {
+ "userId" : 1468392497,
+ "nickname" : "georgianne.yundt",
+ "image" : "http://www.sherilyn-pollich.org/dolores?cumque=magnam&dolor=nulla#deleniti"
+ },
+ "title" : "The Little Foxes",
+ "content" : "Helium",
+ "image" : "https://www.georgette-hirthe.net/aperiam/optio?eum=molestiae&aut=officiis#illum",
+ "createdAt" : "2024-01-18T18:43:04.782134",
+ "updatedAt" : "2024-01-18T18:43:04.78215",
+ "temperatureArrange" : {
+ "min" : 0.0,
+ "max" : 15.0
+ },
+ "likeCnt" : 55,
+ "isLike" : 0
+}, {
+ "postId" : 1153411505,
+ "writer" : {
+ "userId" : 682342469,
+ "nickname" : "marlin.nienow",
+ "image" : "https://www.darrick-kozey.io:61868/perspiciatis#quae"
+ },
+ "title" : "Precious Bane",
+ "content" : "Potassium",
+ "image" : "http://www.corrine-west.biz/a/dolorem?sit=dolorem&molestias=aliquid",
+ "createdAt" : "2024-01-18T18:43:04.78255",
+ "updatedAt" : "2024-01-18T18:43:04.782565",
+ "temperatureArrange" : {
+ "min" : 0.0,
+ "max" : 15.0
+ },
+ "likeCnt" : 70,
+ "isLike" : 0
+} ]
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Name |
+Description |
+Required |
+
+
+
+
+Authorization
|
+JWT 토큰 |
+true |
+
+
+
+
+
+
+
+
+
+
+Part |
+Description |
+
+
+
+
+request
|
+게시글 생성 요청 정보 |
+
+
+postImg
|
+게시글 이미지 파일 |
+
+
+
+
+
+
+
+
+
+
+
+Path |
+Type |
+Description |
+
+
+
+
+title
|
+String
|
+게시글 제목 |
+
+
+content
|
+String
|
+게시글 내용 |
+
+
+
+
+
+
+
+
+
PUT /api/v1/posts/2 HTTP/1.1
+Content-Type: multipart/form-data;charset=UTF-8; boundary=6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
+Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJpc3MiOiJvb3R3IiwiaWF0IjoxNzA1NTcwOTg0LCJleHAiOjE3MDU1NzQ1ODQsInVzZXJfaWQiOjF9.mTcgBjKPrrKkjVtfmEk0MJyDI-8PQyKuXrzwr2w1_I8mwQq5lT6PkW9eMPNfCee2vdtxCj6HF_U2rN_yqcbgRA
+Accept: application/json
+Host: docs.api.com
+
+--6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
+Content-Disposition: form-data; name=request; filename=request.json
+Content-Type: application/json
+
+{"title":"Absalom, Absalom!","content":"Neodymium"}
+--6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
+Content-Disposition: form-data; name=postImg; filename=image.jpeg
+Content-Type: image/jpeg
+
+content
+--6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm--
+
+
+
+
+
+
+
+
+
{
+ "postId" : 2,
+ "title" : "Absalom, Absalom!",
+ "content" : "Neodymium",
+ "image" : "https://www.shelby-schaefer.name:21573/et/distinctiofugiat?magnam=consequatur&consectetur=doloremque#corrupti",
+ "createdAt" : "2024-01-18T18:43:04.934907",
+ "updatedAt" : "2024-01-18T18:43:04.934923",
+ "temperatureArrange" : {
+ "min" : 0.0,
+ "max" : 15.0
+ }
+}
+
+
+
+
+
+
+
+
+
+
+Path |
+Type |
+Description |
+
+
+
+
+postId
|
+Number
|
+게시글 ID |
+
+
+title
|
+String
|
+게시글 제목 |
+
+
+content
|
+String
|
+게시글 내용 |
+
+
+image
|
+String
|
+게시글 이미지 URL |
+
+
+createdAt
|
+String
|
+게시글 생성 일자 |
+
+
+updatedAt
|
+String
|
+게시글 수정 일자 |
+
+
+temperatureArrange.min
|
+Number
|
+최저 기온 |
+
+
+temperatureArrange.max
|
+Number
|
+최고 기온 |
+
+
+
+
+
+
+
+
HTTP/1.1 201 Created
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Location: /api/v1/posts/2
+Content-Type: application/json
+Content-Length: 361
+
+{
+ "postId" : 2,
+ "title" : "Absalom, Absalom!",
+ "content" : "Neodymium",
+ "image" : "https://www.shelby-schaefer.name:21573/et/distinctiofugiat?magnam=consequatur&consectetur=doloremque#corrupti",
+ "createdAt" : "2024-01-18T18:43:04.934907",
+ "updatedAt" : "2024-01-18T18:43:04.934923",
+ "temperatureArrange" : {
+ "min" : 0.0,
+ "max" : 15.0
+ }
+}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Name |
+Description |
+Required |
+
+
+
+
+Authorization
|
+JWT 토큰 |
+true |
+
+
+
+
+Table 2. /api/v1/posts/{postId}
+
+
+
+
+
+
+Parameter |
+Description |
+
+
+
+
+postId
|
+게시글 ID |
+
+
+
+
+
+
+
+
+
DELETE /api/v1/posts/1744803171 HTTP/1.1
+Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJpc3MiOiJvb3R3IiwiaWF0IjoxNzA1NTcwOTg0LCJleHAiOjE3MDU1NzQ1ODQsInVzZXJfaWQiOjF9.mTcgBjKPrrKkjVtfmEk0MJyDI-8PQyKuXrzwr2w1_I8mwQq5lT6PkW9eMPNfCee2vdtxCj6HF_U2rN_yqcbgRA
+Host: docs.api.com
+
+
+
+
+
+
+
+
+
+
+
HTTP/1.1 204 No Content
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Name |
+Description |
+Required |
+
+
+
+
+Authorization
|
+JWT 토큰 |
+true |
+
+
+
+
+
+
{
+ "postId" : 1251693879
+}
+
+
+
+
+
+
+
+
+
+
+Path |
+Type |
+Description |
+
+
+
+
+postId
|
+Number
|
+게시글 ID |
+
+
+
+
+
+
+
+
+
POST /api/v1/posts/1251693879/likes HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJpc3MiOiJvb3R3IiwiaWF0IjoxNzA1NTcwOTgzLCJleHAiOjE3MDU1NzQ1ODMsInVzZXJfaWQiOjIxNDIzNzM3MjN9.soHfQaFbqHJl5xPC3moiDjk4fXWy9l0oTTliB9oSzqsfArjZKeBV5KuWtc_1cm4e1AO-PhBr2V4_m9h2Et_3vg
+Accept: application/json
+Content-Length: 27
+Host: docs.api.com
+
+{
+ "postId" : 1251693879
+}
+
+
+
+
+
+
+
+
+
{
+ "likeId" : 581393781,
+ "userId" : 2142373723,
+ "postId" : 1251693879,
+ "status" : true
+}
+
+
+
+
+
+
+
+
+
+
+Path |
+Type |
+Description |
+
+
+
+
+likeId
|
+Number
|
+좋아요 ID |
+
+
+userId
|
+Number
|
+좋아요를 누른 사용자 ID |
+
+
+postId
|
+Number
|
+게시글 ID |
+
+
+status
|
+Boolean
|
+좋아요 여부 |
+
+
+
+
+
+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json
+Content-Length: 95
+
+{
+ "likeId" : 581393781,
+ "userId" : 2142373723,
+ "postId" : 1251693879,
+ "status" : true
+}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+현재 날씨 조회
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Name |
+Description |
+Required |
+
+
+
+
+Authorization
|
+JWT 토큰 |
+true |
+
+
+
+
+
+
+
+
+
+
+Parameter |
+Description |
+
+
+
+
+nx
|
+사용자 X 좌표 |
+
+
+ny
|
+사용자 Y 좌표 |
+
+
+
+
+
+
+
+
+
GET /api/v1/weather?nx=50&ny=127 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJpc3MiOiJvb3R3IiwiaWF0IjoxNzA1NTcwOTg3LCJleHAiOjE3MDU1NzQ1ODcsInVzZXJfaWQiOjF9.PXvjVy9boT_qqEC9Vt0SWy2OjUPPj3wbUtTJ5iUyh21-u3C9WfWh_gH2AvAqo3Cj6XWP0twgbUmBUlM_XYChFw
+Accept: application/json
+Host: docs.api.com
+
+
+
+
+
+
+
+
+
{
+ "currentDateTime" : "2024-01-10T14:05:00",
+ "currentTemperature" : 0.0,
+ "sky" : "SUNNY",
+ "pty" : "RAIN"
+}
+
+
+
+
+
+
+
+
+
+
+Path |
+Type |
+Description |
+
+
+
+
+currentDateTime
|
+String
|
+현재 시간 |
+
+
+currentTemperature
|
+Number
|
+현재 온도 |
+
+
+sky
|
+String
|
+하늘 상태 코드 |
+
+
+pty
|
+String
|
+강수 상태 코드 |
+
+
+
+
+
+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json
+Content-Length: 114
+
+{
+ "currentDateTime" : "2024-01-10T14:05:00",
+ "currentTemperature" : 0.0,
+ "sky" : "SUNNY",
+ "pty" : "RAIN"
+}
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/main/java/com/backendoori/ootw/OotwApplication.java b/src/main/java/com/backendoori/ootw/OotwApplication.java
index 04a06d80..d1869f36 100644
--- a/src/main/java/com/backendoori/ootw/OotwApplication.java
+++ b/src/main/java/com/backendoori/ootw/OotwApplication.java
@@ -1,5 +1,7 @@
package com.backendoori.ootw;
+import java.util.TimeZone;
+import jakarta.annotation.PostConstruct;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
@@ -8,8 +10,15 @@
@ConfigurationPropertiesScan
public class OotwApplication {
+ public static final String TIMEZONE = "Asia/Seoul";
+
public static void main(String[] args) {
SpringApplication.run(OotwApplication.class, args);
}
+ @PostConstruct
+ public void setTimezone() {
+ TimeZone.setDefault(TimeZone.getTimeZone(TIMEZONE));
+ }
+
}
diff --git a/src/main/java/com/backendoori/ootw/avatar/controller/AvatarItemController.java b/src/main/java/com/backendoori/ootw/avatar/controller/AvatarItemController.java
index 6050fc13..6e0c4c1b 100644
--- a/src/main/java/com/backendoori/ootw/avatar/controller/AvatarItemController.java
+++ b/src/main/java/com/backendoori/ootw/avatar/controller/AvatarItemController.java
@@ -1,5 +1,6 @@
package com.backendoori.ootw.avatar.controller;
+import java.util.List;
import com.backendoori.ootw.avatar.dto.AvatarItemRequest;
import com.backendoori.ootw.avatar.dto.AvatarItemResponse;
import com.backendoori.ootw.avatar.service.AvatarItemService;
@@ -8,6 +9,7 @@
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.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestPart;
@@ -22,11 +24,16 @@ public class AvatarItemController {
private final AvatarItemService appearanceService;
@PostMapping
- public ResponseEntity uploadImage(@RequestPart @Image MultipartFile file,
- @RequestPart @Valid AvatarItemRequest request) {
- AvatarItemResponse avatarItem = appearanceService.uploadItem(file, request);
+ public ResponseEntity upload(@RequestPart @Image MultipartFile file,
+ @RequestPart @Valid AvatarItemRequest request) {
+ AvatarItemResponse avatarItem = appearanceService.upload(file, request);
return ResponseEntity.status(HttpStatus.CREATED).body(avatarItem);
}
+ @GetMapping
+ public ResponseEntity> getAll() {
+ return ResponseEntity.status(HttpStatus.OK).body(appearanceService.getList());
+ }
+
}
diff --git a/src/main/java/com/backendoori/ootw/avatar/domain/AvatarItem.java b/src/main/java/com/backendoori/ootw/avatar/domain/AvatarItem.java
index fcd6b6da..67b1a625 100644
--- a/src/main/java/com/backendoori/ootw/avatar/domain/AvatarItem.java
+++ b/src/main/java/com/backendoori/ootw/avatar/domain/AvatarItem.java
@@ -1,5 +1,9 @@
package com.backendoori.ootw.avatar.domain;
+import static com.backendoori.ootw.avatar.validation.AvatarImageValidator.validateImage;
+import static com.backendoori.ootw.avatar.validation.AvatarImageValidator.validateItemType;
+import static com.backendoori.ootw.avatar.validation.AvatarImageValidator.validateSex;
+
import com.backendoori.ootw.avatar.dto.AvatarItemRequest;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
@@ -24,20 +28,25 @@ public class AvatarItem {
@Column(name = "id")
private Long id;
- @Column(name = "image", nullable = false)
- private String image;
+ @Column(name = "image_url", nullable = false)
+ private String imageUrl;
@Column(name = "type", nullable = false, columnDefinition = "varchar(30)")
@Enumerated(EnumType.STRING)
private ItemType itemType;
- @Column(name = "sex", nullable = false, columnDefinition = "tinyint")
- private boolean sex;
+ @Column(name = "sex", nullable = false, columnDefinition = "varchar(10)")
+ @Enumerated(EnumType.STRING)
+ private Sex sex;
+
+ private AvatarItem(String imageUrl, String type, String sex) {
+ validateImage(imageUrl);
+ validateItemType(type);
+ validateSex(sex);
- private AvatarItem(String image, String type, boolean sex) {
- this.image = image;
+ this.imageUrl = imageUrl;
this.itemType = ItemType.valueOf(type);
- this.sex = sex;
+ this.sex = Sex.valueOf(sex);
}
public static AvatarItem create(AvatarItemRequest requestDto, String url) {
diff --git a/src/main/java/com/backendoori/ootw/avatar/domain/ItemType.java b/src/main/java/com/backendoori/ootw/avatar/domain/ItemType.java
index ee2d5f60..f72c531c 100644
--- a/src/main/java/com/backendoori/ootw/avatar/domain/ItemType.java
+++ b/src/main/java/com/backendoori/ootw/avatar/domain/ItemType.java
@@ -1,5 +1,13 @@
package com.backendoori.ootw.avatar.domain;
+import java.util.Arrays;
+
public enum ItemType {
- HAIR, TOP, PANTS, ACCESSORY, SHOES, BACKGROUND
+ HAIR, TOP, PANTS, ACCESSORY, SHOES, BACKGROUND;
+
+ public static boolean checkValue(String itemType) {
+ return Arrays.stream(ItemType.values())
+ .anyMatch(e -> e.name().equals(itemType));
+ }
+
}
diff --git a/src/main/java/com/backendoori/ootw/avatar/domain/Sex.java b/src/main/java/com/backendoori/ootw/avatar/domain/Sex.java
new file mode 100644
index 00000000..0f6bbe29
--- /dev/null
+++ b/src/main/java/com/backendoori/ootw/avatar/domain/Sex.java
@@ -0,0 +1,13 @@
+package com.backendoori.ootw.avatar.domain;
+
+import java.util.Arrays;
+
+public enum Sex {
+ MALE, FEMALE;
+
+ public static boolean checkValue(String sex) {
+ return Arrays.stream(Sex.values())
+ .anyMatch(e -> e.name().equals(sex));
+ }
+
+}
diff --git a/src/main/java/com/backendoori/ootw/avatar/dto/AvatarItemRequest.java b/src/main/java/com/backendoori/ootw/avatar/dto/AvatarItemRequest.java
index 0a5f2bf2..6285c895 100644
--- a/src/main/java/com/backendoori/ootw/avatar/dto/AvatarItemRequest.java
+++ b/src/main/java/com/backendoori/ootw/avatar/dto/AvatarItemRequest.java
@@ -1,14 +1,15 @@
package com.backendoori.ootw.avatar.dto;
import com.backendoori.ootw.avatar.domain.ItemType;
+import com.backendoori.ootw.avatar.domain.Sex;
import com.backendoori.ootw.common.validation.Enum;
import jakarta.validation.constraints.NotNull;
public record AvatarItemRequest(
@Enum(enumClass = ItemType.class)
String type,
- @NotNull
- boolean sex
+ @Enum(enumClass = Sex.class)
+ String sex
) {
}
diff --git a/src/main/java/com/backendoori/ootw/avatar/dto/AvatarItemResponse.java b/src/main/java/com/backendoori/ootw/avatar/dto/AvatarItemResponse.java
index b69bc3df..bb0bc156 100644
--- a/src/main/java/com/backendoori/ootw/avatar/dto/AvatarItemResponse.java
+++ b/src/main/java/com/backendoori/ootw/avatar/dto/AvatarItemResponse.java
@@ -3,15 +3,18 @@
import com.backendoori.ootw.avatar.domain.AvatarItem;
public record AvatarItemResponse(
+ Long avatarItemId,
String type,
- boolean sex,
+ String sex,
String url
) {
public static AvatarItemResponse from(AvatarItem avatarItem) {
- return new AvatarItemResponse(avatarItem.getItemType().name(),
- avatarItem.isSex(),
- avatarItem.getImage());
+ return new AvatarItemResponse(
+ avatarItem.getId(),
+ avatarItem.getItemType().name(),
+ avatarItem.getSex().name(),
+ avatarItem.getImageUrl());
}
}
diff --git a/src/main/java/com/backendoori/ootw/avatar/service/AvatarItemService.java b/src/main/java/com/backendoori/ootw/avatar/service/AvatarItemService.java
index 7e37bf76..b66b0ff1 100644
--- a/src/main/java/com/backendoori/ootw/avatar/service/AvatarItemService.java
+++ b/src/main/java/com/backendoori/ootw/avatar/service/AvatarItemService.java
@@ -1,10 +1,14 @@
package com.backendoori.ootw.avatar.service;
+import java.util.List;
import com.backendoori.ootw.avatar.domain.AvatarItem;
import com.backendoori.ootw.avatar.dto.AvatarItemRequest;
import com.backendoori.ootw.avatar.dto.AvatarItemResponse;
import com.backendoori.ootw.avatar.repository.AvatarItemRepository;
+import com.backendoori.ootw.common.image.ImageFile;
import com.backendoori.ootw.common.image.ImageService;
+import com.backendoori.ootw.common.image.exception.SaveException;
+import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
@@ -16,11 +20,25 @@ public class AvatarItemService {
private final ImageService imageService;
private final AvatarItemRepository avatarItemRepository;
- public AvatarItemResponse uploadItem(MultipartFile file, AvatarItemRequest requestDto) {
- String url = imageService.uploadImage(file);
- AvatarItem savedItem = avatarItemRepository.save(AvatarItem.create(requestDto, url));
+ @Transactional
+ public AvatarItemResponse upload(MultipartFile file, AvatarItemRequest requestDto) {
+ ImageFile imageFile = imageService.upload(file);
+ try {
+ String url = imageFile.url();
+ AvatarItem savedItem = avatarItemRepository.save(AvatarItem.create(requestDto, url));
- return AvatarItemResponse.from(savedItem);
+ return AvatarItemResponse.from(savedItem);
+ } catch (Exception e) {
+ imageService.delete(imageFile.fileName());
+ throw new SaveException();
+ }
+ }
+
+ public List getList() {
+ return avatarItemRepository.findAll()
+ .stream()
+ .map(AvatarItemResponse::from)
+ .toList();
}
}
diff --git a/src/main/java/com/backendoori/ootw/avatar/validation/AvatarImageValidator.java b/src/main/java/com/backendoori/ootw/avatar/validation/AvatarImageValidator.java
new file mode 100644
index 00000000..8cb95fb2
--- /dev/null
+++ b/src/main/java/com/backendoori/ootw/avatar/validation/AvatarImageValidator.java
@@ -0,0 +1,32 @@
+package com.backendoori.ootw.avatar.validation;
+
+import com.backendoori.ootw.avatar.domain.ItemType;
+import com.backendoori.ootw.avatar.domain.Sex;
+import io.jsonwebtoken.lang.Assert;
+
+public class AvatarImageValidator {
+
+ private static final String NO_IMAGE_URL_MESSAGE = "아바타 이미지 url이 존재하지 않습니다.";
+ private static final String ITEM_TYPE_ESSENTIAL = "아바타 타입은 반드시 포함되어야 합니다.";
+ private static final String INVALID_ITEM_TYPE_MESSAGE = "해당 단어가 아바타 이미지 타입이 존재하지 않습니다.";
+ private static final String SEX_ESSENTIAL = "성별은 반드시 포함되어야 합니다.";
+ private static final String INVALID_WORD_MESSAGE = "해당 단어가 프로젝트 내 성별 분류 체계에 존재하지 않습니다.";
+
+ public static void validateSex(String sex) {
+ Assert.notNull(sex, SEX_ESSENTIAL);
+ Assert.isTrue(!sex.isBlank(), SEX_ESSENTIAL);
+ Assert.isTrue(Sex.checkValue(sex), INVALID_WORD_MESSAGE);
+ }
+
+ public static void validateItemType(String type) {
+ Assert.notNull(type, ITEM_TYPE_ESSENTIAL);
+ Assert.isTrue(!type.isBlank(), ITEM_TYPE_ESSENTIAL);
+ Assert.isTrue(ItemType.checkValue(type), INVALID_ITEM_TYPE_MESSAGE);
+ }
+
+ public static void validateImage(String image) {
+ Assert.notNull(image, NO_IMAGE_URL_MESSAGE);
+ Assert.isTrue(!image.isBlank(), NO_IMAGE_URL_MESSAGE);
+ }
+
+}
diff --git a/src/main/java/com/backendoori/ootw/common/AssertUtil.java b/src/main/java/com/backendoori/ootw/common/AssertUtil.java
index e083b4e2..7906ec73 100644
--- a/src/main/java/com/backendoori/ootw/common/AssertUtil.java
+++ b/src/main/java/com/backendoori/ootw/common/AssertUtil.java
@@ -11,7 +11,7 @@
public final class AssertUtil extends Assert {
public static void notBlank(@Nullable String string, String message) {
- if (string == null || string.isBlank()) {
+ if (Objects.isNull(string) || string.isBlank()) {
throw new IllegalArgumentException(message);
}
}
diff --git a/src/main/java/com/backendoori/ootw/common/OotwMailSender.java b/src/main/java/com/backendoori/ootw/common/OotwMailSender.java
new file mode 100644
index 00000000..30b1b8d0
--- /dev/null
+++ b/src/main/java/com/backendoori/ootw/common/OotwMailSender.java
@@ -0,0 +1,41 @@
+package com.backendoori.ootw.common;
+
+import jakarta.mail.MessagingException;
+import jakarta.mail.internet.MimeMessage;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.mail.javamail.JavaMailSender;
+import org.springframework.mail.javamail.MimeMessageHelper;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Component;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class OotwMailSender {
+
+ private static final String UTF_8 = "utf-8";
+
+ private final JavaMailSender javaMailSender;
+
+ @Async("email")
+ public void sendMail(String receiver, String title, String body) {
+ try {
+ sendMimeMessage(receiver, title, body);
+ } catch (MessagingException e) {
+ log.error("Fail to send email, receiver: {} title: {}, body: {}", receiver, title, body, e);
+ }
+ }
+
+ private void sendMimeMessage(String receiver, String title, String body) throws MessagingException {
+ MimeMessage mimeMessage = javaMailSender.createMimeMessage();
+ MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, UTF_8);
+
+ helper.setTo(receiver);
+ helper.setSubject(title);
+ helper.setText(body, true);
+
+ javaMailSender.send(mimeMessage);
+ }
+
+}
diff --git a/src/main/java/com/backendoori/ootw/common/image/ImageFile.java b/src/main/java/com/backendoori/ootw/common/image/ImageFile.java
new file mode 100644
index 00000000..1aabf702
--- /dev/null
+++ b/src/main/java/com/backendoori/ootw/common/image/ImageFile.java
@@ -0,0 +1,8 @@
+package com.backendoori.ootw.common.image;
+
+public record ImageFile(
+ String url,
+ String fileName
+) {
+
+}
diff --git a/src/main/java/com/backendoori/ootw/common/image/ImageService.java b/src/main/java/com/backendoori/ootw/common/image/ImageService.java
index 511db558..1064f453 100644
--- a/src/main/java/com/backendoori/ootw/common/image/ImageService.java
+++ b/src/main/java/com/backendoori/ootw/common/image/ImageService.java
@@ -4,6 +4,8 @@
public interface ImageService {
- String uploadImage(MultipartFile file);
+ ImageFile upload(MultipartFile file);
+
+ void delete(String fileName);
}
diff --git a/src/main/java/com/backendoori/ootw/common/image/MiniOImageServiceImpl.java b/src/main/java/com/backendoori/ootw/common/image/MiniOImageServiceImpl.java
index 0f3bb5e0..5dee94e9 100644
--- a/src/main/java/com/backendoori/ootw/common/image/MiniOImageServiceImpl.java
+++ b/src/main/java/com/backendoori/ootw/common/image/MiniOImageServiceImpl.java
@@ -1,13 +1,18 @@
package com.backendoori.ootw.common.image;
+import static com.backendoori.ootw.common.image.exception.ImageException.IMAGE_ROLLBACK_FAIL_MESSAGE;
+import static com.backendoori.ootw.common.image.exception.ImageException.IMAGE_UPLOAD_FAIL_MESSAGE;
+import static com.backendoori.ootw.common.validation.ImageValidator.validateImage;
+
import java.io.InputStream;
import java.nio.file.Path;
import java.util.concurrent.TimeUnit;
+import com.backendoori.ootw.common.image.exception.ImageException;
import com.backendoori.ootw.config.MiniOConfig;
-import com.backendoori.ootw.exception.ImageUploadException;
import io.minio.GetPresignedObjectUrlArgs;
import io.minio.MinioClient;
import io.minio.PutObjectArgs;
+import io.minio.RemoveObjectArgs;
import io.minio.http.Method;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -27,7 +32,8 @@ public class MiniOImageServiceImpl implements ImageService {
private Path path;
@Override
- public String uploadImage(MultipartFile file) {
+ public ImageFile upload(MultipartFile file) {
+ validateImage(file);
try {
path = Path.of(file.getOriginalFilename());
InputStream inputStream = file.getInputStream();
@@ -39,17 +45,27 @@ public String uploadImage(MultipartFile file) {
.contentType(contentType)
.build();
minioClient.putObject(args);
+ return new ImageFile(getUrl(), path.toString());
} catch (Exception e) {
- throw new ImageUploadException();
+ throw new ImageException(IMAGE_UPLOAD_FAIL_MESSAGE);
}
+ }
- return getUrl();
+ @Override
+ public void delete(String fileName) {
+ try {
+ minioClient.removeObject(RemoveObjectArgs.builder()
+ .bucket(miniOConfig.getBucket())
+ .object(fileName)
+ .build());
+ } catch (Exception e) {
+ throw new ImageException(IMAGE_ROLLBACK_FAIL_MESSAGE);
+ }
}
private String getUrl() {
- String url = null;
try {
- url = minioClient.getPresignedObjectUrl(
+ return minioClient.getPresignedObjectUrl(
GetPresignedObjectUrlArgs.builder()
.method(Method.GET)
.bucket(miniOConfig.getBucket())
@@ -57,10 +73,8 @@ private String getUrl() {
.expiry(DURATION, TimeUnit.HOURS)
.build());
} catch (Exception e) {
- throw new ImageUploadException();
+ throw new ImageException(IMAGE_UPLOAD_FAIL_MESSAGE);
}
-
- return url;
}
}
diff --git a/src/main/java/com/backendoori/ootw/common/image/exception/ImageControllerAdvice.java b/src/main/java/com/backendoori/ootw/common/image/exception/ImageControllerAdvice.java
new file mode 100644
index 00000000..91a9b179
--- /dev/null
+++ b/src/main/java/com/backendoori/ootw/common/image/exception/ImageControllerAdvice.java
@@ -0,0 +1,33 @@
+package com.backendoori.ootw.common.image.exception;
+
+import com.backendoori.ootw.exception.ErrorResponse;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+@Slf4j
+@RestControllerAdvice
+public class ImageControllerAdvice {
+
+ private static final String IMAGE_RELATED_EXCEPTION = "업로드 요청 중 문제가 발생했습니다.";
+
+ @ExceptionHandler(ImageException.class)
+ public ResponseEntity handleImageUploadException(ImageException e) {
+ log.error("error message : {}", e.getMessage());
+ ErrorResponse errorResponse = new ErrorResponse(IMAGE_RELATED_EXCEPTION);
+
+ return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY)
+ .body(errorResponse);
+ }
+
+ @ExceptionHandler(SaveException.class)
+ public ResponseEntity handleSaveException(SaveException e) {
+ log.error("error message : {}", e.getMessage());
+ ErrorResponse errorResponse = new ErrorResponse(IMAGE_RELATED_EXCEPTION);
+
+ return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY)
+ .body(errorResponse);
+ }
+}
diff --git a/src/main/java/com/backendoori/ootw/common/image/exception/ImageException.java b/src/main/java/com/backendoori/ootw/common/image/exception/ImageException.java
new file mode 100644
index 00000000..2109c917
--- /dev/null
+++ b/src/main/java/com/backendoori/ootw/common/image/exception/ImageException.java
@@ -0,0 +1,15 @@
+package com.backendoori.ootw.common.image.exception;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@Getter
+@RequiredArgsConstructor
+public class ImageException extends RuntimeException {
+
+ public static final String IMAGE_UPLOAD_FAIL_MESSAGE = "이미지 업로드 중 예외가 발생했습니다.";
+ public static final String IMAGE_ROLLBACK_FAIL_MESSAGE = "이미지 롤백 중 예외가 발생했습니다.";
+
+ private final String message;
+
+}
diff --git a/src/main/java/com/backendoori/ootw/common/image/exception/SaveException.java b/src/main/java/com/backendoori/ootw/common/image/exception/SaveException.java
new file mode 100644
index 00000000..4ec98d5c
--- /dev/null
+++ b/src/main/java/com/backendoori/ootw/common/image/exception/SaveException.java
@@ -0,0 +1,12 @@
+package com.backendoori.ootw.common.image.exception;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@Getter
+@RequiredArgsConstructor
+public class SaveException extends RuntimeException {
+
+ private static final String DEFAULT_MESSAGE = "이미지 업로드 후 저장 로직에서 예외가 발생했습니다.";
+
+}
diff --git a/src/main/java/com/backendoori/ootw/common/validation/Image.java b/src/main/java/com/backendoori/ootw/common/validation/Image.java
index 6376e9ce..23ccc5dc 100644
--- a/src/main/java/com/backendoori/ootw/common/validation/Image.java
+++ b/src/main/java/com/backendoori/ootw/common/validation/Image.java
@@ -9,10 +9,10 @@
@Target(value = ElementType.PARAMETER)
@Retention(value = RetentionPolicy.RUNTIME)
-@Constraint(validatedBy = ImageValidator.class)
+@Constraint(validatedBy = ImageAnnotationValidator.class)
public @interface Image {
- String message = "유효하지 않은 이미지를 업로드하였습니다. 다른 이미지를 업로드 해주세요";
+ String message = "유효하지 않은 이미지를 업로드하였습니다.";
String message() default message;
@@ -20,4 +20,6 @@
Class extends Payload>[] payload() default {};
+ boolean ignoreCase() default false;
+
}
diff --git a/src/main/java/com/backendoori/ootw/common/validation/ImageAnnotationValidator.java b/src/main/java/com/backendoori/ootw/common/validation/ImageAnnotationValidator.java
new file mode 100644
index 00000000..e6db8849
--- /dev/null
+++ b/src/main/java/com/backendoori/ootw/common/validation/ImageAnnotationValidator.java
@@ -0,0 +1,40 @@
+package com.backendoori.ootw.common.validation;
+
+import jakarta.validation.ConstraintValidator;
+import jakarta.validation.ConstraintValidatorContext;
+import org.springframework.web.multipart.MultipartFile;
+
+public class ImageAnnotationValidator implements ConstraintValidator {
+
+ private static final String IMAGE_PREFIX = "image";
+ private Image annotation;
+
+
+ @Override
+ public void initialize(Image constraintAnnotation) {
+ this.annotation = constraintAnnotation;
+ }
+
+ @Override
+ public boolean isValid(MultipartFile img, ConstraintValidatorContext context) {
+ if (this.annotation.ignoreCase()) {
+ return true;
+ }
+
+ if (img.isEmpty()) {
+ return false;
+ }
+
+ if (img.getSize() > 10_000_000) {
+ return false;
+ }
+
+ String contentType = img.getContentType();
+ if (!contentType.startsWith(IMAGE_PREFIX)) {
+ return false;
+ }
+
+ return true;
+ }
+
+}
diff --git a/src/main/java/com/backendoori/ootw/common/validation/ImageValidator.java b/src/main/java/com/backendoori/ootw/common/validation/ImageValidator.java
index 57f8d59f..d32e4e35 100644
--- a/src/main/java/com/backendoori/ootw/common/validation/ImageValidator.java
+++ b/src/main/java/com/backendoori/ootw/common/validation/ImageValidator.java
@@ -1,25 +1,23 @@
package com.backendoori.ootw.common.validation;
-import jakarta.validation.ConstraintValidator;
-import jakarta.validation.ConstraintValidatorContext;
+import io.jsonwebtoken.lang.Assert;
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
import org.springframework.web.multipart.MultipartFile;
-public class ImageValidator implements ConstraintValidator {
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+public class ImageValidator {
- @Override
- public boolean isValid(MultipartFile img, ConstraintValidatorContext context) {
- if (img == null || img.isEmpty()) {
- return false;
- }
- if (img.getSize() > 10_000_000) {
- return false;
- }
- String contentType = img.getContentType();
- if (!contentType.startsWith("image")) {
- return false;
- }
+ private static final String IMAGE_PREFIX = "image";
+ private static final String EMPTY_FILE = "비어있는 파일은 업로드 할 수 없습니다.";
+ private static final String FILE_OVER_SIZE = "파일이 허용된 최대 크기를 초과했습니다.";
+ private static final String INVALID_FILE_TYPE = "지원하지 않는 형식의 파일입니다.";
- return true;
+ public static void validateImage(MultipartFile img) {
+ Assert.notNull(img, EMPTY_FILE);
+ Assert.isTrue(!img.isEmpty(), EMPTY_FILE);
+ Assert.isTrue(img.getSize() < 10_000_000, FILE_OVER_SIZE);
+ Assert.isTrue(img.getContentType().startsWith(IMAGE_PREFIX), INVALID_FILE_TYPE);
}
}
diff --git a/src/main/java/com/backendoori/ootw/config/AsyncConfig.java b/src/main/java/com/backendoori/ootw/config/AsyncConfig.java
new file mode 100644
index 00000000..2883291b
--- /dev/null
+++ b/src/main/java/com/backendoori/ootw/config/AsyncConfig.java
@@ -0,0 +1,24 @@
+package com.backendoori.ootw.config;
+
+import java.util.concurrent.Executor;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.annotation.AsyncConfigurer;
+import org.springframework.scheduling.annotation.EnableAsync;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+
+@EnableAsync
+@Configuration
+public class AsyncConfig implements AsyncConfigurer {
+
+ @Bean("email")
+ public Executor customExecutor() {
+ ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
+
+ executor.setThreadNamePrefix("email-task");
+ executor.initialize();
+
+ return executor;
+ }
+
+}
diff --git a/src/main/java/com/backendoori/ootw/config/ExceptionHandlerConfigurer.java b/src/main/java/com/backendoori/ootw/config/ExceptionHandlerConfigurer.java
index 42b3d40a..a8f9d16c 100644
--- a/src/main/java/com/backendoori/ootw/config/ExceptionHandlerConfigurer.java
+++ b/src/main/java/com/backendoori/ootw/config/ExceptionHandlerConfigurer.java
@@ -3,14 +3,14 @@
import java.io.IOException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.ExceptionHandlingConfigurer;
import org.springframework.security.core.AuthenticationException;
-import org.springframework.stereotype.Component;
-@Component
+@Configuration
public class ExceptionHandlerConfigurer implements Customizer> {
@Override
diff --git a/src/main/java/com/backendoori/ootw/config/HttpRequestsConfigurer.java b/src/main/java/com/backendoori/ootw/config/HttpRequestsConfigurer.java
index d32368bd..5ead86ba 100644
--- a/src/main/java/com/backendoori/ootw/config/HttpRequestsConfigurer.java
+++ b/src/main/java/com/backendoori/ootw/config/HttpRequestsConfigurer.java
@@ -1,23 +1,30 @@
package com.backendoori.ootw.config;
+import static org.springframework.security.web.util.matcher.AntPathRequestMatcher.antMatcher;
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer;
-import org.springframework.stereotype.Component;
-@Component
+@Configuration
public class HttpRequestsConfigurer
implements Customizer.AuthorizationManagerRequestMatcherRegistry> {
- private static final String AUTH_PREFIX = "/api/v1/auth";
+ private static final String AUTH_RESOURCE = "/api/v1/auth/**";
+ private static final String POST_RESOURCE = "/api/v1/posts/**";
+ private static final String AVATAR_RESOURCE = "/api/v1/avatar-items/**";
@Override
public void customize(
AuthorizeHttpRequestsConfigurer.AuthorizationManagerRequestMatcherRegistry authorizeRequests) {
authorizeRequests
- .requestMatchers(AUTH_PREFIX + "/signup")
+ .requestMatchers(AUTH_RESOURCE)
+ .permitAll()
+ .requestMatchers(antMatcher(HttpMethod.GET, POST_RESOURCE))
.permitAll()
- .requestMatchers(AUTH_PREFIX + "/login")
+ .requestMatchers(antMatcher(HttpMethod.GET, AVATAR_RESOURCE))
.permitAll()
.anyRequest()
.authenticated();
diff --git a/src/main/java/com/backendoori/ootw/exception/GlobalControllerAdvice.java b/src/main/java/com/backendoori/ootw/exception/GlobalControllerAdvice.java
index cf184ae0..ba402c45 100644
--- a/src/main/java/com/backendoori/ootw/exception/GlobalControllerAdvice.java
+++ b/src/main/java/com/backendoori/ootw/exception/GlobalControllerAdvice.java
@@ -14,6 +14,8 @@
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.HandlerMethodValidationException;
+import org.springframework.web.multipart.MultipartException;
+import org.springframework.web.multipart.support.MissingServletRequestPartException;
@Slf4j
@RestControllerAdvice
@@ -21,6 +23,14 @@ public class GlobalControllerAdvice {
public static final String DEFAULT_MESSAGE = "유효하지 않은 요청 입니다.";
+ @ExceptionHandler({MissingServletRequestPartException.class, MultipartException.class})
+ public ResponseEntity handleMissingServletRequestPartException(Exception e) {
+ ErrorResponse errorResponse = new ErrorResponse(e.getMessage());
+
+ return ResponseEntity.status(HttpStatus.BAD_REQUEST)
+ .body(errorResponse);
+ }
+
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity handleIllegalArgumentException(IllegalArgumentException e) {
ErrorResponse errorResponse = new ErrorResponse(e.getMessage());
@@ -65,6 +75,14 @@ public ResponseEntity handleAuthenticationException(Authenticatio
.body(errorResponse);
}
+ @ExceptionHandler(PermissionException.class)
+ public ResponseEntity handlePermissionException(PermissionException e) {
+ ErrorResponse errorResponse = new ErrorResponse(e.getMessage());
+
+ return ResponseEntity.status(HttpStatus.FORBIDDEN)
+ .body(errorResponse);
+ }
+
@ExceptionHandler(NoSuchElementException.class)
public ResponseEntity handleNoSuchElementException(NoSuchElementException e) {
ErrorResponse errorResponse = new ErrorResponse(e.getMessage());
@@ -81,22 +99,12 @@ public ResponseEntity handleDuplicateKeyException(DuplicateKeyExc
.body(errorResponse);
}
- @ExceptionHandler(ImageUploadException.class)
- public ResponseEntity handleImageUploadException(ImageUploadException e) {
- ErrorResponse errorResponse = new ErrorResponse(e.getMessage());
-
- return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY)
- .body(errorResponse);
- }
-
@ExceptionHandler(Exception.class)
- public ResponseEntity handleException(Exception e) {
- ErrorResponse errorResponse = new ErrorResponse(e.getMessage());
-
+ public ResponseEntity handleException(Exception e) {
log.error(e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
- .body(errorResponse);
+ .build();
}
}
diff --git a/src/main/java/com/backendoori/ootw/exception/ImageUploadException.java b/src/main/java/com/backendoori/ootw/exception/ImageUploadException.java
deleted file mode 100644
index b682b9bb..00000000
--- a/src/main/java/com/backendoori/ootw/exception/ImageUploadException.java
+++ /dev/null
@@ -1,12 +0,0 @@
-package com.backendoori.ootw.exception;
-
-import lombok.Getter;
-import lombok.RequiredArgsConstructor;
-
-@Getter
-@RequiredArgsConstructor
-public class ImageUploadException extends RuntimeException {
-
- private final String message = "이미지 업로드 중 예외가 발생했습니다.";
-
-}
diff --git a/src/main/java/com/backendoori/ootw/exception/PermissionException.java b/src/main/java/com/backendoori/ootw/exception/PermissionException.java
new file mode 100644
index 00000000..acab05a6
--- /dev/null
+++ b/src/main/java/com/backendoori/ootw/exception/PermissionException.java
@@ -0,0 +1,11 @@
+package com.backendoori.ootw.exception;
+
+public class PermissionException extends RuntimeException {
+
+ public static final String DEFAULT_MESSAGE = "요청에 대한 권한이 없습니다.";
+
+ public PermissionException() {
+ super(DEFAULT_MESSAGE);
+ }
+
+}
diff --git a/src/main/java/com/backendoori/ootw/like/controller/LikeController.java b/src/main/java/com/backendoori/ootw/like/controller/LikeController.java
new file mode 100644
index 00000000..4f31fd1c
--- /dev/null
+++ b/src/main/java/com/backendoori/ootw/like/controller/LikeController.java
@@ -0,0 +1,25 @@
+package com.backendoori.ootw.like.controller;
+
+import com.backendoori.ootw.like.dto.controller.LikeResponse;
+import com.backendoori.ootw.like.service.LikeService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.Authentication;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequiredArgsConstructor
+public class LikeController {
+
+ private final LikeService likeService;
+
+ @PostMapping("/api/v1/posts/{postId}/likes")
+ public ResponseEntity pushLike(Authentication authentication,
+ @PathVariable Long postId) {
+ Long userId = (Long) authentication.getPrincipal();
+ return ResponseEntity.ok(likeService.requestLike(userId, postId));
+ }
+
+}
diff --git a/src/main/java/com/backendoori/ootw/like/domain/Like.java b/src/main/java/com/backendoori/ootw/like/domain/Like.java
new file mode 100644
index 00000000..fd0d4910
--- /dev/null
+++ b/src/main/java/com/backendoori/ootw/like/domain/Like.java
@@ -0,0 +1,59 @@
+package com.backendoori.ootw.like.domain;
+
+import com.backendoori.ootw.common.BaseEntity;
+import com.backendoori.ootw.post.domain.Post;
+import com.backendoori.ootw.user.domain.User;
+import io.jsonwebtoken.lang.Assert;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.Table;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Table(name = "likes")
+@Entity
+@Getter
+@Builder
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class Like extends BaseEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "id")
+ private Long id;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "user_id", nullable = false)
+ private User user;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "post_id", nullable = false)
+ private Post post;
+
+ @Column(name = "is_like", columnDefinition = "tinyint")
+ private Boolean isLike;
+
+ private Like(Long id, User user, Post post, Boolean status) {
+ Assert.notNull(user);
+ Assert.notNull(post);
+ Assert.notNull(status);
+ this.id = id;
+ this.user = user;
+ this.post = post;
+ this.isLike = status;
+ }
+
+ public boolean updateStatus() {
+ this.isLike = !isLike;
+ return this.isLike;
+ }
+
+}
diff --git a/src/main/java/com/backendoori/ootw/like/dto/controller/LikeRequest.java b/src/main/java/com/backendoori/ootw/like/dto/controller/LikeRequest.java
new file mode 100644
index 00000000..5fd8a3fb
--- /dev/null
+++ b/src/main/java/com/backendoori/ootw/like/dto/controller/LikeRequest.java
@@ -0,0 +1,15 @@
+package com.backendoori.ootw.like.dto.controller;
+
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Positive;
+
+public record LikeRequest(
+ @Positive
+ @NotNull(message = NULL_MESSAGE)
+ Long postId
+) {
+
+ private static final String NULL_MESSAGE = "반드시 postId 값이 null 입니다.";
+
+}
diff --git a/src/main/java/com/backendoori/ootw/like/dto/controller/LikeResponse.java b/src/main/java/com/backendoori/ootw/like/dto/controller/LikeResponse.java
new file mode 100644
index 00000000..7e75c878
--- /dev/null
+++ b/src/main/java/com/backendoori/ootw/like/dto/controller/LikeResponse.java
@@ -0,0 +1,20 @@
+package com.backendoori.ootw.like.dto.controller;
+
+import com.backendoori.ootw.like.domain.Like;
+
+public record LikeResponse(
+ Long likeId,
+ Long userId,
+ Long postId,
+ boolean status
+) {
+
+ public static LikeResponse from(Like like) {
+ return new LikeResponse(
+ like.getId(),
+ like.getUser().getId(),
+ like.getPost().getId(),
+ like.getIsLike());
+ }
+
+}
diff --git a/src/main/java/com/backendoori/ootw/like/repository/LikeRepository.java b/src/main/java/com/backendoori/ootw/like/repository/LikeRepository.java
new file mode 100644
index 00000000..770d7c57
--- /dev/null
+++ b/src/main/java/com/backendoori/ootw/like/repository/LikeRepository.java
@@ -0,0 +1,25 @@
+package com.backendoori.ootw.like.repository;
+
+import java.util.List;
+import java.util.Optional;
+import com.backendoori.ootw.like.domain.Like;
+import com.backendoori.ootw.post.domain.Post;
+import com.backendoori.ootw.user.domain.User;
+import jakarta.persistence.LockModeType;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Lock;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+public interface LikeRepository extends JpaRepository {
+
+ @Lock(LockModeType.PESSIMISTIC_WRITE)
+ Optional findByUserAndPost(User user, Post post);
+
+ @Query("select l from Like l where l.user.id = :userId and l.post = :post")
+ Optional findByUserIdAndPost(@Param("userId") Long userId, @Param("post") Post post);
+
+ @Query("select l from Like l where l.user.id = :userId and l.isLike = :isLike")
+ List findByUserAndIsLike(@Param("userId") Long userId, @Param("isLike") boolean isLike);
+
+}
diff --git a/src/main/java/com/backendoori/ootw/like/service/LikeService.java b/src/main/java/com/backendoori/ootw/like/service/LikeService.java
new file mode 100644
index 00000000..9114a016
--- /dev/null
+++ b/src/main/java/com/backendoori/ootw/like/service/LikeService.java
@@ -0,0 +1,68 @@
+package com.backendoori.ootw.like.service;
+
+import java.util.NoSuchElementException;
+import com.backendoori.ootw.exception.UserNotFoundException;
+import com.backendoori.ootw.like.domain.Like;
+import com.backendoori.ootw.like.dto.controller.LikeResponse;
+import com.backendoori.ootw.like.repository.LikeRepository;
+import com.backendoori.ootw.post.domain.Post;
+import com.backendoori.ootw.post.repository.PostRepository;
+import com.backendoori.ootw.user.domain.User;
+import com.backendoori.ootw.user.repository.UserRepository;
+import jakarta.transaction.Transactional;
+import lombok.RequiredArgsConstructor;
+import org.jetbrains.annotations.NotNull;
+import org.springframework.stereotype.Service;
+
+@Service
+@RequiredArgsConstructor
+public class LikeService {
+
+ private static final String POST_NOT_FOUND_MESSAGE = "해당 게시글이 존재하지 않습니다.";
+ private static final String LIKE_NOT_FOUND_MESSAGE = "찾으시는 좋아요 정보가 존재하지 않습니다.";
+
+ private final UserRepository userRepository;
+ private final PostRepository postRepository;
+ private final LikeRepository likeRepository;
+
+ @Transactional
+ public LikeResponse requestLike(Long userId, Long postId) {
+ User user = userRepository.findById(userId)
+ .orElseThrow(UserNotFoundException::new);
+
+ Post post = postRepository.findByIdForUpdateLikeCount(postId)
+ .orElseThrow(() -> new NoSuchElementException(POST_NOT_FOUND_MESSAGE));
+
+ likeRepository.findByUserAndPost(user, post).ifPresentOrElse(
+ like -> {
+ boolean likeStatus = like.updateStatus();
+ if (likeStatus) {
+ post.increaseLikeCnt();
+ }
+ if (!likeStatus) {
+ post.decreaseLikeCnt();
+ }
+ },
+ likeNotExist(user, post)
+ );
+
+ Like like = likeRepository.findByUserAndPost(user, post)
+ .orElseThrow(() -> new NoSuchElementException(LIKE_NOT_FOUND_MESSAGE));
+
+ return LikeResponse.from(like);
+ }
+
+ @NotNull
+ private Runnable likeNotExist(User user, Post post) {
+ return () -> {
+ likeRepository.save(
+ Like.builder()
+ .user(user)
+ .post(post)
+ .isLike(true)
+ .build());
+ post.increaseLikeCnt();
+ };
+ }
+
+}
diff --git a/src/main/java/com/backendoori/ootw/post/controller/PostController.java b/src/main/java/com/backendoori/ootw/post/controller/PostController.java
index 5c3ba93a..91946cb6 100644
--- a/src/main/java/com/backendoori/ootw/post/controller/PostController.java
+++ b/src/main/java/com/backendoori/ootw/post/controller/PostController.java
@@ -2,17 +2,22 @@
import java.net.URI;
import java.util.List;
-import com.backendoori.ootw.post.dto.PostReadResponse;
-import com.backendoori.ootw.post.dto.PostSaveRequest;
-import com.backendoori.ootw.post.dto.PostSaveResponse;
+import com.backendoori.ootw.common.validation.Image;
+import com.backendoori.ootw.post.dto.request.PostSaveRequest;
+import com.backendoori.ootw.post.dto.request.PostUpdateRequest;
+import com.backendoori.ootw.post.dto.response.PostReadResponse;
+import com.backendoori.ootw.post.dto.response.PostSaveUpdateResponse;
import com.backendoori.ootw.post.service.PostService;
import jakarta.validation.Valid;
+import jakarta.validation.constraints.Positive;
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.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
@@ -25,21 +30,8 @@ public class PostController {
private final PostService postService;
- @PostMapping
- public ResponseEntity save(
- @RequestPart MultipartFile postImg,
- @RequestPart @Valid PostSaveRequest request) {
- PostSaveResponse response = postService.save(request, postImg);
-
- URI postUri = URI.create("/api/v1/posts/" + response.postId());
-
- return ResponseEntity.status(HttpStatus.CREATED)
- .location(postUri)
- .body(response);
- }
-
@GetMapping("/{postId}")
- public ResponseEntity readDetailByPostId(@PathVariable Long postId) {
+ public ResponseEntity readDetailByPostId(@PathVariable @Positive Long postId) {
return ResponseEntity.status(HttpStatus.OK)
.body(postService.getDetailByPostId(postId));
}
@@ -50,4 +42,39 @@ public ResponseEntity> readAll() {
.body(postService.getAll());
}
+ @DeleteMapping("/{postId}")
+ public ResponseEntity delete(@PathVariable @Positive Long postId) {
+ postService.delete(postId);
+
+ return ResponseEntity.status(HttpStatus.NO_CONTENT)
+ .build();
+ }
+
+ @PostMapping
+ public ResponseEntity save(
+ @RequestPart(required = false) @Image(ignoreCase = true) MultipartFile postImg,
+ @RequestPart @Valid PostSaveRequest request) {
+ PostSaveUpdateResponse response = postService.save(request, postImg);
+
+ return ResponseEntity.status(HttpStatus.CREATED)
+ .location(getPostUri(response.postId()))
+ .body(response);
+ }
+
+ @PutMapping("/{postId}")
+ public ResponseEntity update(
+ @PathVariable @Positive Long postId,
+ @RequestPart(required = false) @Image(ignoreCase = true) MultipartFile postImg,
+ @RequestPart @Valid PostUpdateRequest request) {
+ PostSaveUpdateResponse response = postService.update(postId, postImg, request);
+
+ return ResponseEntity.status(HttpStatus.CREATED)
+ .location(getPostUri(response.postId()))
+ .body(response);
+ }
+
+ private URI getPostUri(Long postId) {
+ return URI.create("/api/v1/posts/" + postId);
+ }
+
}
diff --git a/src/main/java/com/backendoori/ootw/post/domain/Post.java b/src/main/java/com/backendoori/ootw/post/domain/Post.java
index 21f1db13..6786a08f 100644
--- a/src/main/java/com/backendoori/ootw/post/domain/Post.java
+++ b/src/main/java/com/backendoori/ootw/post/domain/Post.java
@@ -1,11 +1,13 @@
package com.backendoori.ootw.post.domain;
+import static com.backendoori.ootw.post.validation.PostValidator.validateContent;
import static com.backendoori.ootw.post.validation.PostValidator.validatePostSaveRequest;
import static com.backendoori.ootw.post.validation.PostValidator.validateTemperatureArrange;
+import static com.backendoori.ootw.post.validation.PostValidator.validateTitle;
import static com.backendoori.ootw.post.validation.PostValidator.validateUser;
import com.backendoori.ootw.common.BaseEntity;
-import com.backendoori.ootw.post.dto.PostSaveRequest;
+import com.backendoori.ootw.post.dto.request.PostSaveRequest;
import com.backendoori.ootw.user.domain.User;
import com.backendoori.ootw.weather.domain.TemperatureArrange;
import jakarta.persistence.Column;
@@ -43,12 +45,15 @@ public class Post extends BaseEntity {
@Column(name = "content", nullable = false)
private String content;
- @Column(name = "image")
- private String image;
+ @Column(name = "image_url")
+ private String imageUrl;
@Embedded
private TemperatureArrange temperatureArrange;
+ @Column(name = "like_cnt")
+ private int likeCnt;
+
private Post(User user, PostSaveRequest request, String imgUrl, TemperatureArrange temperatureArrange) {
validateUser(user);
validatePostSaveRequest(request);
@@ -57,7 +62,7 @@ private Post(User user, PostSaveRequest request, String imgUrl, TemperatureArran
this.user = user;
this.title = request.title();
this.content = request.content();
- this.image = imgUrl;
+ this.imageUrl = imgUrl;
this.temperatureArrange = temperatureArrange;
}
@@ -65,4 +70,25 @@ public static Post from(User user, PostSaveRequest request, String imgUrl, Tempe
return new Post(user, request, imgUrl, temperatureArrange);
}
+ public void increaseLikeCnt() {
+ this.likeCnt++;
+ }
+
+ public void decreaseLikeCnt() {
+ this.likeCnt--;
+ }
+
+ public void updateTitle(String title) {
+ validateTitle(title);
+ this.title = title;
+ }
+
+ public void updateContent(String content) {
+ validateContent(content);
+ this.content = content;
+ }
+
+ public void updateImageUrl(String imageUrl) {
+ this.imageUrl = imageUrl;
+ }
}
diff --git a/src/main/java/com/backendoori/ootw/post/dto/PostReadResponse.java b/src/main/java/com/backendoori/ootw/post/dto/PostReadResponse.java
deleted file mode 100644
index b8bca316..00000000
--- a/src/main/java/com/backendoori/ootw/post/dto/PostReadResponse.java
+++ /dev/null
@@ -1,31 +0,0 @@
-package com.backendoori.ootw.post.dto;
-
-import java.time.LocalDateTime;
-import com.backendoori.ootw.post.domain.Post;
-import com.backendoori.ootw.weather.dto.TemperatureArrangeDto;
-
-public record PostReadResponse(
- Long postId,
- WriterDto writer,
- String title,
- String content,
- String image,
- LocalDateTime createdAt,
- LocalDateTime updatedAt,
- TemperatureArrangeDto temperatureArrange
-) {
-
- public static PostReadResponse from(Post post) {
- return new PostReadResponse(
- post.getId(),
- WriterDto.from(post.getUser()),
- post.getTitle(),
- post.getContent(),
- post.getImage(),
- post.getCreatedAt(),
- post.getUpdatedAt(),
- TemperatureArrangeDto.from(post.getTemperatureArrange())
- );
- }
-
-}
diff --git a/src/main/java/com/backendoori/ootw/post/dto/PostSaveRequest.java b/src/main/java/com/backendoori/ootw/post/dto/PostSaveRequest.java
deleted file mode 100644
index dbedae00..00000000
--- a/src/main/java/com/backendoori/ootw/post/dto/PostSaveRequest.java
+++ /dev/null
@@ -1,29 +0,0 @@
-package com.backendoori.ootw.post.dto;
-
-import jakarta.validation.constraints.Max;
-import jakarta.validation.constraints.Min;
-import jakarta.validation.constraints.NotBlank;
-import jakarta.validation.constraints.NotNull;
-import jakarta.validation.constraints.Size;
-
-public record PostSaveRequest(
- @NotBlank
- @Size(max = 30)
- String title,
-
- @NotBlank
- @Size(max = 500)
- String content,
-
- @Min(0)
- @Max(999)
- @NotNull
- int nx,
-
- @Min(0)
- @Max(999)
- @NotNull
- int ny
-) {
-
-}
diff --git a/src/main/java/com/backendoori/ootw/post/dto/request/PostSaveRequest.java b/src/main/java/com/backendoori/ootw/post/dto/request/PostSaveRequest.java
new file mode 100644
index 00000000..85767b23
--- /dev/null
+++ b/src/main/java/com/backendoori/ootw/post/dto/request/PostSaveRequest.java
@@ -0,0 +1,26 @@
+package com.backendoori.ootw.post.dto.request;
+
+import static com.backendoori.ootw.post.validation.Message.BLANK_POST_CONTENT;
+import static com.backendoori.ootw.post.validation.Message.BLANK_POST_TITLE;
+import static com.backendoori.ootw.post.validation.Message.INVALID_POST_CONTENT;
+import static com.backendoori.ootw.post.validation.Message.INVALID_POST_TITLE;
+
+import com.backendoori.ootw.weather.domain.Coordinate;
+import com.backendoori.ootw.weather.validation.Grid;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+
+public record PostSaveRequest(
+ @NotBlank(message = BLANK_POST_TITLE)
+ @Size(max = 30, message = INVALID_POST_TITLE)
+ String title,
+
+ @NotBlank(message = BLANK_POST_CONTENT)
+ @Size(max = 500, message = INVALID_POST_CONTENT)
+ String content,
+
+ @Grid
+ Coordinate coordinate
+) {
+
+}
diff --git a/src/main/java/com/backendoori/ootw/post/dto/request/PostUpdateRequest.java b/src/main/java/com/backendoori/ootw/post/dto/request/PostUpdateRequest.java
new file mode 100644
index 00000000..035d6244
--- /dev/null
+++ b/src/main/java/com/backendoori/ootw/post/dto/request/PostUpdateRequest.java
@@ -0,0 +1,21 @@
+package com.backendoori.ootw.post.dto.request;
+
+import static com.backendoori.ootw.post.validation.Message.BLANK_POST_CONTENT;
+import static com.backendoori.ootw.post.validation.Message.BLANK_POST_TITLE;
+import static com.backendoori.ootw.post.validation.Message.INVALID_POST_CONTENT;
+import static com.backendoori.ootw.post.validation.Message.INVALID_POST_TITLE;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+
+public record PostUpdateRequest(
+ @NotBlank(message = BLANK_POST_TITLE)
+ @Size(max = 30, message = INVALID_POST_TITLE)
+ String title,
+
+ @NotBlank(message = BLANK_POST_CONTENT)
+ @Size(max = 500, message = INVALID_POST_CONTENT)
+ String content
+) {
+
+}
diff --git a/src/main/java/com/backendoori/ootw/post/dto/response/PostReadResponse.java b/src/main/java/com/backendoori/ootw/post/dto/response/PostReadResponse.java
new file mode 100644
index 00000000..f54fdc3a
--- /dev/null
+++ b/src/main/java/com/backendoori/ootw/post/dto/response/PostReadResponse.java
@@ -0,0 +1,43 @@
+package com.backendoori.ootw.post.dto.response;
+
+import java.time.LocalDateTime;
+import com.backendoori.ootw.post.domain.Post;
+import com.backendoori.ootw.weather.dto.TemperatureArrangeDto;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+@Getter
+@AllArgsConstructor
+public final class PostReadResponse {
+
+ private final Long postId;
+ private final WriterDto writer;
+ private final String title;
+ private final String content;
+ private final String image;
+ private final LocalDateTime createdAt;
+ private final LocalDateTime updatedAt;
+ private final TemperatureArrangeDto temperatureArrange;
+ private final int likeCnt;
+ private int isLike;
+
+ public static PostReadResponse from(Post post) {
+ return new PostReadResponse(
+ post.getId(),
+ WriterDto.from(post.getUser()),
+ post.getTitle(),
+ post.getContent(),
+ post.getImageUrl(),
+ post.getCreatedAt(),
+ post.getUpdatedAt(),
+ TemperatureArrangeDto.from(post.getTemperatureArrange()),
+ post.getLikeCnt(),
+ 0
+ );
+ }
+
+ public void updateIsLike() {
+ this.isLike = 1;
+ }
+
+}
diff --git a/src/main/java/com/backendoori/ootw/post/dto/PostSaveResponse.java b/src/main/java/com/backendoori/ootw/post/dto/response/PostSaveUpdateResponse.java
similarity index 72%
rename from src/main/java/com/backendoori/ootw/post/dto/PostSaveResponse.java
rename to src/main/java/com/backendoori/ootw/post/dto/response/PostSaveUpdateResponse.java
index 02a9fffe..004fba5d 100644
--- a/src/main/java/com/backendoori/ootw/post/dto/PostSaveResponse.java
+++ b/src/main/java/com/backendoori/ootw/post/dto/response/PostSaveUpdateResponse.java
@@ -1,10 +1,10 @@
-package com.backendoori.ootw.post.dto;
+package com.backendoori.ootw.post.dto.response;
import java.time.LocalDateTime;
import com.backendoori.ootw.post.domain.Post;
import com.backendoori.ootw.weather.dto.TemperatureArrangeDto;
-public record PostSaveResponse(
+public record PostSaveUpdateResponse(
Long postId,
String title,
String content,
@@ -14,12 +14,12 @@ public record PostSaveResponse(
TemperatureArrangeDto temperatureArrange
) {
- public static PostSaveResponse from(Post savedPost) {
- return new PostSaveResponse(
+ public static PostSaveUpdateResponse from(Post savedPost) {
+ return new PostSaveUpdateResponse(
savedPost.getId(),
savedPost.getTitle(),
savedPost.getContent(),
- savedPost.getImage(),
+ savedPost.getImageUrl(),
savedPost.getCreatedAt(),
savedPost.getUpdatedAt(),
TemperatureArrangeDto.from(savedPost.getTemperatureArrange())
diff --git a/src/main/java/com/backendoori/ootw/post/dto/WriterDto.java b/src/main/java/com/backendoori/ootw/post/dto/response/WriterDto.java
similarity index 77%
rename from src/main/java/com/backendoori/ootw/post/dto/WriterDto.java
rename to src/main/java/com/backendoori/ootw/post/dto/response/WriterDto.java
index 1f25d4b0..0c2b08da 100644
--- a/src/main/java/com/backendoori/ootw/post/dto/WriterDto.java
+++ b/src/main/java/com/backendoori/ootw/post/dto/response/WriterDto.java
@@ -1,4 +1,4 @@
-package com.backendoori.ootw.post.dto;
+package com.backendoori.ootw.post.dto.response;
import com.backendoori.ootw.user.domain.User;
@@ -9,7 +9,7 @@ public record WriterDto(
) {
public static WriterDto from(User user) {
- return new WriterDto(user.getId(), user.getNickname(), user.getImage());
+ return new WriterDto(user.getId(), user.getNickname(), user.getProfileImageUrl());
}
}
diff --git a/src/main/java/com/backendoori/ootw/post/repository/PostRepository.java b/src/main/java/com/backendoori/ootw/post/repository/PostRepository.java
index fd5145b6..82061f86 100644
--- a/src/main/java/com/backendoori/ootw/post/repository/PostRepository.java
+++ b/src/main/java/com/backendoori/ootw/post/repository/PostRepository.java
@@ -3,8 +3,11 @@
import java.util.List;
import java.util.Optional;
import com.backendoori.ootw.post.domain.Post;
+import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Lock;
+import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
@@ -18,4 +21,8 @@ public interface PostRepository extends JpaRepository {
@Query("select p from Post p order by p.createdAt desc")
List findAllWithUserEntityGraph();
+ @Lock(LockModeType.PESSIMISTIC_WRITE)
+ @Query("select p from Post p where p.id = :postId")
+ Optional findByIdForUpdateLikeCount(@Param("postId") Long postId);
+
}
diff --git a/src/main/java/com/backendoori/ootw/post/service/PostService.java b/src/main/java/com/backendoori/ootw/post/service/PostService.java
index 78016641..b16e643c 100644
--- a/src/main/java/com/backendoori/ootw/post/service/PostService.java
+++ b/src/main/java/com/backendoori/ootw/post/service/PostService.java
@@ -1,13 +1,24 @@
package com.backendoori.ootw.post.service;
+import static com.backendoori.ootw.post.validation.Message.NULL_REQUEST;
+import static com.backendoori.ootw.post.validation.Message.POST_NOT_FOUND;
+
import java.util.List;
import java.util.NoSuchElementException;
+import java.util.Objects;
+import java.util.Optional;
+import com.backendoori.ootw.common.image.ImageFile;
import com.backendoori.ootw.common.image.ImageService;
+import com.backendoori.ootw.common.image.exception.SaveException;
+import com.backendoori.ootw.exception.PermissionException;
import com.backendoori.ootw.exception.UserNotFoundException;
+import com.backendoori.ootw.like.domain.Like;
+import com.backendoori.ootw.like.repository.LikeRepository;
import com.backendoori.ootw.post.domain.Post;
-import com.backendoori.ootw.post.dto.PostReadResponse;
-import com.backendoori.ootw.post.dto.PostSaveRequest;
-import com.backendoori.ootw.post.dto.PostSaveResponse;
+import com.backendoori.ootw.post.dto.request.PostSaveRequest;
+import com.backendoori.ootw.post.dto.request.PostUpdateRequest;
+import com.backendoori.ootw.post.dto.response.PostReadResponse;
+import com.backendoori.ootw.post.dto.response.PostSaveUpdateResponse;
import com.backendoori.ootw.post.repository.PostRepository;
import com.backendoori.ootw.user.domain.User;
import com.backendoori.ootw.user.repository.UserRepository;
@@ -17,43 +28,143 @@
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.Assert;
import org.springframework.web.multipart.MultipartFile;
-@RequiredArgsConstructor
@Service
+@RequiredArgsConstructor
public class PostService {
+ private static final String ANONYMOUS_USER_PRINCIPLE = "anonymousUser";
private final PostRepository postRepository;
private final UserRepository userRepository;
private final ImageService imageService;
private final WeatherService weatherService;
+ private final LikeRepository likeRepository;
@Transactional
- public PostSaveResponse save(PostSaveRequest request, MultipartFile postImg) {
+ public PostSaveUpdateResponse save(PostSaveRequest request, MultipartFile postImg) {
+ Assert.isTrue(Objects.nonNull(request), () -> {
+ throw new IllegalArgumentException(NULL_REQUEST);
+ });
+
User user = userRepository.findById(getUserId())
.orElseThrow(UserNotFoundException::new);
- String imgUrl = imageService.uploadImage(postImg);
- TemperatureArrange temperatureArrange = weatherService.getCurrentTemperatureArrange(request.nx(), request.ny());
+ TemperatureArrange temperatureArrange = weatherService.getCurrentTemperatureArrange(request.coordinate());
- Post savedPost = postRepository.save(Post.from(user, request, imgUrl, temperatureArrange));
+ if (Objects.isNull(postImg) || postImg.isEmpty()) {
+ return savePostWithImageUrl(user, request, null, temperatureArrange);
+ }
- return PostSaveResponse.from(savedPost);
+ ImageFile imgFile = imageService.upload(postImg);
+ try {
+ return savePostWithImageUrl(user, request, imgFile.url(), temperatureArrange);
+ } catch (Exception e) {
+ imageService.delete(imgFile.fileName());
+ throw new SaveException();
+ }
}
@Transactional(readOnly = true)
public PostReadResponse getDetailByPostId(Long postId) {
Post post = postRepository.findByIdWithUserEntityGraph(postId)
- .orElseThrow(() -> new NoSuchElementException("해당하는 게시글이 없습니다."));
+ .orElseThrow(() -> new NoSuchElementException(POST_NOT_FOUND));
+ PostReadResponse response = PostReadResponse.from(post);
+
+ if (!isLogin()) {
+ return response;
+ }
- return PostReadResponse.from(post);
+ Optional like = likeRepository.findByUserIdAndPost(getUserId(), post);
+ like.ifPresent(
+ existLike -> {
+ if (existLike.getIsLike()) {
+ response.updateIsLike();
+ }
+ }
+ );
+
+ return response;
}
@Transactional(readOnly = true)
public List getAll() {
- return postRepository.findAllWithUserEntityGraph()
+ List postResponseList = postRepository.findAllWithUserEntityGraph()
.stream()
.map(PostReadResponse::from)
.toList();
+ if (!isLogin()) {
+ return postResponseList;
+ }
+
+ List likes = getLikedPostId(getUserId());
+ if (likes.isEmpty()) {
+ return postResponseList;
+ }
+
+ return postResponseList.stream()
+ .peek(post -> {
+ if (likes.contains(post.getPostId())) {
+ post.updateIsLike();
+ }
+ }).toList();
+ }
+
+ @Transactional
+ public void delete(Long postId) {
+ Post post = postRepository.findById(postId)
+ .orElseThrow(() -> new NoSuchElementException(POST_NOT_FOUND));
+
+ checkUserHasPostPermission(post);
+
+ postRepository.delete(post);
+ }
+
+ @Transactional
+ public PostSaveUpdateResponse update(Long postId, MultipartFile postImg, PostUpdateRequest request) {
+ Post post = postRepository.findById(postId)
+ .orElseThrow(() -> new NoSuchElementException(POST_NOT_FOUND));
+
+ checkUserHasPostPermission(post);
+
+ Assert.notNull(request, () -> {
+ throw new IllegalArgumentException(NULL_REQUEST);
+ });
+
+ post.updateTitle(request.title());
+ post.updateContent(request.content());
+
+ if (Objects.isNull(postImg) || postImg.isEmpty()) {
+ return updatePostWithImageUrl(post, null);
+ }
+
+ ImageFile imgFile = imageService.upload(postImg);
+ try {
+ // TODO: 기존 저장된 이미지 삭제(원래 null인 경우도 있으니 주의)
+ return updatePostWithImageUrl(post, imgFile.url());
+ } catch (Exception e) {
+ imageService.delete(imgFile.fileName());
+ throw new SaveException();
+ }
+ }
+
+ private PostSaveUpdateResponse updatePostWithImageUrl(Post post, String imgFile) {
+ post.updateImageUrl(imgFile);
+
+ return PostSaveUpdateResponse.from(post);
+ }
+
+ private PostSaveUpdateResponse savePostWithImageUrl(User user, PostSaveRequest request, String imgFile,
+ TemperatureArrange temperatureArrange) {
+ Post savedPost = postRepository.save(Post.from(user, request, imgFile, temperatureArrange));
+
+ return PostSaveUpdateResponse.from(savedPost);
+ }
+
+ private List getLikedPostId(long userId) {
+ return likeRepository.findByUserAndIsLike(userId, true)
+ .stream().map(like -> like.getPost().getId())
+ .toList();
}
private long getUserId() {
@@ -63,4 +174,17 @@ private long getUserId() {
.getPrincipal();
}
+ private boolean isLogin() {
+ return !ANONYMOUS_USER_PRINCIPLE.equals(SecurityContextHolder
+ .getContext()
+ .getAuthentication()
+ .getPrincipal());
+ }
+
+ private void checkUserHasPostPermission(Post post) {
+ Assert.isTrue(post.getUser().isSameId(getUserId()), () -> {
+ throw new PermissionException();
+ });
+ }
+
}
diff --git a/src/main/java/com/backendoori/ootw/post/validation/Message.java b/src/main/java/com/backendoori/ootw/post/validation/Message.java
new file mode 100644
index 00000000..7ed35666
--- /dev/null
+++ b/src/main/java/com/backendoori/ootw/post/validation/Message.java
@@ -0,0 +1,22 @@
+package com.backendoori.ootw.post.validation;
+
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
+
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+public final class Message {
+
+ public static final String POST_NOT_FOUND = "해당하는 게시글이 없습니다.";
+ public static final String NULL_REQUEST = "게시글 생성/수정 요청 정보가 null이어서는 안됩니다.";
+
+ public static final String NULL_WRITER = "게시글 생성 요청 사용자가 null이어서는 안됩니다.";
+
+ public static final String NULL_TEMPERATURE_ARRANGE = "기상청 API에서 기온 범위 값을 null로 반환했습니다.";
+
+ public static final String BLANK_POST_TITLE = "게시글 제목이 null이거나 공백이어서는 안됩니다.";
+ public static final String INVALID_POST_TITLE = "게시글 제목은 30자 이내여야 합니다.";
+
+ public static final String BLANK_POST_CONTENT = "게시글 내용이 null이거나 공백이어서는 안됩니다.";
+ public static final String INVALID_POST_CONTENT = "게시글 내용은 500자 이내여야 합니다.";
+
+}
diff --git a/src/main/java/com/backendoori/ootw/post/validation/PostValidator.java b/src/main/java/com/backendoori/ootw/post/validation/PostValidator.java
index d02f6f63..266195ea 100644
--- a/src/main/java/com/backendoori/ootw/post/validation/PostValidator.java
+++ b/src/main/java/com/backendoori/ootw/post/validation/PostValidator.java
@@ -1,39 +1,48 @@
package com.backendoori.ootw.post.validation;
-import com.backendoori.ootw.post.dto.PostSaveRequest;
+import static com.backendoori.ootw.post.validation.Message.BLANK_POST_CONTENT;
+import static com.backendoori.ootw.post.validation.Message.BLANK_POST_TITLE;
+import static com.backendoori.ootw.post.validation.Message.INVALID_POST_CONTENT;
+import static com.backendoori.ootw.post.validation.Message.INVALID_POST_TITLE;
+import static com.backendoori.ootw.post.validation.Message.NULL_REQUEST;
+import static com.backendoori.ootw.post.validation.Message.NULL_TEMPERATURE_ARRANGE;
+import static com.backendoori.ootw.post.validation.Message.NULL_WRITER;
+
+import com.backendoori.ootw.common.AssertUtil;
+import com.backendoori.ootw.post.dto.request.PostSaveRequest;
import com.backendoori.ootw.user.domain.User;
import com.backendoori.ootw.weather.domain.TemperatureArrange;
-import org.springframework.util.Assert;
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
-public class PostValidator {
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+public final class PostValidator {
private static final Integer MAX_TITLE_LENGTH = 30;
private static final Integer MAX_CONTENT_LENGTH = 500;
public static void validateUser(User user) {
- Assert.notNull(user, "게시글 생성 요청 사용자가 null이어서는 안됩니다.");
+ AssertUtil.notNull(user, NULL_WRITER);
}
public static void validatePostSaveRequest(PostSaveRequest request) {
- Assert.notNull(request, "게시글 생성 요청 정보가 null이어서는 안됩니다.");
+ AssertUtil.notNull(request, NULL_REQUEST);
validateTitle(request.title());
validateContent(request.content());
}
public static void validateTemperatureArrange(TemperatureArrange temperatureArrange) {
- Assert.notNull(temperatureArrange, "게시글 기온 범위가 null이어서는 안됩니다.");
+ AssertUtil.notNull(temperatureArrange, NULL_TEMPERATURE_ARRANGE);
}
- private static void validateTitle(String title) {
- Assert.notNull(title, "게시글 제목이 null이어서는 안됩니다.");
- Assert.isTrue(!title.isBlank(), "게시글 제목이 공백이어서는 안됩니다.");
- Assert.isTrue(!(title.length() > MAX_TITLE_LENGTH), "게시글 제목은 30자 이내여야 합니다.");
+ public static void validateTitle(String title) {
+ AssertUtil.notBlank(title, BLANK_POST_TITLE);
+ AssertUtil.isTrue(!(title.length() > MAX_TITLE_LENGTH), INVALID_POST_TITLE);
}
- private static void validateContent(String content) {
- Assert.notNull(content, "게시글 내용이 null이어서는 안됩니다.");
- Assert.isTrue(!content.isBlank(), "게시글 내용이 공백이어서는 안됩니다.");
- Assert.isTrue(!(content.length() > MAX_CONTENT_LENGTH), "게시글 내용은 500자 이내여야 합니다.");
+ public static void validateContent(String content) {
+ AssertUtil.notBlank(content, BLANK_POST_CONTENT);
+ AssertUtil.isTrue(!(content.length() > MAX_CONTENT_LENGTH), INVALID_POST_CONTENT);
}
}
diff --git a/src/main/java/com/backendoori/ootw/security/jwt/Message.java b/src/main/java/com/backendoori/ootw/security/jwt/Message.java
new file mode 100644
index 00000000..86f64689
--- /dev/null
+++ b/src/main/java/com/backendoori/ootw/security/jwt/Message.java
@@ -0,0 +1,14 @@
+package com.backendoori.ootw.security.jwt;
+
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
+
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+public final class Message {
+
+ public static final String INVALID_SIGNATURE = "Invalid JWT signature.";
+ public static final String EXPIRED_TOKEN = "Expired JWT token.";
+ public static final String UNSUPPORTED_TOKEN = "Unsupported JWT token.";
+ public static final String UNEXPECTED_TOKEN = "JWT token compact of handler are invalid.";
+
+}
diff --git a/src/main/java/com/backendoori/ootw/security/jwt/TokenProvider.java b/src/main/java/com/backendoori/ootw/security/jwt/TokenProvider.java
index d5ce27c1..87293490 100644
--- a/src/main/java/com/backendoori/ootw/security/jwt/TokenProvider.java
+++ b/src/main/java/com/backendoori/ootw/security/jwt/TokenProvider.java
@@ -78,13 +78,13 @@ public boolean validateToken(String token) {
private void loggingException(RuntimeException e) {
if (e instanceof SecurityException || e instanceof MalformedJwtException) {
- log.debug("Invalid JWT signature.", e);
+ log.debug(Message.INVALID_SIGNATURE, e);
} else if (e instanceof ExpiredJwtException) {
- log.debug("Expired JWT token.", e);
+ log.debug(Message.EXPIRED_TOKEN, e);
} else if (e instanceof UnsupportedJwtException) {
- log.debug("Unsupported JWT token.", e);
+ log.debug(Message.UNSUPPORTED_TOKEN, e);
} else {
- log.debug("JWT token compact of handler are invalid.", e);
+ log.debug(Message.UNEXPECTED_TOKEN, e);
}
}
diff --git a/src/main/java/com/backendoori/ootw/user/controller/CertificateController.java b/src/main/java/com/backendoori/ootw/user/controller/CertificateController.java
new file mode 100644
index 00000000..6807e828
--- /dev/null
+++ b/src/main/java/com/backendoori/ootw/user/controller/CertificateController.java
@@ -0,0 +1,37 @@
+package com.backendoori.ootw.user.controller;
+
+import com.backendoori.ootw.user.dto.CertifyDto;
+import com.backendoori.ootw.user.dto.SendCodeDto;
+import com.backendoori.ootw.user.service.CertificateService;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.PatchMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/api/v1/auth")
+@RequiredArgsConstructor
+public class CertificateController {
+
+ private final CertificateService certificateService;
+
+ @PatchMapping("/certificate")
+ public ResponseEntity sendCode(@Valid SendCodeDto sendCodeDto) {
+ certificateService.sendCode(sendCodeDto);
+
+ return ResponseEntity.status(HttpStatus.OK)
+ .build();
+ }
+
+ @PatchMapping("/certify")
+ public ResponseEntity certify(@Valid CertifyDto certifyDto) {
+ certificateService.certify(certifyDto);
+
+ return ResponseEntity.status(HttpStatus.OK)
+ .build();
+ }
+
+}
diff --git a/src/main/java/com/backendoori/ootw/user/controller/UserController.java b/src/main/java/com/backendoori/ootw/user/controller/UserController.java
index bf9eb119..aacbaa92 100644
--- a/src/main/java/com/backendoori/ootw/user/controller/UserController.java
+++ b/src/main/java/com/backendoori/ootw/user/controller/UserController.java
@@ -4,7 +4,6 @@
import com.backendoori.ootw.user.dto.LoginDto;
import com.backendoori.ootw.user.dto.SignupDto;
import com.backendoori.ootw.user.dto.TokenDto;
-import com.backendoori.ootw.user.dto.UserDto;
import com.backendoori.ootw.user.service.UserService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
@@ -24,11 +23,11 @@ public class UserController {
private final UserService userService;
@PostMapping("/signup")
- public ResponseEntity signup(@RequestBody @Valid SignupDto signupDto) {
- UserDto userDto = userService.signup(signupDto);
+ public ResponseEntity signup(@RequestBody @Valid SignupDto signupDto) {
+ userService.signup(signupDto);
return ResponseEntity.status(HttpStatus.CREATED)
- .body(userDto);
+ .build();
}
@PostMapping("/login")
diff --git a/src/main/java/com/backendoori/ootw/user/domain/Certificate.java b/src/main/java/com/backendoori/ootw/user/domain/Certificate.java
new file mode 100644
index 00000000..e68168dd
--- /dev/null
+++ b/src/main/java/com/backendoori/ootw/user/domain/Certificate.java
@@ -0,0 +1,33 @@
+package com.backendoori.ootw.user.domain;
+
+
+import com.backendoori.ootw.common.AssertUtil;
+import com.backendoori.ootw.user.validation.Message;
+import com.backendoori.ootw.user.validation.RFC5322;
+import lombok.Builder;
+import lombok.Getter;
+import org.springframework.data.annotation.Id;
+import org.springframework.data.redis.core.RedisHash;
+
+@Getter
+@Builder
+@RedisHash(value = "certificate", timeToLive = 10 * 60L)
+public class Certificate {
+
+ public static final int SIZE = 6;
+ public static final String REGEX = "^[a-zA-Z0-9]{" + SIZE + "}$";
+
+ @Id
+ private String id;
+
+ private String code;
+
+ public Certificate(String id, String code) {
+ AssertUtil.hasPattern(id, RFC5322.REGEX, Message.INVALID_EMAIL);
+ AssertUtil.hasPattern(code, REGEX, Message.INVALID_CERTIFICATE_CODE);
+
+ this.id = id;
+ this.code = code;
+ }
+
+}
diff --git a/src/main/java/com/backendoori/ootw/user/domain/User.java b/src/main/java/com/backendoori/ootw/user/domain/User.java
index 35f4a99a..aa4b422f 100644
--- a/src/main/java/com/backendoori/ootw/user/domain/User.java
+++ b/src/main/java/com/backendoori/ootw/user/domain/User.java
@@ -1,5 +1,6 @@
package com.backendoori.ootw.user.domain;
+import java.util.Objects;
import com.backendoori.ootw.common.AssertUtil;
import com.backendoori.ootw.common.BaseEntity;
import com.backendoori.ootw.user.validation.Message;
@@ -14,6 +15,7 @@
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
+import org.springframework.security.crypto.password.PasswordEncoder;
@Table(name = "users")
@Entity
@@ -36,19 +38,37 @@ public class User extends BaseEntity {
@Column(name = "nickname", nullable = false)
private String nickname;
- @Column(name = "image")
- private String image;
+ @Column(name = "profile_image_url")
+ private String profileImageUrl;
- public User(Long id, String email, String password, String nickname, String image) {
+ @Column(name = "certified", nullable = false, columnDefinition = "TINYINT(1)")
+ private boolean certified;
+
+ public User(Long id, String email, String password, String nickname, String profileImageUrl, boolean certified) {
AssertUtil.hasPattern(email, RFC5322.REGEX, Message.INVALID_EMAIL);
+ AssertUtil.isTrue(email.length() <= 255, Message.TOO_LONG_EMAIL);
AssertUtil.notBlank(password, Message.BLANK_PASSWORD);
AssertUtil.notBlank(nickname, Message.BLANK_NICKNAME);
+ AssertUtil.isTrue(nickname.length() <= 255, Message.TOO_LONG_NICKNAME);
this.id = id;
this.email = email;
this.password = password;
this.nickname = nickname;
- this.image = image;
+ this.profileImageUrl = profileImageUrl;
+ this.certified = certified;
+ }
+
+ public void certify() {
+ this.certified = true;
+ }
+
+ public boolean matchPassword(PasswordEncoder passwordEncoder, String decrypted) {
+ return passwordEncoder.matches(decrypted, password);
+ }
+
+ public boolean isSameId(Long id) {
+ return Objects.equals(this.id, id);
}
}
diff --git a/src/main/java/com/backendoori/ootw/user/dto/CertifyDto.java b/src/main/java/com/backendoori/ootw/user/dto/CertifyDto.java
new file mode 100644
index 00000000..1eb96c00
--- /dev/null
+++ b/src/main/java/com/backendoori/ootw/user/dto/CertifyDto.java
@@ -0,0 +1,21 @@
+package com.backendoori.ootw.user.dto;
+
+import com.backendoori.ootw.user.domain.Certificate;
+import com.backendoori.ootw.user.validation.RFC5322;
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.Size;
+
+public record CertifyDto(
+ @NotBlank
+ @Size(max = 255)
+ @Email(regexp = RFC5322.REGEX)
+ String email,
+
+ @NotBlank
+ @Pattern(regexp = Certificate.REGEX)
+ String code
+) {
+
+}
diff --git a/src/main/java/com/backendoori/ootw/user/dto/LoginDto.java b/src/main/java/com/backendoori/ootw/user/dto/LoginDto.java
index a2667b18..12af6423 100644
--- a/src/main/java/com/backendoori/ootw/user/dto/LoginDto.java
+++ b/src/main/java/com/backendoori/ootw/user/dto/LoginDto.java
@@ -4,15 +4,14 @@
import com.backendoori.ootw.user.validation.RFC5322;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
-import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
public record LoginDto(
- @NotNull
@NotBlank
+ @Size(max = 255)
@Email(regexp = RFC5322.REGEX)
String email,
- @NotNull
@Password
String password
) {
diff --git a/src/main/java/com/backendoori/ootw/user/dto/SendCodeDto.java b/src/main/java/com/backendoori/ootw/user/dto/SendCodeDto.java
new file mode 100644
index 00000000..e7a9c412
--- /dev/null
+++ b/src/main/java/com/backendoori/ootw/user/dto/SendCodeDto.java
@@ -0,0 +1,15 @@
+package com.backendoori.ootw.user.dto;
+
+import com.backendoori.ootw.user.validation.RFC5322;
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+
+public record SendCodeDto(
+ @NotBlank
+ @Size(max = 255)
+ @Email(regexp = RFC5322.REGEX)
+ String email
+) {
+
+}
diff --git a/src/main/java/com/backendoori/ootw/user/dto/SignupDto.java b/src/main/java/com/backendoori/ootw/user/dto/SignupDto.java
index 59b368c3..14ebb8e4 100644
--- a/src/main/java/com/backendoori/ootw/user/dto/SignupDto.java
+++ b/src/main/java/com/backendoori/ootw/user/dto/SignupDto.java
@@ -5,19 +5,18 @@
import com.backendoori.ootw.user.validation.RFC5322;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
-import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
public record SignupDto(
- @NotNull
@NotBlank
+ @Size(max = 255)
@Email(regexp = RFC5322.REGEX)
String email,
- @NotNull
@Password
String password,
- @NotNull
+ @Size(max = 255)
@NotBlank(message = Message.BLANK_NICKNAME)
String nickname
) {
diff --git a/src/main/java/com/backendoori/ootw/user/dto/UserDto.java b/src/main/java/com/backendoori/ootw/user/dto/UserDto.java
deleted file mode 100644
index 5fb4aee8..00000000
--- a/src/main/java/com/backendoori/ootw/user/dto/UserDto.java
+++ /dev/null
@@ -1,29 +0,0 @@
-package com.backendoori.ootw.user.dto;
-
-import java.time.LocalDateTime;
-import com.backendoori.ootw.user.domain.User;
-import lombok.Builder;
-
-@Builder
-public record UserDto(
- Long id,
- String email,
- String nickname,
- String image,
- LocalDateTime createdAt,
- LocalDateTime updatedAt
-
-) {
-
- public static UserDto from(User user) {
- return UserDto.builder()
- .id(user.getId())
- .email(user.getEmail())
- .nickname(user.getNickname())
- .image(user.getImage())
- .createdAt(user.getCreatedAt())
- .updatedAt(user.getUpdatedAt())
- .build();
- }
-
-}
diff --git a/src/main/java/com/backendoori/ootw/user/exception/AlreadyCertifiedUserException.java b/src/main/java/com/backendoori/ootw/user/exception/AlreadyCertifiedUserException.java
new file mode 100644
index 00000000..d1d35a45
--- /dev/null
+++ b/src/main/java/com/backendoori/ootw/user/exception/AlreadyCertifiedUserException.java
@@ -0,0 +1,5 @@
+package com.backendoori.ootw.user.exception;
+
+public class AlreadyCertifiedUserException extends RuntimeException {
+
+}
diff --git a/src/main/java/com/backendoori/ootw/user/exception/ExpiredCertificateException.java b/src/main/java/com/backendoori/ootw/user/exception/ExpiredCertificateException.java
new file mode 100644
index 00000000..78afe350
--- /dev/null
+++ b/src/main/java/com/backendoori/ootw/user/exception/ExpiredCertificateException.java
@@ -0,0 +1,11 @@
+package com.backendoori.ootw.user.exception;
+
+public class ExpiredCertificateException extends IllegalArgumentException {
+
+ public static final String DEFAULT_MESSAGE = "만료된 인증 코드 입니다.";
+
+ public ExpiredCertificateException() {
+ super(DEFAULT_MESSAGE);
+ }
+
+}
diff --git a/src/main/java/com/backendoori/ootw/user/exception/IncorrectCertificateException.java b/src/main/java/com/backendoori/ootw/user/exception/IncorrectCertificateException.java
new file mode 100644
index 00000000..c0fd4d1b
--- /dev/null
+++ b/src/main/java/com/backendoori/ootw/user/exception/IncorrectCertificateException.java
@@ -0,0 +1,13 @@
+package com.backendoori.ootw.user.exception;
+
+import org.springframework.security.core.AuthenticationException;
+
+public class IncorrectCertificateException extends AuthenticationException {
+
+ public static final String DEFAULT_MESSAGE = "이메일 인증 코드가 일치하지 않습니다.";
+
+ public IncorrectCertificateException() {
+ super(DEFAULT_MESSAGE);
+ }
+
+}
diff --git a/src/main/java/com/backendoori/ootw/user/exception/NonCertifiedUserException.java b/src/main/java/com/backendoori/ootw/user/exception/NonCertifiedUserException.java
new file mode 100644
index 00000000..8a0e0f68
--- /dev/null
+++ b/src/main/java/com/backendoori/ootw/user/exception/NonCertifiedUserException.java
@@ -0,0 +1,13 @@
+package com.backendoori.ootw.user.exception;
+
+import org.springframework.security.core.AuthenticationException;
+
+public class NonCertifiedUserException extends AuthenticationException {
+
+ public static final String DEFAULT_MESSAGE = "인증되지 않은 이메일 입니다.";
+
+ public NonCertifiedUserException() {
+ super(DEFAULT_MESSAGE);
+ }
+
+}
diff --git a/src/main/java/com/backendoori/ootw/user/exception/UserControllerAdvice.java b/src/main/java/com/backendoori/ootw/user/exception/UserControllerAdvice.java
new file mode 100644
index 00000000..e80778b5
--- /dev/null
+++ b/src/main/java/com/backendoori/ootw/user/exception/UserControllerAdvice.java
@@ -0,0 +1,31 @@
+package com.backendoori.ootw.user.exception;
+
+import com.backendoori.ootw.exception.ErrorResponse;
+import com.backendoori.ootw.user.controller.CertificateController;
+import com.backendoori.ootw.user.controller.UserController;
+import org.springframework.core.Ordered;
+import org.springframework.core.annotation.Order;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+@Order(Ordered.HIGHEST_PRECEDENCE)
+@RestControllerAdvice(basePackageClasses = {UserController.class, CertificateController.class})
+public class UserControllerAdvice {
+
+ @ExceptionHandler(AlreadyCertifiedUserException.class)
+ public ResponseEntity handleAlreadyCertifiedUserException() {
+ return ResponseEntity.status(HttpStatus.ALREADY_REPORTED)
+ .build();
+ }
+
+ @ExceptionHandler(NonCertifiedUserException.class)
+ public ResponseEntity handleNonCertifiedUserException(NonCertifiedUserException e) {
+ ErrorResponse errorResponse = new ErrorResponse(e.getMessage());
+
+ return ResponseEntity.status(HttpStatus.FORBIDDEN)
+ .body(errorResponse);
+ }
+
+}
diff --git a/src/main/java/com/backendoori/ootw/user/repository/CertificateRedisRepository.java b/src/main/java/com/backendoori/ootw/user/repository/CertificateRedisRepository.java
new file mode 100644
index 00000000..a47295b5
--- /dev/null
+++ b/src/main/java/com/backendoori/ootw/user/repository/CertificateRedisRepository.java
@@ -0,0 +1,8 @@
+package com.backendoori.ootw.user.repository;
+
+import com.backendoori.ootw.user.domain.Certificate;
+import org.springframework.data.repository.CrudRepository;
+
+public interface CertificateRedisRepository extends CrudRepository {
+
+}
diff --git a/src/main/java/com/backendoori/ootw/user/repository/UserRepository.java b/src/main/java/com/backendoori/ootw/user/repository/UserRepository.java
index 2e7ef1aa..bb80b9ec 100644
--- a/src/main/java/com/backendoori/ootw/user/repository/UserRepository.java
+++ b/src/main/java/com/backendoori/ootw/user/repository/UserRepository.java
@@ -8,4 +8,6 @@ public interface UserRepository extends JpaRepository {
Optional findByEmail(String email);
+ boolean existsByEmail(String email);
+
}
diff --git a/src/main/java/com/backendoori/ootw/user/service/CertificateService.java b/src/main/java/com/backendoori/ootw/user/service/CertificateService.java
new file mode 100644
index 00000000..a28740b1
--- /dev/null
+++ b/src/main/java/com/backendoori/ootw/user/service/CertificateService.java
@@ -0,0 +1,77 @@
+package com.backendoori.ootw.user.service;
+
+import java.text.MessageFormat;
+import com.backendoori.ootw.common.AssertUtil;
+import com.backendoori.ootw.common.OotwMailSender;
+import com.backendoori.ootw.exception.UserNotFoundException;
+import com.backendoori.ootw.user.domain.Certificate;
+import com.backendoori.ootw.user.domain.User;
+import com.backendoori.ootw.user.dto.CertifyDto;
+import com.backendoori.ootw.user.dto.SendCodeDto;
+import com.backendoori.ootw.user.exception.AlreadyCertifiedUserException;
+import com.backendoori.ootw.user.exception.ExpiredCertificateException;
+import com.backendoori.ootw.user.exception.IncorrectCertificateException;
+import com.backendoori.ootw.user.repository.CertificateRedisRepository;
+import com.backendoori.ootw.user.repository.UserRepository;
+import lombok.RequiredArgsConstructor;
+import org.apache.commons.lang3.RandomStringUtils;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@RequiredArgsConstructor
+public class CertificateService {
+
+ public static final String TITLE_FORMAT = "[#OOTW] 이메일 인증 코드 : {0}";
+
+ private final OotwMailSender ootwMailSender;
+ private final UserRepository userRepository;
+ private final CertificateRedisRepository certificateRedisRepository;
+
+ @Transactional
+ public void sendCode(SendCodeDto sendCodeDto) {
+ String email = sendCodeDto.email();
+
+ User user = userRepository.findByEmail(email)
+ .orElseThrow(UserNotFoundException::new);
+
+ AssertUtil.throwIf(user.isCertified(), AlreadyCertifiedUserException::new);
+
+ Certificate certificate = generateCertificate(email);
+ String title = generateTitle(certificate);
+
+ certificateRedisRepository.save(certificate);
+ ootwMailSender.sendMail(email, title, certificate.getCode());
+ }
+
+ @Transactional
+ public void certify(CertifyDto certifyDto) {
+ User user = userRepository.findByEmail(certifyDto.email())
+ .orElseThrow(UserNotFoundException::new);
+
+ AssertUtil.throwIf(user.isCertified(), AlreadyCertifiedUserException::new);
+
+ Certificate certificate = certificateRedisRepository.findById(certifyDto.email())
+ .orElseThrow(ExpiredCertificateException::new);
+ boolean isIncorrectCertificate = !certifyDto.code().equals(certificate.getCode());
+
+ AssertUtil.throwIf(isIncorrectCertificate, IncorrectCertificateException::new);
+
+ user.certify();
+ certificateRedisRepository.delete(certificate);
+ }
+
+ private Certificate generateCertificate(String email) {
+ String code = RandomStringUtils.randomAlphanumeric(Certificate.SIZE);
+
+ return Certificate.builder()
+ .id(email)
+ .code(code)
+ .build();
+ }
+
+ private String generateTitle(Certificate certificate) {
+ return MessageFormat.format(TITLE_FORMAT, certificate.getCode());
+ }
+
+}
diff --git a/src/main/java/com/backendoori/ootw/user/service/UserService.java b/src/main/java/com/backendoori/ootw/user/service/UserService.java
index 082615bc..83dc9561 100644
--- a/src/main/java/com/backendoori/ootw/user/service/UserService.java
+++ b/src/main/java/com/backendoori/ootw/user/service/UserService.java
@@ -5,11 +5,12 @@
import com.backendoori.ootw.security.jwt.TokenProvider;
import com.backendoori.ootw.user.domain.User;
import com.backendoori.ootw.user.dto.LoginDto;
+import com.backendoori.ootw.user.dto.SendCodeDto;
import com.backendoori.ootw.user.dto.SignupDto;
import com.backendoori.ootw.user.dto.TokenDto;
-import com.backendoori.ootw.user.dto.UserDto;
import com.backendoori.ootw.user.exception.AlreadyExistEmailException;
import com.backendoori.ootw.user.exception.IncorrectPasswordException;
+import com.backendoori.ootw.user.exception.NonCertifiedUserException;
import com.backendoori.ootw.user.repository.UserRepository;
import com.backendoori.ootw.user.validation.Message;
import com.backendoori.ootw.user.validation.Password;
@@ -23,51 +24,55 @@
@RequiredArgsConstructor
public class UserService {
+ private final CertificateService certificateService;
private final UserRepository userRepository;
private final TokenProvider tokenProvider;
private final PasswordEncoder passwordEncoder;
@Transactional
- public UserDto signup(SignupDto signupDto) {
- boolean isAlreadyExistEmail = userRepository.findByEmail(signupDto.email())
- .isPresent();
-
- AssertUtil.throwIf(isAlreadyExistEmail, AlreadyExistEmailException::new);
- AssertUtil.isTrue(isValidPassword(signupDto.password()), Message.INVALID_PASSWORD);
+ public void signup(SignupDto signupDto) {
+ validateSignup(signupDto);
User user = buildUser(signupDto);
userRepository.save(user);
-
- return UserDto.from(user);
+ certificateService.sendCode(new SendCodeDto(user.getEmail()));
}
public TokenDto login(LoginDto loginDto) {
User user = userRepository.findByEmail(loginDto.email())
.orElseThrow(UserNotFoundException::new);
- boolean isIncorrectPassword = !matchPassword(loginDto.password(), user.getPassword());
- AssertUtil.throwIf(isIncorrectPassword, IncorrectPasswordException::new);
+ validateLogin(user, loginDto.password());
String token = tokenProvider.createToken(user.getId());
return new TokenDto(token);
}
+ private void validateSignup(SignupDto signupDto) {
+ boolean isAlreadyExistEmail = userRepository.existsByEmail(signupDto.email());
+
+ AssertUtil.throwIf(isAlreadyExistEmail, AlreadyExistEmailException::new);
+ AssertUtil.isTrue(isValidPassword(signupDto.password()), Message.INVALID_PASSWORD);
+ }
+
private User buildUser(SignupDto signupDto) {
return User.builder()
.email(signupDto.email())
.password(passwordEncoder.encode(signupDto.password()))
.nickname(signupDto.nickname())
+ .certified(false)
.build();
}
- private boolean matchPassword(String decrypted, String encrypted) {
- return passwordEncoder.matches(decrypted, encrypted);
- }
-
private boolean isValidPassword(String password) {
return StringUtils.hasLength(password) && password.matches(Password.REGEX);
}
+ private void validateLogin(User user, String decrypted) {
+ AssertUtil.throwIf(!user.isCertified(), NonCertifiedUserException::new);
+ AssertUtil.throwIf(!user.matchPassword(passwordEncoder, decrypted), IncorrectPasswordException::new);
+ }
+
}
diff --git a/src/main/java/com/backendoori/ootw/user/validation/Message.java b/src/main/java/com/backendoori/ootw/user/validation/Message.java
index 22e4f08b..f2d3b5d1 100644
--- a/src/main/java/com/backendoori/ootw/user/validation/Message.java
+++ b/src/main/java/com/backendoori/ootw/user/validation/Message.java
@@ -7,12 +7,13 @@
public final class Message {
public static final String INVALID_EMAIL = "이메일 형식이 올바르지 않습니다.";
-
+ public static final String TOO_LONG_EMAIL = "이메일은 255자 이내여야 합니다.";
public static final String INVALID_PASSWORD = "비밀번호는 숫자, 영문자, 특수문자를 포함한 "
+ Password.MIN_SIZE + "자 이상, "
+ Password.MAX_SIZE + "자 이내의 문자여야 합니다.";
-
public static final String BLANK_PASSWORD = "비밀번호는 공백일 수 없습니다.";
public static final String BLANK_NICKNAME = "닉네임은 공백일 수 없습니다.";
+ public static final String TOO_LONG_NICKNAME = "닉네임은 255자 이내여야 합니다.";
+ public static final String INVALID_CERTIFICATE_CODE = "인증 코드 형식이 올바르지 않습니다.";
}
diff --git a/src/main/java/com/backendoori/ootw/weather/controller/WeatherController.java b/src/main/java/com/backendoori/ootw/weather/controller/WeatherController.java
index 3a88c265..99b01672 100644
--- a/src/main/java/com/backendoori/ootw/weather/controller/WeatherController.java
+++ b/src/main/java/com/backendoori/ootw/weather/controller/WeatherController.java
@@ -1,16 +1,14 @@
package com.backendoori.ootw.weather.controller;
+import com.backendoori.ootw.weather.domain.Coordinate;
import com.backendoori.ootw.weather.dto.WeatherResponse;
import com.backendoori.ootw.weather.service.WeatherService;
-import jakarta.validation.constraints.Max;
-import jakarta.validation.constraints.Min;
-import jakarta.validation.constraints.NotNull;
+import com.backendoori.ootw.weather.validation.Grid;
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.RequestMapping;
-import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@@ -21,20 +19,9 @@ public class WeatherController {
private final WeatherService weatherService;
@GetMapping
- public ResponseEntity readCurrentWeather(
- @Min(0)
- @Max(999)
- @NotNull
- @RequestParam
- int nx,
-
- @Min(0)
- @Max(999)
- @NotNull
- @RequestParam
- int ny) {
+ public ResponseEntity readCurrentWeather(@Grid Coordinate coordinate) {
return ResponseEntity.status(HttpStatus.OK)
- .body(weatherService.getCurrentWeather(nx, ny));
+ .body(weatherService.getCurrentWeather(coordinate));
}
}
diff --git a/src/main/java/com/backendoori/ootw/weather/domain/Coordinate.java b/src/main/java/com/backendoori/ootw/weather/domain/Coordinate.java
new file mode 100644
index 00000000..2a1aef8e
--- /dev/null
+++ b/src/main/java/com/backendoori/ootw/weather/domain/Coordinate.java
@@ -0,0 +1,8 @@
+package com.backendoori.ootw.weather.domain;
+
+public record Coordinate(
+ Integer nx,
+ Integer ny
+) {
+
+}
diff --git a/src/main/java/com/backendoori/ootw/weather/domain/PtyType.java b/src/main/java/com/backendoori/ootw/weather/domain/PtyType.java
index 596ac7ad..4a52d6ec 100644
--- a/src/main/java/com/backendoori/ootw/weather/domain/PtyType.java
+++ b/src/main/java/com/backendoori/ootw/weather/domain/PtyType.java
@@ -1,5 +1,7 @@
package com.backendoori.ootw.weather.domain;
+import static com.backendoori.ootw.weather.validation.Message.CAN_NOT_RETRIEVE_PTYTYPE;
+
import java.util.Arrays;
import java.util.Objects;
import lombok.AccessLevel;
@@ -25,7 +27,7 @@ public static PtyType getByCode(Integer code) {
return Arrays.stream(values())
.filter(skyType -> skyType.matchCode(code))
.findFirst()
- .orElseThrow(() -> new IllegalArgumentException("강수 형태 코드가 유효하지 않은 번호입니다."));
+ .orElseThrow(() -> new IllegalArgumentException(CAN_NOT_RETRIEVE_PTYTYPE));
}
private boolean matchCode(Integer code) {
diff --git a/src/main/java/com/backendoori/ootw/weather/domain/SkyType.java b/src/main/java/com/backendoori/ootw/weather/domain/SkyType.java
index 4b98a92e..68bb316f 100644
--- a/src/main/java/com/backendoori/ootw/weather/domain/SkyType.java
+++ b/src/main/java/com/backendoori/ootw/weather/domain/SkyType.java
@@ -1,5 +1,7 @@
package com.backendoori.ootw.weather.domain;
+import static com.backendoori.ootw.weather.validation.Message.CAN_NOT_RETRIEVE_SKYTYPE;
+
import java.util.Arrays;
import java.util.Objects;
import lombok.AccessLevel;
@@ -20,7 +22,7 @@ public static SkyType getByCode(Integer code) {
return Arrays.stream(values())
.filter(skyType -> skyType.matchCode(code))
.findFirst()
- .orElseThrow(() -> new IllegalArgumentException("하늘상태 코드가 유효하지 않은 번호입니다."));
+ .orElseThrow(() -> new IllegalArgumentException(CAN_NOT_RETRIEVE_SKYTYPE));
}
private boolean matchCode(Integer code) {
diff --git a/src/main/java/com/backendoori/ootw/weather/domain/Temperature.java b/src/main/java/com/backendoori/ootw/weather/domain/Temperature.java
index 59562e47..08af2728 100644
--- a/src/main/java/com/backendoori/ootw/weather/domain/Temperature.java
+++ b/src/main/java/com/backendoori/ootw/weather/domain/Temperature.java
@@ -1,5 +1,7 @@
package com.backendoori.ootw.weather.domain;
+import static com.backendoori.ootw.weather.validation.Message.CAN_NOT_RETRIEVE_TEMPERATURE_ARRANGE;
+
import jakarta.persistence.Embeddable;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
@@ -17,11 +19,12 @@ public class Temperature {
private static final Double MIN_VALUE = -900.0;
private static final Double MAX_VALUE = 900.0;
+
private Double value;
public static void validate(Double value) {
- Assert.notNull(value, "기온 값은 null이 될 수 없습니다.");
- Assert.isTrue(MIN_VALUE < value && value < MAX_VALUE, "기온 값은 -900 이하, 900 이상이 될 수 없습니다.");
+ Assert.notNull(value, CAN_NOT_RETRIEVE_TEMPERATURE_ARRANGE);
+ Assert.isTrue(MIN_VALUE < value && value < MAX_VALUE, CAN_NOT_RETRIEVE_TEMPERATURE_ARRANGE);
}
public static Temperature of(Double value) {
diff --git a/src/main/java/com/backendoori/ootw/weather/domain/TemperatureArrange.java b/src/main/java/com/backendoori/ootw/weather/domain/TemperatureArrange.java
index dd97d73d..1a355efc 100644
--- a/src/main/java/com/backendoori/ootw/weather/domain/TemperatureArrange.java
+++ b/src/main/java/com/backendoori/ootw/weather/domain/TemperatureArrange.java
@@ -1,5 +1,7 @@
package com.backendoori.ootw.weather.domain;
+import static com.backendoori.ootw.weather.validation.Message.CAN_NOT_RETRIEVE_TEMPERATURE_ARRANGE;
+
import java.util.Map;
import com.backendoori.ootw.weather.domain.forecast.ForecastCategory;
import com.backendoori.ootw.weather.exception.ForecastResultErrorManager;
@@ -21,8 +23,6 @@
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class TemperatureArrange {
- private static final String TEMPERATURE_ARRANGE_EXCEPTION = "기상청에서 제공한 일 최저 기온, 일 최고 기온 값이 유효하지 않습니다.";
-
@Embedded
@AttributeOverride(name = "value", column = @Column(name = "min_temperature", nullable = false))
private Temperature min;
@@ -46,7 +46,7 @@ public static TemperatureArrange from(Map weatherInfoM
private static void validateArrange(Temperature dayMinTemperature, Temperature dayMaxTemperature) {
Assert.isTrue(dayMinTemperature.getValue() <= dayMaxTemperature.getValue(), () -> {
- throw new IllegalStateException(TEMPERATURE_ARRANGE_EXCEPTION);
+ throw new IllegalStateException(CAN_NOT_RETRIEVE_TEMPERATURE_ARRANGE);
});
}
diff --git a/src/main/java/com/backendoori/ootw/weather/domain/forecast/ForecastCategory.java b/src/main/java/com/backendoori/ootw/weather/domain/forecast/ForecastCategory.java
index be456755..3b8f171d 100644
--- a/src/main/java/com/backendoori/ootw/weather/domain/forecast/ForecastCategory.java
+++ b/src/main/java/com/backendoori/ootw/weather/domain/forecast/ForecastCategory.java
@@ -5,7 +5,7 @@
public enum ForecastCategory {
- TMX, TMP, PTY, SKY, TMN, T1H;
+ TMX, PTY, SKY, TMN, T1H;
public static boolean anyMatch(String categoryName) {
return Arrays.stream(values()).anyMatch(category -> Objects.equals(categoryName, category.name()));
diff --git a/src/main/java/com/backendoori/ootw/weather/dto/WeatherResponse.java b/src/main/java/com/backendoori/ootw/weather/dto/WeatherResponse.java
index 1da328d5..62f11d7c 100644
--- a/src/main/java/com/backendoori/ootw/weather/dto/WeatherResponse.java
+++ b/src/main/java/com/backendoori/ootw/weather/dto/WeatherResponse.java
@@ -10,15 +10,12 @@
public record WeatherResponse(
LocalDateTime currentDateTime,
- int nx,
- int ny,
double currentTemperature,
String sky,
String pty
) {
- public static WeatherResponse from(LocalDateTime dateTime, int nx, int ny,
- Map weatherInfoMap) {
+ public static WeatherResponse from(LocalDateTime dateTime, Map weatherInfoMap) {
checkIncludeCurrentWeather(weatherInfoMap);
double currentTemperature = Double.parseDouble(weatherInfoMap.get(ForecastCategory.T1H));
@@ -29,7 +26,7 @@ public static WeatherResponse from(LocalDateTime dateTime, int nx, int ny,
int ptyCode = Integer.parseInt(weatherInfoMap.get(ForecastCategory.PTY));
String ptyType = PtyType.getByCode(ptyCode).name();
- return new WeatherResponse(dateTime, nx, ny, currentTemperature, skyType, ptyType);
+ return new WeatherResponse(dateTime, currentTemperature, skyType, ptyType);
}
private static void checkIncludeCurrentWeather(Map currentWeatherMap) {
diff --git a/src/main/java/com/backendoori/ootw/weather/exception/ForecastResultErrorManager.java b/src/main/java/com/backendoori/ootw/weather/exception/ForecastResultErrorManager.java
index 7fc04835..bc7d3691 100644
--- a/src/main/java/com/backendoori/ootw/weather/exception/ForecastResultErrorManager.java
+++ b/src/main/java/com/backendoori/ootw/weather/exception/ForecastResultErrorManager.java
@@ -1,5 +1,7 @@
package com.backendoori.ootw.weather.exception;
+import static com.backendoori.ootw.weather.validation.Message.CAN_NOT_USE_FORECAST_API;
+
import java.util.Arrays;
import java.util.NoSuchElementException;
import java.util.Objects;
@@ -14,7 +16,6 @@ public enum ForecastResultErrorManager {
NODATA_ERROR("03", NoSuchElementException::new),
INVALID_REQUEST_PARAMETER_ERROR("10", IllegalArgumentException::new);
- private static final String API_SERVER_ERROR_MESSAGE = "기상청 API 서비스를 이용할 수 없습니다.";
private static final String NORMAL_SERVICE_CODE = "00";
private final String resultCode;
@@ -35,7 +36,7 @@ public static void checkResultCode(String resultCode) {
}
public static IllegalStateException getApiServerException() {
- return new IllegalStateException(API_SERVER_ERROR_MESSAGE);
+ return new IllegalStateException(CAN_NOT_USE_FORECAST_API);
}
private void throwException() {
diff --git a/src/main/java/com/backendoori/ootw/weather/service/WeatherService.java b/src/main/java/com/backendoori/ootw/weather/service/WeatherService.java
index a97bd103..59e56187 100644
--- a/src/main/java/com/backendoori/ootw/weather/service/WeatherService.java
+++ b/src/main/java/com/backendoori/ootw/weather/service/WeatherService.java
@@ -3,11 +3,13 @@
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
+import com.backendoori.ootw.weather.domain.Coordinate;
import com.backendoori.ootw.weather.domain.TemperatureArrange;
import com.backendoori.ootw.weather.domain.forecast.ForecastCategory;
import com.backendoori.ootw.weather.dto.WeatherResponse;
import com.backendoori.ootw.weather.dto.forecast.BaseDateTime;
import com.backendoori.ootw.weather.util.BaseDateTimeCalculator;
+import com.backendoori.ootw.weather.util.DateTimeProvider;
import com.backendoori.ootw.weather.util.client.ForecastApiClient;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@@ -16,33 +18,32 @@
@RequiredArgsConstructor
public class WeatherService {
+ private final DateTimeProvider dateTimeProvider;
private final ForecastApiClient forecastApiClient;
- public WeatherResponse getCurrentWeather(int nx, int ny) {
- LocalDateTime dateTime = LocalDateTime.now();
+ public WeatherResponse getCurrentWeather(Coordinate location) {
+ LocalDateTime dateTime = dateTimeProvider.now();
BaseDateTime requestBaseDateTime = BaseDateTimeCalculator.getUltraShortForecastRequestBaseDateTime(dateTime);
BaseDateTime fcstBaseDateTime = BaseDateTimeCalculator.getCurrentBaseDateTime(dateTime);
Map weatherInfoMap = new HashMap<>();
- forecastApiClient.requestUltraShortForecastItems(requestBaseDateTime, nx, ny)
+ forecastApiClient.requestUltraShortForecastItems(requestBaseDateTime, location)
.stream()
.filter(item -> item.matchFcstDateTime(fcstBaseDateTime))
.forEach(item -> weatherInfoMap.put(ForecastCategory.valueOf(item.category()), item.fcstValue()));
- return WeatherResponse.from(dateTime, nx, ny, weatherInfoMap);
+ return WeatherResponse.from(dateTime, weatherInfoMap);
}
- public TemperatureArrange getCurrentTemperatureArrange(int nx, int ny) {
- LocalDateTime dateTime = LocalDateTime.now();
+ public TemperatureArrange getCurrentTemperatureArrange(Coordinate location) {
+ LocalDateTime dateTime = dateTimeProvider.now();
BaseDateTime requestBaseDateTime = BaseDateTimeCalculator.getVillageForecastRequestBaseDateTime(dateTime);
BaseDateTime fcstBaseDateTime = BaseDateTimeCalculator.getCurrentBaseDateTime(dateTime);
Map weatherInfoMap = new HashMap<>();
- forecastApiClient.requestVillageForecastItems(requestBaseDateTime, nx, ny)
+ forecastApiClient.requestVillageForecastItems(requestBaseDateTime, location)
.stream()
- .filter(item -> item.fcstDateTime().baseDate().equals(fcstBaseDateTime.baseDate())
- && (item.category().equals(ForecastCategory.TMN.name())
- || item.category().equals(ForecastCategory.TMX.name())))
+ .filter(item -> item.matchFcstDate(fcstBaseDateTime))
.forEach(item -> weatherInfoMap.put(ForecastCategory.valueOf(item.category()), item.fcstValue()));
return TemperatureArrange.from(weatherInfoMap);
diff --git a/src/main/java/com/backendoori/ootw/weather/util/DateTimeProvider.java b/src/main/java/com/backendoori/ootw/weather/util/DateTimeProvider.java
new file mode 100644
index 00000000..1e76e2ca
--- /dev/null
+++ b/src/main/java/com/backendoori/ootw/weather/util/DateTimeProvider.java
@@ -0,0 +1,13 @@
+package com.backendoori.ootw.weather.util;
+
+import java.time.LocalDateTime;
+import org.springframework.stereotype.Component;
+
+@Component
+public class DateTimeProvider {
+
+ public LocalDateTime now() {
+ return LocalDateTime.now();
+ }
+
+}
diff --git a/src/main/java/com/backendoori/ootw/weather/util/client/ForecastApiClient.java b/src/main/java/com/backendoori/ootw/weather/util/client/ForecastApiClient.java
index ee2cdeba..dcf7812f 100644
--- a/src/main/java/com/backendoori/ootw/weather/util/client/ForecastApiClient.java
+++ b/src/main/java/com/backendoori/ootw/weather/util/client/ForecastApiClient.java
@@ -1,6 +1,9 @@
package com.backendoori.ootw.weather.util.client;
+import static com.backendoori.ootw.weather.validation.Message.INVALID_LOCATION_MESSAGE;
+
import java.util.List;
+import com.backendoori.ootw.weather.domain.Coordinate;
import com.backendoori.ootw.weather.dto.forecast.BaseDateTime;
import com.backendoori.ootw.weather.exception.ForecastResultErrorManager;
import com.backendoori.ootw.weather.util.ForecastProperties;
@@ -11,6 +14,7 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
+import org.springframework.util.Assert;
@Component
@RequiredArgsConstructor
@@ -25,8 +29,9 @@ public class ForecastApiClient {
private final ForecastProperties forecastProperties;
public List requestUltraShortForecastItems(BaseDateTime requestBaseDateTime,
- int nx,
- int ny) {
+ Coordinate location) {
+ validateLocation(location);
+
String response = forecastApi.getUltraShortForecast(
forecastProperties.serviceKey(),
NUM_OF_ROWS,
@@ -34,15 +39,16 @@ public List requestUltraShortForecastItems(BaseDateTime requ
DATA_TYPE,
requestBaseDateTime.baseDate(),
requestBaseDateTime.baseTime(),
- nx,
- ny);
+ location.nx(),
+ location.ny());
return parseForecastResult(response);
}
public List requestVillageForecastItems(BaseDateTime requestBaseDateTime,
- int nx,
- int ny) {
+ Coordinate location) {
+ validateLocation(location);
+
String response = forecastApi.getVillageForecast(
forecastProperties.serviceKey(),
NUM_OF_ROWS,
@@ -50,8 +56,8 @@ public List requestVillageForecastItems(BaseDateTime request
DATA_TYPE,
requestBaseDateTime.baseDate(),
requestBaseDateTime.baseTime(),
- nx,
- ny);
+ location.nx(),
+ location.ny());
return parseForecastResult(response);
}
@@ -68,4 +74,10 @@ private List parseForecastResult(String response) {
}
}
+ private void validateLocation(Coordinate location) {
+ Assert.isTrue(0 <= location.nx() && location.nx() <= 999 && 0 <= location.ny() && location.ny() <= 999, () -> {
+ throw new IllegalArgumentException(INVALID_LOCATION_MESSAGE);
+ });
+ }
+
}
diff --git a/src/main/java/com/backendoori/ootw/weather/util/deserializer/ForecastResultItem.java b/src/main/java/com/backendoori/ootw/weather/util/deserializer/ForecastResultItem.java
index 32217878..249899f7 100644
--- a/src/main/java/com/backendoori/ootw/weather/util/deserializer/ForecastResultItem.java
+++ b/src/main/java/com/backendoori/ootw/weather/util/deserializer/ForecastResultItem.java
@@ -18,4 +18,8 @@ public boolean matchFcstDateTime(BaseDateTime baseDateTime) {
return Objects.equals(fcstDateTime, baseDateTime);
}
+ public boolean matchFcstDate(BaseDateTime baseDateTime) {
+ return Objects.equals(fcstDateTime.baseDate(), baseDateTime.baseDate());
+ }
+
}
diff --git a/src/main/java/com/backendoori/ootw/weather/validation/CoordinateValidator.java b/src/main/java/com/backendoori/ootw/weather/validation/CoordinateValidator.java
new file mode 100644
index 00000000..a52168d8
--- /dev/null
+++ b/src/main/java/com/backendoori/ootw/weather/validation/CoordinateValidator.java
@@ -0,0 +1,25 @@
+package com.backendoori.ootw.weather.validation;
+
+import java.util.Objects;
+import com.backendoori.ootw.weather.domain.Coordinate;
+import jakarta.validation.ConstraintValidator;
+import jakarta.validation.ConstraintValidatorContext;
+
+public class CoordinateValidator implements ConstraintValidator {
+
+ public static final int MAX_COORDINATE = 999;
+ public static final int MIN_COORDINATE = 0;
+
+ @Override
+ public boolean isValid(Coordinate coordinate, ConstraintValidatorContext context) {
+ if (Objects.isNull(coordinate) || Objects.isNull(coordinate.nx()) || Objects.isNull(coordinate.ny())) {
+ return false;
+ }
+
+ return coordinate.nx() >= MIN_COORDINATE
+ && MAX_COORDINATE >= coordinate.nx()
+ && coordinate.ny() >= MIN_COORDINATE
+ && MAX_COORDINATE >= coordinate.ny();
+ }
+
+}
diff --git a/src/main/java/com/backendoori/ootw/weather/validation/Grid.java b/src/main/java/com/backendoori/ootw/weather/validation/Grid.java
new file mode 100644
index 00000000..dd93445f
--- /dev/null
+++ b/src/main/java/com/backendoori/ootw/weather/validation/Grid.java
@@ -0,0 +1,25 @@
+package com.backendoori.ootw.weather.validation;
+
+import static com.backendoori.ootw.weather.validation.Message.INVALID_LOCATION_MESSAGE;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+import jakarta.validation.Constraint;
+import jakarta.validation.Payload;
+
+@Target(value = {ElementType.PARAMETER, ElementType.FIELD})
+@Retention(value = RetentionPolicy.RUNTIME)
+@Constraint(validatedBy = CoordinateValidator.class)
+public @interface Grid {
+
+ String message = INVALID_LOCATION_MESSAGE;
+
+ String message() default message;
+
+ Class>[] groups() default {};
+
+ Class extends Payload>[] payload() default {};
+
+}
diff --git a/src/main/java/com/backendoori/ootw/weather/validation/Message.java b/src/main/java/com/backendoori/ootw/weather/validation/Message.java
new file mode 100644
index 00000000..d52874bc
--- /dev/null
+++ b/src/main/java/com/backendoori/ootw/weather/validation/Message.java
@@ -0,0 +1,16 @@
+package com.backendoori.ootw.weather.validation;
+
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
+
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+public final class Message {
+
+ public static final String CAN_NOT_RETRIEVE_TEMPERATURE_ARRANGE = "기상청에서 제공한 일교차가 유효하지 않습니다.";
+ public static final String CAN_NOT_RETRIEVE_SKYTYPE = "기상청 API에서 올바른 하늘 ㅐ상태 정보를 불러올 수 없습니다.";
+ public static final String CAN_NOT_RETRIEVE_PTYTYPE = "기상청 API에서 올바른 강수 형태 정보를 불러올 수 없습니다.";
+ public static final String CAN_NOT_USE_FORECAST_API = "기상청 API 서비스를 이용할 수 없습니다.";
+
+ public static final String INVALID_LOCATION_MESSAGE = "nx, ny 좌표값 모두 null이 될 수 없고 0 이상 999 이하가 되어야 합니다.";
+
+}
diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml
index 358b9b3a..1761e2b0 100644
--- a/src/main/resources/application.yaml
+++ b/src/main/resources/application.yaml
@@ -4,14 +4,30 @@ spring:
username: ${MAIN_MYSQL_USERNAME}
password: ${MAIN_MYSQL_PASSWORD}
url: ${MAIN_MYSQL_URL}
+ hikari:
+ maximum-pool-size: 100
+ minimum-idle: 10
jpa:
hibernate:
ddl-auto: validate
open-in-view: false
servlet:
multipart:
- max-file-size: 10MB
- maxRequestSize: 10MB
+ max-file-size: 100MB
+ maxRequestSize: 100MB
+ data:
+ redis:
+ host: ${REDIS_HOST}
+ port: ${REDIS_PORT}
+ mail:
+ host: smtp.gmail.com
+ port: 587
+ username: ${MAIL_USERNAME}
+ password: ${MAIL_PASSWORD}
+ properties:
+ mail.smtp.auth: true
+ mail.smtp.starttls.enable: true
+ mail.smtp.starttls.required: true
minio:
url: ${MINIO_URL}
bucket: ${MINIO_BUCKET}
@@ -27,4 +43,3 @@ openfeign:
url: ${FORECAST_URL}
server:
port: 8080
-
diff --git a/src/test/java/com/backendoori/ootw/OotwApplicationTests.java b/src/test/java/com/backendoori/ootw/OotwApplicationTests.java
index d144c5d6..24ed82f8 100644
--- a/src/test/java/com/backendoori/ootw/OotwApplicationTests.java
+++ b/src/test/java/com/backendoori/ootw/OotwApplicationTests.java
@@ -1,7 +1,9 @@
package com.backendoori.ootw;
+import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatNoException;
+import java.util.TimeZone;
import org.assertj.core.api.ThrowableAssert.ThrowingCallable;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@@ -23,4 +25,14 @@ void testMain() {
assertThatNoException().isThrownBy(main);
}
+ @DisplayName("애플리케이션을 지정된 Tiemzone으로 설정한다")
+ @Test
+ void testServerTimezone() {
+ // given // when
+ String id = TimeZone.getDefault().getID();
+
+ // then
+ assertThat(id).isEqualTo(OotwApplication.TIMEZONE);
+ }
+
}
diff --git a/src/test/java/com/backendoori/ootw/avatar/controller/AvatarItemControllerTest.java b/src/test/java/com/backendoori/ootw/avatar/controller/AvatarItemControllerTest.java
index 992eaf57..bc366268 100644
--- a/src/test/java/com/backendoori/ootw/avatar/controller/AvatarItemControllerTest.java
+++ b/src/test/java/com/backendoori/ootw/avatar/controller/AvatarItemControllerTest.java
@@ -1,11 +1,19 @@
package com.backendoori.ootw.avatar.controller;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doThrow;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
+import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.nio.charset.StandardCharsets;
+import com.backendoori.ootw.avatar.domain.Sex;
import com.backendoori.ootw.avatar.dto.AvatarItemRequest;
+import com.backendoori.ootw.avatar.repository.AvatarItemRepository;
import com.backendoori.ootw.avatar.service.AvatarItemService;
+import com.backendoori.ootw.common.image.exception.ImageException;
+import com.backendoori.ootw.common.image.exception.SaveException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@@ -20,6 +28,7 @@
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.web.multipart.MultipartFile;
@WithMockUser
@AutoConfigureMockMvc
@@ -27,7 +36,10 @@
class AvatarItemControllerTest {
@MockBean
- AvatarItemService postService;
+ AvatarItemService avatarItemService;
+
+ @MockBean
+ AvatarItemRepository avatarItemRepository;
@Autowired
MockMvc mockMvc;
@@ -41,7 +53,7 @@ public void imageUploadTest() throws Exception {
//given
MockMultipartFile file = new MockMultipartFile("file", "filename.txt",
"image/jpeg", "some xml".getBytes());
- AvatarItemRequest requestDto = new AvatarItemRequest("HAIR", true);
+ AvatarItemRequest requestDto = new AvatarItemRequest("HAIR", Sex.MALE.name());
MockMultipartFile request = new MockMultipartFile("request", "filename.txt",
"application/json", objectMapper.writeValueAsBytes(requestDto));
@@ -55,6 +67,52 @@ public void imageUploadTest() throws Exception {
.andExpect(status().isCreated());
}
+ @Test
+ @DisplayName("아바타 등록 요청 중 이미지 등록 중 예외가 발생하면 커스텀 예외가 발생한다.")
+ public void imageUploadException() throws Exception {
+ //given
+ MockMultipartFile file = new MockMultipartFile("file", "filename.txt", "image/jpeg", "some xml".getBytes());
+ AvatarItemRequest requestDto = new AvatarItemRequest("HAIR", Sex.MALE.name());
+ MockMultipartFile request = new MockMultipartFile("request", "filename.txt", "application/json",
+ objectMapper.writeValueAsBytes(requestDto));
+
+ doThrow(new ImageException("Mock Exception"))
+ .when(avatarItemService)
+ .upload(any(MultipartFile.class), any(AvatarItemRequest.class));
+
+ //when, then
+ mockMvc.perform(multipart("/api/v1/avatar-items")
+ .file(file)
+ .file(request)
+ .contentType(MediaType.MULTIPART_FORM_DATA)
+ .accept(MediaType.APPLICATION_JSON)
+ .characterEncoding(StandardCharsets.UTF_8))
+ .andExpect(status().isUnprocessableEntity());
+ }
+
+ @Test
+ @DisplayName("아바타 등록 요청 중 롤백 일어날 시 이미 사진이 저장된 상황이면 예외를 발생시키고 사진을 지운다.")
+ public void imageSaveException() throws Exception {
+ //given
+ MockMultipartFile file = new MockMultipartFile("file", "filename.txt", "image/jpeg", "some xml".getBytes());
+ AvatarItemRequest requestDto = new AvatarItemRequest("HAIR", Sex.MALE.name());
+ MockMultipartFile request = new MockMultipartFile("request", "filename.txt", "application/json",
+ objectMapper.writeValueAsBytes(requestDto));
+
+ doThrow(new SaveException())
+ .when(avatarItemService)
+ .upload(any(MultipartFile.class), any(AvatarItemRequest.class));
+
+ //when, then
+ mockMvc.perform(multipart("/api/v1/avatar-items")
+ .file(file)
+ .file(request)
+ .contentType(MediaType.MULTIPART_FORM_DATA)
+ .accept(MediaType.APPLICATION_JSON)
+ .characterEncoding(StandardCharsets.UTF_8))
+ .andExpect(status().isUnprocessableEntity());
+ }
+
@ParameterizedTest(name = "[{index}] content-type 이 {0}인 경우")
@ValueSource(strings = {"text/plain", "application/json"})
@DisplayName("아바타 이미지 업로드 시 파일의 유형이 이미지가 아닌 경우 예외가 발생한다.")
@@ -62,7 +120,7 @@ public void imageUpLoadWithInvalidContentType(String contentType) throws Excepti
//given
MockMultipartFile file = new MockMultipartFile("file", "filename.txt",
contentType, "some xml".getBytes());
- AvatarItemRequest dto = new AvatarItemRequest("HAIR", true);
+ AvatarItemRequest dto = new AvatarItemRequest("HAIR", Sex.MALE.name());
MockMultipartFile requestDto = new MockMultipartFile("request", "filename.json",
MediaType.APPLICATION_JSON_VALUE, objectMapper.writeValueAsBytes(dto));
@@ -81,7 +139,7 @@ public void imageUpLoadWithInvalidContentType(String contentType) throws Excepti
public void noImageUpLoad() throws Exception {
//given
MockMultipartFile file = new MockMultipartFile("file", "", "image/png", new byte[0]);
- AvatarItemRequest dto = new AvatarItemRequest("HAIR", true);
+ AvatarItemRequest dto = new AvatarItemRequest("HAIR", Sex.MALE.name());
MockMultipartFile requestDto = new MockMultipartFile("request", "filename.json",
MediaType.APPLICATION_JSON_VALUE, objectMapper.writeValueAsBytes(dto));
@@ -104,7 +162,7 @@ public void upLoadWithInvalidRequest(String type) throws Exception {
//given
MockMultipartFile file = new MockMultipartFile("file", "filename.txt",
"image/jpeg", "some xml".getBytes());
- AvatarItemRequest requestDto = new AvatarItemRequest(type, true);
+ AvatarItemRequest requestDto = new AvatarItemRequest(type, Sex.MALE.name());
MockMultipartFile request = new MockMultipartFile("request", "filename.txt",
"application/json", objectMapper.writeValueAsBytes(requestDto));
@@ -118,4 +176,13 @@ public void upLoadWithInvalidRequest(String type) throws Exception {
.characterEncoding(StandardCharsets.UTF_8))
.andExpect(status().isBadRequest());
}
+
+ @Test
+ @DisplayName("아바타 이미지 리스트 조회에 성공한다.")
+ public void getItemList() throws Exception {
+ //given, when, then
+ mockMvc.perform(get("/api/v1/avatar-items"))
+ .andExpect(status().isOk())
+ .andDo(print());
+ }
}
diff --git a/src/test/java/com/backendoori/ootw/avatar/domain/AvatarItemTest.java b/src/test/java/com/backendoori/ootw/avatar/domain/AvatarItemTest.java
index b282fe2a..67a9b8a0 100644
--- a/src/test/java/com/backendoori/ootw/avatar/domain/AvatarItemTest.java
+++ b/src/test/java/com/backendoori/ootw/avatar/domain/AvatarItemTest.java
@@ -1,27 +1,63 @@
package com.backendoori.ootw.avatar.domain;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import java.util.stream.Stream;
import com.backendoori.ootw.avatar.dto.AvatarItemRequest;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
class AvatarItemTest {
@Test
- @DisplayName("아바타 옷 생성 테스트")
- public void createTest() throws Exception {
+ @DisplayName("아바타 옷 생성에 성공한다.")
+ public void createTest() {
//given
- AvatarItemRequest request = new AvatarItemRequest("HAIR", true);
+ AvatarItemRequest request = new AvatarItemRequest("HAIR", Sex.MALE.name());
String url = "url";
//when
AvatarItem avatarItem = AvatarItem.create(request, url);
-
//then
assertThat(request.type()).isEqualTo(avatarItem.getItemType().name());
- assertThat(request.sex()).isEqualTo(avatarItem.isSex());
+ assertThat(request.sex()).isEqualTo(avatarItem.getSex().name());
+ }
+
+ @ParameterizedTest(name = "[{index}] 아이템 타입이 {0}이고 성별이 {1}이며, 생성된 이미지의 url이 {2} 경우")
+ @MethodSource("provideInvalidAvatarImageInfo")
+ @DisplayName("적절하지 않은 값이 포함되면 아바타 아이템 생성에 실패한다.")
+ public void createTestFailWithInvalidSource(String type, String sex, String url) {
+ //given
+ AvatarItemRequest request = new AvatarItemRequest(type, sex);
+
+ //when, then
+ assertThatThrownBy(() -> AvatarItem.create(request, url))
+ .isInstanceOf(IllegalArgumentException.class);
+
+ }
+
+ static Stream provideInvalidAvatarImageInfo() {
+ String validType = "HAIR";
+ String validSex = "MALE";
+ String validImage = "imageUrl";
+ return Stream.of(
+ Arguments.of(null, validSex, validImage),
+ Arguments.of(validType, null, validImage),
+ Arguments.of("", validSex, validImage),
+ Arguments.of(validType, "", validImage),
+ Arguments.of(" ", validSex, validImage),
+ Arguments.of(validType, " ", validImage),
+ Arguments.of("a".repeat(40), validSex, validImage),
+ Arguments.of(validType, "a".repeat(600), validImage),
+ Arguments.of(validType, validSex, null),
+ Arguments.of(validType, validSex, ""),
+ Arguments.of(validType, validSex, " ")
+ );
}
}
diff --git a/src/test/java/com/backendoori/ootw/avatar/service/AvatarItemServiceTest.java b/src/test/java/com/backendoori/ootw/avatar/service/AvatarItemServiceTest.java
index 68cf2e15..9aa81dcc 100644
--- a/src/test/java/com/backendoori/ootw/avatar/service/AvatarItemServiceTest.java
+++ b/src/test/java/com/backendoori/ootw/avatar/service/AvatarItemServiceTest.java
@@ -1,12 +1,24 @@
package com.backendoori.ootw.avatar.service;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import java.util.List;
+import java.util.stream.Stream;
+import com.backendoori.ootw.avatar.domain.ItemType;
+import com.backendoori.ootw.avatar.domain.Sex;
import com.backendoori.ootw.avatar.dto.AvatarItemRequest;
import com.backendoori.ootw.avatar.dto.AvatarItemResponse;
import com.backendoori.ootw.avatar.repository.AvatarItemRepository;
+import com.backendoori.ootw.common.image.exception.SaveException;
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
@@ -17,6 +29,9 @@
@AutoConfigureMockMvc
class AvatarItemServiceTest {
+ private static final String VALID_TYPE = "HAIR";
+ private static final String VALID_SEX = "MALE";
+
@Autowired
AvatarItemRepository avatarItemRepository;
@@ -26,20 +41,98 @@ class AvatarItemServiceTest {
@Autowired
MockMvc mockMvc;
+ @BeforeEach
+ void setup() {
+ avatarItemRepository.deleteAll();
+ }
+
+ @Nested
+ @DisplayName("아바타 아이템 업로드")
+ class AvatarUpload {
+
+ @ParameterizedTest(name = "[{index}]: 아이템 타입이 {0}인 경우에 저장에 성공한다.")
+ @ValueSource(strings = {"image/jpeg", "image/gif", "image/png", "image/jpg"})
+ @DisplayName("여러 이미지 타입에 대해 업로드에 성공한다")
+ public void avatarImageTest(String contentType) {
+ //given
+ MockMultipartFile file = new MockMultipartFile("file", "filename.jpeg",
+ contentType, "some xml".getBytes());
+ AvatarItemRequest request = new AvatarItemRequest(VALID_TYPE, VALID_SEX);
+
+ //when
+ AvatarItemResponse avatarItemResponse = avatarItemService.upload(file, request);
+
+ //then
+ assertThat(avatarItemResponse.type()).isEqualTo(request.type());
+ assertThat(avatarItemResponse.sex()).isEqualTo(request.sex());
+ }
+
+ @ParameterizedTest(name = "[{index}] 아이템 타입이 {0}, 성별이 {1}인 경우 예외가 발생한다.")
+ @MethodSource("provideInvalidAvatarImageInfo")
+ @DisplayName("잘못된 이이템 타입이나 성별을 기입할 시 예외가 발생한다.")
+ public void uploadFailWithWrongRequest(String type, String sex) {
+ //given , when
+ MockMultipartFile file = new MockMultipartFile("file", "filename.jpeg",
+ "image/jpeg", "some xml".getBytes());
+ AvatarItemRequest request = new AvatarItemRequest(type, sex);
+
+ //then
+ assertThatThrownBy(() -> avatarItemService.upload(file, request))
+ .isInstanceOf(SaveException.class);
+
+ }
+
+ @ParameterizedTest(name = "[{index}] content-type 이 {0}인 경우 예외가 발생한다.")
+ @ValueSource(strings = {"text/plain", "application/json", "xml"})
+ @DisplayName("파일의 유형이 이미지가 아닌 경우 예외가 발생한다.")
+ public void imageUpLoadWithInvalidContentType(String contentType) throws Exception {
+ //given
+ MockMultipartFile file = new MockMultipartFile("file", "filename.txt",
+ contentType, "some xml".getBytes());
+ AvatarItemRequest request = new AvatarItemRequest(ItemType.TOP.name(), Sex.MALE.name());
+
+ //when, then
+ assertThatThrownBy(() -> avatarItemService.upload(file, request))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ static Stream provideInvalidAvatarImageInfo() {
+ String validType = "HAIR";
+ String validSex = "MALE";
+ return java.util.stream.Stream.of(
+ Arguments.of(null, validSex),
+ Arguments.of(validType, null),
+ Arguments.of("", validSex),
+ Arguments.of(validType, ""),
+ Arguments.of(" ", validSex),
+ Arguments.of(validType, " "),
+ Arguments.of("hair", validSex),
+ Arguments.of(validType, "female")
+ );
+ }
+
+ }
+
@Test
- @DisplayName("아바타 이미지 업로드 서비스 로직 테스트")
- public void avatarImageTest() {
- //given
- MockMultipartFile file = new MockMultipartFile("file", "filename.txt",
- "text/plain", "some xml".getBytes());
- AvatarItemRequest request = new AvatarItemRequest("HAIR", true);
+ @DisplayName("모든 아바타 이미지 조회에 성공한다.")
+ public void getList() {
+ //given , when
+ for (int i = 0; i < 3; i++) {
+ MockMultipartFile file = new MockMultipartFile("file", "filename.jpeg",
+ "image/jpeg", "some xml".getBytes());
+ AvatarItemRequest request = new AvatarItemRequest(VALID_TYPE, VALID_SEX);
+ avatarItemService.upload(file, request);
+ }
- //when
- AvatarItemResponse avatarItemResponse = avatarItemService.uploadItem(file, request);
+ List list = avatarItemService.getList();
//then
- assertThat(avatarItemResponse.type()).isEqualTo(request.type());
- assertThat(avatarItemResponse.sex()).isEqualTo(request.sex());
+ assertThat(list).hasSize(3);
+ AvatarItemResponse avatarItemResponse = list.get(0);
+ assertThat(avatarItemResponse).hasFieldOrPropertyWithValue("avatarItemId", avatarItemResponse.avatarItemId());
+ assertThat(avatarItemResponse).hasFieldOrPropertyWithValue("type", avatarItemResponse.type());
+ assertThat(avatarItemResponse).hasFieldOrPropertyWithValue("sex", avatarItemResponse.sex());
+ assertThat(avatarItemResponse).hasFieldOrPropertyWithValue("url", avatarItemResponse.url());
}
}
diff --git a/src/test/java/com/backendoori/ootw/common/MailTest.java b/src/test/java/com/backendoori/ootw/common/MailTest.java
new file mode 100644
index 00000000..d6f5a716
--- /dev/null
+++ b/src/test/java/com/backendoori/ootw/common/MailTest.java
@@ -0,0 +1,30 @@
+package com.backendoori.ootw.common;
+
+import com.icegreen.greenmail.configuration.GreenMailConfiguration;
+import com.icegreen.greenmail.util.GreenMail;
+import com.icegreen.greenmail.util.ServerSetupTest;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+
+public abstract class MailTest {
+
+ protected static final String SMTP_ADDRESS = "greenmail@spring.io";
+ private static final GreenMailConfiguration CONFIG = GreenMailConfiguration.aConfig()
+ .withUser(SMTP_ADDRESS, "greenmail", "test");
+
+ protected GreenMail smtp;
+
+ @BeforeEach
+ final void startSmtp() {
+ smtp = new GreenMail(ServerSetupTest.SMTP)
+ .withConfiguration(CONFIG);
+
+ smtp.start();
+ }
+
+ @AfterEach
+ final void stopSmtp() {
+ smtp.stop();
+ }
+
+}
diff --git a/src/test/java/com/backendoori/ootw/common/OotwMailSenderTest.java b/src/test/java/com/backendoori/ootw/common/OotwMailSenderTest.java
new file mode 100644
index 00000000..e6104495
--- /dev/null
+++ b/src/test/java/com/backendoori/ootw/common/OotwMailSenderTest.java
@@ -0,0 +1,66 @@
+package com.backendoori.ootw.common;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.icegreen.greenmail.util.GreenMailUtil;
+import jakarta.mail.MessagingException;
+import jakarta.mail.internet.MimeMessage;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.system.CapturedOutput;
+import org.springframework.boot.test.system.OutputCaptureExtension;
+
+@ExtendWith(OutputCaptureExtension.class)
+@SpringBootTest
+class OotwMailSenderTest extends MailTest {
+
+ @Autowired
+ OotwMailSender ootwMailSender;
+
+ @DisplayName("메일 전송에 성공한다")
+ @Test
+ void testSendMail() throws MessagingException {
+ // given
+ String title = GreenMailUtil.random();
+ String body = GreenMailUtil.random();
+
+ // when
+ ootwMailSender.sendMail(SMTP_ADDRESS, title, body);
+
+ // then
+ smtp.waitForIncomingEmail(5 * 1000L, 1);
+
+ MimeMessage[] receivedMessages = smtp.getReceivedMessages();
+ MimeMessage message = receivedMessages[0];
+ String actualReceiver = message.getAllRecipients()[0].toString();
+
+ assertThat(receivedMessages).hasSize(1);
+ assertThat(actualReceiver).isEqualTo(SMTP_ADDRESS);
+ assertThat(GreenMailUtil.getBody(message)).isEqualTo(body);
+ }
+
+ @DisplayName("메일 전송에 실패할 경우 예외를 logging 한다")
+ @Test
+ void testSendMailLoggingException(CapturedOutput output) {
+ // given
+ String title = GreenMailUtil.random();
+ String body = GreenMailUtil.random();
+
+ // when
+ ootwMailSender.sendMail("", title, body);
+
+ // then
+ smtp.waitForIncomingEmail(5 * 1000L, 1);
+
+ MimeMessage[] receivedMessages = smtp.getReceivedMessages();
+
+ assertThat(receivedMessages).isEmpty();
+ assertThat(output.getOut())
+ .contains("ERROR")
+ .contains("Fail to send email");
+ }
+
+}
diff --git a/src/test/java/com/backendoori/ootw/common/image/MiniOImageServiceImplTest.java b/src/test/java/com/backendoori/ootw/common/image/MiniOImageServiceImplTest.java
index 7eba159f..b4ba4816 100644
--- a/src/test/java/com/backendoori/ootw/common/image/MiniOImageServiceImplTest.java
+++ b/src/test/java/com/backendoori/ootw/common/image/MiniOImageServiceImplTest.java
@@ -1,14 +1,32 @@
package com.backendoori.ootw.common.image;
import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.when;
+import java.util.stream.Stream;
+import com.backendoori.ootw.common.image.exception.ImageException;
+import com.backendoori.ootw.config.MiniOConfig;
+import io.minio.GetPresignedObjectUrlArgs;
+import io.minio.MinioClient;
+import io.minio.PutObjectArgs;
+import io.minio.RemoveObjectArgs;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.NullSource;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
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.mock.web.MockMultipartFile;
import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.web.multipart.MultipartFile;
@SpringBootTest
@AutoConfigureMockMvc
@@ -20,15 +38,95 @@ class MiniOImageServiceImplTest {
@Autowired
ImageService imageService;
+ @Mock
+ private MinioClient minioClient;
+
+ @Mock
+ private MiniOConfig miniOConfig;
+
+ @InjectMocks
+ private MiniOImageServiceImpl mockingImageService;
+
+ static Stream provideInvalidImageInfo() {
+ String validContentType = "image/jpeg";
+ byte[] validContent = "content".getBytes();
+ return java.util.stream.Stream.of(
+ org.junit.jupiter.params.provider.Arguments.of("xml", validContent),
+ org.junit.jupiter.params.provider.Arguments.of("pdf", validContent),
+ org.junit.jupiter.params.provider.Arguments.of(validContentType, "".getBytes()),
+ org.junit.jupiter.params.provider.Arguments.of(" ", validContent),
+ org.junit.jupiter.params.provider.Arguments.of("", validContent),
+ org.junit.jupiter.params.provider.Arguments.of(validContentType, null)
+ );
+ }
+
@Test
- @DisplayName("아바타 이미지 업로드 서비스 로직 테스트")
- public void imageUploadTest() {
+ @DisplayName("이미지 업로드에 성공한다.")
+ public void imageUpload() {
//given
- MockMultipartFile file = new MockMultipartFile("file", "filename.txt",
- "text/plain", "some xml".getBytes());
+ MockMultipartFile file = new MockMultipartFile("file", "filename.jpeg",
+ "image/jpeg", "some xml".getBytes());
//when, then
- assertThatCode(() -> imageService.uploadImage(file))
+ assertThatCode(() -> imageService.upload(file))
.doesNotThrowAnyException();
}
+ @ParameterizedTest
+ @NullSource
+ @DisplayName("아바타 이미지 업로드 서비스 로직 테스트")
+ public void imageUploadFailWithNullImage(MockMultipartFile file) {
+ //given, when, then
+ assertThatCode(() -> imageService.upload(file))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @ParameterizedTest
+ @MethodSource("provideInvalidImageInfo")
+ @DisplayName("아바타 이미지 업로드 서비스 로직 테스트")
+ public void imageUploadFailWithInvalidImage(String contentType, byte[] content) {
+ //given
+ MockMultipartFile file = new MockMultipartFile("file", "filename.jpeg",
+ contentType, content);
+ //when, then
+ assertThatCode(() -> imageService.upload(file))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ @DisplayName("이미지 업로드 중 예외가 발생하면 커스텀 예외가 발생한다.")
+ void exceptionWhileUploadingImage() throws Exception {
+ // Given
+ MultipartFile file =
+ new MockMultipartFile("file", "testfile.jpeg", "image/jpeg", "test image content".getBytes());
+ doThrow(new RuntimeException("Mock Exception")).when(minioClient).putObject(any(PutObjectArgs.class));
+
+ // When, Then
+ when(miniOConfig.getBucket()).thenReturn("test-bucket");
+
+ assertThatThrownBy(() -> mockingImageService.upload(file))
+ .isInstanceOf(ImageException.class);
+ }
+
+ @Test
+ void exceptionWhileDeletingImage() throws Exception {
+ String fileName = "testfile.jpeg";
+ doThrow(new RuntimeException("Mock Exception")).when(minioClient).removeObject(any(RemoveObjectArgs.class));
+
+ assertThatThrownBy(() -> mockingImageService.delete(fileName))
+ .isInstanceOf(ImageException.class);
+ }
+
+ @Test
+ void exceptionWhileGettingUrl() throws Exception {
+ MultipartFile file =
+ new MockMultipartFile("file", "testfile.jpeg", "image/jpeg", "test image content".getBytes());
+ when(minioClient.getPresignedObjectUrl(any(GetPresignedObjectUrlArgs.class))).thenThrow(
+ new RuntimeException("Mock Exception"));
+
+ assertThatThrownBy(() -> mockingImageService.upload(file))
+ .isInstanceOf(ImageException.class);
+
+ }
+
+
}
diff --git a/src/test/java/com/backendoori/ootw/document/avatar/AvatarItemDocumentationTest.java b/src/test/java/com/backendoori/ootw/document/avatar/AvatarItemDocumentationTest.java
new file mode 100644
index 00000000..15a60c6c
--- /dev/null
+++ b/src/test/java/com/backendoori/ootw/document/avatar/AvatarItemDocumentationTest.java
@@ -0,0 +1,134 @@
+package com.backendoori.ootw.document.avatar;
+
+import static com.backendoori.ootw.document.common.ApiDocumentUtil.field;
+import static com.backendoori.ootw.document.common.ApiDocumentUtil.getDocumentRequest;
+import static com.backendoori.ootw.document.common.ApiDocumentUtil.getDocumentResponse;
+import static com.backendoori.ootw.security.jwt.JwtAuthenticationFilter.TOKEN_HEADER;
+import static com.backendoori.ootw.security.jwt.JwtAuthenticationFilter.TOKEN_PREFIX;
+import static org.mockito.BDDMockito.given;
+import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName;
+import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders;
+import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
+import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
+import static org.springframework.restdocs.payload.PayloadDocumentation.requestPartFields;
+import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields;
+import static org.springframework.restdocs.request.RequestDocumentation.partWithName;
+import static org.springframework.restdocs.request.RequestDocumentation.requestParts;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import com.backendoori.ootw.avatar.domain.ItemType;
+import com.backendoori.ootw.avatar.domain.Sex;
+import com.backendoori.ootw.avatar.dto.AvatarItemRequest;
+import com.backendoori.ootw.avatar.dto.AvatarItemResponse;
+import com.backendoori.ootw.avatar.service.AvatarItemService;
+import com.backendoori.ootw.security.TokenMockMvcTest;
+import net.datafaker.Faker;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.http.MediaType;
+import org.springframework.mock.web.MockMultipartFile;
+import org.springframework.restdocs.payload.JsonFieldType;
+import org.springframework.test.web.servlet.ResultActions;
+
+@SpringBootTest
+@AutoConfigureRestDocs
+class AvatarItemDocumentationTest extends TokenMockMvcTest {
+
+ static final String API = "/api/v1/avatar-items";
+ static final Faker FAKER = new Faker();
+
+ @MockBean
+ AvatarItemService avatarItemService;
+
+ @DisplayName("[POST] upload 201 Created")
+ @Test
+ public void testUploadCreated() throws Exception {
+ // given
+ MockMultipartFile file = new MockMultipartFile("file", "filename.txt",
+ "image/jpeg", "some xml".getBytes());
+ AvatarItemRequest requestDto = new AvatarItemRequest("HAIR", Sex.MALE.name());
+ MockMultipartFile request = new MockMultipartFile("request", "filename.txt",
+ "application/json", objectMapper.writeValueAsBytes(requestDto));
+ long userId = FAKER.number().positive();
+ AvatarItemResponse avatarItemResponse =
+ new AvatarItemResponse(userId, requestDto.type(), requestDto.sex(), FAKER.internet().url());
+
+ setToken(userId);
+ given(avatarItemService.upload(file, requestDto))
+ .willReturn(avatarItemResponse);
+
+ // when
+ ResultActions actions = mockMvc.perform(multipart(API)
+ .file(file)
+ .file(request)
+ .header(TOKEN_HEADER, TOKEN_PREFIX + token)
+ .contentType(MediaType.MULTIPART_FORM_DATA)
+ .accept(MediaType.APPLICATION_JSON)
+ .characterEncoding(StandardCharsets.UTF_8));
+
+ // then
+ actions.andExpect(status().isCreated())
+ .andDo(
+ document("avatar-image-upload",
+ getDocumentRequest(),
+ getDocumentResponse(),
+ requestHeaders(
+ headerWithName("Authorization").description("JWT 토큰")
+ ),
+ requestParts(
+ partWithName("file").description("아바타 이미지 파일"),
+ partWithName("request").description("아바타 이미지 상세 정보")
+ ),
+ requestPartFields(
+ "request",
+ fieldWithPath("type").description("아바타 이미지 타입"),
+ fieldWithPath("sex").description("아바타 이미지 성별")
+ ),
+ responseFields(
+ field("avatarItemId", JsonFieldType.NUMBER, "아바타 이미지 ID"),
+ field("type", JsonFieldType.STRING, "아바타 이미지 타입"),
+ field("sex", JsonFieldType.STRING, "아바타 이미지 성별"),
+ field("url", JsonFieldType.STRING, "아바타 이미지 URL")
+ )
+ )
+ );
+ }
+
+ @DisplayName("[GET] getAll 200 Ok")
+ @Test
+ public void testGetAllOk() throws Exception {
+ // given
+ given(avatarItemService.getList())
+ .willReturn(List.of(
+ new AvatarItemResponse(1L, ItemType.HAIR.name(), Sex.MALE.name(), FAKER.internet().url()),
+ new AvatarItemResponse(2L, ItemType.TOP.name(), Sex.FEMALE.name(), FAKER.internet().url())
+ ));
+
+ // when
+ ResultActions actions = mockMvc.perform(get(API)
+ .accept(MediaType.APPLICATION_JSON));
+
+ // then
+ actions.andExpect(status().isOk())
+ .andDo(
+ document("avatar-image-get-all",
+ getDocumentRequest(),
+ getDocumentResponse(),
+ responseFields(
+ field("[].avatarItemId", JsonFieldType.NUMBER, "아바타 이미지 ID"),
+ field("[].type", JsonFieldType.STRING, "아바타 이미지 타입"),
+ field("[].sex", JsonFieldType.STRING, "아바타 이미지 성별"),
+ field("[].url", JsonFieldType.STRING, "아바타 이미지 URL")
+ )
+ )
+ );
+ }
+
+}
diff --git a/src/test/java/com/backendoori/ootw/document/common/ApiDocumentUtil.java b/src/test/java/com/backendoori/ootw/document/common/ApiDocumentUtil.java
new file mode 100644
index 00000000..8a2d37db
--- /dev/null
+++ b/src/test/java/com/backendoori/ootw/document/common/ApiDocumentUtil.java
@@ -0,0 +1,45 @@
+package com.backendoori.ootw.document.common;
+
+import static org.springframework.restdocs.operation.preprocess.Preprocessors.modifyUris;
+import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest;
+import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse;
+import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint;
+import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
+
+import org.springframework.restdocs.operation.preprocess.OperationRequestPreprocessor;
+import org.springframework.restdocs.operation.preprocess.OperationResponsePreprocessor;
+import org.springframework.restdocs.payload.FieldDescriptor;
+
+public interface ApiDocumentUtil {
+
+ static OperationRequestPreprocessor getDocumentRequest() {
+ return preprocessRequest(
+ modifyUris()
+ .scheme("https")
+ .host("docs.api.com")
+ .removePort(),
+ prettyPrint());
+ }
+
+ static OperationResponsePreprocessor getDocumentResponse() {
+ return preprocessResponse(prettyPrint());
+ }
+
+ static FieldDescriptor field(String name, Object type, String description) {
+ return fieldWithPath(name)
+ .type(type)
+ .description(description);
+ }
+
+ static FieldDescriptor field(String name, Object type, String description, boolean required) {
+ if (required) {
+ return field(name, type, description);
+ }
+
+ return fieldWithPath(name)
+ .type(type)
+ .description(description)
+ .optional();
+ }
+
+}
diff --git a/src/test/java/com/backendoori/ootw/document/like/LikeDocumentationTest.java b/src/test/java/com/backendoori/ootw/document/like/LikeDocumentationTest.java
new file mode 100644
index 00000000..8eada55e
--- /dev/null
+++ b/src/test/java/com/backendoori/ootw/document/like/LikeDocumentationTest.java
@@ -0,0 +1,98 @@
+package com.backendoori.ootw.document.like;
+
+import static com.backendoori.ootw.document.common.ApiDocumentUtil.field;
+import static com.backendoori.ootw.document.common.ApiDocumentUtil.getDocumentRequest;
+import static com.backendoori.ootw.document.common.ApiDocumentUtil.getDocumentResponse;
+import static com.backendoori.ootw.security.jwt.JwtAuthenticationFilter.TOKEN_HEADER;
+import static com.backendoori.ootw.security.jwt.JwtAuthenticationFilter.TOKEN_PREFIX;
+import static org.mockito.BDDMockito.given;
+import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName;
+import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders;
+import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
+import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
+import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields;
+import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import com.backendoori.ootw.like.dto.controller.LikeRequest;
+import com.backendoori.ootw.like.dto.controller.LikeResponse;
+import com.backendoori.ootw.like.service.LikeService;
+import com.backendoori.ootw.security.TokenMockMvcTest;
+import com.backendoori.ootw.user.domain.User;
+import net.datafaker.Faker;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.http.MediaType;
+import org.springframework.restdocs.payload.JsonFieldType;
+import org.springframework.test.web.servlet.ResultActions;
+
+
+@SpringBootTest
+@AutoConfigureRestDocs
+class LikeDocumentationTest extends TokenMockMvcTest {
+
+ static final String API_PREFIX = "/api/v1/posts";
+ static final Faker FAKER = new Faker();
+
+ @MockBean
+ LikeService likeService;
+
+
+ @DisplayName("[POST] pushLike 200 Ok")
+ @Test
+ void testPushLikeOk() throws Exception {
+ // given
+ User user = generateUser();
+ long postId = FAKER.number().positive();
+ LikeRequest request = new LikeRequest(postId);
+
+ setToken(user.getId());
+ given(likeService.requestLike(user.getId(), postId))
+ .willReturn(new LikeResponse((long) FAKER.number().positive(), user.getId(), postId, true));
+
+ // when
+ ResultActions actions = mockMvc.perform(post(API_PREFIX + "/" + postId + "/likes")
+ .header(TOKEN_HEADER, TOKEN_PREFIX + token)
+ .content(objectMapper.writeValueAsBytes(request))
+ .contentType(MediaType.APPLICATION_JSON)
+ .accept(MediaType.APPLICATION_JSON)
+ );
+
+ // then
+ actions.andExpect(status().isOk())
+ .andDo(
+ document("like-push",
+ getDocumentRequest(),
+ getDocumentResponse(),
+ requestHeaders(
+ headerWithName("Authorization").description("JWT 토큰")
+ ),
+ requestFields(
+ fieldWithPath("postId").description("게시글 ID")
+ ),
+ responseFields(
+ field("likeId", JsonFieldType.NUMBER, "좋아요 ID"),
+ field("userId", JsonFieldType.NUMBER, "좋아요를 누른 사용자 ID"),
+ field("postId", JsonFieldType.NUMBER, "게시글 ID"),
+ field("status", JsonFieldType.BOOLEAN, "좋아요 여부")
+ )
+ )
+ );
+ }
+
+ private User generateUser() {
+ return User.builder()
+ .id((long) FAKER.number().positive())
+ .email(FAKER.internet().emailAddress())
+ .password(FAKER.internet().password())
+ .nickname(FAKER.internet().username())
+ .profileImageUrl(FAKER.internet().url())
+ .certified(true)
+ .build();
+ }
+
+}
diff --git a/src/test/java/com/backendoori/ootw/document/post/PostDocumentationTest.java b/src/test/java/com/backendoori/ootw/document/post/PostDocumentationTest.java
new file mode 100644
index 00000000..cea8baca
--- /dev/null
+++ b/src/test/java/com/backendoori/ootw/document/post/PostDocumentationTest.java
@@ -0,0 +1,350 @@
+package com.backendoori.ootw.document.post;
+
+import static com.backendoori.ootw.document.common.ApiDocumentUtil.field;
+import static com.backendoori.ootw.document.common.ApiDocumentUtil.getDocumentRequest;
+import static com.backendoori.ootw.document.common.ApiDocumentUtil.getDocumentResponse;
+import static com.backendoori.ootw.security.jwt.JwtAuthenticationFilter.TOKEN_HEADER;
+import static com.backendoori.ootw.security.jwt.JwtAuthenticationFilter.TOKEN_PREFIX;
+import static com.backendoori.ootw.util.provider.ForecastApiCommonRequestSourceProvider.VALID_COORDINATE;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.BDDMockito.given;
+import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName;
+import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders;
+import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
+import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete;
+import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get;
+import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.multipart;
+import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
+import static org.springframework.restdocs.payload.PayloadDocumentation.requestPartFields;
+import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields;
+import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName;
+import static org.springframework.restdocs.request.RequestDocumentation.partWithName;
+import static org.springframework.restdocs.request.RequestDocumentation.pathParameters;
+import static org.springframework.restdocs.request.RequestDocumentation.requestParts;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import java.nio.charset.StandardCharsets;
+import java.time.LocalDateTime;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import com.backendoori.ootw.post.dto.request.PostSaveRequest;
+import com.backendoori.ootw.post.dto.request.PostUpdateRequest;
+import com.backendoori.ootw.post.dto.response.PostReadResponse;
+import com.backendoori.ootw.post.dto.response.PostSaveUpdateResponse;
+import com.backendoori.ootw.post.dto.response.WriterDto;
+import com.backendoori.ootw.post.service.PostService;
+import com.backendoori.ootw.security.TokenMockMvcTest;
+import com.backendoori.ootw.weather.domain.TemperatureArrange;
+import com.backendoori.ootw.weather.domain.forecast.ForecastCategory;
+import com.backendoori.ootw.weather.dto.TemperatureArrangeDto;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import net.datafaker.Faker;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.http.MediaType;
+import org.springframework.mock.web.MockMultipartFile;
+import org.springframework.restdocs.payload.JsonFieldType;
+import org.springframework.test.web.servlet.ResultActions;
+import org.springframework.test.web.servlet.request.RequestPostProcessor;
+
+@SpringBootTest
+@AutoConfigureRestDocs
+class PostDocumentationTest extends TokenMockMvcTest {
+
+ static final String API_PREFIX = "/api/v1/posts";
+ static final Faker FAKER = new Faker();
+
+ @MockBean
+ PostService postService;
+
+ @DisplayName("[POST] save 201 Created")
+ @Test
+ void testSaveCreated() throws Exception {
+ // given
+ String title = FAKER.book().title();
+ PostSaveRequest postSaveRequest =
+ new PostSaveRequest(title.substring(0, Math.min(title.length(), 30)), FAKER.science().element(),
+ VALID_COORDINATE);
+ MockMultipartFile request = getRequestJson(postSaveRequest);
+ MockMultipartFile postImg = getPostImg();
+
+ setToken(1);
+ given(postService.save(postSaveRequest, postImg))
+ .willReturn(generatePostSaveUpdateResponse(1L, postSaveRequest.title(), postSaveRequest.content()));
+
+ // when
+ ResultActions actions = mockMvc.perform(multipart(API_PREFIX)
+ .file(request)
+ .file(postImg)
+ .header(TOKEN_HEADER, TOKEN_PREFIX + token)
+ .contentType(MediaType.MULTIPART_FORM_DATA)
+ .accept(MediaType.APPLICATION_JSON)
+ .characterEncoding(StandardCharsets.UTF_8)
+ );
+
+ // then
+ actions.andExpect(status().isCreated())
+ .andDo(
+ document("post-create",
+ getDocumentRequest(),
+ getDocumentResponse(),
+ requestHeaders(
+ headerWithName("Authorization").description("JWT 토큰")
+ ),
+ requestParts(
+ partWithName("request").description("게시글 생성 요청 정보"),
+ partWithName("postImg").description("게시글 이미지 파일")
+ ),
+ requestPartFields(
+ "request",
+ fieldWithPath("title").description("게시글 제목"),
+ fieldWithPath("content").description("게시글 내용"),
+ fieldWithPath("coordinate.nx").description("사용자 X 좌표"),
+ fieldWithPath("coordinate.ny").description("사용자 Y 좌표")
+ ),
+ responseFields(
+ field("postId", JsonFieldType.NUMBER, "게시글 ID"),
+ field("title", JsonFieldType.STRING, "게시글 제목"),
+ field("content", JsonFieldType.STRING, "게시글 내용"),
+ field("image", JsonFieldType.STRING, "게시글 이미지 URL"),
+ field("createdAt", JsonFieldType.STRING, "게시글 생성 일자"),
+ field("updatedAt", JsonFieldType.STRING, "게시글 수정 일자"),
+ field("temperatureArrange.min", JsonFieldType.NUMBER, "최저 기온"),
+ field("temperatureArrange.max", JsonFieldType.NUMBER, "최고 기온")
+ )
+ )
+ );
+ }
+
+ @DisplayName("[GET] readDetailByPostId 200 Ok")
+ @Test
+ void testReadDetailByPostIdOk() throws Exception {
+ // given
+ long postId = FAKER.number().positive();
+
+ setToken(1);
+ given(postService.getDetailByPostId(postId))
+ .willReturn(generatePostReadResponse(postId));
+
+ // when
+ ResultActions actions = mockMvc.perform(get(API_PREFIX + "/{postId}", postId)
+ .header(TOKEN_HEADER, TOKEN_PREFIX + token)
+ .contentType(MediaType.APPLICATION_JSON)
+ .accept(MediaType.APPLICATION_JSON)
+ );
+
+ // then
+ actions.andExpect(status().isOk())
+ .andDo(
+ document("post-read-detail",
+ getDocumentRequest(),
+ getDocumentResponse(),
+ requestHeaders(
+ headerWithName("Authorization").description("JWT 토큰").optional()
+ ),
+ pathParameters(
+ parameterWithName("postId").description("게시글 ID")
+ ),
+ responseFields(
+ field("postId", JsonFieldType.NUMBER, "게시글 ID"),
+ field("writer.userId", JsonFieldType.NUMBER, "게시글 작성자 ID"),
+ field("writer.nickname", JsonFieldType.STRING, "게시글 작성자 별명"),
+ field("writer.image", JsonFieldType.STRING, "게시글 작성자 프로필 이미지 URL"),
+ field("title", JsonFieldType.STRING, "게시글 제목"),
+ field("content", JsonFieldType.STRING, "게시글 내용"),
+ field("image", JsonFieldType.STRING, "게시글 이미지 URL"),
+ field("createdAt", JsonFieldType.STRING, "게시글 생성 일자"),
+ field("updatedAt", JsonFieldType.STRING, "게시글 수정 일자"),
+ field("temperatureArrange.min", JsonFieldType.NUMBER, "최저 기온"),
+ field("temperatureArrange.max", JsonFieldType.NUMBER, "최고 기온"),
+ field("likeCnt", JsonFieldType.NUMBER, "좋아요 개수"),
+ field("isLike", JsonFieldType.NUMBER, "좋아요 여부")
+ )
+ )
+ );
+ }
+
+ @DisplayName("[GET] readAll 200 Ok")
+ @Test
+ void testReadAllOk() throws Exception {
+ // given
+ setToken(1);
+ given(postService.getAll())
+ .willReturn(List.of(generatePostReadResponse(FAKER.number().positive()),
+ generatePostReadResponse(FAKER.number().positive()),
+ generatePostReadResponse(FAKER.number().positive())
+ ));
+
+ // when
+ ResultActions actions = mockMvc.perform(get(API_PREFIX)
+ .header(TOKEN_HEADER, TOKEN_PREFIX + token)
+ .contentType(MediaType.APPLICATION_JSON)
+ .accept(MediaType.APPLICATION_JSON)
+ );
+
+ // then
+ actions.andExpect(status().isOk())
+ .andDo(
+ document("post-read-all",
+ getDocumentRequest(),
+ getDocumentResponse(),
+ requestHeaders(
+ headerWithName("Authorization").description("JWT 토큰").optional()
+ ),
+ responseFields(
+ field("[]postId", JsonFieldType.NUMBER, "게시글 ID"),
+ field("[]writer.userId", JsonFieldType.NUMBER, "게시글 작성자 ID"),
+ field("[]writer.nickname", JsonFieldType.STRING, "게시글 작성자 별명"),
+ field("[]writer.image", JsonFieldType.STRING, "게시글 작성자 프로필 이미지 URL"),
+ field("[]title", JsonFieldType.STRING, "게시글 제목"),
+ field("[]content", JsonFieldType.STRING, "게시글 내용"),
+ field("[]image", JsonFieldType.STRING, "게시글 이미지 URL"),
+ field("[]createdAt", JsonFieldType.STRING, "게시글 생성 일자"),
+ field("[]updatedAt", JsonFieldType.STRING, "게시글 수정 일자"),
+ field("[]temperatureArrange.min", JsonFieldType.NUMBER, "최저 기온"),
+ field("[]temperatureArrange.max", JsonFieldType.NUMBER, "최고 기온"),
+ field("[]likeCnt", JsonFieldType.NUMBER, "좋아요 개수"),
+ field("[]isLike", JsonFieldType.NUMBER, "좋아요 여부")
+ )
+ )
+ );
+ }
+
+ @DisplayName("[PUT] update 201 Created")
+ @Test
+ void testUpdateCreated() throws Exception {
+ // given
+ long postId = 2;
+ String title = FAKER.book().title();
+ PostUpdateRequest postUpdateRequest =
+ new PostUpdateRequest(title.substring(0, Math.min(title.length(), 30)), FAKER.science().element());
+ MockMultipartFile request = getRequestJson(postUpdateRequest);
+ MockMultipartFile postImg = getPostImg();
+
+ setToken(1);
+ given(postService.update(any(), any(), any()))
+ .willReturn(generatePostSaveUpdateResponse(postId, postUpdateRequest.title(), postUpdateRequest.content()));
+
+ // when
+ ResultActions actions =
+ mockMvc.perform(multipart(API_PREFIX + "/{postId}", postId)
+ .file(request)
+ .file(postImg)
+ .header(TOKEN_HEADER, TOKEN_PREFIX + token)
+ .accept(MediaType.APPLICATION_JSON)
+ .characterEncoding(StandardCharsets.UTF_8)
+ .with(setMethod("PUT"))
+ );
+
+ // then
+ actions.andExpect(status().isCreated())
+ .andDo(
+ document("post-update",
+ getDocumentRequest(),
+ getDocumentResponse(),
+ requestHeaders(
+ headerWithName("Authorization").description("JWT 토큰")
+ ),
+ pathParameters(
+ parameterWithName("postId").description("게시글 ID")
+ ),
+ requestParts(
+ partWithName("request").description("게시글 생성 요청 정보"),
+ partWithName("postImg").description("게시글 이미지 파일")
+ ),
+ requestPartFields(
+ "request",
+ fieldWithPath("title").description("게시글 제목"),
+ fieldWithPath("content").description("게시글 내용")
+ ),
+ responseFields(
+ field("postId", JsonFieldType.NUMBER, "게시글 ID"),
+ field("title", JsonFieldType.STRING, "게시글 제목"),
+ field("content", JsonFieldType.STRING, "게시글 내용"),
+ field("image", JsonFieldType.STRING, "게시글 이미지 URL"),
+ field("createdAt", JsonFieldType.STRING, "게시글 생성 일자"),
+ field("updatedAt", JsonFieldType.STRING, "게시글 수정 일자"),
+ field("temperatureArrange.min", JsonFieldType.NUMBER, "최저 기온"),
+ field("temperatureArrange.max", JsonFieldType.NUMBER, "최고 기온")
+ )
+ )
+ );
+ }
+
+ @DisplayName("[DELETE] delete 204 NoContent")
+ @Test
+ void testDeleteNoContent() throws Exception {
+ // given
+ long postId = FAKER.number().positive();
+
+ setToken(1);
+
+ // when
+ ResultActions actions = mockMvc.perform(delete(API_PREFIX + "/{postId}", postId)
+ .header(TOKEN_HEADER, TOKEN_PREFIX + token)
+ );
+
+ // then
+ actions.andExpect(status().isNoContent())
+ .andDo(
+ document("post-delete",
+ getDocumentRequest(),
+ getDocumentResponse(),
+ requestHeaders(
+ headerWithName("Authorization").description("JWT 토큰")
+ ),
+ pathParameters(
+ parameterWithName("postId").description("게시글 ID")
+ )
+ )
+ );
+ }
+
+ private PostReadResponse generatePostReadResponse(long postId) {
+ return new PostReadResponse(postId,
+ new WriterDto((long) FAKER.number().positive(), FAKER.internet().username(), FAKER.internet().url()),
+ FAKER.book().title(), FAKER.science().element(), FAKER.internet().url(), LocalDateTime.now(),
+ LocalDateTime.now(), TemperatureArrangeDto.from(generateTemperatureArrange()),
+ FAKER.number().numberBetween(1, 100), FAKER.number().numberBetween(0, 1));
+ }
+
+ private static TemperatureArrange generateTemperatureArrange() {
+ Map weatherInfoMap = new HashMap<>();
+ weatherInfoMap.put(ForecastCategory.TMN, String.valueOf(0.0));
+ weatherInfoMap.put(ForecastCategory.TMX, String.valueOf(15.0));
+
+ return TemperatureArrange.from(weatherInfoMap);
+ }
+
+ private MockMultipartFile getRequestJson(PostSaveRequest postSaveRequest) throws JsonProcessingException {
+ return new MockMultipartFile("request", "request.json", MediaType.APPLICATION_JSON_VALUE,
+ objectMapper.writeValueAsBytes(postSaveRequest));
+ }
+
+ private MockMultipartFile getRequestJson(PostUpdateRequest postUpdateRequest) throws JsonProcessingException {
+ return new MockMultipartFile("request", "request.json", MediaType.APPLICATION_JSON_VALUE,
+ objectMapper.writeValueAsBytes(postUpdateRequest));
+ }
+
+ private MockMultipartFile getPostImg() {
+ return new MockMultipartFile("postImg", "image.jpeg", MediaType.IMAGE_JPEG_VALUE, "content".getBytes());
+ }
+
+ private RequestPostProcessor setMethod(String method) {
+ return req -> {
+ req.setMethod(method);
+
+ return req;
+ };
+ }
+
+ private PostSaveUpdateResponse generatePostSaveUpdateResponse(Long postId, String title, String content) {
+ return new PostSaveUpdateResponse(postId, title, content, FAKER.internet().url(), LocalDateTime.now(),
+ LocalDateTime.now(), TemperatureArrangeDto.from(generateTemperatureArrange()));
+ }
+
+}
diff --git a/src/test/java/com/backendoori/ootw/document/user/CertificateDocumentationTest.java b/src/test/java/com/backendoori/ootw/document/user/CertificateDocumentationTest.java
new file mode 100644
index 00000000..ee965e3e
--- /dev/null
+++ b/src/test/java/com/backendoori/ootw/document/user/CertificateDocumentationTest.java
@@ -0,0 +1,102 @@
+package com.backendoori.ootw.document.user;
+
+import static com.backendoori.ootw.document.common.ApiDocumentUtil.getDocumentRequest;
+import static com.backendoori.ootw.document.common.ApiDocumentUtil.getDocumentResponse;
+import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
+import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName;
+import static org.springframework.restdocs.request.RequestDocumentation.queryParameters;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import com.backendoori.ootw.user.domain.Certificate;
+import com.backendoori.ootw.user.dto.CertifyDto;
+import com.backendoori.ootw.user.dto.SendCodeDto;
+import com.backendoori.ootw.user.service.CertificateService;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import net.datafaker.Faker;
+import org.apache.commons.lang3.RandomStringUtils;
+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.restdocs.AutoConfigureRestDocs;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.http.MediaType;
+import org.springframework.security.test.context.support.WithMockUser;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.ResultActions;
+import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
+
+@WithMockUser
+@SpringBootTest
+@AutoConfigureMockMvc
+@AutoConfigureRestDocs
+public class CertificateDocumentationTest {
+
+ static final String API_PREFIX = "/api/v1/auth";
+ static final Faker FAKER = new Faker();
+
+ @Autowired
+ MockMvc mockMvc;
+ @Autowired
+ ObjectMapper objectMapper;
+
+ @MockBean
+ CertificateService certificateService;
+
+ @DisplayName("[PATCH] certificate 200 Ok")
+ @Test
+ void testCertificateOk() throws Exception {
+ // given
+ SendCodeDto sendCodeDto = new SendCodeDto(FAKER.internet().emailAddress());
+ MockHttpServletRequestBuilder requestBuilder = patch(API_PREFIX + "/certificate")
+ .queryParam("email", sendCodeDto.email())
+ .contentType(MediaType.APPLICATION_JSON);
+
+ // when
+ ResultActions actions = mockMvc.perform(requestBuilder);
+
+ // then
+ actions.andExpect((status().isOk()))
+ .andDo(
+ document("certificate",
+ getDocumentRequest(),
+ getDocumentResponse(),
+ queryParameters(
+ parameterWithName("email").description("Email 주소")
+ )
+ )
+ );
+ }
+
+ @DisplayName("[PATCH] certify 200 Ok")
+ @Test
+ void testCertifyOk() throws Exception {
+ // given
+ String email = FAKER.internet().safeEmailAddress();
+ String code = RandomStringUtils.randomAlphanumeric(Certificate.SIZE);
+ CertifyDto certifyDto = new CertifyDto(email, code);
+ MockHttpServletRequestBuilder requestBuilder = patch(API_PREFIX + "/certify")
+ .queryParam("email", certifyDto.email())
+ .queryParam("code", certifyDto.code())
+ .contentType(MediaType.APPLICATION_JSON);
+
+ // when
+ ResultActions actions = mockMvc.perform(requestBuilder);
+
+ // then
+ actions.andExpect((status().isOk()))
+ .andDo(
+ document("certify",
+ getDocumentRequest(),
+ getDocumentResponse(),
+ queryParameters(
+ parameterWithName("email").description("Email 주소"),
+ parameterWithName("code").description("Email 인증 코드")
+ )
+ )
+ );
+ }
+
+}
diff --git a/src/test/java/com/backendoori/ootw/document/user/UserDocumentationTest.java b/src/test/java/com/backendoori/ootw/document/user/UserDocumentationTest.java
new file mode 100644
index 00000000..1df294fc
--- /dev/null
+++ b/src/test/java/com/backendoori/ootw/document/user/UserDocumentationTest.java
@@ -0,0 +1,127 @@
+package com.backendoori.ootw.document.user;
+
+import static com.backendoori.ootw.document.common.ApiDocumentUtil.field;
+import static com.backendoori.ootw.document.common.ApiDocumentUtil.getDocumentRequest;
+import static com.backendoori.ootw.document.common.ApiDocumentUtil.getDocumentResponse;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.BDDMockito.willDoNothing;
+import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
+import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields;
+import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import com.backendoori.ootw.user.dto.LoginDto;
+import com.backendoori.ootw.user.dto.SignupDto;
+import com.backendoori.ootw.user.dto.TokenDto;
+import com.backendoori.ootw.user.service.UserService;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import net.datafaker.Faker;
+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.restdocs.AutoConfigureRestDocs;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.http.MediaType;
+import org.springframework.restdocs.payload.JsonFieldType;
+import org.springframework.security.test.context.support.WithMockUser;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.ResultActions;
+
+@WithMockUser
+@SpringBootTest
+@AutoConfigureMockMvc
+@AutoConfigureRestDocs
+public class UserDocumentationTest {
+
+ static final String API_PREFIX = "/api/v1/auth";
+ static final Faker FAKER = new Faker();
+
+ @Autowired
+ MockMvc mockMvc;
+ @Autowired
+ ObjectMapper objectMapper;
+
+ @MockBean
+ UserService userService;
+
+ @DisplayName("[POST] signup 201 Created")
+ @Test
+ void testSignupCreated() throws Exception {
+ // given
+ SignupDto signupDto = generateSignupDto();
+
+ willDoNothing().given(userService)
+ .signup(signupDto);
+
+ // when
+ ResultActions actions = mockMvc.perform(
+ post(API_PREFIX + "/signup")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(signupDto)));
+
+ // then
+ actions.andExpect(status().isCreated())
+ .andDo(
+ document("user-signup",
+ getDocumentRequest(),
+ getDocumentResponse(),
+ requestFields(
+ field("email", JsonFieldType.STRING, "Email 주소"),
+ field("password", JsonFieldType.STRING, "비밀번호"),
+ field("nickname", JsonFieldType.STRING, "별명")
+ )
+ )
+ );
+ }
+
+ @DisplayName("[POST] login 201 Created")
+ @Test
+ void testLoginCreated() throws Exception {
+ // given
+ LoginDto loginDto = generateLoginDto();
+ TokenDto tokenDto = new TokenDto(FAKER.hashing().sha512());
+
+ given(userService.login(loginDto)).willReturn(tokenDto);
+
+ // when
+ ResultActions actions = mockMvc.perform(
+ post(API_PREFIX + "/login")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(loginDto)));
+
+ // then
+ actions.andExpect(status().isCreated())
+ .andDo(
+ document("user-login",
+ getDocumentRequest(),
+ getDocumentResponse(),
+ requestFields(
+ field("email", JsonFieldType.STRING, "Email 주소"),
+ field("password", JsonFieldType.STRING, "비밀번호")
+ ),
+ responseFields(
+ field("token", JsonFieldType.STRING, "JWT 토큰")
+ )
+ )
+ );
+ }
+
+ private SignupDto generateSignupDto() {
+ String email = FAKER.internet().emailAddress();
+ String password = FAKER.internet().password(8, 30, true, true, true);
+ String nickname = FAKER.internet().username();
+
+ return new SignupDto(email, password, nickname);
+ }
+
+ private LoginDto generateLoginDto() {
+ String email = FAKER.internet().emailAddress();
+ String password = FAKER.internet().password(8, 30, true, true, true);
+
+ return new LoginDto(email, password);
+ }
+
+}
diff --git a/src/test/java/com/backendoori/ootw/document/weather/WeatherDocumentationTest.java b/src/test/java/com/backendoori/ootw/document/weather/WeatherDocumentationTest.java
new file mode 100644
index 00000000..076e6d84
--- /dev/null
+++ b/src/test/java/com/backendoori/ootw/document/weather/WeatherDocumentationTest.java
@@ -0,0 +1,79 @@
+package com.backendoori.ootw.document.weather;
+
+import static com.backendoori.ootw.document.common.ApiDocumentUtil.field;
+import static com.backendoori.ootw.document.common.ApiDocumentUtil.getDocumentRequest;
+import static com.backendoori.ootw.document.common.ApiDocumentUtil.getDocumentResponse;
+import static com.backendoori.ootw.security.jwt.JwtAuthenticationFilter.TOKEN_HEADER;
+import static com.backendoori.ootw.security.jwt.JwtAuthenticationFilter.TOKEN_PREFIX;
+import static com.backendoori.ootw.util.provider.ForecastApiCommonRequestSourceProvider.VALID_COORDINATE;
+import static com.backendoori.ootw.util.provider.ForecastApiUltraShortResponseSourceProvider.generateWeatherResponse;
+import static org.mockito.BDDMockito.given;
+import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName;
+import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders;
+import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
+import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields;
+import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName;
+import static org.springframework.restdocs.request.RequestDocumentation.queryParameters;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import com.backendoori.ootw.security.TokenMockMvcTest;
+import com.backendoori.ootw.weather.service.WeatherService;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.http.MediaType;
+import org.springframework.restdocs.payload.JsonFieldType;
+import org.springframework.test.web.servlet.ResultActions;
+
+@SpringBootTest
+@AutoConfigureRestDocs
+class WeatherDocumentationTest extends TokenMockMvcTest {
+
+ static final String API = "/api/v1/weather";
+
+ @MockBean
+ WeatherService weatherService;
+
+ @DisplayName("[GET] readCurrentWeather 200 Ok")
+ @Test
+ void testReadCurrentWeatherOk() throws Exception {
+ // given
+ setToken(1);
+ given(weatherService.getCurrentWeather(VALID_COORDINATE))
+ .willReturn(generateWeatherResponse());
+
+ // when
+ ResultActions actions = mockMvc.perform(get(API)
+ .header(TOKEN_HEADER, TOKEN_PREFIX + token)
+ .param("nx", String.valueOf(VALID_COORDINATE.nx()))
+ .param("ny", String.valueOf(VALID_COORDINATE.ny()))
+ .contentType(MediaType.APPLICATION_JSON)
+ .accept(MediaType.APPLICATION_JSON));
+
+ // then
+ actions.andExpect(status().isOk())
+ .andDo(
+ document("weather",
+ getDocumentRequest(),
+ getDocumentResponse(),
+ requestHeaders(
+ headerWithName("Authorization").description("JWT 토큰")
+ ),
+ queryParameters(
+ parameterWithName("nx").description("사용자 X 좌표"),
+ parameterWithName("ny").description("사용자 Y 좌표")
+ ),
+ responseFields(
+ field("currentDateTime", JsonFieldType.STRING, "현재 시간"),
+ field("currentTemperature", JsonFieldType.NUMBER, "현재 온도"),
+ field("sky", JsonFieldType.STRING, "하늘 상태 코드"),
+ field("pty", JsonFieldType.STRING, "강수 상태 코드")
+ )
+ )
+ );
+ }
+
+}
diff --git a/src/test/java/com/backendoori/ootw/like/controller/LikeControllerTest.java b/src/test/java/com/backendoori/ootw/like/controller/LikeControllerTest.java
new file mode 100644
index 00000000..52e2b2a6
--- /dev/null
+++ b/src/test/java/com/backendoori/ootw/like/controller/LikeControllerTest.java
@@ -0,0 +1,197 @@
+package com.backendoori.ootw.like.controller;
+
+import static com.backendoori.ootw.security.jwt.JwtAuthenticationFilter.TOKEN_HEADER;
+import static com.backendoori.ootw.security.jwt.JwtAuthenticationFilter.TOKEN_PREFIX;
+import static com.backendoori.ootw.util.provider.ForecastApiCommonRequestSourceProvider.VALID_COORDINATE;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import java.util.HashMap;
+import java.util.Map;
+import com.backendoori.ootw.like.domain.Like;
+import com.backendoori.ootw.like.dto.controller.LikeRequest;
+import com.backendoori.ootw.like.repository.LikeRepository;
+import com.backendoori.ootw.post.controller.PostController;
+import com.backendoori.ootw.post.domain.Post;
+import com.backendoori.ootw.post.dto.request.PostSaveRequest;
+import com.backendoori.ootw.post.repository.PostRepository;
+import com.backendoori.ootw.post.service.PostService;
+import com.backendoori.ootw.security.TokenMockMvcTest;
+import com.backendoori.ootw.user.domain.User;
+import com.backendoori.ootw.user.repository.UserRepository;
+import com.backendoori.ootw.weather.domain.TemperatureArrange;
+import com.backendoori.ootw.weather.domain.forecast.ForecastCategory;
+import jakarta.transaction.Transactional;
+import net.datafaker.Faker;
+import org.assertj.core.api.Assertions;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.MediaType;
+
+@SpringBootTest
+@ExtendWith(MockitoExtension.class)
+class LikeControllerTest extends TokenMockMvcTest {
+
+ static final Faker FAKER = new Faker();
+ User user;
+ User writer;
+ Post post;
+
+
+ @Autowired
+ PostController postController;
+
+ @Autowired
+ PostService postService;
+
+ @Autowired
+ PostRepository postRepository;
+
+ @Autowired
+ UserRepository userRepository;
+
+ @Autowired
+ LikeRepository likeRepository;
+
+ @BeforeEach
+ void setup() {
+ likeRepository.deleteAll();
+ postRepository.deleteAll();
+ userRepository.deleteAll();
+ user = userRepository.save(generateUser());
+ writer = userRepository.save(generateUser());
+ post = postRepository.save(generatePost(writer));
+ setToken(user.getId());
+ }
+
+ @AfterEach
+ void cleanup() {
+ likeRepository.deleteAll();
+ postRepository.deleteAll();
+ userRepository.deleteAll();
+ }
+
+ private User generateUser() {
+ return User.builder()
+ .email(FAKER.internet().emailAddress())
+ .password(FAKER.internet().password())
+ .nickname(FAKER.internet().username())
+ .profileImageUrl(FAKER.internet().url())
+ .certified(true)
+ .build();
+ }
+
+ private static TemperatureArrange generateTemperatureArrange() {
+ Map weatherInfoMap = new HashMap<>();
+ weatherInfoMap.put(ForecastCategory.TMN, String.valueOf(0.0));
+ weatherInfoMap.put(ForecastCategory.TMX, String.valueOf(15.0));
+
+ return TemperatureArrange.from(weatherInfoMap);
+ }
+
+ private Post generatePost(User user) {
+ PostSaveRequest postSaveRequest =
+ new PostSaveRequest("title", FAKER.gameOfThrones().quote(), VALID_COORDINATE);
+ return Post.from(user, postSaveRequest, FAKER.internet().url(), generateTemperatureArrange());
+ }
+
+
+ @Test
+ @Transactional
+ @DisplayName("정상적으로 좋아요를 누르면 저장에 성공한다.")
+ public void likeSuccess() throws Exception {
+ //given
+ LikeRequest request = new LikeRequest(post.getId());
+
+ //when
+ mockMvc.perform(post("http://localhost:8080/api/v1/posts/" + post.getId() + "/likes")
+ .content(objectMapper.writeValueAsBytes(request))
+ .header(TOKEN_HEADER, TOKEN_PREFIX + token)
+ .contentType(MediaType.APPLICATION_JSON)
+ .accept(MediaType.APPLICATION_JSON)
+ ).andExpect(status().isOk())
+ .andDo(print());
+
+ //then
+ Like byUserAndPost = likeRepository.findByUserAndPost(user, post).get();
+
+ Assertions.assertThat(byUserAndPost.getPost()).isEqualTo(post);
+ Assertions.assertThat(byUserAndPost.getUser()).isEqualTo(user);
+ Assertions.assertThat(byUserAndPost.getIsLike()).isEqualTo(true);
+
+ }
+
+ @Test
+ @Transactional
+ @DisplayName("이미 좋아요를 누른 경우 좋아요가 취소된다.")
+ public void likeCancel() throws Exception {
+ //given
+ Like like = Like.builder().user(user).post(post).isLike(true).build();
+ likeRepository.save(like);
+
+ LikeRequest request = new LikeRequest(post.getId());
+
+ //when
+ mockMvc.perform(post("http://localhost:8080/api/v1/posts/" + post.getId() + "/likes")
+ .content(objectMapper.writeValueAsBytes(request))
+ .header(TOKEN_HEADER, TOKEN_PREFIX + token)
+ .contentType(MediaType.APPLICATION_JSON)
+ .accept(MediaType.APPLICATION_JSON)
+ ).andExpect(status().isOk())
+ .andDo(print());
+
+ //then
+ Like byUserAndPost = likeRepository.findByUserAndPost(user, post).get();
+
+ Assertions.assertThat(byUserAndPost.getPost()).isEqualTo(post);
+ Assertions.assertThat(byUserAndPost.getUser()).isEqualTo(user);
+ Assertions.assertThat(byUserAndPost.getIsLike()).isEqualTo(false);
+
+ }
+
+ @ParameterizedTest
+ @ValueSource(longs = {-1L, 0})
+ @DisplayName("유효하지 않은 postId 로 좋아요를 요청하면 실패한다.")
+ public void likeFailPostNotFound(Long postId) throws Exception {
+ //given
+ LikeRequest request = new LikeRequest(postId);
+
+ //when //then
+ mockMvc.perform(post("http://localhost:8080/api/v1/posts/" + postId + "/likes")
+ .content(objectMapper.writeValueAsBytes(request))
+ .header(TOKEN_HEADER, TOKEN_PREFIX + token)
+ .contentType(MediaType.APPLICATION_JSON)
+ .accept(MediaType.APPLICATION_JSON)
+ ).andExpect(status().isNotFound())
+ .andDo(print());
+
+ }
+
+ @ParameterizedTest
+ @ValueSource(longs = Long.MAX_VALUE)
+ @DisplayName("존재하지 않는 post 에 좋아요를 요청하면 실패한다.")
+ public void likeFailNullPostId(Long postId) throws Exception {
+ //given
+ LikeRequest request = new LikeRequest(postId);
+
+ //when //then
+ mockMvc.perform(post("http://localhost:8080/api/v1/posts/" + postId + "/likes")
+ .content(objectMapper.writeValueAsBytes(request))
+ .header(TOKEN_HEADER, TOKEN_PREFIX + token)
+ .contentType(MediaType.APPLICATION_JSON)
+ .accept(MediaType.APPLICATION_JSON)
+ ).andExpect(status().isNotFound())
+ .andDo(print());
+
+ }
+
+}
diff --git a/src/test/java/com/backendoori/ootw/like/domain/LikeTest.java b/src/test/java/com/backendoori/ootw/like/domain/LikeTest.java
new file mode 100644
index 00000000..dd43d9fb
--- /dev/null
+++ b/src/test/java/com/backendoori/ootw/like/domain/LikeTest.java
@@ -0,0 +1,162 @@
+package com.backendoori.ootw.like.domain;
+
+import static com.backendoori.ootw.util.provider.ForecastApiCommonRequestSourceProvider.VALID_COORDINATE;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.util.HashMap;
+import java.util.Map;
+import com.backendoori.ootw.post.domain.Post;
+import com.backendoori.ootw.post.dto.request.PostSaveRequest;
+import com.backendoori.ootw.user.domain.User;
+import com.backendoori.ootw.weather.domain.TemperatureArrange;
+import com.backendoori.ootw.weather.domain.forecast.ForecastCategory;
+import net.datafaker.Faker;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.NullSource;
+
+class LikeTest {
+
+ static final Faker FAKER = new Faker();
+
+ private User generateUser() {
+ return User.builder()
+ .id((long) FAKER.number().positive())
+ .email(FAKER.internet().emailAddress())
+ .password(FAKER.internet().password())
+ .nickname(FAKER.internet().username())
+ .profileImageUrl(FAKER.internet().url())
+ .build();
+ }
+
+ private static TemperatureArrange generateTemperatureArrange() {
+ Map weatherInfoMap = new HashMap<>();
+ weatherInfoMap.put(ForecastCategory.TMN, String.valueOf(0.0));
+ weatherInfoMap.put(ForecastCategory.TMX, String.valueOf(15.0));
+
+ return TemperatureArrange.from(weatherInfoMap);
+ }
+
+ private Post generatePost(User user) {
+ PostSaveRequest postSaveRequest =
+ new PostSaveRequest("title", FAKER.gameOfThrones().quote(), VALID_COORDINATE);
+ return Post.from(user, postSaveRequest, FAKER.internet().url(), generateTemperatureArrange());
+ }
+
+ @Test
+ @DisplayName("정상적으로 Like 객체가 만들어진다.")
+ public void makeLikeSuccess() {
+ //given
+ User user = generateUser();
+ User writer = generateUser();
+ Post post = generatePost(writer);
+
+ //when, then
+ assertThatCode(() -> Like.builder()
+ .user(user)
+ .post(post)
+ .isLike(true)
+ .build()).doesNotThrowAnyException();
+
+ assertThatCode(() -> Like.builder()
+ .user(user)
+ .post(post)
+ .isLike(false)
+ .build()).doesNotThrowAnyException();
+
+ }
+
+ @ParameterizedTest
+ @NullSource
+ @DisplayName("post가 null 인 경우 좋아요 객체를 생성 시 예외가 발생한다.")
+ public void makeLikeFailByNullPost(Post post) {
+ //given
+ User user = generateUser();
+
+ //when, then
+ assertThatThrownBy(() -> Like.builder()
+ .user(user)
+ .post(post)
+ .isLike(true)
+ .build())
+ .isInstanceOf(IllegalArgumentException.class);
+
+ assertThatThrownBy(() -> Like.builder()
+ .user(user)
+ .post(post)
+ .isLike(false)
+ .build())
+ .isInstanceOf(IllegalArgumentException.class);
+
+ }
+
+ @ParameterizedTest
+ @NullSource
+ @DisplayName("user가 null 인 경우 좋아요 객체를 생성 시 예외가 발생한다.")
+ public void makeLikeFailByNullUser(User user) {
+ //given
+ User writer = generateUser();
+ Post post = generatePost(writer);
+
+ //when, then
+ assertThatThrownBy(() -> Like.builder()
+ .user(user)
+ .post(post)
+ .isLike(true)
+ .build())
+ .isInstanceOf(IllegalArgumentException.class);
+
+ assertThatThrownBy(() -> Like.builder()
+ .user(user)
+ .post(post)
+ .isLike(false)
+ .build())
+ .isInstanceOf(IllegalArgumentException.class);
+
+ }
+
+ @Test
+ @DisplayName("status 값을 주지 않는 경우 예외가 발생한다.")
+ public void makeLikeFailByNullStatus() {
+ //given
+ User user = generateUser();
+ User writer = generateUser();
+ Post post = generatePost(writer);
+
+ //when, then
+ assertThatThrownBy(() -> Like.builder()
+ .user(user)
+ .post(post)
+ .build())
+ .isInstanceOf(IllegalArgumentException.class);
+
+ }
+
+ @Test
+ @DisplayName("정상적으로 Like status가 업데이트 된다.")
+ public void statusUpdateSuccess() {
+ //given
+ User user = generateUser();
+ User writer = generateUser();
+ Post post = generatePost(writer);
+
+ //when, then
+ Like like = Like.builder()
+ .user(user)
+ .post(post)
+ .isLike(true)
+ .build();
+
+ like.updateStatus();
+ assertThat(like.getIsLike()).isEqualTo(false);
+
+ like.updateStatus();
+ assertThat(like.getIsLike()).isEqualTo(true);
+
+ }
+
+
+}
diff --git a/src/test/java/com/backendoori/ootw/like/service/LikeServiceTest.java b/src/test/java/com/backendoori/ootw/like/service/LikeServiceTest.java
new file mode 100644
index 00000000..90acc3c6
--- /dev/null
+++ b/src/test/java/com/backendoori/ootw/like/service/LikeServiceTest.java
@@ -0,0 +1,246 @@
+package com.backendoori.ootw.like.service;
+
+import static com.backendoori.ootw.util.provider.ForecastApiCommonRequestSourceProvider.VALID_COORDINATE;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.NoSuchElementException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import com.backendoori.ootw.exception.UserNotFoundException;
+import com.backendoori.ootw.like.domain.Like;
+import com.backendoori.ootw.like.dto.controller.LikeResponse;
+import com.backendoori.ootw.like.repository.LikeRepository;
+import com.backendoori.ootw.post.controller.PostController;
+import com.backendoori.ootw.post.domain.Post;
+import com.backendoori.ootw.post.dto.request.PostSaveRequest;
+import com.backendoori.ootw.post.repository.PostRepository;
+import com.backendoori.ootw.post.service.PostService;
+import com.backendoori.ootw.security.TokenMockMvcTest;
+import com.backendoori.ootw.user.domain.User;
+import com.backendoori.ootw.user.repository.UserRepository;
+import com.backendoori.ootw.weather.domain.TemperatureArrange;
+import com.backendoori.ootw.weather.domain.forecast.ForecastCategory;
+import net.datafaker.Faker;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+
+@SpringBootTest
+public class LikeServiceTest extends TokenMockMvcTest {
+
+ static final String POST_NOT_FOUND_MESSAGE = "해당 게시글이 존재하지 않습니다.";
+ static final Faker FAKER = new Faker();
+
+ User user;
+
+ User writer;
+
+ Post post;
+
+ @Autowired
+ PostController postController;
+
+ @Autowired
+ PostService postService;
+
+ @Autowired
+ PostRepository postRepository;
+
+ @Autowired
+ UserRepository userRepository;
+
+ @Autowired
+ LikeRepository likeRepository;
+
+ @Autowired
+ LikeService likeService;
+
+
+ @BeforeEach
+ void setup() {
+ likeRepository.deleteAll();
+ postRepository.deleteAll();
+ userRepository.deleteAll();
+ user = userRepository.save(generateUser());
+ writer = userRepository.save(generateUser());
+ post = postRepository.save(generatePost(writer));
+ setToken(user.getId());
+ }
+
+ @AfterEach
+ void cleanup() {
+ likeRepository.deleteAll();
+ postRepository.deleteAll();
+ userRepository.deleteAll();
+ }
+
+ private User generateUser() {
+ return User.builder()
+ .email(FAKER.internet().emailAddress())
+ .password(FAKER.internet().password())
+ .nickname(FAKER.internet().username())
+ .profileImageUrl(FAKER.internet().url())
+ .certified(true)
+ .build();
+ }
+
+ private static TemperatureArrange generateTemperatureArrange() {
+ Map weatherInfoMap = new HashMap<>();
+ weatherInfoMap.put(ForecastCategory.TMN, String.valueOf(0.0));
+ weatherInfoMap.put(ForecastCategory.TMX, String.valueOf(15.0));
+
+ return TemperatureArrange.from(weatherInfoMap);
+ }
+
+ private Post generatePost(User user) {
+ PostSaveRequest postSaveRequest =
+ new PostSaveRequest("title", FAKER.gameOfThrones().quote(), VALID_COORDINATE);
+ return Post.from(user, postSaveRequest, FAKER.internet().url(), generateTemperatureArrange());
+ }
+
+ @Test
+ @DisplayName("정상적으로 likeDto를 받아 좋아요를 누르면 성공한다.")
+ public void likePostSuccess() throws Exception {
+ //given
+ Long postId = post.getId();
+ Long userId = user.getId();
+
+ //when
+ LikeResponse response = likeService.requestLike(userId, postId);
+
+ //then
+ assertThat(response.postId()).isEqualTo(postId);
+ assertThat(response.userId()).isEqualTo(userId);
+ assertThat(response.status()).isEqualTo(true);
+
+ }
+
+ @Test
+ @DisplayName("이미 좋아요를 누른 게시물의 경우 좋아요가 취소된다.")
+ public void likePostCancelSuccess() throws Exception {
+ //given
+ Like like = Like.builder().user(user).post(post).isLike(true).build();
+ likeRepository.save(like);
+ Long postId = post.getId();
+ Long userId = user.getId();
+
+ //when
+ LikeResponse response = likeService.requestLike(userId, postId);
+
+ //then
+ assertThat(response.postId()).isEqualTo(postId);
+ assertThat(response.userId()).isEqualTo(userId);
+ assertThat(response.status()).isEqualTo(false);
+
+ }
+
+ @ParameterizedTest
+ @ValueSource(longs = {-1, 0, Long.MAX_VALUE})
+ @DisplayName("존재하지 않는 게시물에 좋아요를 누르는 경우 요청에 실패한다.")
+ public void likePostFailWithWrongPostId(Long wrongPostId) {
+ //given
+ Long postId = wrongPostId;
+ Long userId = user.getId();
+
+ //when, then
+ assertThatThrownBy(() -> likeService.requestLike(userId, postId))
+ .isInstanceOf(NoSuchElementException.class)
+ .hasMessage(POST_NOT_FOUND_MESSAGE);
+
+ }
+
+ @ParameterizedTest
+ @ValueSource(longs = {-1, 0, Long.MAX_VALUE})
+ @DisplayName("존재하지 않는 유저에 좋아요를 요청한 경우 요청에 실패한다.")
+ public void likePostFailWithWrongUserId(Long wrongUserId) {
+ //given
+ Long postId = post.getId();
+ Long userId = wrongUserId;
+
+ //when, then
+ assertThatThrownBy(() -> likeService.requestLike(userId, postId))
+ .isInstanceOf(UserNotFoundException.class)
+ .hasMessage(UserNotFoundException.DEFAULT_MESSAGE);
+
+ }
+
+ @Test
+ @DisplayName("정상적으로 좋아요 개수를 가져오는 경우")
+ public void countLikePost() {
+ //given
+ Long postId = post.getId();
+ Long userId = user.getId();
+ ;
+
+ //when
+ likeService.requestLike(userId, postId);
+ // then
+
+ Post findPost = postRepository.findById(postId).get();
+ assertThat(findPost.getLikeCnt()).isEqualTo(1);
+
+ likeService.requestLike(userId, postId);
+
+ Post reFindPost = postRepository.findById(postId).get();
+ assertThat(reFindPost.getLikeCnt()).isEqualTo(0);
+ }
+
+ @Test
+ @DisplayName("단일 사용자의 여러 번 좋아요 요청 처리 테스트")
+ void testLikeFunctionalityForSingleUserMultipleRequests() throws InterruptedException {
+ //when
+ int threadCount = 50; // 동시에 실행할 스레드 수
+ ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
+
+ // 동일한 사용자로부터 여러 좋아요 요청을 보내는 스레드 실행
+ for (int i = 0; i < threadCount; i++) {
+ executorService.submit(() -> {
+ likeService.requestLike(user.getId(), post.getId());
+ });
+ }
+
+ executorService.shutdown();
+ assertTrue(executorService.awaitTermination(1, TimeUnit.MINUTES));
+
+ // 좋아요 개수 검증
+ Post updatedPost = postRepository.findById(post.getId()).get();
+ assertThat(updatedPost.getLikeCnt()).isEqualTo(0);
+ }
+
+ @Test
+ @DisplayName("여러 사용자의 단일 좋아요 요청 처리 테스트")
+ void testLikeFunctionalityForMultipleUsersSingleRequest() throws InterruptedException {
+
+ int userCount = 50; // 테스트에 사용할 사용자 수
+ ExecutorService executorService = Executors.newFixedThreadPool(userCount);
+
+ // 여러 사용자로부터 각각 한 번씩 좋아요 요청을 보내는 스레드 실행
+ for (int i = 0; i < userCount; i++) {
+ User multiUser = generateUser();
+ userRepository.save(multiUser);
+
+ executorService.submit(() -> {
+ likeService.requestLike(multiUser.getId(), post.getId());
+ });
+ }
+
+ executorService.shutdown();
+ assertTrue(executorService.awaitTermination(1, TimeUnit.MINUTES));
+
+ // 좋아요 개수 검증
+ Post updatedPost = postRepository.findById(post.getId()).get();
+ assertThat(userCount).isEqualTo(updatedPost.getLikeCnt());
+ }
+
+
+}
diff --git a/src/test/java/com/backendoori/ootw/post/controller/PostControllerTest.java b/src/test/java/com/backendoori/ootw/post/controller/PostControllerTest.java
index ab52b08a..db3ce78f 100644
--- a/src/test/java/com/backendoori/ootw/post/controller/PostControllerTest.java
+++ b/src/test/java/com/backendoori/ootw/post/controller/PostControllerTest.java
@@ -1,9 +1,13 @@
package com.backendoori.ootw.post.controller;
+import static com.backendoori.ootw.post.validation.Message.POST_NOT_FOUND;
import static com.backendoori.ootw.security.jwt.JwtAuthenticationFilter.TOKEN_HEADER;
import static com.backendoori.ootw.security.jwt.JwtAuthenticationFilter.TOKEN_PREFIX;
+import static com.backendoori.ootw.util.provider.ForecastApiCommonRequestSourceProvider.VALID_COORDINATE;
import static org.assertj.core.api.Assertions.assertThat;
-import static org.hamcrest.Matchers.instanceOf;
+import static org.hamcrest.Matchers.is;
+import static org.mockito.BDDMockito.given;
+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.result.MockMvcResultMatchers.content;
@@ -14,10 +18,12 @@
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.stream.Stream;
+import com.backendoori.ootw.exception.PermissionException;
import com.backendoori.ootw.post.domain.Post;
-import com.backendoori.ootw.post.dto.PostReadResponse;
-import com.backendoori.ootw.post.dto.PostSaveRequest;
-import com.backendoori.ootw.post.dto.PostSaveResponse;
+import com.backendoori.ootw.post.dto.request.PostSaveRequest;
+import com.backendoori.ootw.post.dto.response.PostReadResponse;
+import com.backendoori.ootw.post.dto.response.PostSaveUpdateResponse;
import com.backendoori.ootw.post.repository.PostRepository;
import com.backendoori.ootw.post.service.PostService;
import com.backendoori.ootw.security.TokenMockMvcTest;
@@ -25,7 +31,10 @@
import com.backendoori.ootw.user.repository.UserRepository;
import com.backendoori.ootw.weather.domain.TemperatureArrange;
import com.backendoori.ootw.weather.domain.forecast.ForecastCategory;
+import com.backendoori.ootw.weather.service.WeatherService;
+import com.fasterxml.jackson.core.JsonProcessingException;
import net.datafaker.Faker;
+import org.jetbrains.annotations.NotNull;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
@@ -33,20 +42,34 @@
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import org.junit.jupiter.api.TestInstance.Lifecycle;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.security.authentication.TestingAuthenticationToken;
import org.springframework.security.test.context.TestSecurityContextHolder;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
+import org.springframework.test.web.servlet.request.RequestPostProcessor;
@TestInstance(Lifecycle.PER_CLASS)
class PostControllerTest extends TokenMockMvcTest {
- static final int NX = 55;
- static final int NY = 127;
static final Faker FAKER = new Faker();
+ public static final String IMG_URL = "imageUrl";
+ public static final String BASE_URL = "http://localhost:8080/api/v1/posts";
+ public static final String ORIGINAL_FILE_NAME = "filename.jpeg";
+ public static final String FILE_NAME = "postImg";
+ public static final String CONTENT = "CONTENT";
+ public static final String TITLE = "TITLE";
+
+ @NotNull
+ private static MockMultipartFile getPostImg(String originalFileName, String mediaType) {
+ return new MockMultipartFile(FILE_NAME, originalFileName, mediaType, "some xml".getBytes());
+ }
User user;
@@ -62,6 +85,9 @@ class PostControllerTest extends TokenMockMvcTest {
@Autowired
UserRepository userRepository;
+ @MockBean
+ WeatherService weatherService;
+
@BeforeEach
void setup() {
postRepository.deleteAll();
@@ -83,29 +109,417 @@ private User generateUser() {
.email(FAKER.internet().emailAddress())
.password(FAKER.internet().password())
.nickname(FAKER.internet().username())
- .image(FAKER.internet().url())
+ .profileImageUrl(FAKER.internet().url())
+ .certified(true)
.build();
}
+ @NotNull
+ private MockMultipartFile getRequestJson(String title, String content) throws JsonProcessingException {
+ PostSaveRequest postSaveRequest =
+ new PostSaveRequest(title, content, VALID_COORDINATE);
+
+ return new MockMultipartFile("request", "request.json", MediaType.APPLICATION_JSON_VALUE,
+ objectMapper.writeValueAsBytes(postSaveRequest));
+ }
+
+ @NotNull
+ private static RequestPostProcessor makeRequestMethodToPut() {
+ return req -> {
+ req.setMethod("PUT");
+ return req;
+ };
+ }
+
@Nested
- @DisplayName("게시글 저장 테스트")
- class SaveTest {
+ @DisplayName("게시글 삭제하기")
+ class DeleteTest {
+
+ Post userPost;
+ Post otherPost;
+
+ @BeforeEach
+ void setup() {
+ userPost = postRepository.save(
+ Post.from(user, new PostSaveRequest(TITLE, CONTENT, VALID_COORDINATE), null,
+ generateTemperatureArrange()));
+
+ User other = userRepository.save(generateUser());
+ otherPost = postRepository.save(
+ Post.from(other, new PostSaveRequest(TITLE, CONTENT, VALID_COORDINATE), null,
+ generateTemperatureArrange()));
+ }
+
+ @Test
+ @DisplayName("게시글 삭제에 성공한다.")
+ void deleteSuccess() throws Exception {
+ // given // when
+ MockHttpServletRequestBuilder requestBuilder =
+ delete(BASE_URL + "/" + userPost.getId())
+ .header(TOKEN_HEADER, TOKEN_PREFIX + token);
+
+ // then
+ mockMvc.perform(requestBuilder)
+ .andExpect(status().isNoContent())
+ .andReturn();
+ }
+
+ @Test
+ @DisplayName("로그인을 안한 사용자는 게시글 삭제에 접근이 불가하다.")
+ void deleteFaildeleteFailWithUnauthorizedUser() throws Exception {
+ // given // when
+ MockHttpServletRequestBuilder requestBuilder =
+ delete(BASE_URL + "/" + userPost.getId());
+
+ // then
+ mockMvc.perform(requestBuilder)
+ .andExpect(status().isUnauthorized())
+ .andReturn();
+ }
@Test
+ @DisplayName("게시글 주인이 아닌 사용자가 게시글 삭제에 실패한다.")
+ void deleteFailWithNoPermission() throws Exception {
+ // given // when
+ MockHttpServletRequestBuilder requestBuilder =
+ delete(BASE_URL + "/" + otherPost.getId())
+ .header(TOKEN_HEADER, TOKEN_PREFIX + token);
+
+ // then
+ mockMvc.perform(requestBuilder)
+ .andExpect(status().isForbidden())
+ .andExpect(jsonPath("$.message", is(PermissionException.DEFAULT_MESSAGE)))
+ .andReturn();
+ }
+
+ @Test
+ @DisplayName("존재하지 않는 게시글 삭제에 실패한다.")
+ void deleteFailWithNonExistPost() throws Exception {
+ // given // when
+ MockHttpServletRequestBuilder requestBuilder =
+ delete(BASE_URL + "/" + otherPost.getId() + 1)
+ .header(TOKEN_HEADER, TOKEN_PREFIX + token);
+
+ // then
+ mockMvc.perform(requestBuilder)
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.message", is(POST_NOT_FOUND)))
+ .andReturn();
+ }
+
+ }
+
+ @Nested
+ @DisplayName("게시글 수정하기")
+ class UpdateTest {
+
+ Post userPost;
+ Post otherPost;
+
+ @BeforeEach
+ void setup() {
+ userPost = postRepository.save(
+ Post.from(user, new PostSaveRequest(TITLE, CONTENT, VALID_COORDINATE), null,
+ generateTemperatureArrange()));
+
+ User other = userRepository.save(generateUser());
+ otherPost = postRepository.save(
+ Post.from(other, new PostSaveRequest(TITLE, CONTENT, VALID_COORDINATE), null,
+ generateTemperatureArrange()));
+ }
+
+ @Nested
+ @DisplayName("게시글 수정에 성공한다")
+ class UpdateSuccess {
+
+ static Stream provideImageTypes() {
+ return Stream.of(
+ Arguments.of("image.jpeg", MediaType.IMAGE_JPEG_VALUE),
+ Arguments.of("image.gif", MediaType.IMAGE_GIF_VALUE),
+ Arguments.of("image.png", MediaType.IMAGE_PNG_VALUE)
+ );
+ }
+
+ @ParameterizedTest(name = "[{index}]: 아이템 타입이 {0}인 경우에 저장에 성공한다.")
+ @MethodSource("provideImageTypes")
+ @DisplayName(" 게시글 정보와 이미지 수정에 성공한다.")
+ void updateAllSuccess(String originalFileName, String mediaType) throws Exception {
+ // given
+ MockMultipartFile request = getRequestJson(TITLE, CONTENT);
+ MockMultipartFile postImg = getPostImg(originalFileName, mediaType);
+
+ // when
+ MockHttpServletRequestBuilder requestBuilder = multipart(BASE_URL + "/" + userPost.getId())
+ .file(request)
+ .file(postImg)
+ .header(TOKEN_HEADER, TOKEN_PREFIX + token)
+ .contentType(MediaType.MULTIPART_FORM_DATA)
+ .accept(MediaType.APPLICATION_JSON)
+ .characterEncoding(StandardCharsets.UTF_8)
+ .with(makeRequestMethodToPut());
+
+ // then
+ MockHttpServletResponse response = mockMvc.perform(requestBuilder)
+ .andExpect(status().isCreated())
+ .andExpect(content().contentType(MediaType.APPLICATION_JSON))
+ .andReturn()
+ .getResponse();
+
+ assertThat(response.getHeader("location")).contains("/api/v1/posts/");
+ }
+
+ @Test
+ @DisplayName("게시글 정보 수정에 성공한다.")
+ void updatePostUpdateRequestSuccess() throws Exception {
+ // given
+ MockMultipartFile request = getRequestJson(TITLE, CONTENT);
+
+ // when
+ MockHttpServletRequestBuilder requestBuilder = multipart(BASE_URL + "/" + userPost.getId())
+ .file(request)
+ .header(TOKEN_HEADER, TOKEN_PREFIX + token)
+ .accept(MediaType.APPLICATION_JSON)
+ .characterEncoding(StandardCharsets.UTF_8)
+ .with(makeRequestMethodToPut());
+
+ // then
+ MockHttpServletResponse response = mockMvc.perform(requestBuilder)
+ .andExpect(status().isCreated())
+ .andExpect(content().contentType(MediaType.APPLICATION_JSON))
+ .andReturn()
+ .getResponse();
+
+ assertThat(response.getHeader("location")).contains("/api/v1/posts/");
+ }
+
+ }
+
+ @Nested
+ @DisplayName("게시글 수정에 실패한다")
+ class UpdateFail {
+
+ static Stream provideInvalidPostInfo() {
+ return Stream.of(
+ Arguments.of(null, CONTENT),
+ Arguments.of(TITLE, null),
+ Arguments.of("", CONTENT),
+ Arguments.of(TITLE, ""),
+ Arguments.of(" ", CONTENT),
+ Arguments.of(TITLE, " "),
+ Arguments.of("a".repeat(40), CONTENT),
+ Arguments.of(TITLE, "a".repeat(600))
+ );
+ }
+
+ static Stream provideInvalidFile() {
+ return Stream.of(
+ Arguments.of("file.md", MediaType.TEXT_MARKDOWN_VALUE),
+ Arguments.of("file.html", MediaType.TEXT_HTML_VALUE),
+ Arguments.of("file.pdf", MediaType.APPLICATION_PDF_VALUE),
+ Arguments.of("file.txt", MediaType.TEXT_PLAIN_VALUE)
+ );
+ }
+
+ @Test
+ @DisplayName("로그인을 안한 사용자는 게시글 수정에 접근이 불가하다.")
+ void updateFailWithUnauthorizedUser() throws Exception {
+ // given
+ MockMultipartFile request = getRequestJson(TITLE, CONTENT);
+ MockMultipartFile postImg = getPostImg(ORIGINAL_FILE_NAME, MediaType.IMAGE_JPEG_VALUE);
+
+ // when
+ MockHttpServletRequestBuilder requestBuilder = multipart(BASE_URL + "/" + userPost.getId())
+ .file(request)
+ .file(postImg)
+ .contentType(MediaType.MULTIPART_FORM_DATA)
+ .characterEncoding(StandardCharsets.UTF_8)
+ .with(makeRequestMethodToPut());
+
+ // then
+ mockMvc.perform(requestBuilder)
+ .andExpect(status().isUnauthorized())
+ .andReturn();
+ }
+
+ @Test
+ @DisplayName("게시글 주인이 아닌 사용자가 게시글 수정에 실패한다.")
+ void updateFailWithPermission() throws Exception {
+ // given
+ MockMultipartFile request = getRequestJson(TITLE, CONTENT);
+ MockMultipartFile postImg = getPostImg(ORIGINAL_FILE_NAME, MediaType.IMAGE_JPEG_VALUE);
+
+ // when
+ MockHttpServletRequestBuilder requestBuilder = multipart(BASE_URL + "/" + otherPost.getId())
+ .file(request)
+ .file(postImg)
+ .header(TOKEN_HEADER, TOKEN_PREFIX + token)
+ .contentType(MediaType.MULTIPART_FORM_DATA)
+ .characterEncoding(StandardCharsets.UTF_8)
+ .with(makeRequestMethodToPut());
+
+ // then
+ mockMvc.perform(requestBuilder)
+ .andExpect(status().isForbidden())
+ .andExpect(jsonPath("$.message", is(PermissionException.DEFAULT_MESSAGE)))
+ .andReturn();
+ }
+
+ @Test
+ @DisplayName("존재하지 않는 게시글 수정에 실패한다.")
+ void updateFailWithNonExistPost() throws Exception {
+ // given
+ MockMultipartFile request = getRequestJson(TITLE, CONTENT);
+ MockMultipartFile postImg = getPostImg(ORIGINAL_FILE_NAME, MediaType.IMAGE_JPEG_VALUE);
+
+ // when
+ MockHttpServletRequestBuilder requestBuilder = multipart(BASE_URL + "/" + otherPost.getId() + 1)
+ .file(request)
+ .file(postImg)
+ .header(TOKEN_HEADER, TOKEN_PREFIX + token)
+ .contentType(MediaType.MULTIPART_FORM_DATA)
+ .characterEncoding(StandardCharsets.UTF_8)
+ .with(makeRequestMethodToPut());
+
+ // then
+ mockMvc.perform(requestBuilder)
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.message", is(POST_NOT_FOUND)))
+ .andReturn();
+ }
+
+ @Test
+ @DisplayName("수정할 리소스를 전혀 보내지 않으면 실패한다.")
+ void updateFailWithNoResource() throws Exception {
+ // given // when
+ MockHttpServletRequestBuilder requestBuilder = multipart(BASE_URL + "/" + userPost.getId())
+ .header(TOKEN_HEADER, TOKEN_PREFIX + token)
+ .contentType(MediaType.MULTIPART_FORM_DATA)
+ .characterEncoding(StandardCharsets.UTF_8)
+ .with(makeRequestMethodToPut());
+
+ // then
+ mockMvc.perform(requestBuilder)
+ .andExpect(status().isBadRequest())
+ .andReturn();
+ }
+
+ @Test
+ @DisplayName("수정할 이미지만 보내면 수정에 실패한다.")
+ void updateFailWithNullImage() throws Exception {
+ // given
+ MockMultipartFile postImg = getPostImg(ORIGINAL_FILE_NAME, MediaType.IMAGE_JPEG_VALUE);
+
+ // when
+ MockHttpServletRequestBuilder requestBuilder = multipart(BASE_URL + "/" + userPost.getId())
+ .file(postImg)
+ .header(TOKEN_HEADER, TOKEN_PREFIX + token)
+ .contentType(MediaType.MULTIPART_FORM_DATA)
+ .characterEncoding(StandardCharsets.UTF_8)
+ .with(makeRequestMethodToPut());
+
+ // then
+ mockMvc.perform(requestBuilder)
+ .andExpect(status().isBadRequest())
+ .andReturn();
+ }
+
+ @ParameterizedTest(name = "[{index}] 제목이 {0}이고 내용이 {1}인 경우")
+ @MethodSource("provideInvalidPostInfo")
+ @DisplayName("수정할 게시글 정보를 보냈는데 유효하지 않으면 수정에 실패한다.")
+ void updateFailWithInvalidPostUpdateRequest(String title, String content) throws Exception {
+ // given
+ MockMultipartFile request = getRequestJson(title, content);
+ MockMultipartFile postImg = getPostImg(ORIGINAL_FILE_NAME, MediaType.IMAGE_JPEG_VALUE);
+
+ // when
+ MockHttpServletRequestBuilder requestBuilder = multipart(BASE_URL + "/" + userPost.getId())
+ .file(request)
+ .file(postImg)
+ .header(TOKEN_HEADER, TOKEN_PREFIX + token)
+ .contentType(MediaType.MULTIPART_FORM_DATA)
+ .characterEncoding(StandardCharsets.UTF_8)
+ .with(makeRequestMethodToPut());
+
+ // then
+ mockMvc.perform(requestBuilder)
+ .andExpect(status().isBadRequest())
+ .andReturn();
+ }
+
+ @ParameterizedTest(name = "[{index}] 파일 타입이 {1}인 경우")
+ @MethodSource("provideInvalidFile")
+ @DisplayName("수정할 게시글 정보와 파일을 보냈는데 파일이 유효하지 않으면 수정에 실패한다.")
+ void updateFailWithInvalidFileType(String originalFileName, String mediaType) throws Exception {
+ // given
+ MockMultipartFile request = getRequestJson(TITLE, CONTENT);
+ MockMultipartFile postImg = getPostImg(originalFileName, mediaType);
+
+ // when
+ MockHttpServletRequestBuilder requestBuilder = multipart(BASE_URL + "/" + userPost.getId())
+ .file(request)
+ .file(postImg)
+ .header(TOKEN_HEADER, TOKEN_PREFIX + token)
+ .contentType(MediaType.MULTIPART_FORM_DATA)
+ .characterEncoding(StandardCharsets.UTF_8)
+ .with(makeRequestMethodToPut());
+
+ // then
+ mockMvc.perform(requestBuilder)
+ .andExpect(status().isBadRequest())
+ .andReturn();
+ }
+
+ }
+
+ }
+
+ @Nested
+ @DisplayName("게시글 저장하기")
+ class SaveTest {
+
+ static Stream provideInvalidPostInfo() {
+ return Stream.of(
+ Arguments.of(null, CONTENT),
+ Arguments.of(PostControllerTest.TITLE, null),
+ Arguments.of("", CONTENT),
+ Arguments.of(PostControllerTest.TITLE, ""),
+ Arguments.of(" ", CONTENT),
+ Arguments.of(PostControllerTest.TITLE, " "),
+ Arguments.of("a".repeat(40), CONTENT),
+ Arguments.of(PostControllerTest.TITLE, "a".repeat(600))
+ );
+ }
+
+ static Stream provideImageTypes() {
+ return Stream.of(
+ Arguments.of("image.jpeg", MediaType.IMAGE_JPEG_VALUE),
+ Arguments.of("image.gif", MediaType.IMAGE_GIF_VALUE),
+ Arguments.of("image.png", MediaType.IMAGE_PNG_VALUE)
+ );
+ }
+
+ static Stream provideInvalidFile() {
+ return Stream.of(
+ Arguments.of("file.md", MediaType.TEXT_MARKDOWN_VALUE),
+ Arguments.of("file.html", MediaType.TEXT_HTML_VALUE),
+ Arguments.of("file.pdf", MediaType.APPLICATION_PDF_VALUE),
+ Arguments.of("file.txt", MediaType.TEXT_PLAIN_VALUE)
+ );
+ }
+
+ @ParameterizedTest(name = "[{index}]: 아이템 타입이 {0}인 경우에 저장에 성공한다.")
+ @MethodSource("provideImageTypes")
@DisplayName("게시글 저장에 성공한다.")
- void saveSuccess() throws Exception {
+ void saveSuccess(String originalFileName, String mediaType) throws Exception {
// given
- PostSaveRequest postSaveRequest =
- new PostSaveRequest("Test Title", "Test Content", NX, NY);
- MockMultipartFile request =
- new MockMultipartFile("request", "request.json", MediaType.APPLICATION_JSON_VALUE,
- objectMapper.writeValueAsBytes(postSaveRequest));
- MockMultipartFile postImg =
- new MockMultipartFile("postImg", "filename.txt", MediaType.MULTIPART_FORM_DATA_VALUE,
- "some xml".getBytes());
+ given(weatherService.getCurrentTemperatureArrange(VALID_COORDINATE))
+ .willReturn(generateTemperatureArrange());
+
+ MockMultipartFile request = getRequestJson(TITLE, CONTENT);
+ MockMultipartFile postImg = getPostImg(originalFileName, mediaType);
// when
- MockHttpServletRequestBuilder requestBuilder = multipart("http://localhost:8080/api/v1/posts")
+ MockHttpServletRequestBuilder requestBuilder = multipart(BASE_URL)
.file(request)
.file(postImg)
.header(TOKEN_HEADER, TOKEN_PREFIX + token)
@@ -129,17 +543,14 @@ void saveFailNonSavedUser() throws Exception {
// given
setToken(user.getId() + 1);
- PostSaveRequest postSaveRequest =
- new PostSaveRequest("Test Title", "Test Content", NX, NY);
- MockMultipartFile request =
- new MockMultipartFile("request", "request.json", MediaType.APPLICATION_JSON_VALUE,
- objectMapper.writeValueAsBytes(postSaveRequest));
- MockMultipartFile postImg =
- new MockMultipartFile("postImg", "filename.txt", MediaType.MULTIPART_FORM_DATA_VALUE,
- "some xml".getBytes());
+ given(weatherService.getCurrentTemperatureArrange(VALID_COORDINATE))
+ .willReturn(generateTemperatureArrange());
+
+ MockMultipartFile request = getRequestJson(TITLE, CONTENT);
+ MockMultipartFile postImg = getPostImg(ORIGINAL_FILE_NAME, MediaType.IMAGE_JPEG_VALUE);
// when
- MockHttpServletRequestBuilder requestBuilder = multipart("http://localhost:8080/api/v1/posts")
+ MockHttpServletRequestBuilder requestBuilder = multipart(BASE_URL)
.file(request)
.file(postImg)
.header(TOKEN_HEADER, TOKEN_PREFIX + token)
@@ -152,20 +563,16 @@ void saveFailNonSavedUser() throws Exception {
.andExpect(content().contentType(MediaType.APPLICATION_JSON));
}
- @Test
- @DisplayName("유효하지 않은 요청 값(게시글 title)이 포함된 게시글 저장에 실패한다.")
- void saveFailByMethodArgumentNotValidException() throws Exception {
+ @ParameterizedTest(name = "[{index}] 제목이 {0}이고 내용이 {1}인 경우")
+ @MethodSource("provideInvalidPostInfo")
+ @DisplayName("유효하지 않은 요청 값이 포함된 게시글 저장에 실패한다.")
+ void saveFailByInvalidPostSaveRequest(String title, String content) throws Exception {
// given
- PostSaveRequest postSaveRequest = new PostSaveRequest("", "Test Content", NX, NY);
- MockMultipartFile request =
- new MockMultipartFile("request", "request.json", MediaType.APPLICATION_JSON_VALUE,
- objectMapper.writeValueAsBytes(postSaveRequest));
- MockMultipartFile postImg =
- new MockMultipartFile("postImg", "filename.txt", MediaType.MULTIPART_FORM_DATA_VALUE,
- "some xml".getBytes());
+ MockMultipartFile request = getRequestJson(title, content);
+ MockMultipartFile postImg = getPostImg(ORIGINAL_FILE_NAME, MediaType.IMAGE_JPEG_VALUE);
// when
- MockHttpServletRequestBuilder requestBuilder = multipart("http://localhost:8080/api/v1/posts")
+ MockHttpServletRequestBuilder requestBuilder = multipart(BASE_URL)
.file(request)
.file(postImg)
.header(TOKEN_HEADER, TOKEN_PREFIX + token)
@@ -175,24 +582,19 @@ void saveFailByMethodArgumentNotValidException() throws Exception {
// then
mockMvc.perform(requestBuilder)
.andExpect(status().isBadRequest())
- .andExpect(jsonPath("$.message", instanceOf(String.class)))
.andExpect(content().contentType(MediaType.APPLICATION_JSON));
}
- @Test
- @DisplayName("유효하지 않은 요청 값(최저 기온)이 포함된 게시글 저장에 실패한다.")
- void saveFailInvalidValueByIllegalArgumentException() throws Exception {
+ @ParameterizedTest(name = "[{index}] 파일 타입이 {1}인 경우")
+ @MethodSource("provideInvalidFile")
+ @DisplayName("게시글 정보와 파일을 보냈는데 파일이 유효하지 않으면 저장에 실패한다.")
+ void saveFailByInvalidFileType(String originalFileName, String mediaType) throws Exception {
// given
- PostSaveRequest postSaveRequest = new PostSaveRequest("", "Test Content", NX, NY);
- MockMultipartFile request =
- new MockMultipartFile("request", "request.json", MediaType.APPLICATION_JSON_VALUE,
- objectMapper.writeValueAsBytes(postSaveRequest));
- MockMultipartFile postImg =
- new MockMultipartFile("postImg", "filename.txt", MediaType.MULTIPART_FORM_DATA_VALUE,
- "some xml".getBytes());
+ MockMultipartFile request = getRequestJson(TITLE, CONTENT);
+ MockMultipartFile postImg = getPostImg(originalFileName, mediaType);
// when
- MockHttpServletRequestBuilder requestBuilder = multipart("http://localhost:8080/api/v1/posts")
+ MockHttpServletRequestBuilder requestBuilder = multipart(BASE_URL)
.file(request)
.file(postImg)
.header(TOKEN_HEADER, TOKEN_PREFIX + token)
@@ -204,25 +606,24 @@ void saveFailInvalidValueByIllegalArgumentException() throws Exception {
.andExpect(status().isBadRequest())
.andExpect(content().contentType(MediaType.APPLICATION_JSON));
}
-
}
@Nested
@DisplayName("게시글 단건 조회하기")
- class GetDetailByPostId {
+ class GetDetailByPostIdTest {
private static final String URL = "http://localhost:8080/api/v1/posts/";
- PostSaveResponse postSaveResponse;
+ PostSaveUpdateResponse postSaveResponse;
@BeforeEach
void setUp() {
TestSecurityContextHolder.setAuthentication(new TestingAuthenticationToken(user.getId(), null));
Post savedPost = postRepository.save(
- Post.from(user, new PostSaveRequest("Test Title", "Test Content", NX, NY), "imgUrl",
+ Post.from(user, new PostSaveRequest(TITLE, CONTENT, VALID_COORDINATE), IMG_URL,
generateTemperatureArrange()));
- postSaveResponse = PostSaveResponse.from(savedPost);
+ postSaveResponse = PostSaveUpdateResponse.from(savedPost);
}
@Test
@@ -259,7 +660,7 @@ void getDetailByPostIdSuccess() throws Exception {
@Nested
@DisplayName("게시글 목록 조회하기")
- class GetAll {
+ class GetAllTest {
static final Integer SAVE_COUNT = 10;
@@ -268,17 +669,16 @@ void setUp() {
TestSecurityContextHolder.setAuthentication(new TestingAuthenticationToken(user.getId(), null));
for (int i = 0; i < SAVE_COUNT; i++) {
- Post savedPost = postRepository.save(
- Post.from(user, new PostSaveRequest("Test Title", "Test Content", NX, NY), "imgUrl",
+ postRepository.save(Post.from(user, new PostSaveRequest(TITLE, CONTENT, VALID_COORDINATE), IMG_URL,
generateTemperatureArrange()));
}
}
@Test
- @DisplayName("게시글 목록 조회 성공")
+ @DisplayName("게시글 목록 조회에 성공한다.")
void getAllSuccess() throws Exception {
// given // when
- MockHttpServletRequestBuilder requestBuilder = get("http://localhost:8080/api/v1/posts")
+ MockHttpServletRequestBuilder requestBuilder = get(BASE_URL)
.header(TOKEN_HEADER, TOKEN_PREFIX + token)
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON);
@@ -298,6 +698,7 @@ void getAllSuccess() throws Exception {
}
+
private TemperatureArrange generateTemperatureArrange() {
Map weatherInfoMap = new HashMap<>();
weatherInfoMap.put(ForecastCategory.TMN, String.valueOf(0.0));
diff --git a/src/test/java/com/backendoori/ootw/post/domain/PostTest.java b/src/test/java/com/backendoori/ootw/post/domain/PostTest.java
index 39817ac3..e41a104b 100644
--- a/src/test/java/com/backendoori/ootw/post/domain/PostTest.java
+++ b/src/test/java/com/backendoori/ootw/post/domain/PostTest.java
@@ -1,17 +1,25 @@
package com.backendoori.ootw.post.domain;
+import static com.backendoori.ootw.post.validation.Message.BLANK_POST_CONTENT;
+import static com.backendoori.ootw.post.validation.Message.BLANK_POST_TITLE;
+import static com.backendoori.ootw.post.validation.Message.INVALID_POST_CONTENT;
+import static com.backendoori.ootw.post.validation.Message.INVALID_POST_TITLE;
+import static com.backendoori.ootw.post.validation.Message.NULL_TEMPERATURE_ARRANGE;
+import static com.backendoori.ootw.post.validation.Message.NULL_WRITER;
+import static com.backendoori.ootw.util.provider.ForecastApiCommonRequestSourceProvider.VALID_COORDINATE;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.junit.jupiter.api.Assertions.assertAll;
-import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.mock;
import java.util.HashMap;
import java.util.Map;
import java.util.stream.Stream;
-import com.backendoori.ootw.post.dto.PostSaveRequest;
+import com.backendoori.ootw.post.dto.request.PostSaveRequest;
import com.backendoori.ootw.user.domain.User;
import com.backendoori.ootw.weather.domain.TemperatureArrange;
import com.backendoori.ootw.weather.domain.forecast.ForecastCategory;
+import org.assertj.core.api.ThrowableAssert.ThrowingCallable;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
@@ -20,8 +28,6 @@
class PostTest {
- private static final int NX = 55;
- private static final int NY = 127;
private static final String IMG_URL = "imgUrl";
private static final User MOCK_USER = mock(User.class);
@@ -34,20 +40,27 @@ private static TemperatureArrange generateTemperatureArrange() {
}
private static Stream provideInvalidInfo() {
- return Stream.of(Arguments.of("title이 null인 경우",
- new PostSaveRequest(null, "Test Content", NX, NY), generateTemperatureArrange()),
+ return Stream.of(
+ Arguments.of("title이 null인 경우",
+ new PostSaveRequest(null, "Test Content", VALID_COORDINATE), generateTemperatureArrange(),
+ BLANK_POST_TITLE),
Arguments.of("title이 공백인 경우",
- new PostSaveRequest(" ", "Test Content", NX, NY), generateTemperatureArrange()),
+ new PostSaveRequest(" ", "Test Content", VALID_COORDINATE), generateTemperatureArrange(),
+ BLANK_POST_TITLE),
Arguments.of("title이 30자를 넘는 경우",
- new PostSaveRequest("T".repeat(31), "Test Content", NX, NY), generateTemperatureArrange()),
+ new PostSaveRequest("T".repeat(31), "Test Content", VALID_COORDINATE), generateTemperatureArrange(),
+ INVALID_POST_TITLE),
Arguments.of("content가 null인 경우",
- new PostSaveRequest("Test Title", null, NX, NY), generateTemperatureArrange()),
+ new PostSaveRequest("Test Title", null, VALID_COORDINATE), generateTemperatureArrange(),
+ BLANK_POST_CONTENT),
Arguments.of("content가 공백인 경우",
- new PostSaveRequest("Test Title", " ", NX, NY), generateTemperatureArrange()),
+ new PostSaveRequest("Test Title", " ", VALID_COORDINATE), generateTemperatureArrange(),
+ BLANK_POST_CONTENT),
Arguments.of("content가 500자를 넘는 경우",
- new PostSaveRequest("Test Title", "T".repeat(501), NX, NY), generateTemperatureArrange()),
+ new PostSaveRequest("Test Title", "T".repeat(501), VALID_COORDINATE), generateTemperatureArrange(),
+ INVALID_POST_CONTENT),
Arguments.of("temperatureArrange가 null인 경우",
- new PostSaveRequest("Test Title", "T".repeat(501), NX, NY), null)
+ new PostSaveRequest("Test Title", "T", VALID_COORDINATE), null, NULL_TEMPERATURE_ARRANGE)
);
}
@@ -55,7 +68,7 @@ private static Stream provideInvalidInfo() {
@DisplayName("PostSaveRequest로부터 Post를 생성하는 것에 성공한다.")
void createPostSuccess() {
// given
- PostSaveRequest request = new PostSaveRequest("Test Title", "Test Content", NX, NY);
+ PostSaveRequest request = new PostSaveRequest("Test Title", "Test Content", VALID_COORDINATE);
// when
Post createdPost = Post.from(MOCK_USER, request, IMG_URL, generateTemperatureArrange());
@@ -64,18 +77,39 @@ void createPostSuccess() {
assertAll(() -> assertThat(createdPost).hasFieldOrPropertyWithValue("user", MOCK_USER),
() -> assertThat(createdPost).hasFieldOrPropertyWithValue("title", request.title()),
() -> assertThat(createdPost).hasFieldOrPropertyWithValue("content", request.content()),
- () -> assertThat(createdPost).hasFieldOrPropertyWithValue("image", IMG_URL),
+ () -> assertThat(createdPost).hasFieldOrPropertyWithValue("imageUrl", IMG_URL),
() -> assertThat(createdPost).hasFieldOrPropertyWithValue("temperatureArrange",
generateTemperatureArrange()));
}
@ParameterizedTest(name = "[{index}] {0}")
@MethodSource("provideInvalidInfo")
- @DisplayName("from 메서드로 유효하지 않은 User, PostSaveRequest로부터 Post를 생성하는 것에 실패한다.")
- void createPostFail(String info, PostSaveRequest postSaveRequest, TemperatureArrange temperatureArrange) {
- // given // when, then
- assertThrows(IllegalArgumentException.class,
- () -> Post.from(MOCK_USER, postSaveRequest, IMG_URL, temperatureArrange));
+ @DisplayName("from 메서드로 유효하지 않은 PostSaveRequest로부터 Post를 생성하는 것에 실패한다.")
+ void createPostFailWithInvalidInfo(String info, PostSaveRequest postSaveRequest,
+ TemperatureArrange temperatureArrange,
+ String message) {
+ // given // when
+ ThrowingCallable createPost = () -> Post.from(MOCK_USER, postSaveRequest, IMG_URL, temperatureArrange);
+
+ // then
+ assertThatExceptionOfType(IllegalArgumentException.class)
+ .isThrownBy(createPost)
+ .withMessage(message);
+ }
+
+ @Test
+ @DisplayName("from 메서드로 null User로부터 Post를 생성하는 것에 실패한다.")
+ void createPostFailWithNullUser() {
+ // given
+ PostSaveRequest request = new PostSaveRequest("T".repeat(31), "Test Content", VALID_COORDINATE);
+
+ // when
+ ThrowingCallable createPost = () -> Post.from(null, request, IMG_URL, generateTemperatureArrange());
+
+ // then
+ assertThatExceptionOfType(IllegalArgumentException.class)
+ .isThrownBy(createPost)
+ .withMessage(NULL_WRITER);
}
}
diff --git a/src/test/java/com/backendoori/ootw/post/service/PostServiceTest.java b/src/test/java/com/backendoori/ootw/post/service/PostServiceTest.java
index e936972d..59f913a5 100644
--- a/src/test/java/com/backendoori/ootw/post/service/PostServiceTest.java
+++ b/src/test/java/com/backendoori/ootw/post/service/PostServiceTest.java
@@ -1,22 +1,38 @@
package com.backendoori.ootw.post.service;
+import static com.backendoori.ootw.post.validation.Message.BLANK_POST_CONTENT;
+import static com.backendoori.ootw.post.validation.Message.BLANK_POST_TITLE;
+import static com.backendoori.ootw.post.validation.Message.INVALID_POST_CONTENT;
+import static com.backendoori.ootw.post.validation.Message.INVALID_POST_TITLE;
+import static com.backendoori.ootw.post.validation.Message.NULL_REQUEST;
+import static com.backendoori.ootw.post.validation.Message.POST_NOT_FOUND;
+import static com.backendoori.ootw.util.provider.ForecastApiCommonRequestSourceProvider.VALID_COORDINATE;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.junit.jupiter.api.Assertions.assertAll;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.BDDMockito.given;
+import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
+import java.util.stream.Stream;
+import com.backendoori.ootw.common.image.ImageFile;
import com.backendoori.ootw.common.image.ImageService;
+import com.backendoori.ootw.common.image.exception.SaveException;
+import com.backendoori.ootw.exception.PermissionException;
import com.backendoori.ootw.exception.UserNotFoundException;
+import com.backendoori.ootw.like.repository.LikeRepository;
+import com.backendoori.ootw.like.service.LikeService;
import com.backendoori.ootw.post.domain.Post;
-import com.backendoori.ootw.post.dto.PostReadResponse;
-import com.backendoori.ootw.post.dto.PostSaveRequest;
-import com.backendoori.ootw.post.dto.PostSaveResponse;
-import com.backendoori.ootw.post.dto.WriterDto;
+import com.backendoori.ootw.post.dto.request.PostSaveRequest;
+import com.backendoori.ootw.post.dto.request.PostUpdateRequest;
+import com.backendoori.ootw.post.dto.response.PostReadResponse;
+import com.backendoori.ootw.post.dto.response.PostSaveUpdateResponse;
+import com.backendoori.ootw.post.dto.response.WriterDto;
import com.backendoori.ootw.post.repository.PostRepository;
import com.backendoori.ootw.user.domain.User;
import com.backendoori.ootw.user.repository.UserRepository;
@@ -26,7 +42,10 @@
import com.backendoori.ootw.weather.service.WeatherService;
import net.datafaker.Faker;
import org.assertj.core.api.ThrowableAssert.ThrowingCallable;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
@@ -34,34 +53,48 @@
import org.junit.jupiter.api.TestInstance;
import org.junit.jupiter.api.TestInstance.Lifecycle;
import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.NullSource;
-import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
+import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.authentication.TestingAuthenticationToken;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.test.context.TestSecurityContextHolder;
@SpringBootTest
@TestInstance(Lifecycle.PER_CLASS)
class PostServiceTest {
- static final int NX = 55;
- static final int NY = 127;
static final Faker FAKER = new Faker();
+ public static final String IMG_URL = "http://mock.server.com/filename.jpeg";
+ public static final String ORIGINAL_FILE_NAME = "filename.jpeg";
+ public static final String FILE_NAME = "filename";
+ public static final String TITLE = "TITLE";
+ public static final String CONTENT = "CONTENT";
User user;
@Autowired
private PostService postService;
+ @Autowired
+ private LikeRepository likeRepository;
+
@Autowired
private PostRepository postRepository;
@Autowired
private UserRepository userRepository;
+ @Autowired
+ private LikeService likeService;
+
@MockBean
private ImageService imageService;
@@ -70,47 +103,329 @@ class PostServiceTest {
@BeforeEach
void setup() {
+ likeRepository.deleteAll();
postRepository.deleteAll();
userRepository.deleteAll();
user = userRepository.save(generateUser());
-
setAuthentication(user.getId());
}
@AfterAll
void cleanup() {
+ likeRepository.deleteAll();
postRepository.deleteAll();
userRepository.deleteAll();
}
+
@Nested
- @DisplayName("게시글 저장 테스트")
- class SaveTest {
+ @DisplayName("게시글 삭제하기")
+ class DeleteTest {
+
+ Post userPost;
+ Post otherPost;
+
+ @BeforeEach
+ void setup() {
+ userPost = postRepository.save(
+ Post.from(user, new PostSaveRequest("title", "content", VALID_COORDINATE), null,
+ generateTemperatureArrange()));
+
+ User other = userRepository.save(generateUser());
+ otherPost = postRepository.save(
+ Post.from(other, new PostSaveRequest("title", "content", VALID_COORDINATE), null,
+ generateTemperatureArrange()));
+ }
+
+ @Test
+ @DisplayName("게시글 삭제에 성공한다.")
+ void deleteSuccess() {
+ // given // when // then
+ assertDoesNotThrow(() -> postService.delete(userPost.getId()));
+ }
@Test
+ @DisplayName("게시글 주인이 아닌 사용자가 게시글 삭제에 실패한다.")
+ void deleteFailWithNoPermission() {
+ // given // when
+ ThrowingCallable deletePost = () -> postService.delete(otherPost.getId());
+
+ // then
+ assertThatExceptionOfType(PermissionException.class)
+ .isThrownBy(deletePost)
+ .withMessage(PermissionException.DEFAULT_MESSAGE);
+ }
+
+ @Test
+ @DisplayName("존재하지 않는 게시글 삭제에 실패한다.")
+ void deleteFailWithNonExistPost() {
+ // given // when
+ ThrowingCallable deletePost = () -> postService.delete(otherPost.getId() + 1);
+
+ // then
+ assertThatExceptionOfType(NoSuchElementException.class)
+ .isThrownBy(deletePost)
+ .withMessage(POST_NOT_FOUND);
+ }
+
+ }
+
+ @NotNull
+ private static MockMultipartFile getPostImg(String originalFileName, String mediaType) {
+ return new MockMultipartFile(FILE_NAME, originalFileName, mediaType, "some xml".getBytes());
+ }
+
+ @Nested
+ @DisplayName("게시글 수정하기")
+ class UpdateTest {
+
+ Post userPost;
+ Post otherPost;
+
+ @BeforeEach
+ void setup() {
+ userPost = postRepository.save(
+ Post.from(user, new PostSaveRequest("title", "content", VALID_COORDINATE), null,
+ generateTemperatureArrange()));
+
+ User other = userRepository.save(generateUser());
+ otherPost = postRepository.save(
+ Post.from(other, new PostSaveRequest("title", "content", VALID_COORDINATE), null,
+ generateTemperatureArrange()));
+ }
+
+ @Nested
+ @DisplayName("게시글 수정에 성공한다")
+ class UpdateSuccess {
+
+ static Stream provideImageTypes() {
+ return Stream.of(
+ Arguments.of("image.jpeg", MediaType.IMAGE_JPEG_VALUE),
+ Arguments.of("image.gif", MediaType.IMAGE_GIF_VALUE),
+ Arguments.of("image.png", MediaType.IMAGE_PNG_VALUE)
+ );
+ }
+
+ @ParameterizedTest(name = "[{index}]: 아이템 타입이 {0}인 경우에 저장에 성공한다.")
+ @MethodSource("provideImageTypes")
+ @DisplayName(" 게시글 정보와 이미지 수정에 성공한다.")
+ void updateAllSuccess(String originalFileName, String mediaType) {
+ // given
+ PostUpdateRequest request = new PostUpdateRequest(TITLE, CONTENT);
+ MockMultipartFile postImg = getPostImg(originalFileName, mediaType);
+
+ given(imageService.upload(postImg)).willReturn(new ImageFile(IMG_URL, ORIGINAL_FILE_NAME));
+
+ // when
+ PostSaveUpdateResponse response = postService.update(userPost.getId(), postImg, request);
+
+ //then
+ assertAll(
+ () -> assertThat(response).hasFieldOrPropertyWithValue("title", request.title()),
+ () -> assertThat(response).hasFieldOrPropertyWithValue("content", request.content()),
+ () -> assertThat(response).hasFieldOrPropertyWithValue("image", IMG_URL)
+ );
+ }
+
+ @Test
+ @DisplayName("게시글 정보만 수정에 성공한다.")
+ void updatePostUpdateRequestSuccess() {
+ // given
+ PostUpdateRequest request = new PostUpdateRequest(TITLE, CONTENT);
+
+ // when
+ PostSaveUpdateResponse response = postService.update(userPost.getId(), null, request);
+
+ //then
+ assertAll(
+ () -> assertThat(response).hasFieldOrPropertyWithValue("title", request.title()),
+ () -> assertThat(response).hasFieldOrPropertyWithValue("content", request.content())
+ );
+ }
+
+ }
+
+ @Nested
+ @DisplayName("게시글 수정에 실패한다")
+ class UpdateFail {
+
+ static Stream provideInvalidFile() {
+ return Stream.of(
+ Arguments.of("file.md", MediaType.TEXT_MARKDOWN_VALUE),
+ Arguments.of("file.html", MediaType.TEXT_HTML_VALUE),
+ Arguments.of("file.pdf", MediaType.APPLICATION_PDF_VALUE),
+ Arguments.of("file.txt", MediaType.TEXT_PLAIN_VALUE)
+ );
+ }
+
+ static Stream provideInvalidPostUpdateRequest() {
+ return Stream.of(
+ Arguments.of("제목이 null인 경우", new PostUpdateRequest(null, "content"), BLANK_POST_TITLE),
+ Arguments.of("제목이 공백인 경우", new PostUpdateRequest(" ", "content"), BLANK_POST_TITLE),
+ Arguments.of("제목이 30자가 넘는 경우", new PostUpdateRequest("t".repeat(31), "content"),
+ INVALID_POST_TITLE),
+ Arguments.of("내용이 null인 경우", new PostUpdateRequest("title", null), BLANK_POST_CONTENT),
+ Arguments.of("내용이 공백인 경우", new PostUpdateRequest("title", " "), BLANK_POST_CONTENT),
+ Arguments.of("내용이 500자가 넘는 경우", new PostUpdateRequest("title", "t".repeat(501)),
+ INVALID_POST_CONTENT),
+ Arguments.of("제목과 내용이 모두 null인 경우", new PostUpdateRequest(null, null), BLANK_POST_TITLE),
+ Arguments.of("제목과 내용이 모두 공백인 경우", new PostUpdateRequest(" ", " "), BLANK_POST_TITLE)
+ );
+ }
+
+ @Test
+ @DisplayName("게시글 주인이 아닌 사용자가 게시글 수정에 실패한다.")
+ void updateFailWithPermission() {
+ // given
+ PostUpdateRequest request = new PostUpdateRequest(TITLE, CONTENT);
+ MockMultipartFile postImg = getPostImg(ORIGINAL_FILE_NAME, MediaType.IMAGE_JPEG_VALUE);
+
+ // when
+ ThrowingCallable updatePost = () -> postService.update(otherPost.getId(), postImg, request);
+
+ //then
+ assertThatExceptionOfType(PermissionException.class)
+ .isThrownBy(updatePost)
+ .withMessage(PermissionException.DEFAULT_MESSAGE);
+ }
+
+ @Test
+ @DisplayName("존재하지 않는 게시글 수정에 실패한다.")
+ void updateFailWithNonExistPost() {
+ // given
+ PostUpdateRequest request = new PostUpdateRequest(TITLE, CONTENT);
+ MockMultipartFile postImg = getPostImg(ORIGINAL_FILE_NAME, MediaType.IMAGE_JPEG_VALUE);
+
+ // when
+ ThrowingCallable updatePost = () -> postService.update(otherPost.getId() + 1, postImg, request);
+
+ //then
+ assertThatExceptionOfType(NoSuchElementException.class)
+ .isThrownBy(updatePost)
+ .withMessage(POST_NOT_FOUND);
+ }
+
+ @Test
+ @DisplayName("수정할 리소스를 전혀 보내지 않으면 실패한다.")
+ void updateFailWithNoResource() {
+ // given // when
+ ThrowingCallable updatePost = () -> postService.update(userPost.getId(), null, null);
+
+ //then
+ assertThatExceptionOfType(IllegalArgumentException.class)
+ .isThrownBy(updatePost)
+ .withMessage(NULL_REQUEST);
+ }
+
+ @ParameterizedTest(name = "[{index}] {0}")
+ @MethodSource("provideInvalidPostUpdateRequest")
+ @DisplayName("수정할 게시글 정보를 보냈는데 제목이나 내용이 유효하지 않으면 수정에 실패한다.")
+ void updateFailWithInvalidPostUpdateRequest(String testCase, PostUpdateRequest request, String message) {
+ // given // when
+ ThrowingCallable updatePost = () -> postService.update(userPost.getId(), null, request);
+
+ //then
+ assertThatExceptionOfType(IllegalArgumentException.class)
+ .isThrownBy(updatePost)
+ .withMessage(message);
+ }
+
+ @ParameterizedTest(name = "[{index}] 파일 타입이 {1}인 경우")
+ @MethodSource("provideInvalidFile")
+ @DisplayName("수정할 이미지를 보냈는데 이미지 파일이 유효하지 않으면 수정에 실패한다.")
+ void updateFailWithInvalidFileType(String originalFileName, String mediaType) {
+ // given
+ PostUpdateRequest request = new PostUpdateRequest(TITLE, CONTENT);
+ MockMultipartFile postImg = getPostImg(originalFileName, mediaType);
+ given(imageService.upload(postImg)).willThrow(IllegalArgumentException.class);
+
+ // when
+ ThrowingCallable updatePost = () -> postService.update(userPost.getId(), postImg, request);
+
+ //then
+ assertThatExceptionOfType(IllegalArgumentException.class)
+ .isThrownBy(updatePost);
+ }
+
+ }
+
+ }
+
+ @Nested
+ @DisplayName("게시글 저장하기")
+ class SaveTest {
+
+ static Stream provideImageTypes() {
+ return Stream.of(
+ Arguments.of("image.jpeg", MediaType.IMAGE_JPEG_VALUE),
+ Arguments.of("image.gif", MediaType.IMAGE_GIF_VALUE),
+ Arguments.of("image.png", MediaType.IMAGE_PNG_VALUE)
+ );
+ }
+
+ static Stream provideInvalidFile() {
+ return Stream.of(
+ Arguments.of("file.md", MediaType.TEXT_MARKDOWN_VALUE),
+ Arguments.of("file.html", MediaType.TEXT_HTML_VALUE),
+ Arguments.of("file.pdf", MediaType.APPLICATION_PDF_VALUE),
+ Arguments.of("file.txt", MediaType.TEXT_PLAIN_VALUE)
+ );
+ }
+
+ static Stream provideInvalidPostInfo() {
+ return Stream.of(
+ Arguments.of(new PostSaveRequest(null, CONTENT, VALID_COORDINATE)),
+ Arguments.of(new PostSaveRequest(TITLE, null, VALID_COORDINATE)),
+ Arguments.of(new PostSaveRequest("", CONTENT, VALID_COORDINATE)),
+ Arguments.of(new PostSaveRequest(TITLE, "", VALID_COORDINATE)),
+ Arguments.of(new PostSaveRequest(" ", CONTENT, VALID_COORDINATE)),
+ Arguments.of(new PostSaveRequest(TITLE, " ", VALID_COORDINATE)),
+ Arguments.of(new PostSaveRequest("a".repeat(40), CONTENT, VALID_COORDINATE)),
+ Arguments.of(new PostSaveRequest(TITLE, "a".repeat(600), VALID_COORDINATE))
+ );
+ }
+
+ @ParameterizedTest(name = "[{index}]: 아이템 타입이 {0}인 경우에 저장에 성공한다.")
+ @MethodSource("provideImageTypes")
@DisplayName("게시글 저장에 성공한다.")
- void saveSuccess() {
+ void saveSuccess(String originalFileName, String mediaType) {
// given
- PostSaveRequest request = new PostSaveRequest("Test Title", "Test Content", NX, NY);
- MockMultipartFile postImg = new MockMultipartFile("file", "filename.txt",
- "text/plain", "some xml".getBytes());
+ PostSaveRequest request = new PostSaveRequest(TITLE, CONTENT, VALID_COORDINATE);
+ MockMultipartFile postImg = getPostImg(originalFileName, mediaType);
- given(imageService.uploadImage(postImg)).willReturn("imgUrl");
- given(weatherService.getCurrentTemperatureArrange(request.nx(), request.ny())).willReturn(
+ given(imageService.upload(postImg)).willReturn(new ImageFile(IMG_URL, ORIGINAL_FILE_NAME));
+ given(weatherService.getCurrentTemperatureArrange(VALID_COORDINATE)).willReturn(
generateTemperatureArrange());
// when
- PostSaveResponse postSaveResponse = postService.save(request, postImg);
+ PostSaveUpdateResponse postSaveResponse = postService.save(request, postImg);
+
+ //then
+ assertThat(postSaveResponse).hasFieldOrPropertyWithValue("title", request.title());
+ assertThat(postSaveResponse).hasFieldOrPropertyWithValue("content", request.content());
+ assertThat(postSaveResponse).hasFieldOrPropertyWithValue("image",
+ imageService.upload(postImg).url());
+ assertThat(postSaveResponse).hasFieldOrPropertyWithValue("temperatureArrange",
+ TemperatureArrangeDto.from(generateTemperatureArrange()));
+ }
+
+ @Test
+ @DisplayName("게시글 정보만 저장에 성공한다.")
+ void updatePostUpdateRequestSuccess() {
+ // given
+ PostSaveRequest request = new PostSaveRequest(TITLE, CONTENT, VALID_COORDINATE);
+
+ given(weatherService.getCurrentTemperatureArrange(VALID_COORDINATE)).willReturn(
+ generateTemperatureArrange());
+
+ // when
+ PostSaveUpdateResponse response = postService.save(request, null);
//then
assertAll(
- () -> assertThat(postSaveResponse).hasFieldOrPropertyWithValue("title", request.title()),
- () -> assertThat(postSaveResponse).hasFieldOrPropertyWithValue("content", request.content()),
- () -> assertThat(postSaveResponse).hasFieldOrPropertyWithValue("image",
- imageService.uploadImage(postImg)),
- () -> assertThat(postSaveResponse).hasFieldOrPropertyWithValue("temperatureArrange",
- TemperatureArrangeDto.from(generateTemperatureArrange()))
+ () -> assertThat(response).hasFieldOrPropertyWithValue("title", request.title()),
+ () -> assertThat(response).hasFieldOrPropertyWithValue("content", request.content())
);
}
@@ -120,9 +435,8 @@ void saveFailUserNotFound() {
// given
setAuthentication(user.getId() + 1);
- PostSaveRequest postSaveRequest = new PostSaveRequest("Test Title", "Test Content", NX, NY);
- MockMultipartFile postImg = new MockMultipartFile("file", "filename.txt",
- "text/plain", "some xml".getBytes());
+ PostSaveRequest postSaveRequest = new PostSaveRequest(TITLE, CONTENT, VALID_COORDINATE);
+ MockMultipartFile postImg = getPostImg(ORIGINAL_FILE_NAME, MediaType.IMAGE_JPEG_VALUE);
// when
ThrowingCallable savePost = () -> postService.save(postSaveRequest, postImg);
@@ -133,16 +447,49 @@ void saveFailUserNotFound() {
.withMessage(UserNotFoundException.DEFAULT_MESSAGE);
}
- // TODO: 그 외 파라미터도 일일이 테스트 할까 고민!(일단 보류)
- @ParameterizedTest(name = "[{index}] 최저 기온이 {0}인 경우")
- @ValueSource(doubles = {-900.0, 900.0})
+ @ParameterizedTest(name = "[{index}] 제목이 {0}이고 내용이 {1}인 경우")
+ @MethodSource("provideInvalidPostInfo")
+ @DisplayName("유효하지 않은 값(게시글 정보)가 들어갈 경우 게시글 저장에 실패한다.")
+ void saveFailWithInvalidValue(@Nullable PostSaveRequest request) {
+ // given
+ MockMultipartFile postImg = getPostImg(ORIGINAL_FILE_NAME, MediaType.IMAGE_JPEG_VALUE);
+
+ given(imageService.upload(postImg)).willReturn(new ImageFile(IMG_URL, ORIGINAL_FILE_NAME));
+ given(weatherService.getCurrentTemperatureArrange(VALID_COORDINATE)).willReturn(
+ generateTemperatureArrange());
+
+ // when, then
+ assertThrows(SaveException.class,
+ () -> postService.save(request, postImg));
+ }
+
+ @ParameterizedTest
@NullSource
- @DisplayName("유효하지 않은 값(최저 기온)이 들어갈 경우 게시글 저장에 실패한다.")
- void saveFailInvalidValue(Double minTemperature) {
+ @DisplayName("게시글 정보가 null로 들어갈 경우 게시글 저장에 실패한다.")
+ void saveFailWithNullPostSaveRequest(PostSaveRequest request) {
// given
- PostSaveRequest postSaveRequest = new PostSaveRequest("Test Title", "Test Content", NX, NY);
- MockMultipartFile postImg = new MockMultipartFile("file", "filename.txt",
- "text/plain", "some xml".getBytes());
+ MockMultipartFile postImg = getPostImg(ORIGINAL_FILE_NAME, MediaType.IMAGE_JPEG_VALUE);
+
+ given(imageService.upload(postImg)).willReturn(new ImageFile(IMG_URL, ORIGINAL_FILE_NAME));
+ given(weatherService.getCurrentTemperatureArrange(VALID_COORDINATE)).willReturn(
+ generateTemperatureArrange());
+
+ // when, then
+ assertThrows(IllegalArgumentException.class,
+ () -> postService.save(request, postImg));
+ }
+
+ @ParameterizedTest(name = "[{index}] 파일 타입이 {1}인 경우")
+ @MethodSource("provideInvalidFile")
+ @DisplayName("수정할 이미지를 보냈는데 이미지 파일이 유효하지 않으면 수정에 실패한다.")
+ void updateFailWithInvalidFileType(String originalFileName, String mediaType) {
+ // given
+ given(weatherService.getCurrentTemperatureArrange(VALID_COORDINATE)).willReturn(
+ generateTemperatureArrange());
+
+ PostSaveRequest postSaveRequest = new PostSaveRequest(TITLE, CONTENT, VALID_COORDINATE);
+ MockMultipartFile postImg = getPostImg(originalFileName, mediaType);
+ given(imageService.upload(postImg)).willThrow(IllegalArgumentException.class);
// when, then
assertThrows(IllegalArgumentException.class,
@@ -153,22 +500,23 @@ void saveFailInvalidValue(Double minTemperature) {
@Nested
@DisplayName("게시글 단건 조회하기")
- class GetDetailByPostId {
+ class GetDetailByPostIdTest {
- PostSaveResponse postSaveResponse;
+ PostSaveUpdateResponse postSaveResponse;
@BeforeEach
void setUp() {
Post savedPost = postRepository.save(
- Post.from(user, new PostSaveRequest("Test Title", "Test Content", NX, NY), "imgUrl",
+ Post.from(user, new PostSaveRequest(TITLE, CONTENT, VALID_COORDINATE), IMG_URL,
generateTemperatureArrange()));
- postSaveResponse = PostSaveResponse.from(savedPost);
+ postSaveResponse = PostSaveUpdateResponse.from(savedPost);
}
@Test
@DisplayName("게시글 단건 조회에 성공한다.")
void getDetailByPostIdSuccess() {
// given
+ setAuthentication(user.getId());
WriterDto savedPostWriter = WriterDto.from(user);
// when
@@ -193,38 +541,99 @@ void getDetailByPostIdSuccess() {
@Test
@DisplayName("저장되지 않은 게시글 Id로 요청할 경우 게시글 단건 조회에 실패한다.")
void getDetailByPostIdFailNotSavedPost() {
- // given, when. then
- assertThrows(NoSuchElementException.class,
- () -> postService.getDetailByPostId(postSaveResponse.postId() + 1));
+ // given, when.
+ ThrowingCallable getDetailByPostId = () -> postService.getDetailByPostId(postSaveResponse.postId() + 1);
+
+ // then
+ assertThatExceptionOfType(NoSuchElementException.class)
+ .isThrownBy(getDetailByPostId)
+ .withMessage(POST_NOT_FOUND);
+ }
+
+ @Test
+ @DisplayName("로그인 후 좋아요 여부가 포함된 게시글 단건 조회에 성공한다.")
+ void getAllSuccessWithLogin() {
+ // given
+ List postList = postRepository.findAll();
+ setAuthentication(user.getId());
+
+ for (Post likePost : postList) {
+ likeService.requestLike(user.getId(), likePost.getId());
+ }
+
+ // when
+ PostReadResponse findPost = postService.getDetailByPostId(postSaveResponse.postId());
+
+ // then
+ assertThat(findPost.getIsLike()).isEqualTo(1);
+ assertThat(findPost.getLikeCnt()).isEqualTo(1);
+
+ }
+
+ @Test
+ @DisplayName("로그인은 했지만 좋아요를 누르지 않은 경우에도 게시글 단건 조회에 성공한다.")
+ void getAllSuccessWithLoginNoLike() {
+ // given
+ setAuthentication(user.getId());
+
+ // when
+ PostReadResponse findPost = postService.getDetailByPostId(postSaveResponse.postId());
+
+ // then
+ assertThat(findPost.getIsLike()).isEqualTo(0);
+ assertThat(findPost.getLikeCnt()).isEqualTo(0);
+ }
+
+ @Test
+ @DisplayName("로그인은 안했을 때 좋아요를 누르지 않은 경우에도 게시글 단건 조회에 성공한다.")
+ void getAllSuccessWithoutLogin() {
+ // given, when
+ setAnonymousAuthentication();
+ PostReadResponse findPost = postService.getDetailByPostId(postSaveResponse.postId());
+
+ // then
+ assertThat(findPost.getIsLike()).isEqualTo(0);
+ assertThat(findPost.getLikeCnt()).isEqualTo(0);
+
}
}
@Nested
@DisplayName("게시글 목록 조회하기")
- class GetAll {
+ class GetAllTest {
static final Integer SAVE_COUNT = 10;
@BeforeEach
void setUp() {
for (int i = 0; i < SAVE_COUNT; i++) {
- Post savedPost = postRepository.save(
- Post.from(user, new PostSaveRequest("Test Title", "Test Content", NX, NY), "imgUrl",
+ postRepository.save(
+ Post.from(user, new PostSaveRequest(TITLE, CONTENT, VALID_COORDINATE), IMG_URL,
generateTemperatureArrange()));
}
}
+ @AfterEach
+ void clearUp() {
+ for (int i = 0; i < SAVE_COUNT; i++) {
+ likeRepository.deleteAll();
+ postRepository.deleteAll();
+ userRepository.deleteAll();
+ }
+ }
+
@Test
@DisplayName("게시글 목록 최신순(default) 조회에 성공한다.")
void getAllSuccess() {
// given, when
+ setAnonymousAuthentication();
List posts = postService.getAll();
List expectedSortedPosts = posts.stream().sorted((post1, post2) -> {
- if (post1.createdAt().isAfter(post2.createdAt())) {
+ if (post1.getCreatedAt().isAfter(post2.getCreatedAt())) {
return -1;
}
- if (post1.createdAt().isBefore(post2.createdAt())) {
+ if (post1.getCreatedAt().isBefore(post2.getCreatedAt())) {
return 1;
}
return 0;
@@ -237,6 +646,68 @@ void getAllSuccess() {
);
}
+ @Test
+ @DisplayName("로그인 후 좋아요 여부가 포함된 게시글 목록 최신순 조회에 성공한다.")
+ void getAllSuccessWithLogin() {
+ // given
+ List postList = postRepository.findAll();
+
+ for (Post likePost : postList) {
+ likeService.requestLike(user.getId(), likePost.getId());
+ }
+
+ // when
+ List posts = postService.getAll();
+
+ // then
+ for (PostReadResponse response : posts) {
+ assertThat(response.getIsLike()).isEqualTo(1);
+ assertThat(response.getLikeCnt()).isEqualTo(1);
+ }
+
+ }
+
+ @Test
+ @DisplayName("로그인은 했지만 좋아요를 누르지 않은 경우에도 게시글 목록 최신순 조회에 성공한다.")
+ void getAllSuccessWithLoginNoLike() {
+ // given, when
+ List posts = postService.getAll();
+
+ // then
+ for (PostReadResponse response : posts) {
+ assertThat(response.getIsLike()).isEqualTo(0);
+ }
+
+ }
+
+ @Test
+ @DisplayName("다른 사람이 좋아요를 눌렀어도 로그인을 안한 경우에도 게시글 목록 최신순 조회에 성공한다.")
+ void getAllSuccessWithLikedPost() {
+ // given
+ List postList = postRepository.findAll();
+
+ for (Post likePost : postList) {
+ likeService.requestLike(user.getId(), likePost.getId());
+ }
+ setAnonymousAuthentication();
+
+ // when
+ List posts = postService.getAll();
+
+ // then
+ for (PostReadResponse response : posts) {
+ assertThat(response.getIsLike()).isEqualTo(0);
+ assertThat(response.getLikeCnt()).isEqualTo(1);
+ }
+
+ }
+
+ }
+
+ private static void setAnonymousAuthentication() {
+ SecurityContextHolder.getContext()
+ .setAuthentication(new AnonymousAuthenticationToken("key", "anonymousUser",
+ Collections.singleton(new SimpleGrantedAuthority("ROLE_ANONYMOUS"))));
}
private User generateUser() {
@@ -245,7 +716,8 @@ private User generateUser() {
.email(FAKER.internet().emailAddress())
.password(FAKER.internet().password())
.nickname(FAKER.internet().username())
- .image(FAKER.internet().url())
+ .profileImageUrl(FAKER.internet().url())
+ .certified(true)
.build();
}
diff --git a/src/test/java/com/backendoori/ootw/security/jwt/TokenProviderTest.java b/src/test/java/com/backendoori/ootw/security/jwt/TokenProviderTest.java
index e2a6e049..0b45a100 100644
--- a/src/test/java/com/backendoori/ootw/security/jwt/TokenProviderTest.java
+++ b/src/test/java/com/backendoori/ootw/security/jwt/TokenProviderTest.java
@@ -19,7 +19,7 @@
class TokenProviderTest {
- static Faker faker = new Faker();
+ static final Faker FAKER = new Faker();
String issuer;
SecretKey key;
@@ -29,10 +29,10 @@ class TokenProviderTest {
@BeforeEach
void setup() {
- issuer = faker.name().firstName();
+ issuer = FAKER.name().firstName();
key = generateSecretKey();
encodedKey = encodeBytes(key.getEncoded());
- tokenValidityInSeconds = faker.number().numberBetween(10, Integer.MAX_VALUE);
+ tokenValidityInSeconds = FAKER.number().numberBetween(10, Integer.MAX_VALUE);
tokenProvider = createTokenProvider(issuer, encodedKey, tokenValidityInSeconds);
}
@@ -40,7 +40,7 @@ void setup() {
@Test
void testCreateToken() {
// given
- long userId = faker.number().positive();
+ long userId = FAKER.number().positive();
// when
String token = tokenProvider.createToken(userId);
@@ -61,7 +61,7 @@ void testCreateToken() {
@Test
void testGetAuthentication() {
// given
- long userId = faker.number().positive();
+ long userId = FAKER.number().positive();
String token = tokenProvider.createToken(userId);
@@ -121,7 +121,7 @@ class ValidateTokenTest {
@Test
void success() {
// given
- long userId = faker.number().positive();
+ long userId = FAKER.number().positive();
String token = tokenProvider.createToken(userId);
@@ -135,7 +135,7 @@ void success() {
@DisplayName("잘못된 형식의 토큰은 false를 반환한다")
@Test
void failMalformed() {
- long userId = faker.number().positive();
+ long userId = FAKER.number().positive();
String token = tokenProvider.createToken(userId);
String malformed = token.replace(".", "..");
@@ -150,7 +150,7 @@ void failMalformed() {
@DisplayName("위조된 토큰은 false를 반환한다")
@Test
void failForged() {
- long userId = faker.number().positive();
+ long userId = FAKER.number().positive();
String token = tokenProvider.createToken(userId);
String forgedToken = forgeToken(token, userId);
@@ -166,9 +166,9 @@ void failForged() {
@Test
void failOtherIssuer() {
// given
- String otherIssuer = faker.name().firstName();
+ String otherIssuer = FAKER.name().firstName();
TokenProvider otherTokenProvider = createTokenProvider(otherIssuer, encodedKey, tokenValidityInSeconds);
- long userId = faker.number().positive();
+ long userId = FAKER.number().positive();
String otherToken = otherTokenProvider.createToken(userId);
@@ -185,7 +185,7 @@ void failSignedOtherKey() {
// given
String otherKey = encodeBytes(generateSecretKey().getEncoded());
TokenProvider otherTokenProvider = createTokenProvider(issuer, otherKey, tokenValidityInSeconds);
- long userId = faker.number().positive();
+ long userId = FAKER.number().positive();
String otherToken = otherTokenProvider.createToken(userId);
@@ -200,7 +200,7 @@ void failSignedOtherKey() {
@Test
void failUnsupported() {
// given
- long userId = faker.number().positive();
+ long userId = FAKER.number().positive();
String token = tokenProvider.createToken(userId);
String unSecuredToken = unSecureToken(token);
@@ -217,7 +217,7 @@ void failUnsupported() {
void failExpiredJwt() {
// given
tokenProvider = createTokenProvider(issuer, encodedKey, 0);
- long userId = faker.number().positive();
+ long userId = FAKER.number().positive();
String token = tokenProvider.createToken(userId);
// when
diff --git a/src/test/java/com/backendoori/ootw/user/controller/CertificateControllerTest.java b/src/test/java/com/backendoori/ootw/user/controller/CertificateControllerTest.java
new file mode 100644
index 00000000..5698ce60
--- /dev/null
+++ b/src/test/java/com/backendoori/ootw/user/controller/CertificateControllerTest.java
@@ -0,0 +1,231 @@
+package com.backendoori.ootw.user.controller;
+
+import static org.mockito.Mockito.doThrow;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import java.util.stream.Stream;
+import com.backendoori.ootw.exception.UserNotFoundException;
+import com.backendoori.ootw.security.jwt.TokenProvider;
+import com.backendoori.ootw.user.domain.Certificate;
+import com.backendoori.ootw.user.dto.CertifyDto;
+import com.backendoori.ootw.user.dto.SendCodeDto;
+import com.backendoori.ootw.user.exception.AlreadyCertifiedUserException;
+import com.backendoori.ootw.user.exception.IncorrectCertificateException;
+import com.backendoori.ootw.user.service.CertificateService;
+import net.datafaker.Faker;
+import org.apache.commons.lang3.RandomStringUtils;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtensionContext;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.ArgumentsProvider;
+import org.junit.jupiter.params.provider.ArgumentsSource;
+import org.junit.jupiter.params.provider.NullAndEmptySource;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.http.MediaType;
+import org.springframework.security.test.context.support.WithMockUser;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.ResultActions;
+import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
+
+@WithMockUser
+@WebMvcTest(CertificateController.class)
+class CertificateControllerTest {
+
+ static final Faker FAKER = new Faker();
+
+ @Autowired
+ MockMvc mockMvc;
+
+ @MockBean
+ CertificateService certificateService;
+ @MockBean
+ TokenProvider tokenProvider;
+
+ @DisplayName("인증 코드 발송 테스트")
+ @Nested
+ class CertificationTest {
+
+ SendCodeDto sendCodeDto;
+
+ @BeforeEach
+ void setSendCodeDto() {
+ sendCodeDto = new SendCodeDto(FAKER.internet().emailAddress());
+ }
+
+ @DisplayName("인증 코드 발송에 성공할 경우 200 status를 반환한다.")
+ @Test
+ void ok() throws Exception {
+ // given
+ MockHttpServletRequestBuilder requestBuilder = patch("/api/v1/auth/certificate")
+ .with(csrf())
+ .queryParam("email", sendCodeDto.email())
+ .contentType(MediaType.APPLICATION_JSON);
+
+ // when
+ ResultActions actions = mockMvc.perform(requestBuilder);
+
+ // then
+ actions.andExpect((status().isOk()));
+ }
+
+ @DisplayName("이미 인증된 사용자의 경우 208 status를 반환한다.")
+ @Test
+ void alreadyReported() throws Exception {
+ // given
+ MockHttpServletRequestBuilder requestBuilder = patch("/api/v1/auth/certificate")
+ .with(csrf())
+ .queryParam("email", sendCodeDto.email())
+ .contentType(MediaType.APPLICATION_JSON);
+
+ doThrow(AlreadyCertifiedUserException.class)
+ .when(certificateService)
+ .sendCode(sendCodeDto);
+
+ // when
+ ResultActions actions = mockMvc.perform(requestBuilder);
+
+ // then
+ actions.andExpect((status().isAlreadyReported()));
+ }
+
+ @DisplayName("이메일 형식이 올바르지 않은 경우 400 status를 반환한다.")
+ @NullAndEmptySource
+ @ArgumentsSource(InvalidEmailProvider.class)
+ @ParameterizedTest
+ void badRequest(String email) throws Exception {
+ // given
+ MockHttpServletRequestBuilder requestBuilder = patch("/api/v1/auth/certificate")
+ .with(csrf())
+ .queryParam("email", email)
+ .contentType(MediaType.APPLICATION_JSON);
+
+ // when
+ ResultActions actions = mockMvc.perform(requestBuilder);
+
+ // then
+ actions.andExpect((status().isBadRequest()));
+ }
+
+ static class InvalidEmailProvider implements ArgumentsProvider {
+
+ @Override
+ public Stream extends Arguments> provideArguments(ExtensionContext extensionContext) {
+ return Stream.of(
+ Arguments.of(FAKER.app().name()),
+ Arguments.of(FAKER.name().fullName()),
+ Arguments.of(FAKER.internet().url()),
+ Arguments.of(FAKER.internet().domainName()),
+ Arguments.of(FAKER.internet().webdomain()),
+ Arguments.of(FAKER.internet().botUserAgentAny())
+ );
+ }
+
+ }
+
+ }
+
+ @DisplayName("사용자 코드 인증 테스트")
+ @Nested
+ class CertifyTest {
+
+ CertifyDto certifyDto;
+
+ @BeforeEach
+ void setup() {
+ String email = FAKER.internet().safeEmailAddress();
+ String code = RandomStringUtils.randomAlphanumeric(Certificate.SIZE);
+
+ certifyDto = new CertifyDto(email, code);
+ }
+
+ @DisplayName("이메일 인증에 성공하면 200 status를 반환한다")
+ @Test
+ void ok() throws Exception {
+ // given
+ MockHttpServletRequestBuilder requestBuilder = patch("/api/v1/auth/certify")
+ .with(csrf())
+ .queryParam("email", certifyDto.email())
+ .queryParam("code", certifyDto.code())
+ .contentType(MediaType.APPLICATION_JSON);
+
+ // when
+ ResultActions actions = mockMvc.perform(requestBuilder);
+
+ // then
+ actions.andExpect((status().isOk()));
+ }
+
+ @DisplayName("존재하지 않는 사용자의 경우 404 status를 반환한다")
+ @Test
+ void notFound() throws Exception {
+ // given
+ MockHttpServletRequestBuilder requestBuilder = patch("/api/v1/auth/certify")
+ .with(csrf())
+ .queryParam("email", certifyDto.email())
+ .queryParam("code", certifyDto.code())
+ .contentType(MediaType.APPLICATION_JSON);
+
+ doThrow(UserNotFoundException.class)
+ .when(certificateService)
+ .certify(certifyDto);
+
+ // when
+ ResultActions actions = mockMvc.perform(requestBuilder);
+
+ // then
+ actions.andExpect((status().isNotFound()));
+ }
+
+ @DisplayName("이미 인증된 사용자의 경우 208 status를 반환한다")
+ @Test
+ void alreadyReported() throws Exception {
+ // given
+ MockHttpServletRequestBuilder requestBuilder = patch("/api/v1/auth/certify")
+ .with(csrf())
+ .queryParam("email", certifyDto.email())
+ .queryParam("code", certifyDto.code())
+ .contentType(MediaType.APPLICATION_JSON);
+
+ doThrow(AlreadyCertifiedUserException.class)
+ .when(certificateService)
+ .certify(certifyDto);
+
+ // when
+ ResultActions actions = mockMvc.perform(requestBuilder);
+
+ // then
+ actions.andExpect((status().isAlreadyReported()));
+ }
+
+ @DisplayName("인증 코드가 다른 경우 401 status를 반환한다")
+ @Test
+ void unauthorized() throws Exception {
+ // given
+ MockHttpServletRequestBuilder requestBuilder = patch("/api/v1/auth/certify")
+ .with(csrf())
+ .queryParam("email", certifyDto.email())
+ .queryParam("code", certifyDto.code())
+ .contentType(MediaType.APPLICATION_JSON);
+
+ doThrow(IncorrectCertificateException.class)
+ .when(certificateService)
+ .certify(certifyDto);
+
+ // when
+ ResultActions actions = mockMvc.perform(requestBuilder);
+
+ // then
+ actions.andExpect((status().isUnauthorized()));
+ }
+
+ }
+
+}
diff --git a/src/test/java/com/backendoori/ootw/user/controller/UserControllerTest.java b/src/test/java/com/backendoori/ootw/user/controller/UserControllerTest.java
index 3280063e..62f9ee58 100644
--- a/src/test/java/com/backendoori/ootw/user/controller/UserControllerTest.java
+++ b/src/test/java/com/backendoori/ootw/user/controller/UserControllerTest.java
@@ -1,24 +1,22 @@
package com.backendoori.ootw.user.controller;
import static org.hamcrest.Matchers.is;
-import static org.hamcrest.Matchers.startsWith;
import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.doThrow;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
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 java.time.LocalDateTime;
-import java.time.temporal.ChronoUnit;
import java.util.stream.Stream;
import com.backendoori.ootw.exception.UserNotFoundException;
import com.backendoori.ootw.security.jwt.TokenProvider;
import com.backendoori.ootw.user.dto.LoginDto;
import com.backendoori.ootw.user.dto.SignupDto;
import com.backendoori.ootw.user.dto.TokenDto;
-import com.backendoori.ootw.user.dto.UserDto;
import com.backendoori.ootw.user.exception.AlreadyExistEmailException;
import com.backendoori.ootw.user.exception.IncorrectPasswordException;
+import com.backendoori.ootw.user.exception.NonCertifiedUserException;
import com.backendoori.ootw.user.service.UserService;
import com.fasterxml.jackson.databind.ObjectMapper;
import net.datafaker.Faker;
@@ -43,7 +41,7 @@
@WebMvcTest(UserController.class)
class UserControllerTest {
- static Faker faker = new Faker();
+ static final Faker FAKER = new Faker();
@Autowired
MockMvc mockMvc;
@@ -64,9 +62,6 @@ class SignupTest {
void created() throws Exception {
// given
SignupDto signupDto = generateSignupDto();
- UserDto userDto = createUser(signupDto);
-
- given(userService.signup(signupDto)).willReturn(userDto);
// when
ResultActions actions = mockMvc.perform(
@@ -76,13 +71,7 @@ void created() throws Exception {
.content(objectMapper.writeValueAsString(signupDto)));
// then
- actions.andExpect(status().isCreated())
- .andExpect(jsonPath("$.id", is(userDto.id()), Long.class))
- .andExpect(jsonPath("$.email", is(userDto.email())))
- .andExpect(jsonPath("$.nickname", is(userDto.nickname())))
- .andExpect(jsonPath("$.image", is(userDto.image())))
- .andExpect(jsonPath("$.createdAt", startsWith(removeMills(userDto.createdAt()))))
- .andExpect(jsonPath("$.updatedAt", startsWith(removeMills(userDto.updatedAt()))));
+ actions.andExpect(status().isCreated());
}
@DisplayName("잘못된 형식의 email일 경우 400 status를 반환한다")
@@ -91,8 +80,8 @@ void created() throws Exception {
@ParameterizedTest
void badRequestInvalidEmail(String email) throws Exception {
// given
- String password = faker.internet().password(8, 30, true, true, true);
- String nickname = faker.internet().username();
+ String password = FAKER.internet().password(8, 30, true, true, true);
+ String nickname = FAKER.internet().username();
SignupDto signupDto = new SignupDto(email, password, nickname);
// when
@@ -112,8 +101,8 @@ void badRequestInvalidEmail(String email) throws Exception {
@ParameterizedTest
void badRequestInvalidPassword(String password) throws Exception {
// given
- String email = faker.internet().emailAddress();
- String nickname = faker.internet().username();
+ String email = FAKER.internet().emailAddress();
+ String nickname = FAKER.internet().username();
SignupDto signupDto = new SignupDto(email, password, nickname);
// when
@@ -132,8 +121,8 @@ void badRequestInvalidPassword(String password) throws Exception {
@ParameterizedTest
void badRequestBlankNickname(String nickname) throws Exception {
// given
- String email = faker.internet().emailAddress();
- String password = faker.internet().password(8, 30, true, true, true);
+ String email = FAKER.internet().emailAddress();
+ String password = FAKER.internet().password(8, 30, true, true, true);
SignupDto signupDto = new SignupDto(email, password, nickname);
// when
@@ -153,7 +142,9 @@ void unauthorizedAlreadyExistEmail() throws Exception {
// given
SignupDto signupDto = generateSignupDto();
- given(userService.signup(signupDto)).willThrow(new AlreadyExistEmailException());
+ doThrow(AlreadyExistEmailException.class)
+ .when(userService)
+ .signup(signupDto);
// when
ResultActions actions = mockMvc.perform(
@@ -177,7 +168,7 @@ class LoginTest {
void created() throws Exception {
// given
LoginDto loginDto = generateLoginDto();
- TokenDto tokenDto = new TokenDto(faker.hashing().sha512());
+ TokenDto tokenDto = new TokenDto(FAKER.hashing().sha512());
given(userService.login(loginDto)).willReturn(tokenDto);
@@ -199,7 +190,7 @@ void created() throws Exception {
@ParameterizedTest
void badRequestInvalidEmail(String email) throws Exception {
// given
- String password = faker.internet().password(8, 30, true, true, true);
+ String password = FAKER.internet().password(8, 30, true, true, true);
LoginDto loginDto = new LoginDto(email, password);
// when
@@ -213,6 +204,25 @@ void badRequestInvalidEmail(String email) throws Exception {
actions.andExpect(status().isBadRequest());
}
+ @DisplayName("이메일이 인증되지 않은 경우 403 status를 반환한다")
+ @Test
+ void nonCertifiedEmail() throws Exception {
+ // given
+ LoginDto loginDto = generateLoginDto();
+
+ given(userService.login(loginDto)).willThrow(new NonCertifiedUserException());
+
+ // when
+ ResultActions actions = mockMvc.perform(
+ post("/api/v1/auth/login")
+ .with(csrf())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(loginDto)));
+
+ // then
+ actions.andExpect(status().isForbidden());
+ }
+
@DisplayName("email이 일치하는 사용자가 없으면 404 status를 반환한다")
@Test
void unauthorizedNotExistUser() throws Exception {
@@ -238,7 +248,7 @@ void unauthorizedNotExistUser() throws Exception {
@ParameterizedTest
void badRequestInvalidPassword(String password) throws Exception {
// given
- String email = faker.internet().emailAddress();
+ String email = FAKER.internet().emailAddress();
LoginDto loginDto = new LoginDto(email, password);
// when
@@ -274,47 +284,31 @@ void unauthorizedIncorrectPassword() throws Exception {
}
private SignupDto generateSignupDto() {
- String email = faker.internet().emailAddress();
- String password = faker.internet().password(8, 30, true, true, true);
- String nickname = faker.internet().username();
+ String email = FAKER.internet().emailAddress();
+ String password = FAKER.internet().password(8, 30, true, true, true);
+ String nickname = FAKER.internet().username();
return new SignupDto(email, password, nickname);
}
- private UserDto createUser(SignupDto signupDto) {
- return UserDto.builder()
- .id((long) faker.number().positive())
- .email(signupDto.email())
- .nickname(signupDto.nickname())
- .image(signupDto.email())
- .createdAt(LocalDateTime.now())
- .updatedAt(LocalDateTime.now())
- .build();
-
- }
-
private LoginDto generateLoginDto() {
- String email = faker.internet().emailAddress();
- String password = faker.internet().password(8, 30, true, true, true);
+ String email = FAKER.internet().emailAddress();
+ String password = FAKER.internet().password(8, 30, true, true, true);
return new LoginDto(email, password);
}
- private String removeMills(LocalDateTime localDateTime) {
- return localDateTime.truncatedTo(ChronoUnit.SECONDS).toString();
- }
-
static class InvalidEmailProvider implements ArgumentsProvider {
@Override
public Stream extends Arguments> provideArguments(ExtensionContext extensionContext) {
return Stream.of(
- Arguments.of(faker.app().name()),
- Arguments.of(faker.name().fullName()),
- Arguments.of(faker.internet().url()),
- Arguments.of(faker.internet().domainName()),
- Arguments.of(faker.internet().webdomain()),
- Arguments.of(faker.internet().botUserAgentAny())
+ Arguments.of(FAKER.app().name()),
+ Arguments.of(FAKER.name().fullName()),
+ Arguments.of(FAKER.internet().url()),
+ Arguments.of(FAKER.internet().domainName()),
+ Arguments.of(FAKER.internet().webdomain()),
+ Arguments.of(FAKER.internet().botUserAgentAny())
);
}
@@ -325,10 +319,10 @@ static class InvalidPasswordProvider implements ArgumentsProvider {
@Override
public Stream extends Arguments> provideArguments(ExtensionContext extensionContext) {
return Stream.of(
- Arguments.of(faker.internet().password(1, 7, true, true, true)),
- Arguments.of(faker.internet().password(31, 50, true, true, true)),
- Arguments.of(faker.internet().password(8, 30, true, false, true)),
- Arguments.of(faker.internet().password(8, 30, true, true, false))
+ Arguments.of(FAKER.internet().password(1, 7, true, true, true)),
+ Arguments.of(FAKER.internet().password(31, 50, true, true, true)),
+ Arguments.of(FAKER.internet().password(8, 30, true, false, true)),
+ Arguments.of(FAKER.internet().password(8, 30, true, true, false))
);
}
diff --git a/src/test/java/com/backendoori/ootw/user/domain/UserTest.java b/src/test/java/com/backendoori/ootw/user/domain/UserTest.java
index 3f1d58d4..7692a20f 100644
--- a/src/test/java/com/backendoori/ootw/user/domain/UserTest.java
+++ b/src/test/java/com/backendoori/ootw/user/domain/UserTest.java
@@ -16,7 +16,7 @@
class UserTest {
- static final Faker faker = new Faker();
+ static final Faker FAKER = new Faker();
Long id;
String email;
@@ -26,11 +26,11 @@ class UserTest {
@BeforeEach
void setup() {
- id = (long) faker.number().positive();
- email = faker.internet().emailAddress();
- password = faker.internet().password();
- nickname = faker.internet().username();
- image = faker.internet().url();
+ id = (long) FAKER.number().positive();
+ email = FAKER.internet().emailAddress();
+ password = FAKER.internet().password();
+ nickname = FAKER.internet().username();
+ image = FAKER.internet().url();
}
@DisplayName("instance 생성에 성공한다.")
@@ -62,6 +62,21 @@ void testCreateInvalidEmail(String email) {
.withMessage(Message.INVALID_EMAIL);
}
+ @DisplayName("이메일이 255자를 초과하는 경우 생성에 실패한다.")
+ @Test
+ void testCreateTooLongEmail() {
+ // given
+ this.email = FAKER.natoPhoneticAlphabet().codeWord().repeat(65) + "@" + FAKER.internet().domainName();
+
+ // when
+ ThrowingCallable createUser = this::buildUser;
+
+ // then
+ assertThatIllegalArgumentException()
+ .isThrownBy(createUser)
+ .withMessage(Message.TOO_LONG_EMAIL);
+ }
+
@DisplayName("비밀번호가 공백인 경우 생성에 실패한다.")
@NullAndEmptySource
@ParameterizedTest
@@ -94,24 +109,40 @@ void testCreateBlankNickName(String nickname) {
.withMessage(Message.BLANK_NICKNAME);
}
+ @DisplayName("닉네임이 255자를 초과하는 경우 생성에 실패한다.")
+ @Test
+ void testCreateTooLongNickname() {
+ // given
+ this.nickname = FAKER.natoPhoneticAlphabet().codeWord().repeat(65);
+
+ // when
+ ThrowingCallable createUser = this::buildUser;
+
+ // then
+ assertThatIllegalArgumentException()
+ .isThrownBy(createUser)
+ .withMessage(Message.TOO_LONG_NICKNAME);
+ }
+
private static Stream generateInvalidEmails() {
return Stream.of(
- faker.app().name(),
- faker.name().fullName(),
- faker.internet().url(),
- faker.internet().domainName(),
- faker.internet().webdomain(),
- faker.internet().botUserAgentAny()
+ FAKER.app().name(),
+ FAKER.name().fullName(),
+ FAKER.internet().url(),
+ FAKER.internet().domainName(),
+ FAKER.internet().webdomain(),
+ FAKER.internet().botUserAgentAny()
);
}
- private User buildUser() {
- return User.builder()
+ private void buildUser() {
+ User.builder()
.id(id)
.email(email)
.password(password)
.nickname(nickname)
- .image(image)
+ .profileImageUrl(image)
+ .certified(false)
.build();
}
diff --git a/src/test/java/com/backendoori/ootw/user/service/CertificateServiceTest.java b/src/test/java/com/backendoori/ootw/user/service/CertificateServiceTest.java
new file mode 100644
index 00000000..5f3b963b
--- /dev/null
+++ b/src/test/java/com/backendoori/ootw/user/service/CertificateServiceTest.java
@@ -0,0 +1,201 @@
+package com.backendoori.ootw.user.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+
+import com.backendoori.ootw.common.MailTest;
+import com.backendoori.ootw.exception.UserNotFoundException;
+import com.backendoori.ootw.user.domain.Certificate;
+import com.backendoori.ootw.user.domain.User;
+import com.backendoori.ootw.user.dto.CertifyDto;
+import com.backendoori.ootw.user.dto.SendCodeDto;
+import com.backendoori.ootw.user.exception.AlreadyCertifiedUserException;
+import com.backendoori.ootw.user.exception.ExpiredCertificateException;
+import com.backendoori.ootw.user.exception.IncorrectCertificateException;
+import com.backendoori.ootw.user.repository.CertificateRedisRepository;
+import com.backendoori.ootw.user.repository.UserRepository;
+import com.icegreen.greenmail.util.GreenMailUtil;
+import net.datafaker.Faker;
+import org.apache.commons.lang3.RandomStringUtils;
+import org.assertj.core.api.ThrowableAssert.ThrowingCallable;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+
+@SpringBootTest
+class CertificateServiceTest extends MailTest {
+
+ static final Faker FAKER = new Faker();
+
+ @Autowired
+ CertificateService certificateService;
+ @Autowired
+ UserRepository userRepository;
+ @Autowired
+ CertificateRedisRepository certificateRedisRepository;
+
+ User user;
+
+ @BeforeEach
+ void setup() {
+ user = User.builder()
+ .id((long) FAKER.number().positive())
+ .email(SMTP_ADDRESS)
+ .password(FAKER.internet().password())
+ .nickname(FAKER.internet().username())
+ .profileImageUrl(FAKER.internet().url())
+ .certified(false)
+ .build();
+
+ userRepository.save(user);
+ }
+
+ @AfterEach
+ void cleanup() {
+ certificateRedisRepository.deleteById(user.getEmail());
+ userRepository.deleteById(user.getId());
+ }
+
+ @DisplayName("인증 코드 발송 테스트")
+ @Nested
+ class SendCodeTest {
+
+ SendCodeDto sendCodeDto;
+
+ @BeforeEach
+ void setSendCodeDto() {
+ sendCodeDto = new SendCodeDto(user.getEmail());
+ }
+
+ @DisplayName("사용자 이메일로 인증 코드를 보내는데 성공한다")
+ @Test
+ void success() {
+ // given // when
+ certificateService.sendCode(sendCodeDto);
+
+ // then
+ smtp.waitForIncomingEmail(30 * 1000L, 1);
+
+ String actualCode = GreenMailUtil.getBody(smtp.getReceivedMessages()[0]);
+ Certificate certificate = certificateRedisRepository.findById(user.getEmail())
+ .orElseThrow();
+
+ assertThat(actualCode).isEqualTo(certificate.getCode());
+ }
+
+ @DisplayName("이미 인증된 사용자의 경우 예외가 발생한다")
+ @Test
+ void failAlreadyCertified() {
+ // given
+ user.certify();
+ userRepository.save(user);
+
+ // when
+ ThrowingCallable sendCertificate = () -> certificateService.sendCode(sendCodeDto);
+
+ // then
+ assertThatExceptionOfType(AlreadyCertifiedUserException.class)
+ .isThrownBy(sendCertificate);
+ }
+
+ }
+
+ @DisplayName("코드 인증 테스트")
+ @Nested
+ class CertifyTest {
+
+ CertifyDto certifyDto;
+ Certificate certificate;
+
+ @BeforeEach
+ void setup() {
+ certifyDto = new CertifyDto(user.getEmail(), RandomStringUtils.randomAlphanumeric(Certificate.SIZE));
+ certificate = Certificate.builder()
+ .id(certifyDto.email())
+ .code(certifyDto.code())
+ .build();
+ }
+
+ @DisplayName("사용자 이메일 인증에 성공한다")
+ @Test
+ void success() {
+ // given
+ certificateRedisRepository.save(certificate);
+
+ // when
+ certificateService.certify(certifyDto);
+
+ // then
+ User actualUser = userRepository.findById(user.getId())
+ .orElseThrow();
+
+ assertThat(actualUser.isCertified()).isTrue();
+ }
+
+ @DisplayName("존재하지 않는 사용자에 대한 인증 요청은 예외가 발생한다")
+ @Test
+ void failUserNotFound() {
+ // given
+ String email = FAKER.animal().name() + "." + user.getEmail();
+ CertifyDto notExistUserIdDto = new CertifyDto(email, certifyDto.code());
+
+ // when
+ ThrowingCallable certify = () -> certificateService.certify(notExistUserIdDto);
+
+ // then
+ assertThatExceptionOfType(UserNotFoundException.class)
+ .isThrownBy(certify)
+ .withMessage(UserNotFoundException.DEFAULT_MESSAGE);
+ }
+
+ @DisplayName("이미 인증된 사용자에 대한 인증 요청 시 예외가 발생한다")
+ @Test
+ void failAlreadyCertified() {
+ // given
+ user.certify();
+ userRepository.save(user);
+
+ // when
+ ThrowingCallable certify = () -> certificateService.certify(certifyDto);
+
+ // then
+ assertThatExceptionOfType(AlreadyCertifiedUserException.class)
+ .isThrownBy(certify);
+ }
+
+ @DisplayName("인증 코드가 존재하지 않을 시 예외가 발생한다.")
+ @Test
+ void failCertificateNotFound() {
+ // given // when
+ ThrowingCallable certify = () -> certificateService.certify(certifyDto);
+
+ // then
+ assertThatExceptionOfType(ExpiredCertificateException.class)
+ .isThrownBy(certify);
+ }
+
+ @DisplayName("인증 코드가 다를 경우 예외가 발생한다")
+ @Test
+ void failIncorrectCertificate() {
+ // given
+ certificateRedisRepository.save(certificate);
+
+ String incorrectCode = RandomStringUtils.random(Certificate.SIZE);
+ CertifyDto incorrectCertificateDto = new CertifyDto(user.getEmail(), incorrectCode);
+
+ // when
+ ThrowingCallable certify = () -> certificateService.certify(incorrectCertificateDto);
+
+ // then
+ assertThatExceptionOfType(IncorrectCertificateException.class)
+ .isThrownBy(certify)
+ .withMessage(IncorrectCertificateException.DEFAULT_MESSAGE);
+ }
+
+ }
+
+}
diff --git a/src/test/java/com/backendoori/ootw/user/service/UserServiceTest.java b/src/test/java/com/backendoori/ootw/user/service/UserServiceTest.java
index 401f3c7b..43372b10 100644
--- a/src/test/java/com/backendoori/ootw/user/service/UserServiceTest.java
+++ b/src/test/java/com/backendoori/ootw/user/service/UserServiceTest.java
@@ -1,7 +1,6 @@
package com.backendoori.ootw.user.service;
import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatException;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.assertj.core.api.Assertions.assertThatNoException;
@@ -14,6 +13,8 @@
import com.backendoori.ootw.user.dto.TokenDto;
import com.backendoori.ootw.user.exception.AlreadyExistEmailException;
import com.backendoori.ootw.user.exception.IncorrectPasswordException;
+import com.backendoori.ootw.user.exception.NonCertifiedUserException;
+import com.backendoori.ootw.user.repository.CertificateRedisRepository;
import com.backendoori.ootw.user.repository.UserRepository;
import com.backendoori.ootw.user.validation.Message;
import net.datafaker.Faker;
@@ -26,29 +27,31 @@
import org.junit.jupiter.api.TestInstance;
import org.junit.jupiter.api.TestInstance.Lifecycle;
import org.junit.jupiter.params.ParameterizedTest;
-import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.crypto.password.PasswordEncoder;
-@SpringBootTest()
+@SpringBootTest
@TestInstance(Lifecycle.PER_CLASS)
class UserServiceTest {
- static Faker faker = new Faker();
+ static final Faker FAKER = new Faker();
- @Autowired
- UserRepository userRepository;
@Autowired
PasswordEncoder passwordEncoder;
@Autowired
+ CertificateRedisRepository certificateRedisRepository;
+ @Autowired
+ UserRepository userRepository;
+ @Autowired
UserService userService;
@BeforeAll
@AfterEach
void cleanup() {
+ certificateRedisRepository.deleteAll();
userRepository.deleteAll();
}
@@ -105,10 +108,10 @@ void failInvalidPassword(String password) {
private static Stream generateInvalidPasswords() {
return Stream.of(
- faker.internet().password(1, 7, true, true, true),
- faker.internet().password(31, 50, true, true, true),
- faker.internet().password(8, 30, true, false, true),
- faker.internet().password(8, 30, true, true, false)
+ FAKER.internet().password(1, 7, true, true, true),
+ FAKER.internet().password(31, 50, true, true, true),
+ FAKER.internet().password(8, 30, true, false, true),
+ FAKER.internet().password(8, 30, true, true, false)
);
}
@@ -122,8 +125,8 @@ class LoginTest {
@Test
void success() {
// given
- String password = faker.internet().password();
- User user = userRepository.save(generateUser(password));
+ String password = FAKER.internet().password();
+ User user = userRepository.save(generateUser(password, true));
LoginDto loginDto = new LoginDto(user.getEmail(), password);
// when
@@ -140,7 +143,7 @@ void success() {
@Test
void failUserNotFound() {
// given
- String password = faker.internet().password();
+ String password = FAKER.internet().password();
User user = generateUser(password);
LoginDto loginDto = new LoginDto(user.getEmail(), password + password);
@@ -157,8 +160,8 @@ void failUserNotFound() {
@Test
void failIncorrectPassword() {
// given
- String password = faker.internet().password();
- User user = userRepository.save(generateUser(password));
+ String password = FAKER.internet().password();
+ User user = userRepository.save(generateUser(password, true));
LoginDto loginDto = new LoginDto(user.getEmail(), password + password);
// when
@@ -170,25 +173,47 @@ void failIncorrectPassword() {
.withMessage(IncorrectPasswordException.DEFAULT_MESSAGE);
}
+ @DisplayName("이메일이 인증되지 않으면 로그인에 실패한다")
+ @Test
+ void failNonCertified() {
+ // given
+ String password = FAKER.internet().password();
+ User user = userRepository.save(generateUser(password, false));
+ LoginDto loginDto = new LoginDto(user.getEmail(), password + password);
+
+ // when
+ ThrowingCallable login = () -> userService.login(loginDto);
+
+ // then
+ assertThatExceptionOfType(NonCertifiedUserException.class)
+ .isThrownBy(login)
+ .withMessage(NonCertifiedUserException.DEFAULT_MESSAGE);
+ }
+
}
private SignupDto generateSignupDto() {
- return generateSignupDto(faker.internet().password(8, 30, true, true, true));
+ return generateSignupDto(FAKER.internet().password(8, 30, true, true, true));
}
private SignupDto generateSignupDto(String password) {
- String email = faker.internet().emailAddress();
- String nickname = faker.internet().username();
+ String email = FAKER.internet().emailAddress();
+ String nickname = FAKER.internet().username();
return new SignupDto(email, password, nickname);
}
private User generateUser(String password) {
+ return generateUser(password, false);
+ }
+
+ private User generateUser(String password, boolean certified) {
return User.builder()
- .email(faker.internet().emailAddress())
+ .email(FAKER.internet().emailAddress())
.password(passwordEncoder.encode(password))
- .nickname(faker.internet().username())
- .image(faker.internet().url())
+ .nickname(FAKER.internet().username())
+ .profileImageUrl(FAKER.internet().url())
+ .certified(certified)
.build();
}
diff --git a/src/test/java/com/backendoori/ootw/util/provider/ForecastApiCommonRequestSourceProvider.java b/src/test/java/com/backendoori/ootw/util/provider/ForecastApiCommonRequestSourceProvider.java
new file mode 100644
index 00000000..ab074755
--- /dev/null
+++ b/src/test/java/com/backendoori/ootw/util/provider/ForecastApiCommonRequestSourceProvider.java
@@ -0,0 +1,24 @@
+package com.backendoori.ootw.util.provider;
+
+import java.time.LocalDateTime;
+import com.backendoori.ootw.weather.domain.Coordinate;
+import com.backendoori.ootw.weather.dto.forecast.BaseDateTime;
+import com.backendoori.ootw.weather.util.BaseDateTimeCalculator;
+
+public class ForecastApiCommonRequestSourceProvider {
+
+ public static final Integer VALID_NX = 50;
+ public static final Integer VALID_NY = 127;
+ public static final Coordinate VALID_COORDINATE = new Coordinate(VALID_NX, VALID_NY);
+ public static final Integer NO_DATA_NX = 0;
+ public static final Integer NO_DATA_NY = 0;
+ public static final Coordinate NO_DATA_COORDINATE = new Coordinate(NO_DATA_NX, NO_DATA_NY);
+ public static final LocalDateTime DATETIME = LocalDateTime.of(2024, 1, 10, 14, 5);
+ public static final BaseDateTime ULTRA_SHORT_FORECAST_BASE_DATETIME =
+ BaseDateTimeCalculator.getUltraShortForecastRequestBaseDateTime(DATETIME);
+ public static final BaseDateTime VILLAGE_FORECAST_BASE_DATETIME =
+ BaseDateTimeCalculator.getVillageForecastRequestBaseDateTime(DATETIME);
+ public static final BaseDateTime FCST_DATETIME =
+ BaseDateTimeCalculator.getCurrentBaseDateTime(DATETIME);
+
+}
diff --git a/src/test/java/com/backendoori/ootw/util/provider/ForecastApiCommonResponseSourceProvider.java b/src/test/java/com/backendoori/ootw/util/provider/ForecastApiCommonResponseSourceProvider.java
new file mode 100644
index 00000000..cf5fd1a0
--- /dev/null
+++ b/src/test/java/com/backendoori/ootw/util/provider/ForecastApiCommonResponseSourceProvider.java
@@ -0,0 +1,32 @@
+package com.backendoori.ootw.util.provider;
+
+import static com.backendoori.ootw.util.provider.ForecastApiCommonRequestSourceProvider.FCST_DATETIME;
+import static com.backendoori.ootw.util.provider.ForecastApiCommonRequestSourceProvider.ULTRA_SHORT_FORECAST_BASE_DATETIME;
+import static com.backendoori.ootw.util.provider.ForecastApiCommonRequestSourceProvider.VALID_NX;
+import static com.backendoori.ootw.util.provider.ForecastApiCommonRequestSourceProvider.VALID_NY;
+import static com.backendoori.ootw.util.provider.ForecastApiUltraShortResponseSourceProvider.T1H_VALUE;
+
+import com.backendoori.ootw.weather.domain.forecast.ForecastCategory;
+import com.backendoori.ootw.weather.util.deserializer.ForecastResultItem;
+
+public class ForecastApiCommonResponseSourceProvider {
+
+ public static final String NO_DATA_FORECAST_RESPONSE =
+ "{\"response\":{\"header\":{\"resultCode\":\"03\",\"resultMsg\":\"NO_DATA\"}}}";
+ public static final String INVALID_PARAMETER_FORECAST_RESPONSE =
+ "{\"response\":{\"header\":{\"resultCode\":\"10\",\"resultMsg\":\"파라미터가 잘못되엇습니다.\"}}}";
+ public static final ForecastResultItem FORECAST_RESPONSE_WITH_ONE_ITEM = new ForecastResultItem(
+ ULTRA_SHORT_FORECAST_BASE_DATETIME,
+ FCST_DATETIME,
+ ForecastCategory.T1H.name(),
+ T1H_VALUE,
+ VALID_NX,
+ VALID_NY
+ );
+ public static final String ULTRA_SHORT_FORECAST_RESPONSE_WITH_ONE_ITEM_RESPONSE =
+ "{\"response\":{\"header\":{\"resultCode\":\"00\",\"resultMsg\":\"NORMAL_SERVICE\"},\"body\":{\"items\":{\"item\":[{\"baseDate\":\"20240110\",\"baseTime\":\"1305\",\"category\":\"T1H\",\"fcstDate\":\"20240110\",\"fcstTime\":\"1400\",\"fcstValue\":\"0.0\",\"nx\":\"50\",\"ny\":\"127\"}]}}}}";
+ public static final String VILLAGE_FORECAST_RESPONSE_WITH_ONE_ITEM_RESPONSE =
+ "{\"response\":{\"header\":{\"resultCode\":\"00\",\"resultMsg\":\"NORMAL_SERVICE\"},\"body\":{\"items\":{\"item\":[{\"baseDate\":\"20240110\",\"baseTime\":\"1305\",\"category\":\"T1H\",\"fcstDate\":\"20240110\",\"fcstTime\":\"1400\",\"fcstValue\":\"0.0\",\"nx\":\"50\",\"ny\":\"127\"}]}}}}";
+ public static final String INVALID_FORECAST_RESPONSE = "INVALID_FORECAST_RESPONSE";
+
+}
diff --git a/src/test/java/com/backendoori/ootw/util/provider/ForecastApiUltraShortResponseSourceProvider.java b/src/test/java/com/backendoori/ootw/util/provider/ForecastApiUltraShortResponseSourceProvider.java
new file mode 100644
index 00000000..08d25f31
--- /dev/null
+++ b/src/test/java/com/backendoori/ootw/util/provider/ForecastApiUltraShortResponseSourceProvider.java
@@ -0,0 +1,64 @@
+package com.backendoori.ootw.util.provider;
+
+import static com.backendoori.ootw.util.provider.ForecastApiCommonRequestSourceProvider.DATETIME;
+import static com.backendoori.ootw.util.provider.ForecastApiCommonRequestSourceProvider.FCST_DATETIME;
+import static com.backendoori.ootw.util.provider.ForecastApiCommonRequestSourceProvider.ULTRA_SHORT_FORECAST_BASE_DATETIME;
+import static com.backendoori.ootw.util.provider.ForecastApiCommonRequestSourceProvider.VALID_COORDINATE;
+import static com.backendoori.ootw.util.provider.ForecastApiCommonRequestSourceProvider.VALID_NX;
+import static com.backendoori.ootw.util.provider.ForecastApiCommonRequestSourceProvider.VALID_NY;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import com.backendoori.ootw.weather.domain.forecast.ForecastCategory;
+import com.backendoori.ootw.weather.dto.WeatherResponse;
+import com.backendoori.ootw.weather.util.deserializer.ForecastResultItem;
+
+public class ForecastApiUltraShortResponseSourceProvider {
+
+ public static final String T1H_VALUE = "0.0";
+ public static final String SKY_VALUE = "1";
+ public static final String PTY_VALUE = "1";
+ public static final List VALID_ULTRA_SHORT_FORECAST_ITEMS = List.of(
+ new ForecastResultItem(
+ ULTRA_SHORT_FORECAST_BASE_DATETIME,
+ FCST_DATETIME,
+ ForecastCategory.T1H.name(),
+ T1H_VALUE,
+ VALID_NX,
+ VALID_NY),
+ new ForecastResultItem(
+ ULTRA_SHORT_FORECAST_BASE_DATETIME,
+ FCST_DATETIME,
+ ForecastCategory.SKY.name(),
+ SKY_VALUE,
+ VALID_NX,
+ VALID_NY),
+ new ForecastResultItem(
+ ULTRA_SHORT_FORECAST_BASE_DATETIME,
+ FCST_DATETIME,
+ ForecastCategory.PTY.name(),
+ PTY_VALUE,
+ VALID_NX,
+ VALID_NY)
+ );
+
+ public static Map generateUltraShortWeatherInfoMap() {
+ Map weatherInfoMap = new HashMap<>();
+ weatherInfoMap.put(ForecastCategory.T1H, T1H_VALUE);
+ weatherInfoMap.put(ForecastCategory.SKY, SKY_VALUE);
+ weatherInfoMap.put(ForecastCategory.PTY, PTY_VALUE);
+
+ return weatherInfoMap;
+ }
+
+ public static WeatherResponse generateWeatherResponse() {
+ Map weatherInfoMap = new HashMap<>();
+ weatherInfoMap.put(ForecastCategory.T1H, T1H_VALUE);
+ weatherInfoMap.put(ForecastCategory.SKY, SKY_VALUE);
+ weatherInfoMap.put(ForecastCategory.PTY, PTY_VALUE);
+
+ return WeatherResponse.from(DATETIME, weatherInfoMap);
+ }
+
+}
diff --git a/src/test/java/com/backendoori/ootw/util/provider/ForecastApiVillageResponseSourceProvider.java b/src/test/java/com/backendoori/ootw/util/provider/ForecastApiVillageResponseSourceProvider.java
new file mode 100644
index 00000000..3ccc6265
--- /dev/null
+++ b/src/test/java/com/backendoori/ootw/util/provider/ForecastApiVillageResponseSourceProvider.java
@@ -0,0 +1,44 @@
+package com.backendoori.ootw.util.provider;
+
+import static com.backendoori.ootw.util.provider.ForecastApiCommonRequestSourceProvider.FCST_DATETIME;
+import static com.backendoori.ootw.util.provider.ForecastApiCommonRequestSourceProvider.VALID_NX;
+import static com.backendoori.ootw.util.provider.ForecastApiCommonRequestSourceProvider.VALID_NY;
+import static com.backendoori.ootw.util.provider.ForecastApiCommonRequestSourceProvider.VILLAGE_FORECAST_BASE_DATETIME;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import com.backendoori.ootw.weather.domain.forecast.ForecastCategory;
+import com.backendoori.ootw.weather.util.deserializer.ForecastResultItem;
+
+public class ForecastApiVillageResponseSourceProvider {
+
+ public static final String TMX_VALUE = "1.0";
+ public static final String TMN_VALUE = "-1.0";
+
+ public static final List VALID_VILLAGE_FORECAST_ITEMS = List.of(
+ new ForecastResultItem(
+ VILLAGE_FORECAST_BASE_DATETIME,
+ FCST_DATETIME,
+ ForecastCategory.TMN.name(),
+ TMN_VALUE,
+ VALID_NX,
+ VALID_NY),
+ new ForecastResultItem(
+ VILLAGE_FORECAST_BASE_DATETIME,
+ FCST_DATETIME,
+ ForecastCategory.TMX.name(),
+ TMX_VALUE,
+ VALID_NX,
+ VALID_NY)
+ );
+
+ public static Map generateVillageWeatherInfoMap() {
+ Map weatherInfoMap = new HashMap<>();
+ weatherInfoMap.put(ForecastCategory.TMN, TMN_VALUE);
+ weatherInfoMap.put(ForecastCategory.TMX, TMX_VALUE);
+
+ return weatherInfoMap;
+ }
+
+}
diff --git a/src/test/java/com/backendoori/ootw/weather/controller/WeatherControllerTest.java b/src/test/java/com/backendoori/ootw/weather/controller/WeatherControllerTest.java
index e13f6553..a0f9a7b5 100644
--- a/src/test/java/com/backendoori/ootw/weather/controller/WeatherControllerTest.java
+++ b/src/test/java/com/backendoori/ootw/weather/controller/WeatherControllerTest.java
@@ -1,15 +1,32 @@
package com.backendoori.ootw.weather.controller;
+import static com.backendoori.ootw.util.provider.ForecastApiCommonRequestSourceProvider.NO_DATA_COORDINATE;
+import static com.backendoori.ootw.util.provider.ForecastApiCommonRequestSourceProvider.VALID_COORDINATE;
+import static com.backendoori.ootw.util.provider.ForecastApiCommonRequestSourceProvider.VALID_NX;
+import static com.backendoori.ootw.util.provider.ForecastApiCommonRequestSourceProvider.VALID_NY;
+import static com.backendoori.ootw.util.provider.ForecastApiUltraShortResponseSourceProvider.generateWeatherResponse;
+import static com.backendoori.ootw.weather.validation.Message.INVALID_LOCATION_MESSAGE;
+import static org.hamcrest.Matchers.is;
+import static org.mockito.BDDMockito.given;
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.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+import java.util.NoSuchElementException;
+import java.util.stream.Stream;
+import com.backendoori.ootw.weather.domain.Coordinate;
+import com.backendoori.ootw.weather.service.WeatherService;
import net.datafaker.Faker;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
import 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.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;
@@ -26,17 +43,28 @@ class WeatherControllerTest {
@Autowired
MockMvc mockMvc;
+ @MockBean
+ WeatherService weatherService;
+
+ static Stream provideInvalidCoordinate() {
+ return Stream.of(
+ Arguments.of(new Coordinate(VALID_NX, FAKER.number().negative())),
+ Arguments.of(new Coordinate(FAKER.number().negative(), VALID_NY)),
+ Arguments.of(new Coordinate(VALID_NX, FAKER.number().numberBetween(1000, 10000))),
+ Arguments.of(new Coordinate(FAKER.number().numberBetween(1000, 10000), VALID_NY))
+ );
+ }
+
@Test
@DisplayName("현재 날씨 불러오기에 성공한다.")
void readCurrentWeatherSuccess() throws Exception {
// given
- Integer nx = 60;
- Integer ny = 127;
+ given(weatherService.getCurrentWeather(VALID_COORDINATE)).willReturn(generateWeatherResponse());
// when
MockHttpServletRequestBuilder requestBuilder = get(URL)
- .param("nx", String.valueOf(nx))
- .param("ny", String.valueOf(ny))
+ .param("nx", String.valueOf(VALID_COORDINATE.nx()))
+ .param("ny", String.valueOf(VALID_COORDINATE.ny()))
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON);
@@ -46,37 +74,50 @@ void readCurrentWeatherSuccess() throws Exception {
.andExpect(content().contentType(MediaType.APPLICATION_JSON));
}
- @Test
+ @ParameterizedTest
+ @MethodSource("provideInvalidCoordinate")
@DisplayName("유효하지 않은 위치 값으로 현재 날씨 불러오기에 실패한다.")
- void readCurrentWeatherFailByIllegalLocation() throws Exception {
+ void readCurrentWeatherFailByIllegalLocation(Coordinate invalidCoordinate) throws Exception {
// given
- Integer nx = FAKER.number().negative();
- Integer ny = FAKER.number().negative();
+ MockHttpServletRequestBuilder requestBuilder = get(URL)
+ .param("nx", String.valueOf(invalidCoordinate.nx()))
+ .param("ny", String.valueOf(invalidCoordinate.ny()))
+ .contentType(MediaType.APPLICATION_JSON)
+ .accept(MediaType.APPLICATION_JSON);
- // when
+ // when // then
+ mockMvc.perform(requestBuilder)
+ .andExpect(status().isBadRequest())
+ .andExpect(content().contentType(MediaType.APPLICATION_JSON))
+ .andExpect(jsonPath("$.message", is(INVALID_LOCATION_MESSAGE)));
+ }
+
+ @Test
+ @DisplayName("위치 값을 보내주지 않아 현재 날씨 불러오기에 실패한다.")
+ void readCurrentWeatherFailByNullLocation() throws Exception {
+ // given // when
MockHttpServletRequestBuilder requestBuilder = get(URL)
- .param("nx", String.valueOf(nx))
- .param("ny", String.valueOf(ny))
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON);
// then
mockMvc.perform(requestBuilder)
.andExpect(status().isBadRequest())
- .andExpect(content().contentType(MediaType.APPLICATION_JSON));
+ .andExpect(content().contentType(MediaType.APPLICATION_JSON))
+ .andExpect(jsonPath("$.message", is(INVALID_LOCATION_MESSAGE)));
}
@Test
@DisplayName("정보가 없는 위치 값으로 현재 날씨 불러오기에 실패한다.")
void readCurrentWeatherFailByNoData() throws Exception {
// given
- Integer nx = 0;
- Integer ny = 0;
+ given(weatherService.getCurrentWeather(NO_DATA_COORDINATE))
+ .willThrow(NoSuchElementException.class);
// when
MockHttpServletRequestBuilder requestBuilder = get(URL)
- .param("nx", String.valueOf(nx))
- .param("ny", String.valueOf(ny))
+ .param("nx", String.valueOf(NO_DATA_COORDINATE.nx()))
+ .param("ny", String.valueOf(NO_DATA_COORDINATE.ny()))
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON);
diff --git a/src/test/java/com/backendoori/ootw/weather/domain/PtyTypeTest.java b/src/test/java/com/backendoori/ootw/weather/domain/PtyTypeTest.java
index 3c9b9406..8cadd41a 100644
--- a/src/test/java/com/backendoori/ootw/weather/domain/PtyTypeTest.java
+++ b/src/test/java/com/backendoori/ootw/weather/domain/PtyTypeTest.java
@@ -1,8 +1,10 @@
package com.backendoori.ootw.weather.domain;
+import static com.backendoori.ootw.weather.validation.Message.CAN_NOT_RETRIEVE_PTYTYPE;
import static org.assertj.core.api.Assertions.assertThat;
-import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import org.assertj.core.api.ThrowableAssert.ThrowingCallable;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
@@ -16,8 +18,13 @@ class PtyTypeTest {
@ValueSource(ints = {-1, 100})
@NullSource
void getByCodeFail(Integer code) {
- // given, when, then
- assertThrows(IllegalArgumentException.class, () -> PtyType.getByCode(code));
+ // given // when
+ ThrowingCallable getByCode = () -> PtyType.getByCode(code);
+
+ // then
+ assertThatExceptionOfType(IllegalArgumentException.class)
+ .isThrownBy(getByCode)
+ .withMessage(CAN_NOT_RETRIEVE_PTYTYPE);
}
@DisplayName("강수 형태 코드가 유효한 값인 경우")
diff --git a/src/test/java/com/backendoori/ootw/weather/domain/SkyTypeTest.java b/src/test/java/com/backendoori/ootw/weather/domain/SkyTypeTest.java
index 8e319ca1..e507939d 100644
--- a/src/test/java/com/backendoori/ootw/weather/domain/SkyTypeTest.java
+++ b/src/test/java/com/backendoori/ootw/weather/domain/SkyTypeTest.java
@@ -1,8 +1,10 @@
package com.backendoori.ootw.weather.domain;
+import static com.backendoori.ootw.weather.validation.Message.CAN_NOT_RETRIEVE_SKYTYPE;
import static org.assertj.core.api.Assertions.assertThat;
-import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import org.assertj.core.api.ThrowableAssert.ThrowingCallable;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
@@ -16,8 +18,13 @@ class SkyTypeTest {
@ValueSource(ints = {-1, 0, 100})
@NullSource
void getByCodeFail(Integer code) {
- // given, when, then
- assertThrows(IllegalArgumentException.class, () -> SkyType.getByCode(code));
+ // given // when
+ ThrowingCallable getByCode = () -> SkyType.getByCode(code);
+
+ // then
+ assertThatExceptionOfType(IllegalArgumentException.class)
+ .isThrownBy(getByCode)
+ .withMessage(CAN_NOT_RETRIEVE_SKYTYPE);
}
@DisplayName("하늘 상태 코드가 유효한 값인 경우")
diff --git a/src/test/java/com/backendoori/ootw/weather/domain/TemperatureArrangeTest.java b/src/test/java/com/backendoori/ootw/weather/domain/TemperatureArrangeTest.java
index dd777d00..7fa50e81 100644
--- a/src/test/java/com/backendoori/ootw/weather/domain/TemperatureArrangeTest.java
+++ b/src/test/java/com/backendoori/ootw/weather/domain/TemperatureArrangeTest.java
@@ -1,13 +1,16 @@
package com.backendoori.ootw.weather.domain;
+import static com.backendoori.ootw.weather.validation.Message.CAN_NOT_RETRIEVE_TEMPERATURE_ARRANGE;
+import static com.backendoori.ootw.weather.validation.Message.CAN_NOT_USE_FORECAST_API;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.junit.jupiter.api.Assertions.assertAll;
-import static org.junit.jupiter.api.Assertions.assertThrows;
import java.util.HashMap;
import java.util.Map;
import java.util.stream.Stream;
import com.backendoori.ootw.weather.domain.forecast.ForecastCategory;
+import org.assertj.core.api.ThrowableAssert.ThrowingCallable;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
@@ -53,10 +56,32 @@ void createTemperatureArrangeSuccess() {
@ParameterizedTest
@MethodSource("provideInvalidWeatherInfoMap")
- @DisplayName("TMN, TMX가 포함되지 않은 결과 맵(map)으로부터 TemperatureArrange 생성에 성공한다.")
- void createTemperatureArrangeFail(Map weatherInfoMap) {
- // given // when // then
- assertThrows(IllegalStateException.class, () -> TemperatureArrange.from(weatherInfoMap));
+ @DisplayName("TMN, TMX가 포함되지 않은 결과 맵(map)으로부터 TemperatureArrange 생성에 실패한다.")
+ void createTemperatureArrangeFailWithInvalidValue(Map weatherInfoMap) {
+ // given // when
+ ThrowingCallable createTemperatureArrange = () -> TemperatureArrange.from(weatherInfoMap);
+
+ // then
+ assertThatExceptionOfType(IllegalStateException.class)
+ .isThrownBy(createTemperatureArrange)
+ .withMessage(CAN_NOT_USE_FORECAST_API);
+ }
+
+ @Test
+ @DisplayName("유효하지 않은 TMN, TMX가 포함된 결과 맵(map)으로부터 TemperatureArrange 생성에 실패한다.")
+ void createTemperatureArrangeFailWithInvalidArrange() {
+ // given
+ Map weatherInfoMap = new HashMap<>();
+ weatherInfoMap.put(ForecastCategory.TMN, "10.0");
+ weatherInfoMap.put(ForecastCategory.TMX, "0.0");
+
+ // when
+ ThrowingCallable createTemperatureArrange = () -> TemperatureArrange.from(weatherInfoMap);
+
+ // then
+ assertThatExceptionOfType(IllegalStateException.class)
+ .isThrownBy(createTemperatureArrange)
+ .withMessage(CAN_NOT_RETRIEVE_TEMPERATURE_ARRANGE);
}
}
diff --git a/src/test/java/com/backendoori/ootw/weather/dto/WeatherResponseTest.java b/src/test/java/com/backendoori/ootw/weather/dto/WeatherResponseTest.java
index 9669f307..395a388d 100644
--- a/src/test/java/com/backendoori/ootw/weather/dto/WeatherResponseTest.java
+++ b/src/test/java/com/backendoori/ootw/weather/dto/WeatherResponseTest.java
@@ -1,10 +1,11 @@
package com.backendoori.ootw.weather.dto;
+import static com.backendoori.ootw.util.provider.ForecastApiCommonRequestSourceProvider.DATETIME;
+import static com.backendoori.ootw.util.provider.ForecastApiCommonRequestSourceProvider.VALID_COORDINATE;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertThrows;
-import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
import java.util.stream.Stream;
@@ -19,10 +20,6 @@
class WeatherResponseTest {
- static final LocalDateTime DATE_TIME = LocalDateTime.of(2024, 1, 9, 0, 0);
- static final int NX = 55;
- static final int NY = 127;
-
private static Stream> provideInvalidWeatherInfoMap() {
HashMap weatherInfoMapWithOnlyT1h = new HashMap<>();
weatherInfoMapWithOnlyT1h.put(ForecastCategory.T1H, String.valueOf(0.0));
@@ -58,7 +55,7 @@ void createWeatherResponseSuccess() {
weatherInfoMap.put(ForecastCategory.PTY, String.valueOf(ptyCode));
// when // then
- WeatherResponse weatherResponse = WeatherResponse.from(DATE_TIME, NX, NY, weatherInfoMap);
+ WeatherResponse weatherResponse = WeatherResponse.from(DATETIME, weatherInfoMap);
assertAll(
() -> assertThat(weatherResponse).hasFieldOrPropertyWithValue("currentTemperature",
currentTemperature.getValue()),
@@ -72,7 +69,8 @@ void createWeatherResponseSuccess() {
@DisplayName("SKY, PTY, T1H가 포함되지 않은 결과 맵(map)으로부터 WeatherResponse 생성에 성공한다.")
void createWeatherResponseFail(Map weatherInfoMap) {
// given // when // then
- assertThrows(IllegalStateException.class, () -> WeatherResponse.from(DATE_TIME, NX, NY, weatherInfoMap));
+ assertThrows(IllegalStateException.class,
+ () -> WeatherResponse.from(DATETIME, weatherInfoMap));
}
}
diff --git a/src/test/java/com/backendoori/ootw/weather/exception/ForecastResultErrorManagerTest.java b/src/test/java/com/backendoori/ootw/weather/exception/ForecastResultErrorManagerTest.java
index 46c88575..08f2457f 100644
--- a/src/test/java/com/backendoori/ootw/weather/exception/ForecastResultErrorManagerTest.java
+++ b/src/test/java/com/backendoori/ootw/weather/exception/ForecastResultErrorManagerTest.java
@@ -1,11 +1,13 @@
package com.backendoori.ootw.weather.exception;
import static com.backendoori.ootw.weather.exception.ForecastResultErrorManager.checkResultCode;
+import static com.backendoori.ootw.weather.validation.Message.CAN_NOT_USE_FORECAST_API;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
-import static org.junit.jupiter.api.Assertions.assertThrows;
import java.util.NoSuchElementException;
import java.util.stream.Stream;
+import org.assertj.core.api.ThrowableAssert.ThrowingCallable;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
@@ -18,9 +20,11 @@ class ForecastResultErrorManagerTest {
private static Stream provideErrorCodeWithExceptionClass() {
return Stream.of(
- Arguments.of("01", IllegalArgumentException.class),
- Arguments.of("03", NoSuchElementException.class),
- Arguments.of("10", IllegalArgumentException.class));
+ Arguments.of("01", IllegalArgumentException.class, ForecastResultErrorManager.APPLICATION_ERROR.name()),
+ Arguments.of("03", NoSuchElementException.class, ForecastResultErrorManager.NODATA_ERROR.name()),
+ Arguments.of("10", IllegalArgumentException.class,
+ ForecastResultErrorManager.INVALID_REQUEST_PARAMETER_ERROR.name())
+ );
}
@Test
@@ -36,9 +40,14 @@ void checkResultNormalCodeSuccess() {
@ParameterizedTest(name = "[{index}] 코드가 {0}이면 {1}가 발생한다.")
@MethodSource("provideErrorCodeWithExceptionClass")
@DisplayName("정의된 에러 코드이면 명시된 예외가 발생한다.")
- void checkResultErrorCodeSuccess(String errorResultCode, Class exceptionClass) {
- // given // when // then
- assertThrows(exceptionClass, () -> checkResultCode(errorResultCode));
+ void checkResultErrorCodeSuccess(String errorResultCode, Class exceptionClass, String message) {
+ // given // when
+ ThrowingCallable checkResultCode = () -> checkResultCode(errorResultCode);
+
+ // then
+ assertThatExceptionOfType(exceptionClass)
+ .isThrownBy(checkResultCode)
+ .withMessage(message);
}
@ParameterizedTest(name = "[{index}] 코드가 {0}이면 IllegalStateException이 발생한다.")
@@ -46,8 +55,13 @@ void checkResultErrorCodeSuccess(String errorResultCode, Class
@NullAndEmptySource
@DisplayName("정의된 에러 코드가 아니면 IllegalStateException이 발생한다.")
void checkResultCodeFail(String notDefinedErrorResultCode) {
- // given // when // then
- assertThrows(IllegalStateException.class, () -> checkResultCode(notDefinedErrorResultCode));
+ // given // when
+ ThrowingCallable checkResultCode = () -> checkResultCode(notDefinedErrorResultCode);
+
+ // then
+ assertThatExceptionOfType(IllegalStateException.class)
+ .isThrownBy(checkResultCode)
+ .withMessage(CAN_NOT_USE_FORECAST_API);
}
}
diff --git a/src/test/java/com/backendoori/ootw/weather/service/WeatherServiceTest.java b/src/test/java/com/backendoori/ootw/weather/service/WeatherServiceTest.java
new file mode 100644
index 00000000..8ecac015
--- /dev/null
+++ b/src/test/java/com/backendoori/ootw/weather/service/WeatherServiceTest.java
@@ -0,0 +1,200 @@
+package com.backendoori.ootw.weather.service;
+
+import static com.backendoori.ootw.util.provider.ForecastApiCommonRequestSourceProvider.DATETIME;
+import static com.backendoori.ootw.util.provider.ForecastApiCommonRequestSourceProvider.NO_DATA_COORDINATE;
+import static com.backendoori.ootw.util.provider.ForecastApiCommonRequestSourceProvider.ULTRA_SHORT_FORECAST_BASE_DATETIME;
+import static com.backendoori.ootw.util.provider.ForecastApiCommonRequestSourceProvider.VALID_COORDINATE;
+import static com.backendoori.ootw.util.provider.ForecastApiCommonRequestSourceProvider.VILLAGE_FORECAST_BASE_DATETIME;
+import static com.backendoori.ootw.util.provider.ForecastApiUltraShortResponseSourceProvider.VALID_ULTRA_SHORT_FORECAST_ITEMS;
+import static com.backendoori.ootw.util.provider.ForecastApiUltraShortResponseSourceProvider.generateUltraShortWeatherInfoMap;
+import static com.backendoori.ootw.util.provider.ForecastApiVillageResponseSourceProvider.VALID_VILLAGE_FORECAST_ITEMS;
+import static com.backendoori.ootw.util.provider.ForecastApiVillageResponseSourceProvider.generateVillageWeatherInfoMap;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.mockito.BDDMockito.given;
+
+import java.util.Map;
+import java.util.NoSuchElementException;
+import com.backendoori.ootw.weather.domain.Coordinate;
+import com.backendoori.ootw.weather.domain.TemperatureArrange;
+import com.backendoori.ootw.weather.domain.forecast.ForecastCategory;
+import com.backendoori.ootw.weather.dto.WeatherResponse;
+import com.backendoori.ootw.weather.util.DateTimeProvider;
+import com.backendoori.ootw.weather.util.client.ForecastApiClient;
+import net.datafaker.Faker;
+import org.assertj.core.api.ThrowableAssert.ThrowingCallable;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+
+@SpringBootTest
+class WeatherServiceTest {
+
+ static final Faker FAKER = new Faker();
+ static final Coordinate invalidLocation = new Coordinate(FAKER.number().negative(), FAKER.number().negative());
+
+ @MockBean
+ DateTimeProvider dateTimeProvider;
+
+ @MockBean
+ ForecastApiClient forecastApiClient;
+
+ @Autowired
+ WeatherService weatherService;
+
+ @BeforeEach
+ void setup() {
+ given(dateTimeProvider.now()).willReturn(DATETIME);
+ }
+
+ @Nested
+ @DisplayName("현재 날씨 정보 조회")
+ class GetCurrentWeatherTest {
+
+ @Test
+ @DisplayName("유효한 위치 정보로 현재 날씨 정보 조회에 성공한다.")
+ void getCurrentWeatherSuccess() {
+ // given
+ Map weatherInfoMap = generateUltraShortWeatherInfoMap();
+ WeatherResponse expectedResponse = WeatherResponse.from(DATETIME, weatherInfoMap);
+
+ given(
+ forecastApiClient.requestUltraShortForecastItems(ULTRA_SHORT_FORECAST_BASE_DATETIME, VALID_COORDINATE))
+ .willReturn(VALID_ULTRA_SHORT_FORECAST_ITEMS);
+
+ // when
+ WeatherResponse response = weatherService.getCurrentWeather(VALID_COORDINATE);
+
+ // then
+ assertThat(response).isEqualTo(expectedResponse);
+
+ }
+
+ @Test
+ @DisplayName("위치에 해당하는 날씨 데이터가 없어서 현재 날씨 정보 조회에 실패한다.")
+ void getCurrentWeatherFailWithNoData() {
+ // given
+ given(
+ forecastApiClient.requestUltraShortForecastItems(ULTRA_SHORT_FORECAST_BASE_DATETIME,
+ NO_DATA_COORDINATE))
+ .willThrow(NoSuchElementException.class);
+
+ // when
+ ThrowingCallable requestCurrentWeather = () -> weatherService.getCurrentWeather(NO_DATA_COORDINATE);
+
+ // then
+ assertThatExceptionOfType(NoSuchElementException.class)
+ .isThrownBy(requestCurrentWeather);
+ }
+
+ @Test
+ @DisplayName("파라미터 값이 유효하지 않아 현재 날씨 정보 조회에 실패한다.")
+ void getCurrentWeatherFailWithInvalidParameter() {
+ // given
+ given(forecastApiClient.requestUltraShortForecastItems(ULTRA_SHORT_FORECAST_BASE_DATETIME, invalidLocation))
+ .willThrow(IllegalArgumentException.class);
+
+ // when
+ ThrowingCallable requestCurrentWeather = () -> weatherService.getCurrentWeather(invalidLocation);
+
+ // then
+ assertThatExceptionOfType(IllegalArgumentException.class)
+ .isThrownBy(requestCurrentWeather);
+ }
+
+ @Test
+ @DisplayName("기상청 API에서 기타 오류가 발생한 경우 현재 날씨 정보 조회에 실패한다.")
+ void getCurrentWeatherFailWithApiServerError() {
+ // given
+ given(
+ forecastApiClient.requestUltraShortForecastItems(ULTRA_SHORT_FORECAST_BASE_DATETIME, VALID_COORDINATE))
+ .willThrow(IllegalStateException.class);
+
+ // when
+ ThrowingCallable requestCurrentWeather = () -> weatherService.getCurrentWeather(VALID_COORDINATE);
+
+ // then
+ assertThatExceptionOfType(IllegalStateException.class)
+ .isThrownBy(requestCurrentWeather);
+ }
+
+ }
+
+ @Nested
+ @DisplayName("오늘의 일교차 조회")
+ class GetCurrentTemperatureArrangeTest {
+
+ @Test
+ @DisplayName("유효한 위치 정보로 오늘의 일교차 정보 조회에 성공한다.")
+ void getCurrentTemperatureArrangeSuccess() {
+ // given
+ Map weatherInfoMap = generateVillageWeatherInfoMap();
+ TemperatureArrange expectedTemperatureArrange = TemperatureArrange.from(weatherInfoMap);
+
+ given(forecastApiClient.requestVillageForecastItems(VILLAGE_FORECAST_BASE_DATETIME, VALID_COORDINATE))
+ .willReturn(VALID_VILLAGE_FORECAST_ITEMS);
+
+ // when
+ TemperatureArrange temperatureArrange = weatherService.getCurrentTemperatureArrange(VALID_COORDINATE);
+
+ // then
+ assertThat(temperatureArrange).isEqualTo(expectedTemperatureArrange);
+ }
+
+ @Test
+ @DisplayName("위치에 해당하는 날씨 데이터가 없어서 오늘의 일교차 정보 조회에 실패한다.")
+ void getCurrentTemperatureArrangeFailWithNoData() {
+ // given
+ given(forecastApiClient.requestVillageForecastItems(VILLAGE_FORECAST_BASE_DATETIME, NO_DATA_COORDINATE))
+ .willThrow(NoSuchElementException.class);
+
+ // when
+ ThrowingCallable requestCurrentTemperatureArrange = () ->
+ weatherService.getCurrentTemperatureArrange(NO_DATA_COORDINATE);
+
+ // then
+ assertThatExceptionOfType(NoSuchElementException.class)
+ .isThrownBy(requestCurrentTemperatureArrange);
+ }
+
+ @Test
+ @DisplayName("파라미터 값이 유효하지 않아 오늘의 일교차 정보 조회에 실패한다.")
+ void getCurrentTemperatureArrangeFailWithInvalidParameter() {
+ // given
+ given(
+ forecastApiClient.requestVillageForecastItems(VILLAGE_FORECAST_BASE_DATETIME, invalidLocation))
+ .willThrow(IllegalArgumentException.class);
+
+ // when
+ ThrowingCallable requestCurrentTemperatureArrange = () ->
+ weatherService.getCurrentTemperatureArrange(invalidLocation);
+
+ // then
+ assertThatExceptionOfType(IllegalArgumentException.class)
+ .isThrownBy(requestCurrentTemperatureArrange);
+ }
+
+ @Test
+ @DisplayName("기상청 API에서 기타 오류가 발생한 경우 오늘의 일교차 정보 조회에 실패한다.")
+ void getCurrentTemperatureArrangeFailWithApiServerError() {
+ // given
+ given(forecastApiClient.requestVillageForecastItems(VILLAGE_FORECAST_BASE_DATETIME, VALID_COORDINATE))
+ .willThrow(IllegalStateException.class);
+
+ // when
+ ThrowingCallable
+ requestCurrentTemperatureArrange = () ->
+ weatherService.getCurrentTemperatureArrange(VALID_COORDINATE);
+
+ // then
+ assertThatExceptionOfType(IllegalStateException.class)
+ .isThrownBy(requestCurrentTemperatureArrange);
+ }
+
+ }
+
+}
diff --git a/src/test/java/com/backendoori/ootw/weather/util/DateTimeProviderTest.java b/src/test/java/com/backendoori/ootw/weather/util/DateTimeProviderTest.java
new file mode 100644
index 00000000..18467113
--- /dev/null
+++ b/src/test/java/com/backendoori/ootw/weather/util/DateTimeProviderTest.java
@@ -0,0 +1,21 @@
+package com.backendoori.ootw.weather.util;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.within;
+
+import java.time.LocalDateTime;
+import java.time.temporal.ChronoUnit;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+class DateTimeProviderTest {
+
+ DateTimeProvider dateTimeProvider = new DateTimeProvider();
+
+ @Test
+ @DisplayName("현재 시간을 불러오는 데 성공하다.")
+ void nowSuccess() {
+ assertThat(dateTimeProvider.now()).isCloseTo(LocalDateTime.now(), within(1, ChronoUnit.SECONDS));
+ }
+
+}
diff --git a/src/test/java/com/backendoori/ootw/weather/util/client/ForecastApiClientTest.java b/src/test/java/com/backendoori/ootw/weather/util/client/ForecastApiClientTest.java
index f5b9b290..1f15eadf 100644
--- a/src/test/java/com/backendoori/ootw/weather/util/client/ForecastApiClientTest.java
+++ b/src/test/java/com/backendoori/ootw/weather/util/client/ForecastApiClientTest.java
@@ -1,83 +1,207 @@
package com.backendoori.ootw.weather.util.client;
+import static com.backendoori.ootw.util.provider.ForecastApiCommonRequestSourceProvider.NO_DATA_COORDINATE;
+import static com.backendoori.ootw.util.provider.ForecastApiCommonRequestSourceProvider.ULTRA_SHORT_FORECAST_BASE_DATETIME;
+import static com.backendoori.ootw.util.provider.ForecastApiCommonRequestSourceProvider.VALID_COORDINATE;
+import static com.backendoori.ootw.util.provider.ForecastApiCommonRequestSourceProvider.VALID_NX;
+import static com.backendoori.ootw.util.provider.ForecastApiCommonRequestSourceProvider.VALID_NY;
+import static com.backendoori.ootw.util.provider.ForecastApiCommonRequestSourceProvider.VILLAGE_FORECAST_BASE_DATETIME;
+import static com.backendoori.ootw.util.provider.ForecastApiCommonResponseSourceProvider.FORECAST_RESPONSE_WITH_ONE_ITEM;
+import static com.backendoori.ootw.util.provider.ForecastApiCommonResponseSourceProvider.INVALID_FORECAST_RESPONSE;
+import static com.backendoori.ootw.util.provider.ForecastApiCommonResponseSourceProvider.INVALID_PARAMETER_FORECAST_RESPONSE;
+import static com.backendoori.ootw.util.provider.ForecastApiCommonResponseSourceProvider.NO_DATA_FORECAST_RESPONSE;
+import static com.backendoori.ootw.util.provider.ForecastApiCommonResponseSourceProvider.ULTRA_SHORT_FORECAST_RESPONSE_WITH_ONE_ITEM_RESPONSE;
+import static com.backendoori.ootw.util.provider.ForecastApiCommonResponseSourceProvider.VILLAGE_FORECAST_RESPONSE_WITH_ONE_ITEM_RESPONSE;
+import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.given;
-import java.time.LocalDateTime;
+import java.util.List;
import java.util.NoSuchElementException;
import java.util.stream.Stream;
-import com.backendoori.ootw.weather.dto.forecast.BaseDateTime;
-import com.backendoori.ootw.weather.util.BaseDateTimeCalculator;
+import com.backendoori.ootw.weather.domain.Coordinate;
+import com.backendoori.ootw.weather.util.deserializer.ForecastResultItem;
import net.datafaker.Faker;
import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
@SpringBootTest
class ForecastApiClientTest {
- static final Integer VALID_NX = 50;
- static final Integer VALID_NY = 127;
- static final BaseDateTime TEMP_BASE_DATETIME =
- BaseDateTimeCalculator.getUltraShortForecastRequestBaseDateTime(LocalDateTime.now());
static final Faker FAKER = new Faker();
- @Autowired
+ @MockBean
ForecastApi forecastApi;
+
@Autowired
ForecastApiClient forecastApiClient;
- static Stream provideInvalidRange() {
- return Stream.of(
- Arguments.of(FAKER.number().negative(), VALID_NY),
- Arguments.of(VALID_NX, FAKER.number().negative()),
- Arguments.of(FAKER.number().numberBetween(1000, 10000), VALID_NY),
- Arguments.of(VALID_NX, FAKER.number().numberBetween(1000, 10000)));
- }
+ @Nested
+ @DisplayName("현재 초단기예보 불러오기")
+ class RequestUltraShortForecastItems {
- @Test
- @DisplayName("정보가 없는 위치 값으로 현재 초단기예보 불러오기에 실패한다.")
- void requestUltraShortForecastItemsFailByNoSuchElementException() {
- // given
- int nx = 0;
- int ny = 0;
+ static Stream provideInvalidCoordinate() {
+ return Stream.of(
+ Arguments.of(new Coordinate(VALID_NX, FAKER.number().negative())),
+ Arguments.of(new Coordinate(FAKER.number().negative(), VALID_NY)),
+ Arguments.of(new Coordinate(VALID_NX, FAKER.number().numberBetween(1000, 10000))),
+ Arguments.of(new Coordinate(FAKER.number().numberBetween(1000, 10000), VALID_NY))
+ );
+ }
- // when // then
- assertThrows(NoSuchElementException.class,
- () -> forecastApiClient.requestUltraShortForecastItems(TEMP_BASE_DATETIME, nx, ny));
- }
+ void givenUltraShortForecastApiResponse(Coordinate coordinate, String response) {
+ given(forecastApi.getUltraShortForecast(
+ anyString(),
+ anyInt(),
+ anyInt(),
+ anyString(),
+ eq(ULTRA_SHORT_FORECAST_BASE_DATETIME.baseDate()),
+ eq(ULTRA_SHORT_FORECAST_BASE_DATETIME.baseTime()),
+ eq(coordinate.nx()),
+ eq(coordinate.ny())))
+ .willReturn(response);
+ }
- @ParameterizedTest
- @MethodSource("provideInvalidRange")
- @DisplayName("유효하지 않은 파라미터 범위로 현재 초단기예보 불러오기에 실패한다.")
- void requestUltraShortForecastItemsFailByIllegalRange(Integer nx, Integer ny) {
- // given // when // then
- assertThrows(IllegalArgumentException.class,
- () -> forecastApiClient.requestUltraShortForecastItems(TEMP_BASE_DATETIME, nx, ny));
- }
+ @Test
+ @DisplayName("초단기예보를 불러오고 응답 파싱에 성공한다.")
+ void requestUltraShortForecastItemsSuccess() {
+ // given
+ givenUltraShortForecastApiResponse(VALID_COORDINATE, ULTRA_SHORT_FORECAST_RESPONSE_WITH_ONE_ITEM_RESPONSE);
+
+ // when
+ List resultItems = forecastApiClient
+ .requestUltraShortForecastItems(ULTRA_SHORT_FORECAST_BASE_DATETIME, VALID_COORDINATE);
+
+ // then
+ assertThat(resultItems).contains(FORECAST_RESPONSE_WITH_ONE_ITEM);
+ }
- @Test
- @DisplayName("정보가 없는 위치 값으로 현재 단기예보 불러오기에 실패한다.")
- void requestVillageForecastItemsFailByNoSuchElementException() {
- // given
- int nx = 0;
- int ny = 0;
+ @Test
+ @DisplayName("사용할 수 없는 응답이 오는 경우, 초단기예보를 불러오고 응답 파싱에 실패한다.")
+ void requestUltraShortForecastItemsFail() {
+ // given
+ givenUltraShortForecastApiResponse(VALID_COORDINATE, INVALID_FORECAST_RESPONSE);
+
+ // when // then
+ assertThrows(IllegalStateException.class,
+ () -> forecastApiClient.requestUltraShortForecastItems(ULTRA_SHORT_FORECAST_BASE_DATETIME,
+ VALID_COORDINATE));
+ }
+
+ @Test
+ @DisplayName("정보가 없는 위치 값으로 현재 초단기예보 불러오기에 실패한다.")
+ void requestUltraShortForecastItemsFailByNoSuchElementException() {
+ // given
+ givenUltraShortForecastApiResponse(NO_DATA_COORDINATE, NO_DATA_FORECAST_RESPONSE);
+
+ // when // then
+ assertThrows(NoSuchElementException.class,
+ () -> forecastApiClient.requestUltraShortForecastItems(ULTRA_SHORT_FORECAST_BASE_DATETIME,
+ NO_DATA_COORDINATE));
+ }
+
+ @ParameterizedTest
+ @MethodSource("provideInvalidCoordinate")
+ @DisplayName("유효하지 않은 파라미터 범위로 현재 초단기예보 불러오기에 실패한다.")
+ void requestUltraShortForecastItemsFailByIllegalRange(Coordinate invalidCoordinate) {
+ // given
+ givenUltraShortForecastApiResponse(invalidCoordinate, INVALID_PARAMETER_FORECAST_RESPONSE);
+
+ // when // then
+ assertThrows(IllegalArgumentException.class,
+ () -> forecastApiClient.requestUltraShortForecastItems(ULTRA_SHORT_FORECAST_BASE_DATETIME,
+ invalidCoordinate));
+ }
- // when // then
- assertThrows(NoSuchElementException.class,
- () -> forecastApiClient.requestUltraShortForecastItems(TEMP_BASE_DATETIME, nx, ny));
}
- @ParameterizedTest
- @MethodSource("provideInvalidRange")
- @DisplayName("유효하지 않은 파라미터 범위로 현재 단기예보 불러오기에 실패한다.")
- void requestVillageForecastItemsFailByIllegalRange(Integer nx, Integer ny) {
- // given // when // then
- assertThrows(IllegalArgumentException.class,
- () -> forecastApiClient.requestVillageForecastItems(TEMP_BASE_DATETIME, nx, ny));
+ @Nested
+ @DisplayName("현재 단기예보 불러오기")
+ class RequestVillageForecastItems {
+
+ static Stream provideInvalidLocation() {
+ return Stream.of(
+ Arguments.of(new Coordinate(VALID_NX, FAKER.number().negative())),
+ Arguments.of(new Coordinate(FAKER.number().negative(), VALID_NY)),
+ Arguments.of(new Coordinate(VALID_NX, FAKER.number().numberBetween(1000, 10000))),
+ Arguments.of(new Coordinate(FAKER.number().numberBetween(1000, 10000), VALID_NY))
+ );
+ }
+
+ void givenVillageForecastApiResponse(Coordinate coordinate, String response) {
+ given(forecastApi.getVillageForecast(
+ anyString(),
+ anyInt(),
+ anyInt(),
+ anyString(),
+ eq(VILLAGE_FORECAST_BASE_DATETIME.baseDate()),
+ eq(VILLAGE_FORECAST_BASE_DATETIME.baseTime()),
+ eq(coordinate.nx()),
+ eq(coordinate.ny())))
+ .willReturn(response);
+ }
+
+ @Test
+ @DisplayName("단기예보를 불러오고 응답 파싱에 성공한다.")
+ void requestVillageForecastItemsSuccess() {
+ // given
+ givenVillageForecastApiResponse(VALID_COORDINATE, VILLAGE_FORECAST_RESPONSE_WITH_ONE_ITEM_RESPONSE);
+
+ // when
+ List resultItems = forecastApiClient
+ .requestVillageForecastItems(VILLAGE_FORECAST_BASE_DATETIME, VALID_COORDINATE);
+
+ // then
+ assertThat(resultItems).contains(FORECAST_RESPONSE_WITH_ONE_ITEM);
+
+ }
+
+ @Test
+ @DisplayName("사용할 수 없는 응답이 오는 경우, 단기예보를 불러오고 응답 파싱에 실패한다.")
+ void requestVillageForecastItemsFail() {
+ // given
+ givenVillageForecastApiResponse(VALID_COORDINATE, INVALID_FORECAST_RESPONSE);
+
+ // when // then
+ assertThrows(IllegalStateException.class,
+ () -> forecastApiClient.requestVillageForecastItems(VILLAGE_FORECAST_BASE_DATETIME, VALID_COORDINATE));
+ }
+
+ @Test
+ @DisplayName("정보가 없는 위치 값으로 현재 단기예보 불러오기에 실패한다.")
+ void requestVillageForecastItemsFailByNoSuchElementException() {
+ // given
+ givenVillageForecastApiResponse(NO_DATA_COORDINATE, NO_DATA_FORECAST_RESPONSE);
+
+ // when // then
+ assertThrows(NoSuchElementException.class,
+ () -> forecastApiClient.requestVillageForecastItems(VILLAGE_FORECAST_BASE_DATETIME,
+ NO_DATA_COORDINATE));
+ }
+
+ @ParameterizedTest
+ @MethodSource("provideInvalidLocation")
+ @DisplayName("유효하지 않은 파라미터 범위로 현재 단기예보 불러오기에 실패한다.")
+ void requestVillageForecastItemsFailByIllegalRange(Coordinate invalidCoordinate) {
+ // given
+ givenVillageForecastApiResponse(invalidCoordinate, INVALID_PARAMETER_FORECAST_RESPONSE);
+
+ // when // then
+ assertThrows(IllegalArgumentException.class,
+ () -> forecastApiClient.requestVillageForecastItems(ULTRA_SHORT_FORECAST_BASE_DATETIME,
+ invalidCoordinate));
+ }
+
}
}
diff --git a/src/test/resources/application.yaml b/src/test/resources/application.yaml
index d247ed7f..a3caa316 100644
--- a/src/test/resources/application.yaml
+++ b/src/test/resources/application.yaml
@@ -8,6 +8,25 @@ spring:
hibernate:
ddl-auto: validate
open-in-view: false
+ data:
+ redis:
+ host: 127.0.0.1
+ port: 6379
+ mail:
+ host: 127.0.0.1
+ port: 3025
+ username: greenmail
+ password: test
+ properties:
+ mail:
+ smtp:
+ auth: true
+ starttls:
+ enable: true
+ transport:
+ protocol: smtp
+ debug: true
+ default-encoding: UTF-8
minio:
url: ${MINIO_URL}
bucket: ${MINIO_BUCKET}
diff --git a/src/test/resources/org/springframework/restdocs/templates/asciidoctor/request-headers.snippet b/src/test/resources/org/springframework/restdocs/templates/asciidoctor/request-headers.snippet
new file mode 100644
index 00000000..439fd66f
--- /dev/null
+++ b/src/test/resources/org/springframework/restdocs/templates/asciidoctor/request-headers.snippet
@@ -0,0 +1,13 @@
+==== Request Headers
+
+|===
+|Name|Description|Required
+
+{{#headers}}
+
+|{{#tableCellContent}}`+{{name}}+`{{/tableCellContent}}
+|{{#tableCellContent}}{{description}}{{/tableCellContent}}
+|{{#tableCellContent}}{{#optional}}false{{/optional}}{{^optional}}true{{/optional}}{{/tableCellContent}}
+
+{{/headers}}
+|===