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 + + + + + + + +
+

User API

+
+
+
+
+
+
+

signup

+
+
+ + + + + +
+ + +사용자 회원가입 +
+
+
+

Request

+
+
+
{
+  "email" : "gavin.lakin@hotmail.com",
+  "password" : "BNc@^R!#tM&Prm0WcOIf5Jm1w",
+  "nickname" : "esteban.haag"
+}
+
+
+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

email

String

Email 주소

password

String

비밀번호

nickname

String

별명

+
+

Request example

+
+
+
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"
+}
+
+
+
+
+
+

Response

+
+

Response example

+
+
+
HTTP/1.1 201 Created
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+
+
+
+
+
+
+
+

login

+
+
+ + + + + +
+ + +사용자 로그인 +
+
+
+

Request

+
+
+
{
+  "email" : "williams.flatley@yahoo.com",
+  "password" : "Xz#0^3%v8$yk0o%o"
+}
+
+
+ +++++ + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

email

String

Email 주소

password

String

비밀번호

+
+

Request example

+
+
+
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"
+}
+
+
+
+
+
+

Response

+
+
+
{
+  "token" : "a5fbfa1c2fd6a064babe4b7234f141fe57733ee9c53839bb31a0284a7b0a3a49b2894252b81e86078ecc0d9a7e24dc81372376b006d457ede46a8d9d1ea0ff93"
+}
+
+
+ +++++ + + + + + + + + + + + + + + +
PathTypeDescription

token

String

JWT 토큰

+
+

Response example

+
+
+
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"
+}
+
+
+
+
+
+
+
+

certificate

+
+
+ + + + + +
+ + +이메일 인증 코드 발송 +
+
+
+

Request

+ ++++ + + + + + + + + + + + + +
ParameterDescription

email

Email 주소

+
+

Request example

+
+
+
PATCH /api/v1/auth/certificate?email=magnolia.becker@yahoo.com HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Host: docs.api.com
+
+
+
+
+
+

Response

+
+

Response example

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+
+
+
+
+
+
+
+

certify

+
+
+ + + + + +
+ + +이메일 인증 +
+
+
+

Request

+ ++++ + + + + + + + + + + + + + + + + +
ParameterDescription

email

Email 주소

code

Email 인증 코드

+
+

Request example

+
+
+
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
+
+
+
+
+
+

Response

+
+

Response example

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+
+
+
+
+
+
+

Avatar Item API

+
+
+
+
+
+
+

upload

+
+
+ + + + + +
+ + +아바타 이미지 업로드 +
+
+
+

Request

+
+

Request Headers

+ +++++ + + + + + + + + + + + + + + +
NameDescriptionRequired

Authorization

JWT 토큰

true

+ ++++ + + + + + + + + + + + + + + + + +
PartDescription

file

아바타 이미지 파일

request

아바타 이미지 상세 정보

+ +++++ + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

type

String

아바타 이미지 타입

sex

String

아바타 이미지 성별

+
+
+

Request example

+
+
+
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--
+
+
+
+
+
+

Response

+
+
+
{
+  "avatarItemId" : 528299030,
+  "type" : "HAIR",
+  "sex" : "MALE",
+  "url" : "https://www.alva-reinger.org:4583/nam/harumofficiis?consectetur=esse&sequi=dolorem"
+}
+
+
+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

avatarItemId

Number

아바타 이미지 ID

type

String

아바타 이미지 타입

sex

String

아바타 이미지 성별

url

String

아바타 이미지 URL

+
+

Response example

+
+
+
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 all

+
+
+ + + + + +
+ + +전체 아바타 이미지 조회 +
+
+
+

Request

+
+

Request example

+
+
+
GET /api/v1/avatar-items HTTP/1.1
+Accept: application/json
+Host: docs.api.com
+
+
+
+
+
+

Response

+
+
+
[ {
+  "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"
+} ]
+
+
+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

[].avatarItemId

Number

아바타 이미지 ID

[].type

String

아바타 이미지 타입

[].sex

String

아바타 이미지 성별

[].url

String

아바타 이미지 URL

+
+

Response example

+
+
+
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"
+} ]
+
+
+
+
+
+
+

Post API

+
+
+
+
+
+
+

post create

+
+
+ + + + + +
+ + +게시글 생성 +
+
+
+

Request

+
+

Request Headers

+ +++++ + + + + + + + + + + + + + + +
NameDescriptionRequired

Authorization

JWT 토큰

true

+ ++++ + + + + + + + + + + + + + + + + +
PartDescription

request

게시글 생성 요청 정보

postImg

게시글 이미지 파일

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

title

String

게시글 제목

content

String

게시글 내용

coordinate.nx

Number

사용자 X 좌표

coordinate.ny

Number

사용자 Y 좌표

+
+
+

Request example

+
+
+
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--
+
+
+
+
+
+

Response

+
+
+
{
+  "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
+  }
+}
+
+
+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

postId

Number

게시글 ID

title

String

게시글 제목

content

String

게시글 내용

image

String

게시글 이미지 URL

createdAt

String

게시글 생성 일자

updatedAt

String

게시글 수정 일자

temperatureArrange.min

Number

최저 기온

temperatureArrange.max

Number

최고 기온

+
+

Response example

+
+
+
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
+  }
+}
+
+
+
+
+
+
+
+

post read detail

+
+
+ + + + + +
+ + +게시글 상세 조회 +
+
+
+

Request

+
+

Request Headers

+ +++++ + + + + + + + + + + + + + + +
NameDescriptionRequired

Authorization

JWT 토큰

false

+ + ++++ + + + + + + + + + + + + +
Table 1. /api/v1/posts/{postId}
ParameterDescription

postId

게시글 ID

+
+
+

Request example

+
+
+
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
+
+
+
+
+
+

Response

+
+
+
{
+  "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
+}
+
+
+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

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

좋아요 여부

+
+

Response example

+
+
+
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
+}
+
+
+
+
+
+
+
+

post read all

+
+
+ + + + + +
+ + +게시글 전체 조회 +
+
+
+

Request

+
+

Request Headers

+ +++++ + + + + + + + + + + + + + + +
NameDescriptionRequired

Authorization

JWT 토큰

false

+
+
+

Request example

+
+
+
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
+
+
+
+
+
+

Response

+
+
+
[ {
+  "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
+} ]
+
+
+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

[]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

좋아요 여부

+
+

Response example

+
+
+
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
+} ]
+
+
+
+
+
+
+
+

post update

+
+
+ + + + + +
+ + +게시글 변경 +
+
+
+

Request

+
+

Request Headers

+ +++++ + + + + + + + + + + + + + + +
NameDescriptionRequired

Authorization

JWT 토큰

true

+ ++++ + + + + + + + + + + + + + + + + +
PartDescription

request

게시글 생성 요청 정보

postImg

게시글 이미지 파일

+ +++++ + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

title

String

게시글 제목

content

String

게시글 내용

+
+
+

Request example

+
+
+
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--
+
+
+
+
+
+

Response

+
+
+
{
+  "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
+  }
+}
+
+
+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

postId

Number

게시글 ID

title

String

게시글 제목

content

String

게시글 내용

image

String

게시글 이미지 URL

createdAt

String

게시글 생성 일자

updatedAt

String

게시글 수정 일자

temperatureArrange.min

Number

최저 기온

temperatureArrange.max

Number

최고 기온

+
+

Response example

+
+
+
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
+  }
+}
+
+
+
+
+
+
+
+

post delete

+
+
+ + + + + +
+ + +게시글 삭제 +
+
+
+

Request

+
+

Request Headers

+ +++++ + + + + + + + + + + + + + + +
NameDescriptionRequired

Authorization

JWT 토큰

true

+ + ++++ + + + + + + + + + + + + +
Table 2. /api/v1/posts/{postId}
ParameterDescription

postId

게시글 ID

+
+
+

Request example

+
+
+
DELETE /api/v1/posts/1744803171 HTTP/1.1
+Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJpc3MiOiJvb3R3IiwiaWF0IjoxNzA1NTcwOTg0LCJleHAiOjE3MDU1NzQ1ODQsInVzZXJfaWQiOjF9.mTcgBjKPrrKkjVtfmEk0MJyDI-8PQyKuXrzwr2w1_I8mwQq5lT6PkW9eMPNfCee2vdtxCj6HF_U2rN_yqcbgRA
+Host: docs.api.com
+
+
+
+
+
+

Response

+
+

Response example

+
+
+
HTTP/1.1 204 No Content
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+
+
+
+
+
+
+
+

like push

+
+
+ + + + + +
+ + +게시글 좋아요 +
+
+
+

Request

+
+

Request Headers

+ +++++ + + + + + + + + + + + + + + +
NameDescriptionRequired

Authorization

JWT 토큰

true

+
+
+
{
+  "postId" : 1251693879
+}
+
+
+ +++++ + + + + + + + + + + + + + + +
PathTypeDescription

postId

Number

게시글 ID

+
+
+

Request example

+
+
+
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
+}
+
+
+
+
+
+

Response

+
+
+
{
+  "likeId" : 581393781,
+  "userId" : 2142373723,
+  "postId" : 1251693879,
+  "status" : true
+}
+
+
+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

likeId

Number

좋아요 ID

userId

Number

좋아요를 누른 사용자 ID

postId

Number

게시글 ID

status

Boolean

좋아요 여부

+
+

Response example

+
+
+
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
+}
+
+
+
+
+
+
+

Weather API

+
+
+
+
+
+
+

current weather

+
+
+ + + + + +
+ + +현재 날씨 조회 +
+
+
+

Request

+
+

Request Headers

+ +++++ + + + + + + + + + + + + + + +
NameDescriptionRequired

Authorization

JWT 토큰

true

+ ++++ + + + + + + + + + + + + + + + + +
ParameterDescription

nx

사용자 X 좌표

ny

사용자 Y 좌표

+
+
+

Request example

+
+
+
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
+
+
+
+
+
+

Response

+
+
+
{
+  "currentDateTime" : "2024-01-10T14:05:00",
+  "currentTemperature" : 0.0,
+  "sky" : "SUNNY",
+  "pty" : "RAIN"
+}
+
+
+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

currentDateTime

String

현재 시간

currentTemperature

Number

현재 온도

sky

String

하늘 상태 코드

pty

String

강수 상태 코드

+
+

Response example

+
+
+
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[] 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[] 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 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 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 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}} +|===