diff --git a/.github/workflows/cd-dev.yml b/.github/workflows/cd-dev.yml index 29812158..e648bde4 100644 --- a/.github/workflows/cd-dev.yml +++ b/.github/workflows/cd-dev.yml @@ -69,4 +69,4 @@ jobs: script_stop: true script: | sudo fuser -k -n tcp 8080 || true - nohup java -jar -Dspring.profiles.active=dev photopic-dev.jar > ./output.log 2>&1 & + nohup java -Xms256m -Xmx742m -jar -Dspring.profiles.active=dev photopic-dev.jar > ./output.log 2>&1 & diff --git a/.gitignore b/.gitignore index c2065bc2..653e7c70 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,7 @@ out/ ### VS Code ### .vscode/ + +.DS_Store + +application*.yml diff --git a/build.gradle b/build.gradle index 8005ef46..f036af26 100644 --- a/build.gradle +++ b/build.gradle @@ -41,10 +41,15 @@ dependencies { // image implementation 'software.amazon.awssdk:s3:2.30.18' implementation 'org.imgscalr:imgscalr-lib:4.2' + implementation 'com.sksamuel.scrimage:scrimage-core:4.3.0' + implementation 'com.sksamuel.scrimage:scrimage-webp:4.3.0' // gson implementation 'com.google.code.gson:gson:2.8.6' + // base64 + implementation 'io.seruco.encoding:base62:0.1.3' + compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.h2database:h2' runtimeOnly 'com.mysql:mysql-connector-j' diff --git a/server-config b/server-config index 04635874..e30e07df 160000 --- a/server-config +++ b/server-config @@ -1 +1 @@ -Subproject commit 046358747964e2371da593cf009b05635e255bdc +Subproject commit e30e07dfdf425a36d06189d1699d5edfb887a383 diff --git a/src/docs/asciidoc/auth.adoc b/src/docs/asciidoc/auth.adoc new file mode 100644 index 00000000..bf0d05db --- /dev/null +++ b/src/docs/asciidoc/auth.adoc @@ -0,0 +1,22 @@ +[[인증-API]] +== 인증 API + +[[카카오-로그인]] +=== `POST` 카카오 로그인 + +operation::auth-controller-test/kakao-o-auth-sign-in[snippets='http-request,curl-request,request-fields,http-response,response-cookies,response-fields'] + +[[토큰-재발급]] +=== `POST` 토큰 재발급 + +operation::auth-controller-test/reissue[snippets='http-request,curl-request,request-cookies,http-response,response-cookies,response-fields'] + +[[게스트-토큰-발급]] +=== `POST` 게스트 토큰 발급 + +operation::auth-controller-test/guest-token[snippets='http-request,curl-request,http-response,response-fields'] + +[[로그아웃]] +=== `POST` 로그아웃 + +operation::auth-controller-test/sign-out[snippets='http-request,curl-request,request-cookies,request-headers,http-response,response-cookies'] diff --git a/src/docs/asciidoc/comments.adoc b/src/docs/asciidoc/comments.adoc new file mode 100644 index 00000000..1491db53 --- /dev/null +++ b/src/docs/asciidoc/comments.adoc @@ -0,0 +1,17 @@ +[[댓글-API]] +== 댓글 API + +[[댓글-생성]] +=== `POST` 댓글 생성 + +operation::comment-controller-test/create-comment[snippets='http-request,curl-request,path-parameters,request-headers,request-fields,http-response'] + +[[댓글-조회]] +=== `GET` 댓글 조회 + +operation::comment-controller-test/find-comments[snippets='http-request,curl-request,path-parameters,http-response,response-fields'] + +[[댓글-삭제]] +=== 댓글 삭제 (미구현) + +operation::comment-controller-test/delete-comment[snippets='http-request,curl-request,path-parameters,request-headers,http-response'] diff --git a/src/docs/asciidoc/images.adoc b/src/docs/asciidoc/images.adoc new file mode 100644 index 00000000..2b08f951 --- /dev/null +++ b/src/docs/asciidoc/images.adoc @@ -0,0 +1,7 @@ +[[이미지-API]] +== 이미지 API + +[[이미지-업로드]] +=== `POST` 이미지 업로드 + +operation::image-controller-test/create-image-file[snippets='http-request,curl-request,request-headers,request-parts,http-response,response-fields'] \ No newline at end of file diff --git a/src/docs/asciidoc/index.adoc b/src/docs/asciidoc/index.adoc new file mode 100644 index 00000000..be2279b8 --- /dev/null +++ b/src/docs/asciidoc/index.adoc @@ -0,0 +1,111 @@ += API Docs +api문서 +:doctype: book +:icons: font +:source-highlighter: highlightjs +:toc: left +:toclevels: 2 +:sectlinks: + +[[overview]] +== 개요 + +``` +뽀또픽 API 문서입니다. +잘못되었거나 추가 및 수정되어야 할 내용이 있으면 언제든지 연락주세요. +``` + +[[상태코드]] +=== 상태 코드 + +``` +HTTP 상태 코드 본 REST API에서 사용하는 HTTP 상태 코드는 가능한한 표준 HTTP와 REST 규약을 따릅니다. +``` + +|=== + +| 상태 코드 | 용례 +| `200 OK`| 요청을 성공적으로 처리함 +| `400 Bad Request`| 잘못된 요청을 보낸 경우. 응답 본문에 더 오류에 대한 정보가 담겨있음 +| `401 Unauthorization`| 인증에 실패한 경우. 응답 본문에 더 오류에 대한 정보가 담겨있음 +| `404 Not Found`| 요청한 리소스가 없음. +| `500 Internal Server Error`| 서버 내부 오류가 발생한 경우. +| `503 Service Unavailable`| 서버가 요청을 처리할 준비가 되지 않은 경우. + +|=== + +[[에러]] +=== 에러 + +``` +오류 에러 응답이 발생했을 때 (상태 코드 >= 400), 본문에 해당 문제를 기술한 JSON 객체가 담겨있음 +``` + +```HTTP +HTTP/1.1 200 OK +content-type: application/json + +{ + "errorCode": "에러 코드" +} +``` + +[[인증]] +=== 인증 + +``` +인증 토큰은 다음과 같은 형식으로 전달됨 +``` + +```HTTP +POST /posts HTTP/1.1 +Content-Type: application/json;charset=UTF-8 +Authorization: Bearer accessToken +``` + +#### 테스트용 개발 토큰 + +``` +user1 +eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjEiLCJpYXQiOjE3NDAyOTQyMzEsImlzcyI6InN3eXA4dGVhbTIiLCJleHAiOjMzMjc2Mjk0MjMxfQ.gqA245tRiBQB9owKRWIpX1we1T362R-xDTt4YT9AhRY + +user2 +eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjIiLCJpYXQiOjE3NDA0NDM0ODIsImlzcyI6InN3eXA4dGVhbTIiLCJleHAiOjMzMjc2NDQzNDgyfQ.2sTlCtSHb4eGzhlL6WlRT6xvJLtvipnHp6EAmC4j1UQ +``` + +[[인증-예외]] +=== 인증 예외 + +``` +인증 토큰관련 예외가 발생하면 다음과 같은 에러 코드와 함께 401 상태 코드를 응답함 +``` + +|=== + +| 에러 코드 | 용례 +|`EXPIRED_TOKEN`| 토큰이 만료되었을 경우 +|`INVALID_TOKEN`| 잘못된 형식의 토큰인 경우 +|`INVALID_AUTH_HEADER`| Authorization 헤더가 존재하지 않거나 Bearer 형식이 아닌 경우 +|=== + +예시 + +```HTTP +HTTP/1.1 401 Unauthorized + +{ + "errorCode": "EXPIRED_TOKEN" +} +``` + +include::auth.adoc[] + +include::users.adoc[] + +include::images.adoc[] + +include::posts.adoc[] + +include::votes.adoc[] + +include::comments.adoc[] \ No newline at end of file diff --git a/src/docs/asciidoc/posts.adoc b/src/docs/asciidoc/posts.adoc new file mode 100644 index 00000000..7087793d --- /dev/null +++ b/src/docs/asciidoc/posts.adoc @@ -0,0 +1,45 @@ +[[게시글-API]] +== 게시글 API + +[[게시글-작성]] +=== `POST` 게시글 작성 + +operation::post-controller-test/create-post[snippets='http-request,curl-request,request-headers,request-fields,http-response'] + +[[게시글-상세-조회]] +=== `GET` 게시글 상세 조회 + +operation::post-controller-test/find-post[snippets='http-request,curl-request,path-parameters,http-response,response-fields'] + +[[개사굴-공유-url-조회]] +=== `GET` 게시글 공유 url 조회 + +operation::post-controller-test/find-post_share-url[snippets='http-request,curl-request,path-parameters,http-response,response-fields'] + +[[게시글-목록-조회]] + +[[사진-투표-현황-조회]] +=== `GET` 사진 투표 현황 조회 + +operation::post-controller-test/find-vote-status[snippets='http-request,curl-request,request-headers,path-parameters,http-response,response-fields'] + + +[[유저가-작성한-게시글-조회]] +=== `GET` 유저가 작성한 게시글 조회 + +operation::post-controller-test/find-my-post[snippets='http-request,curl-request,query-parameters,request-headers,http-response,response-fields'] + +[[유저가-참여한-게시글-조회]] +=== `GET` 유저가 참여한 게시글 조회 + +operation::post-controller-test/find-voted-post[snippets='http-request,curl-request,query-parameters,request-headers,http-response,response-fields'] + +[[게시글-투표-마감]] +=== `POST` 게시글 투표 마감 + +operation::post-controller-test/close-post[snippets='http-request,curl-request,path-parameters,request-headers,http-response'] + +[[게시글-삭제]] +=== `DELETE` 게시글 삭제 + +operation::post-controller-test/delete-post[snippets='http-request,curl-request,path-parameters,request-headers,http-response'] diff --git a/src/docs/asciidoc/users.adoc b/src/docs/asciidoc/users.adoc new file mode 100644 index 00000000..ae247c22 --- /dev/null +++ b/src/docs/asciidoc/users.adoc @@ -0,0 +1,12 @@ +[[유저-API]] +== 유저 API + +[[유저-정보-조회]] +=== `GET` 유저 정보 조회 + +operation::user-controller-test/find-user-info[snippets='http-request,curl-request,path-parameters,http-response,response-fields'] + +[[본인-정보-조회]] +=== `GET` 본인 정보 조회 + +operation::user-controller-test/find-me[snippets='http-request,curl-request,request-headers,http-response,response-fields'] diff --git a/src/docs/asciidoc/votes.adoc b/src/docs/asciidoc/votes.adoc new file mode 100644 index 00000000..98b1cdc6 --- /dev/null +++ b/src/docs/asciidoc/votes.adoc @@ -0,0 +1,22 @@ +[[투표-API]] +== 투표 API + +[[투표]] +=== `POST` 투표 + +operation::vote-controller-test/vote[snippets='http-request,curl-request,request-headers,request-fields,http-response'] + +[[게스트-투표]] +=== `POST` 게스트 투표 + +operation::vote-controller-test/guest-vote[snippets='http-request,curl-request,request-headers,request-fields,http-response'] + +[[투표-변경]] +=== 투표 변경 (투표 API로 통일) + +// operation::vote-controller-test/change-vote[snippets='http-request,curl-request,request-headers,request-fields,http-response'] + +[[게스트-투표-변경]] +=== 게스트 투표 변경 (미구현, 게스트 투표 API로 통일) + +// operation::vote-controller-test/guest-change-vote[snippets='http-request,curl-request,request-headers,request-fields,http-response'] diff --git a/src/main/java/com/swyp8team2/auth/application/AuthService.java b/src/main/java/com/swyp8team2/auth/application/AuthService.java new file mode 100644 index 00000000..24b8c3fc --- /dev/null +++ b/src/main/java/com/swyp8team2/auth/application/AuthService.java @@ -0,0 +1,68 @@ +package com.swyp8team2.auth.application; + +import com.swyp8team2.auth.application.jwt.JwtService; +import com.swyp8team2.auth.application.oauth.OAuthService; +import com.swyp8team2.auth.application.oauth.dto.OAuthUserInfo; +import com.swyp8team2.auth.domain.Provider; +import com.swyp8team2.auth.domain.SocialAccount; +import com.swyp8team2.auth.domain.SocialAccountRepository; +import com.swyp8team2.auth.presentation.dto.TokenResponse; +import com.swyp8team2.common.annotation.GuestTokenCryptoService; +import com.swyp8team2.crypto.application.CryptoService; +import com.swyp8team2.user.application.UserService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class AuthService { + + private final JwtService jwtService; + private final OAuthService oAuthService; + private final SocialAccountRepository socialAccountRepository; + private final UserService userService; + private final CryptoService cryptoService; + + public AuthService( + JwtService jwtService, + OAuthService oAuthService, + SocialAccountRepository socialAccountRepository, + UserService userService, + @GuestTokenCryptoService CryptoService cryptoService) { + this.jwtService = jwtService; + this.oAuthService = oAuthService; + this.socialAccountRepository = socialAccountRepository; + this.userService = userService; + this.cryptoService = cryptoService; + } + + @Transactional + public TokenResponse oauthSignIn(String code, String redirectUri) { + OAuthUserInfo oAuthUserInfo = oAuthService.getUserInfo(code, redirectUri); + SocialAccount socialAccount = socialAccountRepository.findBySocialIdAndProvider( + oAuthUserInfo.socialId(), + Provider.KAKAO + ).orElseGet(() -> createUser(oAuthUserInfo)); + return jwtService.createToken(socialAccount.getUserId()); + } + + private SocialAccount createUser(OAuthUserInfo oAuthUserInfo) { + Long userId = userService.createUser(oAuthUserInfo.nickname(), oAuthUserInfo.profileImageUrl()); + return socialAccountRepository.save(SocialAccount.create(userId, oAuthUserInfo)); + } + + @Transactional + public TokenResponse reissue(String refreshToken) { + return jwtService.reissue(refreshToken); + } + + @Transactional + public String createGuestToken() { + Long guestId = userService.createGuest(); + return cryptoService.encrypt(String.valueOf(guestId)); + } + + @Transactional + public void signOut(Long userId, String refreshToken) { + jwtService.signOut(userId, refreshToken); + } +} diff --git a/src/main/java/com/swyp8team2/auth/application/jwt/JwtClaim.java b/src/main/java/com/swyp8team2/auth/application/jwt/JwtClaim.java new file mode 100644 index 00000000..149aa64d --- /dev/null +++ b/src/main/java/com/swyp8team2/auth/application/jwt/JwtClaim.java @@ -0,0 +1,24 @@ +package com.swyp8team2.auth.application.jwt; + +public class JwtClaim { + + public static final String ID = "id"; + + private final String id; + + public JwtClaim(long id) { + this.id = String.valueOf(id); + } + + public static JwtClaim from(long id) { + return new JwtClaim(id); + } + + public Long idAsLong() { + return Long.parseLong(id); + } + + public String id() { + return id; + } +} \ No newline at end of file diff --git a/src/main/java/com/swyp8team2/auth/application/jwt/JwtProvider.java b/src/main/java/com/swyp8team2/auth/application/jwt/JwtProvider.java new file mode 100644 index 00000000..343f285b --- /dev/null +++ b/src/main/java/com/swyp8team2/auth/application/jwt/JwtProvider.java @@ -0,0 +1,94 @@ +package com.swyp8team2.auth.application.jwt; + +import com.swyp8team2.common.exception.ErrorCode; +import com.swyp8team2.common.exception.InternalServerException; +import com.swyp8team2.common.exception.UnauthorizedException; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.JwtParser; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.security.Key; +import java.time.Clock; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Date; +import java.util.Objects; + +@Slf4j +@Component +public class JwtProvider { + + private static final long ACCESS_TOKEN_EXPIRATION_MINUTES = 30; + private static final long REFRESH_TOKEN_EXPIRATION_MINUTES = 60 * 24 * 14; + + private final Key key; + private final Clock clock; + private final String issuer; + + public JwtProvider( + @Value("${jwt.token.secret-key}") String key, + @Value("${jwt.token.issuer}") String issuer, + Clock clock) { + this.key = Keys.hmacShaKeyFor(key.getBytes(StandardCharsets.UTF_8)); + this.clock = clock; + this.issuer = issuer; + } + + public TokenPair createToken(JwtClaim claim) { + return new TokenPair(createAccessToken(claim), createRefreshToken(claim)); + } + + public String createAccessToken(JwtClaim claim) { + return createToken(claim, ACCESS_TOKEN_EXPIRATION_MINUTES); + } + + public String createRefreshToken(JwtClaim claim) { + return createToken(claim, REFRESH_TOKEN_EXPIRATION_MINUTES); + } + + private String createToken(JwtClaim claim, long expiration) { + if (Objects.isNull(claim)) { + throw new InternalServerException(ErrorCode.INVALID_INPUT_VALUE); + } + Instant now = clock.instant(); + Instant expiredAt = now.plus(expiration, ChronoUnit.MINUTES); + + return Jwts.builder() + .claim(JwtClaim.ID, claim.id()) + .setIssuedAt(Date.from(now)) + .setIssuer(issuer) + .setExpiration(Date.from(expiredAt)) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + } + + public JwtClaim parseToken(String token) { + try { + JwtParser parser = Jwts.parserBuilder() + .requireIssuer(issuer) + .setSigningKey(key) + .build(); + + Claims claims = parser.parseClaimsJws(token) + .getBody(); + String userId = (String) claims.get(JwtClaim.ID); + return new JwtClaim(Long.parseLong(userId)); + } catch (ExpiredJwtException e) { + log.trace("Expired Jwt Token: {}", e.getMessage()); + throw new UnauthorizedException(ErrorCode.EXPIRED_TOKEN); + } catch (JwtException e) { + log.trace("Invalid Jwt Token: {}", e.getMessage()); + throw new UnauthorizedException(ErrorCode.INVALID_TOKEN); + } catch (Exception e) { + throw new InternalServerException(e); + } + } +} diff --git a/src/main/java/com/swyp8team2/auth/application/jwt/JwtService.java b/src/main/java/com/swyp8team2/auth/application/jwt/JwtService.java new file mode 100644 index 00000000..75228fbd --- /dev/null +++ b/src/main/java/com/swyp8team2/auth/application/jwt/JwtService.java @@ -0,0 +1,58 @@ +package com.swyp8team2.auth.application.jwt; + +import com.swyp8team2.auth.domain.RefreshToken; +import com.swyp8team2.auth.domain.RefreshTokenRepository; +import com.swyp8team2.auth.presentation.dto.TokenResponse; +import com.swyp8team2.common.exception.BadRequestException; +import com.swyp8team2.common.exception.ErrorCode; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class JwtService { + + private final JwtProvider jwtProvider; + private final RefreshTokenRepository refreshTokenRepository; + + @Transactional + public TokenResponse createToken(long userId) { + TokenPair tokenPair = jwtProvider.createToken(new JwtClaim(userId)); + RefreshToken refreshToken = refreshTokenRepository.findByUserId(userId) + .orElseGet(() -> new RefreshToken(userId, tokenPair.refreshToken())); + refreshToken.setRefreshToken(tokenPair.refreshToken()); + refreshTokenRepository.save(refreshToken); + + log.debug("createToken userId: {} accessToken: {} refreshToken: {}", + userId, tokenPair.accessToken(), tokenPair.refreshToken()); + return new TokenResponse(tokenPair, userId); + } + + @Transactional + public TokenResponse reissue(String refreshToken) { + JwtClaim claim = jwtProvider.parseToken(refreshToken); + RefreshToken findRefreshToken = refreshTokenRepository.findByUserId(claim.idAsLong()) + .orElseThrow(() -> new BadRequestException(ErrorCode.REFRESH_TOKEN_NOT_FOUND)); + + TokenPair tokenPair = jwtProvider.createToken(new JwtClaim(claim.idAsLong())); + findRefreshToken.rotate(refreshToken, tokenPair.refreshToken()); + + log.debug("reissue userId: {} accessToken: {} refreshToken: {}", + claim.id(), tokenPair.accessToken(), tokenPair.refreshToken()); + return new TokenResponse(tokenPair, claim.idAsLong()); + } + + @Transactional + public void signOut(Long userId, String refreshToken) { + RefreshToken token = refreshTokenRepository.findByUserId(userId) + .orElseThrow(() -> new BadRequestException(ErrorCode.REFRESH_TOKEN_NOT_FOUND)); + + if (!token.getToken().equals(refreshToken)) { + throw new BadRequestException(ErrorCode.REFRESH_TOKEN_MISMATCHED); + } + refreshTokenRepository.delete(token); + } +} diff --git a/src/main/java/com/swyp8team2/auth/application/jwt/TokenPair.java b/src/main/java/com/swyp8team2/auth/application/jwt/TokenPair.java new file mode 100644 index 00000000..e7a714ab --- /dev/null +++ b/src/main/java/com/swyp8team2/auth/application/jwt/TokenPair.java @@ -0,0 +1,18 @@ +package com.swyp8team2.auth.application.jwt; + +import com.swyp8team2.common.exception.ErrorCode; +import com.swyp8team2.common.exception.InternalServerException; + +import java.util.Objects; + +public record TokenPair( + String accessToken, + String refreshToken +) { + + public TokenPair { + if (Objects.isNull(accessToken) || Objects.isNull(refreshToken)) { + throw new InternalServerException(ErrorCode.INVALID_INPUT_VALUE); + } + } +} diff --git a/src/main/java/com/swyp8team2/auth/application/oauth/KakaoOAuthClient.java b/src/main/java/com/swyp8team2/auth/application/oauth/KakaoOAuthClient.java new file mode 100644 index 00000000..17540f1a --- /dev/null +++ b/src/main/java/com/swyp8team2/auth/application/oauth/KakaoOAuthClient.java @@ -0,0 +1,21 @@ +package com.swyp8team2.auth.application.oauth; + +import com.swyp8team2.auth.application.oauth.dto.KakaoAuthResponse; +import com.swyp8team2.auth.application.oauth.dto.KakaoUserInfoResponse; +import org.springframework.util.MultiValueMap; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.service.annotation.GetExchange; +import org.springframework.web.service.annotation.PostExchange; + +import static org.springframework.http.HttpHeaders.AUTHORIZATION; +import static org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED_VALUE; + +public interface KakaoOAuthClient { + + @PostExchange(url = "https://kauth.kakao.com/oauth/token", contentType = APPLICATION_FORM_URLENCODED_VALUE) + KakaoAuthResponse fetchToken(@RequestParam("params") MultiValueMap params); + + @GetExchange("https://kapi.kakao.com/v2/user/me") + KakaoUserInfoResponse fetchUserInfo(@RequestHeader(name = AUTHORIZATION) String bearerToken); +} diff --git a/src/main/java/com/swyp8team2/auth/application/oauth/OAuthService.java b/src/main/java/com/swyp8team2/auth/application/oauth/OAuthService.java new file mode 100644 index 00000000..dd6516a4 --- /dev/null +++ b/src/main/java/com/swyp8team2/auth/application/oauth/OAuthService.java @@ -0,0 +1,47 @@ +package com.swyp8team2.auth.application.oauth; + +import com.swyp8team2.auth.application.oauth.dto.KakaoAuthResponse; +import com.swyp8team2.auth.application.oauth.dto.OAuthUserInfo; +import com.swyp8team2.common.config.KakaoOAuthConfig; +import com.swyp8team2.common.exception.BadRequestException; +import com.swyp8team2.common.exception.ErrorCode; +import com.swyp8team2.common.exception.InternalServerException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +@Slf4j +@Service +@RequiredArgsConstructor +public class OAuthService { + + private static final String BEARER = "Bearer "; + + private final KakaoOAuthConfig kakaoOAuthConfig; + private final KakaoOAuthClient kakaoOAuthClient; + + public OAuthUserInfo getUserInfo(String code, String redirectUri) { + try { + KakaoAuthResponse kakaoAuthResponse = kakaoOAuthClient.fetchToken(tokenRequestParams(code, redirectUri)); + log.info("getUserInfo kakaoAuthResponse: {}", kakaoAuthResponse); + return kakaoOAuthClient + .fetchUserInfo(BEARER + kakaoAuthResponse.accessToken()) + .toOAuthUserInfo(); + } catch (Exception e) { + log.debug("소셜 로그인 실패 {}", e.getMessage()); + throw new BadRequestException(ErrorCode.SOCIAL_AUTHENTICATION_FAILED); + } + } + + private MultiValueMap tokenRequestParams(String authCode, String redirectUri) { + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("grant_type", "authorization_code"); + params.add("client_id", kakaoOAuthConfig.clientId()); + params.add("redirect_uri", redirectUri); + params.add("code", authCode); + params.add("client_secret", kakaoOAuthConfig.clientSecret()); + return params; + } +} diff --git a/src/main/java/com/swyp8team2/auth/application/oauth/dto/KakaoAuthResponse.java b/src/main/java/com/swyp8team2/auth/application/oauth/dto/KakaoAuthResponse.java new file mode 100644 index 00000000..dacc574c --- /dev/null +++ b/src/main/java/com/swyp8team2/auth/application/oauth/dto/KakaoAuthResponse.java @@ -0,0 +1,9 @@ +package com.swyp8team2.auth.application.oauth.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record KakaoAuthResponse( + String accessToken +) { } diff --git a/src/main/java/com/swyp8team2/auth/application/oauth/dto/KakaoUserInfoResponse.java b/src/main/java/com/swyp8team2/auth/application/oauth/dto/KakaoUserInfoResponse.java new file mode 100644 index 00000000..bae00bdb --- /dev/null +++ b/src/main/java/com/swyp8team2/auth/application/oauth/dto/KakaoUserInfoResponse.java @@ -0,0 +1,43 @@ +package com.swyp8team2.auth.application.oauth.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.swyp8team2.auth.domain.Provider; + +import java.util.Objects; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record KakaoUserInfoResponse( + String id, + KakaoAccount kakaoAccount +) { + + public OAuthUserInfo toOAuthUserInfo() { + String profileImageUrl; + String nickname; + if (Objects.isNull(kakaoAccount.profile())) { + profileImageUrl = null; + nickname = null; + } else { + profileImageUrl = kakaoAccount.profile().profileImageUrl(); + nickname = kakaoAccount.profile().nickname(); + } + return new OAuthUserInfo( + id, + profileImageUrl, + nickname, + Provider.KAKAO + ); + } + + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + public record KakaoAccount( + Profile profile + ) {} + + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + public record Profile( + String nickname, + String profileImageUrl + ) {} +} diff --git a/src/main/java/com/swyp8team2/auth/application/oauth/dto/OAuthUserInfo.java b/src/main/java/com/swyp8team2/auth/application/oauth/dto/OAuthUserInfo.java new file mode 100644 index 00000000..8d987ab1 --- /dev/null +++ b/src/main/java/com/swyp8team2/auth/application/oauth/dto/OAuthUserInfo.java @@ -0,0 +1,11 @@ +package com.swyp8team2.auth.application.oauth.dto; + +import com.swyp8team2.auth.domain.Provider; + +public record OAuthUserInfo( + String socialId, + String profileImageUrl, + String nickname, + Provider provider +) { +} diff --git a/src/main/java/com/swyp8team2/auth/domain/Provider.java b/src/main/java/com/swyp8team2/auth/domain/Provider.java new file mode 100644 index 00000000..6469c1c4 --- /dev/null +++ b/src/main/java/com/swyp8team2/auth/domain/Provider.java @@ -0,0 +1,24 @@ +package com.swyp8team2.auth.domain; + +import com.swyp8team2.common.exception.ErrorCode; +import com.swyp8team2.common.exception.InternalServerException; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; + +@Getter +@RequiredArgsConstructor +public enum Provider { + + KAKAO("kakao"); + + private final String registrationId; + + public static Provider of(String registrationId) { + return Arrays.stream(Provider.values()) + .filter(provider -> provider.registrationId.equals(registrationId)) + .findFirst() + .orElseThrow(() -> new InternalServerException(ErrorCode.INVALID_INPUT_VALUE)); + } +} diff --git a/src/main/java/com/swyp8team2/auth/domain/RefreshToken.java b/src/main/java/com/swyp8team2/auth/domain/RefreshToken.java new file mode 100644 index 00000000..3f005c67 --- /dev/null +++ b/src/main/java/com/swyp8team2/auth/domain/RefreshToken.java @@ -0,0 +1,45 @@ +package com.swyp8team2.auth.domain; + +import com.swyp8team2.common.exception.BadRequestException; +import com.swyp8team2.common.exception.ErrorCode; +import com.swyp8team2.common.util.Validator; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RefreshToken { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private Long userId; + + private String token; + + public RefreshToken(Long userId, String token) { + Validator.validateNull(userId); + Validator.validateEmptyString(token); + this.userId = userId; + this.token = token; + } + + public void rotate(String currentToken, String newToken) { + if (!this.token.equals(currentToken)) { + throw new BadRequestException(ErrorCode.REFRESH_TOKEN_MISMATCHED); + } + setRefreshToken(newToken); + } + + public void setRefreshToken(String token) { + Validator.validateEmptyString(token); + this.token = token; + } +} diff --git a/src/main/java/com/swyp8team2/auth/domain/RefreshTokenRepository.java b/src/main/java/com/swyp8team2/auth/domain/RefreshTokenRepository.java new file mode 100644 index 00000000..625a1c74 --- /dev/null +++ b/src/main/java/com/swyp8team2/auth/domain/RefreshTokenRepository.java @@ -0,0 +1,11 @@ +package com.swyp8team2.auth.domain; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface RefreshTokenRepository extends JpaRepository { + Optional findByUserId(Long userId); + + void deleteByUserId(Long userId); +} diff --git a/src/main/java/com/swyp8team2/auth/domain/SocialAccount.java b/src/main/java/com/swyp8team2/auth/domain/SocialAccount.java new file mode 100644 index 00000000..3675e6c4 --- /dev/null +++ b/src/main/java/com/swyp8team2/auth/domain/SocialAccount.java @@ -0,0 +1,48 @@ +package com.swyp8team2.auth.domain; + +import com.swyp8team2.auth.application.oauth.dto.OAuthUserInfo; +import com.swyp8team2.common.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import static com.swyp8team2.common.util.Validator.validateEmptyString; +import static com.swyp8team2.common.util.Validator.validateNull; + +@Getter +@Entity +@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED) +public class SocialAccount extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private Long userId; + + @Column(nullable = false, unique = true) + private String socialId; + + @Enumerated(EnumType.STRING) + private Provider provider; + + public SocialAccount(Long id, Long userId, String socialId, Provider provider) { + validateNull(userId, provider); + validateEmptyString(socialId); + this.id = id; + this.userId = userId; + this.socialId = socialId; + this.provider = provider; + } + + public static SocialAccount create(Long userId, OAuthUserInfo oAuthUserInfo) { + validateNull(oAuthUserInfo); + return new SocialAccount(null, userId, oAuthUserInfo.socialId(), oAuthUserInfo.provider()); + } +} diff --git a/src/main/java/com/swyp8team2/auth/domain/SocialAccountRepository.java b/src/main/java/com/swyp8team2/auth/domain/SocialAccountRepository.java new file mode 100644 index 00000000..4de3f26d --- /dev/null +++ b/src/main/java/com/swyp8team2/auth/domain/SocialAccountRepository.java @@ -0,0 +1,12 @@ +package com.swyp8team2.auth.domain; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface SocialAccountRepository extends JpaRepository { + + Optional findBySocialIdAndProvider(String socialId, Provider provider); +} diff --git a/src/main/java/com/swyp8team2/auth/domain/UserInfo.java b/src/main/java/com/swyp8team2/auth/domain/UserInfo.java new file mode 100644 index 00000000..84ac2c45 --- /dev/null +++ b/src/main/java/com/swyp8team2/auth/domain/UserInfo.java @@ -0,0 +1,34 @@ +package com.swyp8team2.auth.domain; + +import com.swyp8team2.user.domain.Role; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +import static com.swyp8team2.common.util.Validator.validateNull; + +public record UserInfo(long userId, Role role) implements UserDetails { + + public UserInfo { + validateNull(userId); + } + + @Override + public Collection getAuthorities() { + return List.of(new SimpleGrantedAuthority("ROLE_" + role.name())); + } + + @Override + public String getPassword() { + return ""; + } + + @Override + public String getUsername() { + return String.valueOf(userId); + } +} diff --git a/src/main/java/com/swyp8team2/auth/presentation/AuthController.java b/src/main/java/com/swyp8team2/auth/presentation/AuthController.java new file mode 100644 index 00000000..f0c6eeb4 --- /dev/null +++ b/src/main/java/com/swyp8team2/auth/presentation/AuthController.java @@ -0,0 +1,82 @@ +package com.swyp8team2.auth.presentation; + + +import com.swyp8team2.auth.application.AuthService; +import com.swyp8team2.auth.application.jwt.TokenPair; +import com.swyp8team2.auth.domain.UserInfo; +import com.swyp8team2.auth.presentation.dto.GuestTokenResponse; +import com.swyp8team2.auth.presentation.dto.OAuthSignInRequest; +import com.swyp8team2.auth.presentation.dto.TokenResponse; +import com.swyp8team2.auth.presentation.dto.AuthResponse; +import com.swyp8team2.common.exception.BadRequestException; +import com.swyp8team2.common.exception.ErrorCode; +import com.swyp8team2.common.presentation.CustomHeader; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.CookieValue; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Objects; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/auth") +public class AuthController { + + private final RefreshTokenCookieGenerator refreshTokenCookieGenerator; + private final AuthService authService; + + @PostMapping("/oauth2/code/kakao") + public ResponseEntity kakaoOAuthSignIn( + @Valid @RequestBody OAuthSignInRequest request, + HttpServletResponse response + ) { + TokenResponse tokenResponse = authService.oauthSignIn(request.code(), request.redirectUri()); + TokenPair tokenPair = tokenResponse.tokenPair(); + Cookie cookie = refreshTokenCookieGenerator.createCookie(tokenPair.refreshToken()); + response.addCookie(cookie); + return ResponseEntity.ok(new AuthResponse(tokenPair.accessToken(), tokenResponse.userId())); + } + + @PostMapping("/reissue") + public ResponseEntity reissue( + @CookieValue(name = CustomHeader.CustomCookie.REFRESH_TOKEN, required = false) String refreshToken, + HttpServletResponse response + ) { + if (Objects.isNull(refreshToken)) { + throw new BadRequestException(ErrorCode.INVALID_REFRESH_TOKEN_HEADER); + } + TokenResponse tokenResponse = authService.reissue(refreshToken); + TokenPair tokenPair = tokenResponse.tokenPair(); + Cookie cookie = refreshTokenCookieGenerator.createCookie(tokenPair.refreshToken()); + response.addCookie(cookie); + return ResponseEntity.ok(new AuthResponse(tokenPair.accessToken(), tokenResponse.userId())); + } + + @PostMapping("/guest/token") + public ResponseEntity guestToken() { + String guestToken = authService.createGuestToken(); + return ResponseEntity.ok(new GuestTokenResponse(guestToken)); + } + + @PostMapping("/sign-out") + public ResponseEntity signOut( + @CookieValue(name = CustomHeader.CustomCookie.REFRESH_TOKEN, required = false) String refreshToken, + HttpServletResponse response, + @AuthenticationPrincipal UserInfo userInfo + ) { + if (Objects.isNull(refreshToken)) { + throw new BadRequestException(ErrorCode.INVALID_REFRESH_TOKEN_HEADER); + } + refreshTokenCookieGenerator.removeCookie(response); + authService.signOut(userInfo.userId(), refreshToken); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/swyp8team2/auth/presentation/RefreshTokenCookieGenerator.java b/src/main/java/com/swyp8team2/auth/presentation/RefreshTokenCookieGenerator.java new file mode 100644 index 00000000..825df15d --- /dev/null +++ b/src/main/java/com/swyp8team2/auth/presentation/RefreshTokenCookieGenerator.java @@ -0,0 +1,43 @@ +package com.swyp8team2.auth.presentation; + +import com.swyp8team2.common.presentation.CustomHeader; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class RefreshTokenCookieGenerator { + + @Value("${spring.profiles.active:default}") + private String activeProfile; + + public Cookie createCookie(String refreshToken) { + Cookie cookie = new Cookie(CustomHeader.CustomCookie.REFRESH_TOKEN, refreshToken); + cookie.setHttpOnly(true); + if ("local".equals(activeProfile)) { + cookie.setSecure(false); + } else { + cookie.setSecure(true); + cookie.setAttribute("SameSite", "None"); + } + cookie.setPath("/"); + cookie.setMaxAge(60 * 60 * 24 * 14); + return cookie; + } + + public void removeCookie(HttpServletResponse response) { + Cookie cookie = new Cookie(CustomHeader.CustomCookie.REFRESH_TOKEN, null); + cookie.setHttpOnly(true); + cookie.setSecure(true); + if ("local".equals(activeProfile)) { + cookie.setSecure(false); + } else { + cookie.setSecure(true); + cookie.setAttribute("SameSite", "None"); + } + cookie.setPath("/"); + cookie.setMaxAge(0); + response.addCookie(cookie); + } +} diff --git a/src/main/java/com/swyp8team2/auth/presentation/dto/AuthResponse.java b/src/main/java/com/swyp8team2/auth/presentation/dto/AuthResponse.java new file mode 100644 index 00000000..65d2c20a --- /dev/null +++ b/src/main/java/com/swyp8team2/auth/presentation/dto/AuthResponse.java @@ -0,0 +1,4 @@ +package com.swyp8team2.auth.presentation.dto; + +public record AuthResponse(String accessToken, Long userId) { +} diff --git a/src/main/java/com/swyp8team2/auth/presentation/dto/GuestTokenResponse.java b/src/main/java/com/swyp8team2/auth/presentation/dto/GuestTokenResponse.java new file mode 100644 index 00000000..ee76d2f6 --- /dev/null +++ b/src/main/java/com/swyp8team2/auth/presentation/dto/GuestTokenResponse.java @@ -0,0 +1,4 @@ +package com.swyp8team2.auth.presentation.dto; + +public record GuestTokenResponse(String guestToken) { +} diff --git a/src/main/java/com/swyp8team2/auth/presentation/dto/OAuthSignInRequest.java b/src/main/java/com/swyp8team2/auth/presentation/dto/OAuthSignInRequest.java new file mode 100644 index 00000000..7fa26ca1 --- /dev/null +++ b/src/main/java/com/swyp8team2/auth/presentation/dto/OAuthSignInRequest.java @@ -0,0 +1,12 @@ +package com.swyp8team2.auth.presentation.dto; + +import jakarta.validation.constraints.NotNull; + +public record OAuthSignInRequest( + @NotNull + String code, + + @NotNull + String redirectUri +) { +} diff --git a/src/main/java/com/swyp8team2/auth/presentation/dto/TokenResponse.java b/src/main/java/com/swyp8team2/auth/presentation/dto/TokenResponse.java new file mode 100644 index 00000000..dad3501e --- /dev/null +++ b/src/main/java/com/swyp8team2/auth/presentation/dto/TokenResponse.java @@ -0,0 +1,9 @@ +package com.swyp8team2.auth.presentation.dto; + +import com.swyp8team2.auth.application.jwt.TokenPair; + +public record TokenResponse( + TokenPair tokenPair, + Long userId +) { +} diff --git a/src/main/java/com/swyp8team2/auth/presentation/filter/GuestAuthFilter.java b/src/main/java/com/swyp8team2/auth/presentation/filter/GuestAuthFilter.java new file mode 100644 index 00000000..a7f6a006 --- /dev/null +++ b/src/main/java/com/swyp8team2/auth/presentation/filter/GuestAuthFilter.java @@ -0,0 +1,65 @@ +package com.swyp8team2.auth.presentation.filter; + +import com.swyp8team2.auth.domain.UserInfo; +import com.swyp8team2.common.annotation.GuestTokenCryptoService; +import com.swyp8team2.common.exception.ApplicationException; +import com.swyp8team2.common.exception.BadRequestException; +import com.swyp8team2.common.exception.ErrorCode; +import com.swyp8team2.common.presentation.CustomHeader; +import com.swyp8team2.crypto.application.CryptoService; +import com.swyp8team2.user.domain.Role; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.util.AntPathMatcher; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Objects; + +import static com.swyp8team2.auth.presentation.filter.JwtAuthenticationEntryPoint.EXCEPTION_KEY; + +@Slf4j +public class GuestAuthFilter extends OncePerRequestFilter { + + private final CryptoService cryptoService; + + public GuestAuthFilter(@GuestTokenCryptoService CryptoService cryptoService) { + this.cryptoService = cryptoService; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + try { + AntPathMatcher matcher = new AntPathMatcher(); + if (!matcher.match("/posts/{postId}/votes/guest", request.getRequestURI())) { + return; + } + String token = request.getHeader(CustomHeader.GUEST_TOKEN); + if (Objects.isNull(token)) { + throw new BadRequestException(ErrorCode.INVALID_GUEST_HEADER); + } + String guestId = cryptoService.decrypt(token); + Authentication authentication = getAuthentication(Long.parseLong(guestId)); + SecurityContextHolder.getContext().setAuthentication(authentication); + } catch (ApplicationException e) { + request.setAttribute(EXCEPTION_KEY, e); + } catch (Exception e) { + log.debug("GuestAuthFilter error", e); + request.setAttribute(EXCEPTION_KEY, new BadRequestException(ErrorCode.INVALID_TOKEN)); + } finally { + doFilter(request, response, filterChain); + } + } + + private Authentication getAuthentication(long userId) { + UserInfo userInfo = new UserInfo(userId, Role.GUEST); + return new UsernamePasswordAuthenticationToken(userInfo, null, userInfo.getAuthorities()); + } +} diff --git a/src/main/java/com/swyp8team2/auth/presentation/filter/HeaderTokenExtractor.java b/src/main/java/com/swyp8team2/auth/presentation/filter/HeaderTokenExtractor.java new file mode 100644 index 00000000..4bd616c7 --- /dev/null +++ b/src/main/java/com/swyp8team2/auth/presentation/filter/HeaderTokenExtractor.java @@ -0,0 +1,18 @@ +package com.swyp8team2.auth.presentation.filter; + +import com.swyp8team2.common.exception.ErrorCode; +import com.swyp8team2.common.exception.UnauthorizedException; + +import java.util.Objects; + +public class HeaderTokenExtractor { + + public static final String BEARER = "Bearer "; + + public String extractToken(String authorization) { + if (Objects.isNull(authorization) || !authorization.startsWith(BEARER)) { + throw new UnauthorizedException(ErrorCode.INVALID_AUTH_HEADER); + } + return authorization.substring(BEARER.length()); + } +} diff --git a/src/main/java/com/swyp8team2/auth/presentation/filter/JwtAuthFilter.java b/src/main/java/com/swyp8team2/auth/presentation/filter/JwtAuthFilter.java new file mode 100644 index 00000000..b7f16f08 --- /dev/null +++ b/src/main/java/com/swyp8team2/auth/presentation/filter/JwtAuthFilter.java @@ -0,0 +1,51 @@ +package com.swyp8team2.auth.presentation.filter; + +import com.swyp8team2.auth.application.jwt.JwtClaim; +import com.swyp8team2.auth.application.jwt.JwtProvider; +import com.swyp8team2.auth.domain.UserInfo; +import com.swyp8team2.common.exception.ApplicationException; +import com.swyp8team2.user.domain.Role; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +import static com.swyp8team2.auth.presentation.filter.JwtAuthenticationEntryPoint.EXCEPTION_KEY; + +@Slf4j +@RequiredArgsConstructor +public class JwtAuthFilter extends OncePerRequestFilter { + + private final JwtProvider jwtProvider; + private final HeaderTokenExtractor headerTokenExtractor; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + try { + String authorization = request.getHeader(HttpHeaders.AUTHORIZATION); + JwtClaim claim = jwtProvider.parseToken(headerTokenExtractor.extractToken(authorization)); + + Authentication authentication = getAuthentication(claim.idAsLong()); + SecurityContextHolder.getContext().setAuthentication(authentication); + } catch (ApplicationException e) { + request.setAttribute(EXCEPTION_KEY, e); + } finally { + doFilter(request, response, filterChain); + } + } + + private Authentication getAuthentication(long userId) { + UserInfo userInfo = new UserInfo(userId, Role.USER); + return new UsernamePasswordAuthenticationToken(userInfo, null, userInfo.getAuthorities()); + } +} diff --git a/src/main/java/com/swyp8team2/auth/presentation/filter/JwtAuthenticationEntryPoint.java b/src/main/java/com/swyp8team2/auth/presentation/filter/JwtAuthenticationEntryPoint.java new file mode 100644 index 00000000..e39ca988 --- /dev/null +++ b/src/main/java/com/swyp8team2/auth/presentation/filter/JwtAuthenticationEntryPoint.java @@ -0,0 +1,39 @@ +package com.swyp8team2.auth.presentation.filter; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.web.servlet.HandlerExceptionResolver; + +import java.io.IOException; +import java.util.Objects; + +@Slf4j +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + public static final String EXCEPTION_KEY = "exception"; + + private final HandlerExceptionResolver exceptionResolver; + + public JwtAuthenticationEntryPoint( + @Qualifier("handlerExceptionResolver") HandlerExceptionResolver exceptionResolver + ) { + this.exceptionResolver = exceptionResolver; + } + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException + ) throws IOException { + Exception e = (Exception) request.getAttribute(EXCEPTION_KEY); + + if (Objects.nonNull(e)) { + exceptionResolver.resolveException(request, response, null, e); + } + } +} diff --git a/src/main/java/com/swyp8team2/comment/application/CommentService.java b/src/main/java/com/swyp8team2/comment/application/CommentService.java new file mode 100644 index 00000000..bfe61db3 --- /dev/null +++ b/src/main/java/com/swyp8team2/comment/application/CommentService.java @@ -0,0 +1,53 @@ +package com.swyp8team2.comment.application; + +import com.swyp8team2.auth.domain.UserInfo; +import com.swyp8team2.comment.domain.Comment; +import com.swyp8team2.comment.domain.CommentRepository; +import com.swyp8team2.comment.presentation.dto.CommentResponse; +import com.swyp8team2.comment.presentation.dto.CreateCommentRequest; +import com.swyp8team2.common.dto.CursorBasePaginatedResponse; +import com.swyp8team2.common.exception.BadRequestException; +import com.swyp8team2.common.exception.ErrorCode; +import com.swyp8team2.user.domain.User; +import com.swyp8team2.user.domain.UserRepository; +import com.swyp8team2.vote.domain.Vote; +import com.swyp8team2.vote.domain.VoteRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +@Slf4j +public class CommentService { + + private final UserRepository userRepository; + private final CommentRepository commentRepository; + private final VoteRepository voteRepository; + + @Transactional + public void createComment(Long postId, CreateCommentRequest request, UserInfo userInfo) { + Comment comment = new Comment(postId, userInfo.userId(), request.content()); + commentRepository.save(comment); + } + + public CursorBasePaginatedResponse findComments(Long userId, Long postId, Long cursor, int size) { + Slice commentSlice = commentRepository.findByPostId(postId, cursor, PageRequest.ofSize(size)); + return CursorBasePaginatedResponse.of( + commentSlice.map(comment -> createCommentResponse(comment, userId)) + ); + } + + private CommentResponse createCommentResponse(Comment comment, Long userId) { + User author = userRepository.findById(comment.getUserNo()) + .orElseThrow(() -> new BadRequestException(ErrorCode.USER_NOT_FOUND)); + Long voteImageId = voteRepository.findByUserIdAndPostId(userId, comment.getPostId()) + .map(Vote::getPostImageId) + .orElse(null); + return CommentResponse.of(comment, author, author.getId().equals(userId), voteImageId); + } +} diff --git a/src/main/java/com/swyp8team2/comment/domain/Comment.java b/src/main/java/com/swyp8team2/comment/domain/Comment.java new file mode 100644 index 00000000..686c4d38 --- /dev/null +++ b/src/main/java/com/swyp8team2/comment/domain/Comment.java @@ -0,0 +1,52 @@ +package com.swyp8team2.comment.domain; + +import com.swyp8team2.common.domain.BaseEntity; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.GenerationType; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import static com.swyp8team2.common.util.Validator.validateEmptyString; +import static com.swyp8team2.common.util.Validator.validateNull; + +@Entity +@Getter +@Table(name = "comments") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Comment extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotNull + private Long postId; + + @NotNull + private Long userNo; + + @NotNull + private String content; + + public Comment(Long id, Long postId, Long userNo, String content) { + validateNull(postId, userNo); + validateEmptyString(content); + this.id = id; + this.postId = postId; + this.userNo = userNo; + this.content = content; + } + + public Comment(Long postId, Long userNo, String content) { + validateNull(postId, userNo); + validateEmptyString(content); + this.postId = postId; + this.userNo = userNo; + this.content = content; + } +} diff --git a/src/main/java/com/swyp8team2/comment/domain/CommentRepository.java b/src/main/java/com/swyp8team2/comment/domain/CommentRepository.java new file mode 100644 index 00000000..0dfbfbb9 --- /dev/null +++ b/src/main/java/com/swyp8team2/comment/domain/CommentRepository.java @@ -0,0 +1,26 @@ +package com.swyp8team2.comment.domain; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface CommentRepository extends JpaRepository { + + @Query(""" + SELECT c + FROM Comment c + WHERE c.postId = :postId + AND (:cursor is null or c.id > :cursor) + ORDER BY c.createdAt ASC + """) + Slice findByPostId( + @Param("postId") Long postId, + @Param("cursor") Long cursor, + Pageable pageable + ); + +} diff --git a/src/main/java/com/swyp8team2/comment/presentation/CommentController.java b/src/main/java/com/swyp8team2/comment/presentation/CommentController.java new file mode 100644 index 00000000..b727fb2b --- /dev/null +++ b/src/main/java/com/swyp8team2/comment/presentation/CommentController.java @@ -0,0 +1,63 @@ +package com.swyp8team2.comment.presentation; + +import com.swyp8team2.auth.domain.UserInfo; +import com.swyp8team2.comment.application.CommentService; +import com.swyp8team2.comment.presentation.dto.AuthorDto; +import com.swyp8team2.comment.presentation.dto.CommentResponse; +import com.swyp8team2.comment.presentation.dto.CreateCommentRequest; +import com.swyp8team2.common.dto.CursorBasePaginatedResponse; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +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.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/posts/{postId}/comments") +public class CommentController { + + private final CommentService commentService; + + @PostMapping("") + public ResponseEntity createComment( + @PathVariable("postId") Long postId, + @Valid @RequestBody CreateCommentRequest request, + @AuthenticationPrincipal UserInfo userInfo + ) { + commentService.createComment(postId, request, userInfo); + return ResponseEntity.ok().build(); + } + + @GetMapping("") + public ResponseEntity> selectComments( + @PathVariable("postId") Long postId, + @RequestParam(value = "cursor", required = false) @Min(0) Long cursor, + @RequestParam(value = "size", required = false, defaultValue = "10") @Min(1) int size, + @AuthenticationPrincipal UserInfo userInfo + ) { + Long userId = Optional.ofNullable(userInfo).map(UserInfo::userId).orElse(null); + return ResponseEntity.ok(commentService.findComments(userId, postId, cursor, size)); + } + + @DeleteMapping("/{commentId}") + public ResponseEntity deleteComment( + @PathVariable("postId") Long postId, + @PathVariable("commentId") Long commentId, + @AuthenticationPrincipal UserInfo userInfo + ) { + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/swyp8team2/comment/presentation/dto/AuthorDto.java b/src/main/java/com/swyp8team2/comment/presentation/dto/AuthorDto.java new file mode 100644 index 00000000..69cff2d9 --- /dev/null +++ b/src/main/java/com/swyp8team2/comment/presentation/dto/AuthorDto.java @@ -0,0 +1,8 @@ +package com.swyp8team2.comment.presentation.dto; + +public record AuthorDto( + Long userId, + String nickname, + String profileUrl +) { +} diff --git a/src/main/java/com/swyp8team2/comment/presentation/dto/CommentResponse.java b/src/main/java/com/swyp8team2/comment/presentation/dto/CommentResponse.java new file mode 100644 index 00000000..89848202 --- /dev/null +++ b/src/main/java/com/swyp8team2/comment/presentation/dto/CommentResponse.java @@ -0,0 +1,34 @@ +package com.swyp8team2.comment.presentation.dto; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.swyp8team2.comment.domain.Comment; +import com.swyp8team2.common.dto.CursorDto; +import com.swyp8team2.user.domain.User; + +import java.time.LocalDateTime; + +public record CommentResponse( + Long commentId, + String content, + AuthorDto author, + Long voteImageId, + LocalDateTime createdAt, + boolean isAuthor +) implements CursorDto { + + @Override + @JsonIgnore + public long getId() { + return commentId; + } + + public static CommentResponse of(Comment comment, User user, boolean isAuthor, Long voteImageId) { + return new CommentResponse(comment.getId(), + comment.getContent(), + new AuthorDto(user.getId(), user.getNickname(), user.getProfileUrl()), + voteImageId, + comment.getCreatedAt(), + isAuthor + ); + } +} diff --git a/src/main/java/com/swyp8team2/comment/presentation/dto/CreateCommentRequest.java b/src/main/java/com/swyp8team2/comment/presentation/dto/CreateCommentRequest.java new file mode 100644 index 00000000..624a0fed --- /dev/null +++ b/src/main/java/com/swyp8team2/comment/presentation/dto/CreateCommentRequest.java @@ -0,0 +1,9 @@ +package com.swyp8team2.comment.presentation.dto; + +import jakarta.validation.constraints.NotEmpty; + +public record CreateCommentRequest( + @NotEmpty + String content +) { +} diff --git a/src/main/java/com/swyp8team2/common/annotation/GuestTokenCryptoService.java b/src/main/java/com/swyp8team2/common/annotation/GuestTokenCryptoService.java new file mode 100644 index 00000000..90a6e2db --- /dev/null +++ b/src/main/java/com/swyp8team2/common/annotation/GuestTokenCryptoService.java @@ -0,0 +1,16 @@ +package com.swyp8team2.common.annotation; + +import org.springframework.beans.factory.annotation.Qualifier; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Qualifier(GuestTokenCryptoService.QUALIFIER) +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.PARAMETER, ElementType.METHOD}) +public @interface GuestTokenCryptoService { + + String QUALIFIER = "guestTokenCryptoService"; +} diff --git a/src/main/java/com/swyp8team2/common/annotation/ShareUrlCryptoService.java b/src/main/java/com/swyp8team2/common/annotation/ShareUrlCryptoService.java new file mode 100644 index 00000000..82be1bc7 --- /dev/null +++ b/src/main/java/com/swyp8team2/common/annotation/ShareUrlCryptoService.java @@ -0,0 +1,16 @@ +package com.swyp8team2.common.annotation; + +import org.springframework.beans.factory.annotation.Qualifier; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Qualifier(ShareUrlCryptoService.QUALIFIER) +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.PARAMETER, ElementType.METHOD, ElementType.FIELD}) +public @interface ShareUrlCryptoService { + + String QUALIFIER = "shareUrlCryptoService"; +} diff --git a/src/main/java/com/swyp8team2/common/config/CommonConfig.java b/src/main/java/com/swyp8team2/common/config/CommonConfig.java new file mode 100644 index 00000000..efd9c6a0 --- /dev/null +++ b/src/main/java/com/swyp8team2/common/config/CommonConfig.java @@ -0,0 +1,18 @@ +package com.swyp8team2.common.config; + +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.Clock; + +@Configuration +@ConfigurationPropertiesScan +public class CommonConfig { + + + @Bean + public Clock clock() { + return Clock.systemDefaultZone(); + } +} diff --git a/src/main/java/com/swyp8team2/common/config/CorsConfig.java b/src/main/java/com/swyp8team2/common/config/CorsConfig.java new file mode 100644 index 00000000..969bfe20 --- /dev/null +++ b/src/main/java/com/swyp8team2/common/config/CorsConfig.java @@ -0,0 +1,41 @@ +package com.swyp8team2.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; + +@Configuration +public class CorsConfig { + + @Bean + @Profile("prod") + public UrlBasedCorsConfigurationSource corsConfigurationSourceProd() { + CorsConfiguration configuration = getCorsConfiguration(); + configuration.setAllowedOrigins(List.of("https://photopic.site")); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } + + @Bean + @Profile({"local", "dev", "default", "test"}) + public UrlBasedCorsConfigurationSource corsConfigurationSourceLocal() { + CorsConfiguration configuration = getCorsConfiguration(); + configuration.setAllowedOrigins(List.of("http://localhost:5173", "https://dev.photopic.site")); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } + + private static CorsConfiguration getCorsConfiguration() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedMethods(List.of("GET","POST", "PUT", "DELETE", "PATCH")); + configuration.setAllowCredentials(true); + configuration.addAllowedHeader("*"); + return configuration; + } +} diff --git a/src/main/java/com/swyp8team2/common/config/CryptoConfig.java b/src/main/java/com/swyp8team2/common/config/CryptoConfig.java new file mode 100644 index 00000000..7e722dfd --- /dev/null +++ b/src/main/java/com/swyp8team2/common/config/CryptoConfig.java @@ -0,0 +1,39 @@ +package com.swyp8team2.common.config; + +import com.swyp8team2.common.annotation.GuestTokenCryptoService; +import com.swyp8team2.crypto.application.CryptoService; +import com.swyp8team2.common.annotation.ShareUrlCryptoService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.encrypt.AesBytesEncryptor; + +@Configuration +public class CryptoConfig { + + private final String guestTokenSymmetricKey; + private final String shareUrlSymmetricKey; + private final String salt; + + public CryptoConfig( + @Value("${crypto.secret-key.guest-token}") String guestTokenSymmetricKey, + @Value("${crypto.secret-key.share-url}") String shareUrlSymmetricKey, + @Value("${crypto.salt}") String salt + ) { + this.guestTokenSymmetricKey = guestTokenSymmetricKey; + this.shareUrlSymmetricKey = shareUrlSymmetricKey; + this.salt = salt; + } + + @GuestTokenCryptoService + @Bean(name = GuestTokenCryptoService.QUALIFIER) + public CryptoService guestTokenCryptoService() throws Exception { + return new CryptoService(new AesBytesEncryptor(guestTokenSymmetricKey, salt)); + } + + @ShareUrlCryptoService + @Bean(name = ShareUrlCryptoService.QUALIFIER) + public CryptoService shareUrlCryptoService() throws Exception { + return new CryptoService(new AesBytesEncryptor(shareUrlSymmetricKey, salt)); + } +} diff --git a/src/main/java/com/swyp8team2/common/config/HttpInterfaceConfig.java b/src/main/java/com/swyp8team2/common/config/HttpInterfaceConfig.java new file mode 100644 index 00000000..6f385379 --- /dev/null +++ b/src/main/java/com/swyp8team2/common/config/HttpInterfaceConfig.java @@ -0,0 +1,26 @@ +package com.swyp8team2.common.config; + +import com.swyp8team2.auth.application.oauth.KakaoOAuthClient; +import com.swyp8team2.common.exception.DiscordClient; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.support.RestClientAdapter; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; + +@Configuration +public class HttpInterfaceConfig { + + @Bean + public KakaoOAuthClient kakaoAuthClient() { + RestClientAdapter adapter = RestClientAdapter.create(RestClient.create()); + HttpServiceProxyFactory build = HttpServiceProxyFactory + .builderFor(adapter).build(); + return build.createClient(KakaoOAuthClient.class); + } + + @Bean + public RestClient restClient() { + return RestClient.create(); + } +} diff --git a/src/main/java/com/swyp8team2/common/config/JpaConfig.java b/src/main/java/com/swyp8team2/common/config/JpaConfig.java new file mode 100644 index 00000000..9109b4a3 --- /dev/null +++ b/src/main/java/com/swyp8team2/common/config/JpaConfig.java @@ -0,0 +1,9 @@ +package com.swyp8team2.common.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +public class JpaConfig { +} diff --git a/src/main/java/com/swyp8team2/common/config/KakaoOAuthConfig.java b/src/main/java/com/swyp8team2/common/config/KakaoOAuthConfig.java new file mode 100644 index 00000000..7fcf0fe9 --- /dev/null +++ b/src/main/java/com/swyp8team2/common/config/KakaoOAuthConfig.java @@ -0,0 +1,13 @@ +package com.swyp8team2.common.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "oauth2.kakao") +public record KakaoOAuthConfig( + String authorizationUri, + String clientId, + String clientSecret, + String[] scope, + String userInfoUri +) { +} diff --git a/src/main/java/com/swyp8team2/common/config/SecurityConfig.java b/src/main/java/com/swyp8team2/common/config/SecurityConfig.java new file mode 100644 index 00000000..f283fa95 --- /dev/null +++ b/src/main/java/com/swyp8team2/common/config/SecurityConfig.java @@ -0,0 +1,126 @@ +package com.swyp8team2.common.config; + +import com.swyp8team2.auth.application.jwt.JwtProvider; +import com.swyp8team2.auth.presentation.filter.GuestAuthFilter; +import com.swyp8team2.auth.presentation.filter.HeaderTokenExtractor; +import com.swyp8team2.auth.presentation.filter.JwtAuthFilter; +import com.swyp8team2.auth.presentation.filter.JwtAuthenticationEntryPoint; +import com.swyp8team2.common.annotation.GuestTokenCryptoService; +import com.swyp8team2.crypto.application.CryptoService; +import com.swyp8team2.user.domain.Role; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.security.servlet.PathRequest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.servlet.util.matcher.MvcRequestMatcher; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.servlet.HandlerExceptionResolver; +import org.springframework.web.servlet.handler.HandlerMappingIntrospector; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + private final HandlerExceptionResolver handlerExceptionResolver; + private final CryptoService cryptoService; + + public SecurityConfig( + @Qualifier("handlerExceptionResolver") HandlerExceptionResolver handlerExceptionResolver, + @GuestTokenCryptoService CryptoService cryptoService + ) { + this.handlerExceptionResolver = handlerExceptionResolver; + this.cryptoService = cryptoService; + } + + @Bean + public WebSecurityCustomizer webSecurityCustomizer() { + return web -> web.ignoring() + .requestMatchers( + "/", + "/error", + "/index.html", + "/css/**", + "/images/**", + "/js/**", + "/favicon.ico", + "/docs/**", + "/actuator/health" + ); + } + + @Bean + @Profile("local") + @ConditionalOnProperty(name = "spring.h2.console.enabled", havingValue = "true") + public WebSecurityCustomizer configureH2ConsoleEnable() { + return web -> web.ignoring() + .requestMatchers(PathRequest.toH2Console()); + } + + @Bean + public SecurityFilterChain securityFilterChain( + HttpSecurity http, + HandlerMappingIntrospector introspect, + JwtProvider jwtProvider, + UrlBasedCorsConfigurationSource corsConfigurationSource + ) throws Exception { + http + .httpBasic(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .logout(AbstractHttpConfigurer::disable) + .csrf(AbstractHttpConfigurer::disable) + .cors(cors -> cors.configurationSource(corsConfigurationSource)) + .headers(headers -> + headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin)) + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + + .authorizeHttpRequests(authorize -> + authorize + .requestMatchers(getWhiteList(introspect)).permitAll() + .requestMatchers(getGuestTokenRequestList(introspect)) + .hasAnyRole(Role.USER.name(), Role.GUEST.name()) + .anyRequest().authenticated()) + + .addFilterBefore( + new JwtAuthFilter(jwtProvider, new HeaderTokenExtractor()), + UsernamePasswordAuthenticationFilter.class) + .addFilterAfter( + new GuestAuthFilter(cryptoService), + JwtAuthFilter.class + ) + .exceptionHandling(exception -> + exception.authenticationEntryPoint( + new JwtAuthenticationEntryPoint(handlerExceptionResolver))); + return http.build(); + } + + public static MvcRequestMatcher[] getWhiteList(HandlerMappingIntrospector introspect) { + MvcRequestMatcher.Builder mvc = new MvcRequestMatcher.Builder(introspect); + return new MvcRequestMatcher[]{ + mvc.pattern("/auth/reissue"), + mvc.pattern("/auth/guest/token"), + mvc.pattern(HttpMethod.GET, "/posts/shareUrl/{shareUrl}"), + mvc.pattern(HttpMethod.GET, "/posts/{postId}"), + mvc.pattern(HttpMethod.GET, "/posts/{postId}/comments"), + mvc.pattern("/auth/oauth2/**"), + }; + } + + public static MvcRequestMatcher[] getGuestTokenRequestList(HandlerMappingIntrospector introspect) { + MvcRequestMatcher.Builder mvc = new MvcRequestMatcher.Builder(introspect); + return new MvcRequestMatcher[]{ + mvc.pattern("/posts/{postId}/votes/guest"), + }; + } +} diff --git a/src/main/java/com/swyp8team2/common/dev/DataInitConfig.java b/src/main/java/com/swyp8team2/common/dev/DataInitConfig.java new file mode 100644 index 00000000..59bd22d8 --- /dev/null +++ b/src/main/java/com/swyp8team2/common/dev/DataInitConfig.java @@ -0,0 +1,19 @@ +package com.swyp8team2.common.dev; + +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +@Profile({"dev", "local"}) +@Component +@RequiredArgsConstructor +public class DataInitConfig { + + private final DataInitializer dataInitializer; + +// @PostConstruct + public void init() { + dataInitializer.init(); + } +} diff --git a/src/main/java/com/swyp8team2/common/dev/DataInitializer.java b/src/main/java/com/swyp8team2/common/dev/DataInitializer.java new file mode 100644 index 00000000..2328c4ef --- /dev/null +++ b/src/main/java/com/swyp8team2/common/dev/DataInitializer.java @@ -0,0 +1,101 @@ +package com.swyp8team2.common.dev; + +import com.swyp8team2.auth.application.jwt.JwtService; +import com.swyp8team2.auth.application.jwt.TokenPair; +import com.swyp8team2.auth.presentation.dto.TokenResponse; +import com.swyp8team2.comment.domain.Comment; +import com.swyp8team2.comment.domain.CommentRepository; +import com.swyp8team2.common.annotation.ShareUrlCryptoService; +import com.swyp8team2.crypto.application.CryptoService; +import com.swyp8team2.image.domain.ImageFile; +import com.swyp8team2.image.domain.ImageFileRepository; +import com.swyp8team2.image.presentation.dto.ImageFileDto; +import com.swyp8team2.post.application.PostService; +import com.swyp8team2.post.domain.Post; +import com.swyp8team2.post.domain.PostImage; +import com.swyp8team2.post.domain.PostRepository; +import com.swyp8team2.user.domain.NicknameAdjective; +import com.swyp8team2.user.domain.NicknameAdjectiveRepository; +import com.swyp8team2.user.domain.User; +import com.swyp8team2.user.domain.UserRepository; +import com.swyp8team2.vote.application.VoteService; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +@Profile({"dev", "local"}) +@Component +public class DataInitializer { + + private final NicknameAdjectiveRepository nicknameAdjectiveRepository; + private final UserRepository userRepository; + private final ImageFileRepository imageFileRepository; + private final PostRepository postRepository; + private final CryptoService shaereUrlCryptoService; + private final JwtService jwtService; + private final VoteService voteService; + private final CommentRepository commentRepository; + + public DataInitializer( + NicknameAdjectiveRepository nicknameAdjectiveRepository, + UserRepository userRepository, + ImageFileRepository imageFileRepository, + PostRepository postRepository, + @ShareUrlCryptoService CryptoService shaereUrlCryptoService, + JwtService jwtService, + VoteService voteService, + CommentRepository commentRepository + ) { + this.nicknameAdjectiveRepository = nicknameAdjectiveRepository; + this.userRepository = userRepository; + this.imageFileRepository = imageFileRepository; + this.postRepository = postRepository; + this.shaereUrlCryptoService = shaereUrlCryptoService; + this.jwtService = jwtService; + this.voteService = voteService; + this.commentRepository = commentRepository; + } + + + @Transactional + public void init() { + if (userRepository.count() > 0) { + return; + } + List adjectives = nicknameAdjectiveRepository.findAll(); + User testUser = userRepository.save(User.create("nickname", "https://t1.kakaocdn.net/account_images/default_profile.jpeg")); + TokenResponse tokenResponse = jwtService.createToken(testUser.getId()); + TokenPair tokenPair = tokenResponse.tokenPair(); + System.out.println("accessToken = " + tokenPair.accessToken()); + System.out.println("refreshToken = " + tokenPair.refreshToken()); + List users = new ArrayList<>(); + List posts = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + String userName = adjectives.size() < 10 ? "user" + i : adjectives.get(i).getAdjective(); + User user = userRepository.save(User.create(userName, "https://t1.kakaocdn.net/account_images/default_profile.jpeg")); + users.add(user); + for (int j = 0; j < 30; j += 2) { + ImageFile imageFile1 = imageFileRepository.save(ImageFile.create(new ImageFileDto("202502240006030.png", "https://image.photopic.site/images-dev/202502240006030.png", "https://image.photopic.site/images-dev/resized_202502240006030.png"))); + ImageFile imageFile2 = imageFileRepository.save(ImageFile.create(new ImageFileDto("202502240006030.png", "https://image.photopic.site/images-dev/202502240006030.png", "https://image.photopic.site/images-dev/resized_202502240006030.png"))); + Post post = postRepository.save(Post.create(user.getId(), "description" + j, List.of(PostImage.create("뽀또A", imageFile1.getId()), PostImage.create("뽀또B", imageFile2.getId())))); + post.setShareUrl(shaereUrlCryptoService.encrypt(String.valueOf(post.getId()))); + posts.add(post); + } + + } + for (User user : users) { + for (Post post : posts) { + Random random = new Random(); + int num = random.nextInt(2); + voteService.vote(user.getId(), post.getId(), post.getImages().get(num).getId()); + commentRepository.save(new Comment(post.getId(), user.getId(), "댓글 내용" + random.nextInt(100))); + } + } + } +} diff --git a/src/main/java/com/swyp8team2/common/domain/AuditorAwareImpl.java b/src/main/java/com/swyp8team2/common/domain/AuditorAwareImpl.java new file mode 100644 index 00000000..33a8add0 --- /dev/null +++ b/src/main/java/com/swyp8team2/common/domain/AuditorAwareImpl.java @@ -0,0 +1,2 @@ +package com.swyp8team2.common.domain;public class AuditorAwareImpl { +} diff --git a/src/main/java/com/swyp8team2/common/domain/BaseEntity.java b/src/main/java/com/swyp8team2/common/domain/BaseEntity.java new file mode 100644 index 00000000..1e75cd58 --- /dev/null +++ b/src/main/java/com/swyp8team2/common/domain/BaseEntity.java @@ -0,0 +1,33 @@ +package com.swyp8team2.common.domain; + +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import org.springframework.data.annotation.CreatedBy; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedBy; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Getter +@EntityListeners(AuditingEntityListener.class) +@MappedSuperclass +public abstract class BaseEntity { + + @CreatedBy + private Long createdBy; + + @CreatedDate + private LocalDateTime createdAt; + + @LastModifiedBy + private Long updatedBy; + + @LastModifiedDate + private LocalDateTime updatedAt; + + private boolean deleted = false; + private LocalDateTime deletedAt; +} diff --git a/src/main/java/com/swyp8team2/common/dto/CursorBasePaginatedResponse.java b/src/main/java/com/swyp8team2/common/dto/CursorBasePaginatedResponse.java new file mode 100644 index 00000000..379f2f23 --- /dev/null +++ b/src/main/java/com/swyp8team2/common/dto/CursorBasePaginatedResponse.java @@ -0,0 +1,20 @@ +package com.swyp8team2.common.dto; + +import org.springframework.data.domain.Slice; + +import java.util.List; + +public record CursorBasePaginatedResponse( + Long nextCursor, + boolean hasNext, + List data +) { + + public static CursorBasePaginatedResponse of(Slice slice) { + return new CursorBasePaginatedResponse<>( + slice.getContent().isEmpty() ? null : slice.getContent().getLast().getId(), + slice.hasNext(), + slice.getContent() + ); + } +} diff --git a/src/main/java/com/swyp8team2/common/dto/CursorDto.java b/src/main/java/com/swyp8team2/common/dto/CursorDto.java new file mode 100644 index 00000000..bb4ee23f --- /dev/null +++ b/src/main/java/com/swyp8team2/common/dto/CursorDto.java @@ -0,0 +1,6 @@ +package com.swyp8team2.common.dto; + +public interface CursorDto { + + long getId(); +} diff --git a/src/main/java/com/swyp8team2/common/exception/ApplicationControllerAdvice.java b/src/main/java/com/swyp8team2/common/exception/ApplicationControllerAdvice.java new file mode 100644 index 00000000..4dc5fd55 --- /dev/null +++ b/src/main/java/com/swyp8team2/common/exception/ApplicationControllerAdvice.java @@ -0,0 +1,91 @@ +package com.swyp8team2.common.exception; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.env.Environment; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingRequestHeaderException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.method.annotation.HandlerMethodValidationException; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.servlet.resource.NoResourceFoundException; + +import javax.naming.AuthenticationException; +import java.nio.file.AccessDeniedException; +import java.util.Arrays; + +@Slf4j +@RestControllerAdvice +@RequiredArgsConstructor +public class ApplicationControllerAdvice { + + private final DiscordMessageSender discordMessageSender; + private final Environment environment; + + @ExceptionHandler(BadRequestException.class) + public ResponseEntity handle(BadRequestException e) { + ErrorResponse response = new ErrorResponse(e.getErrorCode()); + return ResponseEntity.badRequest() + .body(response); + } + + @ExceptionHandler(UnauthorizedException.class) + public ResponseEntity handle(UnauthorizedException e) { + ErrorResponse response = new ErrorResponse(e.getErrorCode()); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(response); + } + + @ExceptionHandler({ + MethodArgumentNotValidException.class, + HttpMessageNotReadableException.class, + MissingRequestHeaderException.class, + HandlerMethodValidationException.class + }) + public ResponseEntity invalidArgument(Exception e) { + log.debug("invalidArgument: {}", e.getMessage()); + return ResponseEntity.badRequest() + .body(new ErrorResponse(ErrorCode.INVALID_ARGUMENT)); + } + + @ExceptionHandler({HttpRequestMethodNotSupportedException.class, MethodArgumentTypeMismatchException.class}) + public ResponseEntity notFound(HttpRequestMethodNotSupportedException e) { + log.debug("notFound: {}", e.getMessage()); + return ResponseEntity.notFound().build(); + } + + @ExceptionHandler(NoResourceFoundException.class) + public ResponseEntity handle(NoResourceFoundException e) { + log.debug("NoResourceFoundException {}", e.getMessage()); + return ResponseEntity.notFound().build(); + } + + + @ExceptionHandler(AuthenticationException.class) + public ResponseEntity handle(AuthenticationException e) { + log.info(e.getMessage()); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(new ErrorResponse(ErrorCode.INVALID_TOKEN)); + } + + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity handle(AccessDeniedException e) { + log.info(e.getMessage()); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(new ErrorResponse(ErrorCode.INVALID_TOKEN)); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handle(Exception e, WebRequest webRequest) { + log.error("Exception", e); + if (!Arrays.asList(environment.getActiveProfiles()).contains("local")) { + discordMessageSender.sendDiscordAlarm(e, webRequest); + } + return ResponseEntity.internalServerError() + .body(new ErrorResponse(ErrorCode.INTERNAL_SERVER_ERROR)); + } +} diff --git a/src/main/java/com/swyp8team2/common/exception/ApplicationException.java b/src/main/java/com/swyp8team2/common/exception/ApplicationException.java new file mode 100644 index 00000000..bad6539c --- /dev/null +++ b/src/main/java/com/swyp8team2/common/exception/ApplicationException.java @@ -0,0 +1,19 @@ +package com.swyp8team2.common.exception; + +import lombok.Getter; + +@Getter +public class ApplicationException extends RuntimeException { + + private final ErrorCode errorCode; + + public ApplicationException(Throwable cause, ErrorCode errorCode) { + super(cause); + this.errorCode = errorCode; + } + + public ApplicationException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/com/swyp8team2/common/exception/BadRequestException.java b/src/main/java/com/swyp8team2/common/exception/BadRequestException.java new file mode 100644 index 00000000..c428ca56 --- /dev/null +++ b/src/main/java/com/swyp8team2/common/exception/BadRequestException.java @@ -0,0 +1,8 @@ +package com.swyp8team2.common.exception; + +public class BadRequestException extends ApplicationException { + + public BadRequestException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/swyp8team2/common/exception/DiscordClient.java b/src/main/java/com/swyp8team2/common/exception/DiscordClient.java new file mode 100644 index 00000000..53de6ae4 --- /dev/null +++ b/src/main/java/com/swyp8team2/common/exception/DiscordClient.java @@ -0,0 +1,27 @@ +package com.swyp8team2.common.exception; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +@Component +public class DiscordClient { + + private final RestClient restClient; + private final String discordWebhookUrl; + + public DiscordClient( + RestClient restClient, + @Value("${discord.webhook.url}") String discordWebhookUrl) { + this.restClient = restClient; + this.discordWebhookUrl = discordWebhookUrl; + } + + public void sendAlarm(DiscordMessage request) { + restClient.post() + .uri(discordWebhookUrl) + .body(request) + .retrieve() + .toBodilessEntity(); + } +} diff --git a/src/main/java/com/swyp8team2/common/exception/DiscordMessage.java b/src/main/java/com/swyp8team2/common/exception/DiscordMessage.java new file mode 100644 index 00000000..9f966081 --- /dev/null +++ b/src/main/java/com/swyp8team2/common/exception/DiscordMessage.java @@ -0,0 +1,18 @@ +package com.swyp8team2.common.exception; + +import lombok.Builder; + +import java.util.List; + +@Builder +public record DiscordMessage( + String content, + List embeds +) { + + @Builder + record Embed( + String title, + String description + ) { } +} diff --git a/src/main/java/com/swyp8team2/common/exception/DiscordMessageSender.java b/src/main/java/com/swyp8team2/common/exception/DiscordMessageSender.java new file mode 100644 index 00000000..122e0d53 --- /dev/null +++ b/src/main/java/com/swyp8team2/common/exception/DiscordMessageSender.java @@ -0,0 +1,78 @@ +package com.swyp8team2.common.exception; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.ServletWebRequest; +import org.springframework.web.context.request.WebRequest; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.time.Clock; +import java.time.Instant; +import java.util.Arrays; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class DiscordMessageSender { + + private final DiscordClient discordClient; + private final Clock clock; + private final Environment environment; + + public void sendDiscordAlarm(Exception e, WebRequest request) { + discordClient.sendAlarm(createMessage(e, request)); + } + + private DiscordMessage createMessage(Exception e, WebRequest request) { + List profiles = Arrays.asList(environment.getActiveProfiles()); + return DiscordMessage.builder() + .content("# 🚨 에러 발생") + .embeds( + List.of( + DiscordMessage.Embed.builder() + .title("ℹ️ 에러 정보") + .description( + """ + ### 📝 환경 정보 + %s + ### 🕖 발생 시간 + %s + ### 🔗 요청 URL + %s + ### 📄 Stack Trace + ``` + %s + ``` + """.formatted( + String.join("", profiles), + Instant.now(clock), + createRequestFullPath(request), + getStackTrace(e).substring(0, 1000) + )) + .build() + ) + ) + .build(); + } + + private String createRequestFullPath(WebRequest webRequest) { + HttpServletRequest request = ((ServletWebRequest) webRequest).getRequest(); + String fullPath = request.getMethod() + " " + request.getRequestURL(); + + String queryString = request.getQueryString(); + if (queryString != null) { + fullPath += "?" + queryString; + } + + return fullPath; + } + + private String getStackTrace(Exception e) { + StringWriter stringWriter = new StringWriter(); + e.printStackTrace(new PrintWriter(stringWriter)); + return stringWriter.toString(); + } +} diff --git a/src/main/java/com/swyp8team2/common/exception/ErrorCode.java b/src/main/java/com/swyp8team2/common/exception/ErrorCode.java new file mode 100644 index 00000000..6ee4f4b3 --- /dev/null +++ b/src/main/java/com/swyp8team2/common/exception/ErrorCode.java @@ -0,0 +1,46 @@ +package com.swyp8team2.common.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ErrorCode { + //400 + USER_NOT_FOUND("존재하지 않는 유저"), + INVALID_ARGUMENT("잘못된 파라미터 요청"), + REFRESH_TOKEN_MISMATCHED("리프레시 토큰 불일치"), + REFRESH_TOKEN_NOT_FOUND("리프레시 토큰을 찾을 수 없음"), + INVALID_REFRESH_TOKEN_HEADER("잘못된 리프레시 토큰 헤더"), + MISSING_FILE_EXTENSION("확장자가 누락됨"), + UNSUPPORTED_FILE_EXTENSION("지원하지 않는 확장자"), + EXCEED_MAX_FILE_SIZE("파일 크기 초과"), + POST_NOT_FOUND("존재하지 않는 게시글"), + DESCRIPTION_LENGTH_EXCEEDED("게시글 설명 길이 초과"), + INVALID_POST_IMAGE_COUNT("게시글 이미지 개수 오류"), + NOT_POST_AUTHOR("게시글 작성자가 아님"), + POST_ALREADY_CLOSED("이미 마감된 게시글"), + INVALID_GUEST_HEADER("잘못된 게스트 토큰 헤더"), + FILE_NAME_TOO_LONG("파일 이름이 너무 김"), + + //401 + EXPIRED_TOKEN("토큰 만료"), + INVALID_TOKEN("유효하지 않은 토큰"), + INVALID_AUTH_HEADER("잘못된 인증 헤더"), + OAUTH_LOGIN_FAILED("소셜 로그인 실패"), + + //500 + INTERNAL_SERVER_ERROR("서버 내부 오류"), + INVALID_INPUT_VALUE("잘못된 입력 값"), + SOCIAL_AUTHENTICATION_FAILED("소셜 로그인 실패"), + POST_IMAGE_NAME_GENERATOR_INDEX_OUT_OF_BOUND("이미지 이름 생성기 인덱스 초과"), + IMAGE_FILE_NOT_FOUND("존재하지 않는 이미지"), + POST_IMAGE_NOT_FOUND("게시글 이미지 없음"), + SHARE_URL_ALREADY_EXISTS("공유 URL이 이미 존재"), + + //503 + SERVICE_UNAVAILABLE("서비스 이용 불가"), + ; + + private final String message; +} diff --git a/src/main/java/com/swyp8team2/common/exception/ErrorResponse.java b/src/main/java/com/swyp8team2/common/exception/ErrorResponse.java new file mode 100644 index 00000000..d4d166c7 --- /dev/null +++ b/src/main/java/com/swyp8team2/common/exception/ErrorResponse.java @@ -0,0 +1,4 @@ +package com.swyp8team2.common.exception; + +public record ErrorResponse(ErrorCode errorCode) { +} diff --git a/src/main/java/com/swyp8team2/common/exception/InternalServerException.java b/src/main/java/com/swyp8team2/common/exception/InternalServerException.java new file mode 100644 index 00000000..b29c8626 --- /dev/null +++ b/src/main/java/com/swyp8team2/common/exception/InternalServerException.java @@ -0,0 +1,16 @@ +package com.swyp8team2.common.exception; + +public class InternalServerException extends ApplicationException { + + public InternalServerException() { + super(ErrorCode.INTERNAL_SERVER_ERROR); + } + + public InternalServerException(Exception e) { + super(e, ErrorCode.INTERNAL_SERVER_ERROR); + } + + public InternalServerException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/swyp8team2/common/exception/ServiceUnavailableException.java b/src/main/java/com/swyp8team2/common/exception/ServiceUnavailableException.java new file mode 100644 index 00000000..3b15df8c --- /dev/null +++ b/src/main/java/com/swyp8team2/common/exception/ServiceUnavailableException.java @@ -0,0 +1,8 @@ +package com.swyp8team2.common.exception; + +public class ServiceUnavailableException extends ApplicationException { + + public ServiceUnavailableException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/swyp8team2/common/exception/UnauthorizedException.java b/src/main/java/com/swyp8team2/common/exception/UnauthorizedException.java new file mode 100644 index 00000000..6c12129d --- /dev/null +++ b/src/main/java/com/swyp8team2/common/exception/UnauthorizedException.java @@ -0,0 +1,8 @@ +package com.swyp8team2.common.exception; + +public class UnauthorizedException extends ApplicationException { + + public UnauthorizedException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/swyp8team2/common/presentation/CustomHeader.java b/src/main/java/com/swyp8team2/common/presentation/CustomHeader.java new file mode 100644 index 00000000..147385f9 --- /dev/null +++ b/src/main/java/com/swyp8team2/common/presentation/CustomHeader.java @@ -0,0 +1,11 @@ +package com.swyp8team2.common.presentation; + +public abstract class CustomHeader { + + public static final String GUEST_TOKEN = "Guest-Token"; + public static final String AUTHORIZATION_REFRESH = "Authorization-Refresh"; + + public static class CustomCookie{ + public static final String REFRESH_TOKEN = "refreshToken"; + } +} diff --git a/src/main/java/com/swyp8team2/common/presentation/HttpLoggingFilter.java b/src/main/java/com/swyp8team2/common/presentation/HttpLoggingFilter.java new file mode 100644 index 00000000..1794ab22 --- /dev/null +++ b/src/main/java/com/swyp8team2/common/presentation/HttpLoggingFilter.java @@ -0,0 +1,154 @@ +package com.swyp8team2.common.presentation; + +import com.nimbusds.jose.shaded.gson.Gson; +import com.nimbusds.jose.shaded.gson.GsonBuilder; +import com.nimbusds.jose.shaded.gson.JsonElement; +import com.nimbusds.jose.shaded.gson.JsonParser; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.MDC; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.util.ContentCachingRequestWrapper; +import org.springframework.web.util.ContentCachingResponseWrapper; +import org.springframework.web.util.WebUtils; + +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.nio.charset.StandardCharsets; +import java.util.Enumeration; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.stream.Stream; + +@Slf4j +@Component +public class HttpLoggingFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + if (request.getRequestURI().startsWith("/docs") || request.getRequestURI().startsWith("/h2-console")) { + chain.doFilter(request, response); + return; + } + ContentCachingRequestWrapper requestWrapper = new ContentCachingRequestWrapper(request); + ContentCachingResponseWrapper responseWrapper = new ContentCachingResponseWrapper(response); + + long start = System.currentTimeMillis(); + chain.doFilter(requestWrapper, responseWrapper); + long end = System.currentTimeMillis(); + + String requestId = UUID.randomUUID().toString().substring(0, 8); + + // logback 설정 해야됨 + MDC.put("requestId", requestId); + + try { + log.debug(""" + | + | [REQUEST] {} {} {} ({}s) + | Headers : {} + | RequestBody : {} + | RequestParams : {} + | Response : {} + """.trim(), + request.getMethod(), + request.getRequestURI(), + HttpStatus.valueOf(responseWrapper.getStatus()), + (end - start) / 1000.0, + getHeaders(request), + getRequestBody(requestWrapper), + getRequestParameters(request), + getResponseBody(responseWrapper) + ); + responseWrapper.copyBodyToResponse(); + } catch (Exception e) { + log.debug("Logging Error", e); + } finally { + MDC.clear(); + } + } + + private String getHeaders(HttpServletRequest request) { + StringBuilder sb = new StringBuilder("{\n"); + + List loggingHeaders = Stream.of( + HttpHeaders.USER_AGENT.toLowerCase(), + HttpHeaders.AUTHORIZATION.toLowerCase(), + HttpHeaders.COOKIE.toLowerCase(), + HttpHeaders.CONTENT_TYPE.toLowerCase(), + HttpHeaders.HOST.toLowerCase(), + HttpHeaders.REFERER.toLowerCase(), + HttpHeaders.ORIGIN.toLowerCase() + ).map(String::toLowerCase) + .toList(); + Enumeration headerArray = request.getHeaderNames(); + while (headerArray.hasMoreElements()) { + String headerName = headerArray.nextElement(); + if (loggingHeaders.contains(headerName)) { + sb.append("\t%s=%s,\n".formatted(headerName, request.getHeader(headerName))); + } + } + sb.append("}"); + return sb.toString(); + } + + private String getRequestParameters(HttpServletRequest request) { + Map paramMap = request.getParameterMap(); + StringBuilder params = new StringBuilder(); + + for (Map.Entry entry : paramMap.entrySet()) { + String key = entry.getKey(); + String[] values = entry.getValue(); + for (String value : values) { + params.append(key).append("=").append(value).append("&"); + } + } + + if (!params.isEmpty()) { + params.deleteCharAt(params.length() - 1); + } + + return params.toString(); + } + + private String getRequestBody(ContentCachingRequestWrapper request) { + ContentCachingRequestWrapper wrapper = WebUtils.getNativeRequest(request, ContentCachingRequestWrapper.class); + assert wrapper != null; + + byte[] buf = wrapper.getContentAsByteArray(); + if (buf.length > 0) { + try { + return new String(buf, wrapper.getCharacterEncoding()); + } catch (UnsupportedEncodingException e) { + return ""; + } + } + return ""; + } + + private String getResponseBody(final HttpServletResponse response) throws IOException { + String payload = null; + ContentCachingResponseWrapper wrapper = + WebUtils.getNativeResponse(response, ContentCachingResponseWrapper.class); + assert wrapper != null; + + byte[] buf = wrapper.getContentAsByteArray(); + if (buf.length > 0) { + Gson gson = new GsonBuilder().setPrettyPrinting().create(); + String responseBody = new String(buf, StandardCharsets.UTF_8); + JsonElement jsonElement = JsonParser.parseString(responseBody); + payload = gson.toJson(jsonElement); + } + wrapper.copyBodyToResponse(); + return Objects.isNull(payload) ? "" : payload; + } +} diff --git a/src/main/java/com/swyp8team2/common/util/DateTime.java b/src/main/java/com/swyp8team2/common/util/DateTime.java new file mode 100644 index 00000000..bc6b1f36 --- /dev/null +++ b/src/main/java/com/swyp8team2/common/util/DateTime.java @@ -0,0 +1,13 @@ +package com.swyp8team2.common.util; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +public class DateTime { + + public static String getCurrentTimestamp() { + LocalDateTime now = LocalDateTime.now(); + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); + return now.format(formatter); + } +} diff --git a/src/main/java/com/swyp8team2/common/util/Validator.java b/src/main/java/com/swyp8team2/common/util/Validator.java new file mode 100644 index 00000000..5e736223 --- /dev/null +++ b/src/main/java/com/swyp8team2/common/util/Validator.java @@ -0,0 +1,26 @@ +package com.swyp8team2.common.util; + +import com.swyp8team2.common.exception.ErrorCode; +import com.swyp8team2.common.exception.InternalServerException; + +import java.util.Arrays; +import java.util.Objects; + +public class Validator { + + public static void validateNull(Object... object) { + Arrays.stream(object) + .filter(Objects::isNull) + .forEach(o -> { + throw new InternalServerException(ErrorCode.INVALID_INPUT_VALUE); + }); + } + + public static void validateEmptyString(String... strings) { + Arrays.stream(strings) + .filter(s -> Objects.isNull(s) || s.isEmpty()) + .forEach(s -> { + throw new InternalServerException(ErrorCode.INVALID_INPUT_VALUE); + }); + } +} diff --git a/src/main/java/com/swyp8team2/crypto/application/CryptoService.java b/src/main/java/com/swyp8team2/crypto/application/CryptoService.java new file mode 100644 index 00000000..4ff34182 --- /dev/null +++ b/src/main/java/com/swyp8team2/crypto/application/CryptoService.java @@ -0,0 +1,43 @@ +package com.swyp8team2.crypto.application; + +import com.swyp8team2.common.exception.BadRequestException; +import com.swyp8team2.common.exception.ErrorCode; +import com.swyp8team2.common.exception.InternalServerException; +import io.seruco.encoding.base62.Base62; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.crypto.encrypt.AesBytesEncryptor; +import org.springframework.util.StringUtils; + +import java.nio.charset.StandardCharsets; + +@Slf4j +@RequiredArgsConstructor +public class CryptoService { + + private final AesBytesEncryptor encryptor; + + public String encrypt(String data) { + try { + byte[] encrypt = encryptor.encrypt(data.getBytes(StandardCharsets.UTF_8)); + return new String(Base62.createInstance().encode(encrypt), StandardCharsets.UTF_8); + } catch (Exception e) { + log.debug("encrypt error {}", e.getMessage()); + throw new BadRequestException(ErrorCode.INVALID_TOKEN); + } + } + + public String decrypt(String encryptedData) { + try { + if (!StringUtils.hasText(encryptedData)) { + throw new InternalServerException(ErrorCode.INVALID_TOKEN); + } + byte[] decryptBytes = Base62.createInstance().decode(encryptedData.getBytes(StandardCharsets.UTF_8)); + byte[] decrypt = encryptor.decrypt(decryptBytes); + return new String(decrypt, StandardCharsets.UTF_8); + } catch (Exception e) { + log.debug("decrypt error {}", e.getMessage()); + throw new BadRequestException(ErrorCode.INVALID_TOKEN); + } + } +} diff --git a/src/main/java/com/swyp8team2/image/application/ImageService.java b/src/main/java/com/swyp8team2/image/application/ImageService.java new file mode 100644 index 00000000..b5468f29 --- /dev/null +++ b/src/main/java/com/swyp8team2/image/application/ImageService.java @@ -0,0 +1,38 @@ +package com.swyp8team2.image.application; + +import com.swyp8team2.image.domain.ImageFile; +import com.swyp8team2.image.domain.ImageFileRepository; +import com.swyp8team2.image.presentation.dto.ImageFileDto; +import com.swyp8team2.image.presentation.dto.ImageFileResponse; +import com.swyp8team2.image.util.FileValidator; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@Slf4j +@RequiredArgsConstructor +public class ImageService { + + private final R2Storage r2Storage; + private final FileValidator fileValidator; + private final ImageFileRepository imageFileRepository; + + public ImageFileResponse uploadImageFile(MultipartFile... files) { + fileValidator.validate(files); + List imageFiles = r2Storage.uploadImageFile(files); + List imageFileIds = imageFiles.stream() + .map(this::createImageFile) + .collect(Collectors.toList()); + return new ImageFileResponse(imageFileIds); + } + + public Long createImageFile(ImageFileDto imageFiledto) { + ImageFile imageFile = imageFileRepository.save(ImageFile.create(imageFiledto)); + return imageFile.getId(); + } +} diff --git a/src/main/java/com/swyp8team2/image/application/R2Storage.java b/src/main/java/com/swyp8team2/image/application/R2Storage.java new file mode 100644 index 00000000..8222f2b0 --- /dev/null +++ b/src/main/java/com/swyp8team2/image/application/R2Storage.java @@ -0,0 +1,171 @@ +package com.swyp8team2.image.application; + +import com.sksamuel.scrimage.ImmutableImage; +import com.swyp8team2.common.exception.BadRequestException; +import com.swyp8team2.common.exception.ErrorCode; +import com.swyp8team2.common.exception.ServiceUnavailableException; +import com.swyp8team2.common.util.DateTime; +import com.swyp8team2.image.presentation.dto.ImageFileDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.imgscalr.Scalr; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectResponse; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +@Component +@Slf4j +@RequiredArgsConstructor +public class R2Storage { + + @Value("${file.endpoint}") + private String imageDomainUrl; + + @Value("${r2.bucket.name}") + private String bucketName; + + @Value("${r2.bucket.path}") + private String filePath; + + @Value("${r2.bucket.resize-path}") + private String resizedFilePath; + + @Value("${r2.bucket.resize-height}") + private int resizeHeight; + + private final S3Client s3Client; + + public List uploadImageFile(MultipartFile... files) { + List imageFiles = new ArrayList<>(); + List tempFiles = new ArrayList<>(); + try { + for (int i = 0; i < files.length; i++) { + MultipartFile file = files[i]; + String originFileName = file.getOriginalFilename(); + if (originFileName.length() > 100) { + throw new BadRequestException(ErrorCode.FILE_NAME_TOO_LONG); + } + String realFileName = getRealFileName(originFileName, filePath, i); + File tempFile = File.createTempFile("upload_", "_" + originFileName); + file.transferTo(tempFile); + + int splitIndex = originFileName.lastIndexOf("/"); + String imageType = originFileName.substring(splitIndex + 1).toLowerCase(); + + File originFile = s3PutObject(tempFile, realFileName, imageType); + String imageUrl = imageDomainUrl + realFileName; + String resizeImageUrl = resizeImage(tempFile, realFileName, resizeHeight); + + log.debug("uploadImageFile originFileName: {}, imageUrl: {}, resizeImageUrl: {}", + originFileName, imageUrl, resizeImageUrl); + + imageFiles.add(new ImageFileDto(originFileName, imageUrl, resizeImageUrl)); + tempFiles.add(originFile); + } + + return imageFiles; + } catch (IOException e) { + log.error("Failed to create temp file", e); + throw new ServiceUnavailableException(ErrorCode.SERVICE_UNAVAILABLE); + } finally { + tempFiles.forEach(this::deleteTempFile); + } + } + + private String resizeImage(File file, String realFileName, int targetHeight) { + try { + String ext = Optional.of(realFileName) + .filter(name -> name.contains(".")) + .map(name -> name.substring(name.lastIndexOf('.') + 1)) + .orElseThrow(() -> new BadRequestException(ErrorCode.MISSING_FILE_EXTENSION)) + .toLowerCase(); + + BufferedImage srcImage; + if ("webp".equals(ext)) { + srcImage = ImmutableImage.loader().fromFile(file).awt(); + } else { + srcImage = ImageIO.read(file); + } + BufferedImage resizedImage = highQualityResize(srcImage, targetHeight); + + int splitIndex = realFileName.lastIndexOf("/") + 1; + realFileName = realFileName.substring(splitIndex); + String dstKey = resizedFilePath + realFileName; + String imageType = realFileName.substring(realFileName.lastIndexOf(".") + 1).toLowerCase(); + + File tempFile = File.createTempFile("resized_", "." + imageType); + ImageIO.write(resizedImage, imageType, tempFile); + + s3PutObject(tempFile, dstKey, imageType); + deleteTempFile(tempFile); + + return imageDomainUrl + dstKey; + } catch (IOException e) { + log.error("Failed to create temp file", e); + throw new ServiceUnavailableException(ErrorCode.SERVICE_UNAVAILABLE); + } + } + + private BufferedImage highQualityResize(BufferedImage originalImage, int targetHeight) { + int originalWidth = originalImage.getWidth(); + int originalHeight = originalImage.getHeight(); + + double scale = (double) targetHeight / originalHeight; + int targetWidth = (int) (originalWidth * scale); + + return Scalr.resize(originalImage, Scalr.Method.QUALITY, Scalr.Mode.FIT_EXACT, targetWidth, targetHeight); + } + + private File s3PutObject(File file, String realFileName, String imageType) { + Map metadata = getMetadataMap(imageType); + PutObjectRequest objectRequest = PutObjectRequest.builder() + .bucket(bucketName) + .metadata(metadata) + .key(realFileName) + .build(); + + PutObjectResponse putObjectResponse = s3Client.putObject(objectRequest, RequestBody.fromFile(file)); + return file; + } + + private Map getMetadataMap(String imageType) { + Map metadata = new HashMap<>(); + switch (imageType) { + case "png" -> { + metadata.put("Content-Type", "image/png"); + } case "gif" -> { + metadata.put("Content-Type", "image/gif"); + } case "webp" -> { + metadata.put("Content-Type", "image/webp"); + } default -> { + metadata.put("Content-Type", "image/jpeg"); + } + } + return metadata; + } + + private String getRealFileName(String originFileName, String filePath, int sequence) { + String objectType = originFileName.substring(originFileName.lastIndexOf(".")).toLowerCase(); + return filePath + DateTime.getCurrentTimestamp() + sequence + objectType; + } + + private void deleteTempFile(File tempFile) { + if (!tempFile.delete()) { + log.error("Failed to delete temp file: {}", tempFile.getName()); + } + } +} diff --git a/src/main/java/com/swyp8team2/image/config/S3Config.java b/src/main/java/com/swyp8team2/image/config/S3Config.java new file mode 100644 index 00000000..3e65b030 --- /dev/null +++ b/src/main/java/com/swyp8team2/image/config/S3Config.java @@ -0,0 +1,42 @@ +package com.swyp8team2.image.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.core.checksums.RequestChecksumCalculation; +import software.amazon.awssdk.core.checksums.ResponseChecksumValidation; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.S3Configuration; + +import java.net.URI; + +@Configuration +public class S3Config { + + @Value("${r2.access-key}") + private String r2AccessKey; + + @Value("${r2.secret-key}") + private String r2SecretKey; + + @Value("${r2.endpoint}") + private String endpoint; + + @Bean + public S3Client s3Client() { + return S3Client.builder() + .region(Region.of("auto")) + .endpointOverride(URI.create(endpoint)) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(r2AccessKey, r2SecretKey))) + .serviceConfiguration(S3Configuration.builder() + .pathStyleAccessEnabled(true) + .build()) + .requestChecksumCalculation(RequestChecksumCalculation.WHEN_REQUIRED) + .responseChecksumValidation(ResponseChecksumValidation.WHEN_REQUIRED) + .build(); + } +} diff --git a/src/main/java/com/swyp8team2/image/domain/ImageFile.java b/src/main/java/com/swyp8team2/image/domain/ImageFile.java new file mode 100644 index 00000000..12ce861c --- /dev/null +++ b/src/main/java/com/swyp8team2/image/domain/ImageFile.java @@ -0,0 +1,37 @@ +package com.swyp8team2.image.domain; + +import com.swyp8team2.common.domain.BaseEntity; +import com.swyp8team2.image.presentation.dto.ImageFileDto; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "image_files") +@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED) +public class ImageFile extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 100) + private String originImageName; + + @Column(nullable = false, length = 200) + private String imageUrl; + + @Column(nullable = false, length = 200) + private String thumbnailUrl; + + private ImageFile(String originImageName, String imageUrl, String thumbnailUrl) { + this.originImageName = originImageName; + this.imageUrl = imageUrl; + this.thumbnailUrl = thumbnailUrl; + } + + public static ImageFile create(ImageFileDto dto) { + return new ImageFile(dto.originFileName(), dto.imageUrl(), dto.thumbnailUrl()); + } +} diff --git a/src/main/java/com/swyp8team2/image/domain/ImageFileRepository.java b/src/main/java/com/swyp8team2/image/domain/ImageFileRepository.java new file mode 100644 index 00000000..c0ae2259 --- /dev/null +++ b/src/main/java/com/swyp8team2/image/domain/ImageFileRepository.java @@ -0,0 +1,8 @@ +package com.swyp8team2.image.domain; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface ImageFileRepository extends JpaRepository { +} diff --git a/src/main/java/com/swyp8team2/image/presentation/ImageController.java b/src/main/java/com/swyp8team2/image/presentation/ImageController.java new file mode 100644 index 00000000..6c1912d0 --- /dev/null +++ b/src/main/java/com/swyp8team2/image/presentation/ImageController.java @@ -0,0 +1,27 @@ +package com.swyp8team2.image.presentation; + +import com.swyp8team2.image.application.ImageService; +import com.swyp8team2.image.presentation.dto.ImageFileResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/image") +public class ImageController { + + private final ImageService r2Service; + + @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity createImageFile(@RequestPart("files") MultipartFile... files) { + ImageFileResponse response = r2Service.uploadImageFile(files); + return ResponseEntity.ok(response); + } + +} diff --git a/src/main/java/com/swyp8team2/image/presentation/dto/ImageFileDto.java b/src/main/java/com/swyp8team2/image/presentation/dto/ImageFileDto.java new file mode 100644 index 00000000..58ef06e5 --- /dev/null +++ b/src/main/java/com/swyp8team2/image/presentation/dto/ImageFileDto.java @@ -0,0 +1,7 @@ +package com.swyp8team2.image.presentation.dto; + +public record ImageFileDto(String originFileName, + String imageUrl, + String thumbnailUrl) { + +} diff --git a/src/main/java/com/swyp8team2/image/presentation/dto/ImageFileResponse.java b/src/main/java/com/swyp8team2/image/presentation/dto/ImageFileResponse.java new file mode 100644 index 00000000..1cae47a5 --- /dev/null +++ b/src/main/java/com/swyp8team2/image/presentation/dto/ImageFileResponse.java @@ -0,0 +1,6 @@ +package com.swyp8team2.image.presentation.dto; + +import java.util.List; + +public record ImageFileResponse(List imageFileId) { +} diff --git a/src/main/java/com/swyp8team2/image/util/FileValidator.java b/src/main/java/com/swyp8team2/image/util/FileValidator.java new file mode 100644 index 00000000..32a00c82 --- /dev/null +++ b/src/main/java/com/swyp8team2/image/util/FileValidator.java @@ -0,0 +1,49 @@ +package com.swyp8team2.image.util; + +import com.swyp8team2.common.exception.BadRequestException; +import com.swyp8team2.common.exception.ErrorCode; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import java.util.Arrays; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +@Component +public class FileValidator { + + private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB + + private final Set allowedExtensions; + + public FileValidator(@Value("${file.allowed-extensions}") String allowedExtensionsConfig) { + this.allowedExtensions = Arrays.stream(allowedExtensionsConfig.split(",")) + .map(String::trim) + .map(String::toLowerCase) + .collect(Collectors.toSet()); + } + + public void validate(MultipartFile... files) { + Arrays.stream(files) + .forEach(this::validate); + } + + private void validate(MultipartFile file) { + if (file.getSize() > MAX_FILE_SIZE) { + throw new BadRequestException(ErrorCode.EXCEED_MAX_FILE_SIZE); + } + + String originalFilename = file.getOriginalFilename(); + String ext = Optional.of(originalFilename) + .filter(name -> name.contains(".")) + .map(name -> name.substring(name.lastIndexOf('.') + 1)) + .orElseThrow(() -> new BadRequestException(ErrorCode.MISSING_FILE_EXTENSION)) + .toLowerCase(); + + if (!allowedExtensions.contains(ext)) { + throw new BadRequestException(ErrorCode.UNSUPPORTED_FILE_EXTENSION); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/swyp8team2/post/application/PostImageNameGenerator.java b/src/main/java/com/swyp8team2/post/application/PostImageNameGenerator.java new file mode 100644 index 00000000..ab1edd99 --- /dev/null +++ b/src/main/java/com/swyp8team2/post/application/PostImageNameGenerator.java @@ -0,0 +1,21 @@ +package com.swyp8team2.post.application; + +import com.swyp8team2.common.exception.ErrorCode; +import com.swyp8team2.common.exception.InternalServerException; + +public class PostImageNameGenerator { + + private int index = 0; + private final String[] alphabets = new String[]{ + "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", + "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", + "U", "V", "W", "X", "Y", "Z" + }; + + public String generate() { + if (index >= alphabets.length) { + throw new InternalServerException(ErrorCode.POST_IMAGE_NAME_GENERATOR_INDEX_OUT_OF_BOUND); + } + return "뽀또" + alphabets[index++]; + } +} diff --git a/src/main/java/com/swyp8team2/post/application/PostService.java b/src/main/java/com/swyp8team2/post/application/PostService.java new file mode 100644 index 00000000..f48b9c13 --- /dev/null +++ b/src/main/java/com/swyp8team2/post/application/PostService.java @@ -0,0 +1,175 @@ +package com.swyp8team2.post.application; + +import com.swyp8team2.common.annotation.ShareUrlCryptoService; +import com.swyp8team2.common.dto.CursorBasePaginatedResponse; +import com.swyp8team2.common.exception.BadRequestException; +import com.swyp8team2.common.exception.ErrorCode; +import com.swyp8team2.common.exception.InternalServerException; +import com.swyp8team2.crypto.application.CryptoService; +import com.swyp8team2.image.domain.ImageFile; +import com.swyp8team2.image.domain.ImageFileRepository; +import com.swyp8team2.post.domain.Post; +import com.swyp8team2.post.domain.PostImage; +import com.swyp8team2.post.domain.PostRepository; +import com.swyp8team2.post.presentation.dto.CreatePostRequest; +import com.swyp8team2.post.presentation.dto.CreatePostResponse; +import com.swyp8team2.post.presentation.dto.PostResponse; +import com.swyp8team2.post.presentation.dto.PostImageVoteStatusResponse; +import com.swyp8team2.post.presentation.dto.SimplePostResponse; +import com.swyp8team2.post.presentation.dto.PostImageResponse; +import com.swyp8team2.user.domain.User; +import com.swyp8team2.user.domain.UserRepository; +import com.swyp8team2.vote.domain.Vote; +import com.swyp8team2.vote.domain.VoteRepository; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Objects; + +@Service +@Transactional(readOnly = true) +public class PostService { + + private final PostRepository postRepository; + private final UserRepository userRepository; + private final RatioCalculator ratioCalculator; + private final ImageFileRepository imageFileRepository; + private final VoteRepository voteRepository; + private final CryptoService shareUrlCryptoService; + + public PostService( + PostRepository postRepository, + UserRepository userRepository, + RatioCalculator ratioCalculator, + ImageFileRepository imageFileRepository, + VoteRepository voteRepository, + @ShareUrlCryptoService CryptoService shareUrlCryptoService + ) { + this.postRepository = postRepository; + this.userRepository = userRepository; + this.ratioCalculator = ratioCalculator; + this.imageFileRepository = imageFileRepository; + this.voteRepository = voteRepository; + this.shareUrlCryptoService = shareUrlCryptoService; + } + + @Transactional + public CreatePostResponse create(Long userId, CreatePostRequest request) { + List postImages = createPostImages(request); + Post post = Post.create(userId, request.description(), postImages); + Post save = postRepository.save(post); + save.setShareUrl(shareUrlCryptoService.encrypt(String.valueOf(save.getId()))); + return new CreatePostResponse(save.getId(), save.getShareUrl()); + } + + private List createPostImages(CreatePostRequest request) { + PostImageNameGenerator nameGenerator = new PostImageNameGenerator(); + return request.images().stream() + .map(voteRequestDto -> PostImage.create( + nameGenerator.generate(), + voteRequestDto.imageFileId() + )).toList(); + } + + public PostResponse findById(Long userId, Long postId) { + Post post = postRepository.findById(postId) + .orElseThrow(() -> new BadRequestException(ErrorCode.POST_NOT_FOUND)); + User author = userRepository.findById(post.getUserId()) + .orElseThrow(() -> new BadRequestException(ErrorCode.USER_NOT_FOUND)); + List votes = createPostImageResponse(userId, postId, post); + boolean isAuthor = post.getUserId().equals(userId); + return PostResponse.of(post, author, votes, isAuthor); + } + + private List createPostImageResponse(Long userId, Long postId, Post post) { + List images = post.getImages(); + return images.stream() + .map(image -> createVoteResponseDto(image, userId, postId)) + .toList(); + } + + private PostImageResponse createVoteResponseDto(PostImage image, Long userId, Long postId) { + ImageFile imageFile = imageFileRepository.findById(image.getImageFileId()) + .orElseThrow(() -> new InternalServerException(ErrorCode.IMAGE_FILE_NOT_FOUND)); + boolean voted = Objects.nonNull(userId) && getVoted(image, userId, postId); + return new PostImageResponse( + image.getId(), + image.getName(), + imageFile.getImageUrl(), + imageFile.getThumbnailUrl(), + voted + ); + } + + private Boolean getVoted(PostImage image, Long userId, Long postId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new BadRequestException(ErrorCode.USER_NOT_FOUND)); + return voteRepository.findByUserIdAndPostId(user.getId(), postId) + .map(vote -> vote.getPostImageId().equals(image.getId())) + .orElse(false); + } + + public CursorBasePaginatedResponse findUserPosts(Long userId, Long cursor, int size) { + Slice postSlice = postRepository.findByUserId(userId, cursor, PageRequest.ofSize(size)); + return CursorBasePaginatedResponse.of(postSlice.map(this::createSimplePostResponse) + ); + } + + private SimplePostResponse createSimplePostResponse(Post post) { + ImageFile bestPickedImage = imageFileRepository.findById(post.getBestPickedImage().getImageFileId()) + .orElseThrow(() -> new InternalServerException(ErrorCode.IMAGE_FILE_NOT_FOUND)); + return SimplePostResponse.of(post, bestPickedImage.getThumbnailUrl()); + } + + public CursorBasePaginatedResponse findVotedPosts(Long userId, Long cursor, int size) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new BadRequestException(ErrorCode.USER_NOT_FOUND)); + List postIds = voteRepository.findByUserId(user.getId()) + .map(Vote::getPostId) + .toList(); + Slice postSlice = postRepository.findByIdIn(postIds, cursor, PageRequest.ofSize(size)); + return CursorBasePaginatedResponse.of(postSlice.map(this::createSimplePostResponse)); + } + + public List findPostStatus(Long postId) { + Post post = postRepository.findById(postId) + .orElseThrow(() -> new BadRequestException(ErrorCode.POST_NOT_FOUND)); + int totalVoteCount = getTotalVoteCount(post.getImages()); + return post.getImages().stream() + .map(image -> { + String ratio = ratioCalculator.calculate(totalVoteCount, image.getVoteCount()); + return new PostImageVoteStatusResponse(image.getId(), image.getName(), image.getVoteCount(), ratio); + }).toList(); + } + + private int getTotalVoteCount(List images) { + int totalVoteCount = 0; + for (PostImage image : images) { + totalVoteCount += image.getVoteCount(); + } + return totalVoteCount; + } + + @Transactional + public void delete(Long userId, Long postId) { + Post post = postRepository.findById(postId) + .orElseThrow(() -> new BadRequestException(ErrorCode.POST_NOT_FOUND)); + post.validateOwner(userId); + postRepository.delete(post); + } + + @Transactional + public void close(Long userId, Long postId) { + Post post = postRepository.findById(postId) + .orElseThrow(() -> new BadRequestException(ErrorCode.POST_NOT_FOUND)); + post.close(userId); + } + + public PostResponse findByShareUrl(Long userId, String shareUrl) { + String decrypt = shareUrlCryptoService.decrypt(shareUrl); + return findById(userId, Long.valueOf(decrypt)); + } +} diff --git a/src/main/java/com/swyp8team2/post/application/RatioCalculator.java b/src/main/java/com/swyp8team2/post/application/RatioCalculator.java new file mode 100644 index 00000000..1f4c8ec1 --- /dev/null +++ b/src/main/java/com/swyp8team2/post/application/RatioCalculator.java @@ -0,0 +1,21 @@ +package com.swyp8team2.post.application; + +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +@Component +public class RatioCalculator { + + public String calculate(int totalVoteCount, int voteCount) { + if (totalVoteCount == 0) { + return "0.0"; + } + BigDecimal totalCount = new BigDecimal(totalVoteCount); + BigDecimal count = new BigDecimal(voteCount); + BigDecimal bigDecimal = count.divide(totalCount, 3, RoundingMode.HALF_UP) + .multiply(new BigDecimal(100)); + return String.format("%.1f", bigDecimal); + } +} diff --git a/src/main/java/com/swyp8team2/post/domain/Post.java b/src/main/java/com/swyp8team2/post/domain/Post.java new file mode 100644 index 00000000..79ff5700 --- /dev/null +++ b/src/main/java/com/swyp8team2/post/domain/Post.java @@ -0,0 +1,123 @@ +package com.swyp8team2.post.domain; + +import com.swyp8team2.common.domain.BaseEntity; +import com.swyp8team2.common.exception.BadRequestException; +import com.swyp8team2.common.exception.ErrorCode; +import com.swyp8team2.common.exception.InternalServerException; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; + +@Getter +@Entity +@ToString(exclude = "images") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Post extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String description; + + private Long userId; + + @Enumerated(EnumType.STRING) + private Status status; + + @OneToMany(mappedBy = "post", orphanRemoval = true, cascade = CascadeType.ALL) + private List images = new ArrayList<>(); + + private String shareUrl; + + public Post(Long id, Long userId, String description, Status status, List images, String shareUrl) { + validateDescription(description); + validatePostImages(images); + this.id = id; + this.description = description; + this.userId = userId; + this.status = status; + this.images = images; + images.forEach(image -> image.setPost(this)); + this.shareUrl = shareUrl; + } + + private void validatePostImages(List images) { + if (images.size() < 2 || images.size() > 9) { + throw new BadRequestException(ErrorCode.INVALID_POST_IMAGE_COUNT); + } + } + + private void validateDescription(String description) { + if (description.length() > 100) { + throw new BadRequestException(ErrorCode.DESCRIPTION_LENGTH_EXCEEDED); + } + } + + public static Post create(Long userId, String description, List images) { + return new Post(null, userId, description, Status.PROGRESS, images, null); + } + + public PostImage getBestPickedImage() { + return images.stream() + .max(Comparator.comparing(PostImage::getVoteCount)) + .orElseThrow(() -> new InternalServerException(ErrorCode.POST_IMAGE_NOT_FOUND)); + } + + public void vote(Long imageId) { + PostImage image = images.stream() + .filter(postImage -> postImage.getId().equals(imageId)) + .findFirst() + .orElseThrow(() -> new BadRequestException(ErrorCode.POST_IMAGE_NOT_FOUND)); + image.increaseVoteCount(); + } + + public void cancelVote(Long imageId) { + PostImage image = images.stream() + .filter(postImage -> postImage.getId().equals(imageId)) + .findFirst() + .orElseThrow(() -> new InternalServerException(ErrorCode.POST_IMAGE_NOT_FOUND)); + image.decreaseVoteCount(); + } + + public void close(Long userId) { + validateOwner(userId); + if (status == Status.CLOSED) { + throw new BadRequestException(ErrorCode.POST_ALREADY_CLOSED); + } + this.status = Status.CLOSED; + } + + public void validateOwner(Long userId) { + if (!this.userId.equals(userId)) { + throw new BadRequestException(ErrorCode.NOT_POST_AUTHOR); + } + } + + public void validateProgress() { + if (!this.status.equals(Status.PROGRESS)) { + throw new BadRequestException(ErrorCode.POST_ALREADY_CLOSED); + } + } + + public void setShareUrl(String shareUrl) { + if (Objects.nonNull(this.shareUrl)) { + throw new InternalServerException(ErrorCode.SHARE_URL_ALREADY_EXISTS); + } + this.shareUrl = shareUrl; + } +} diff --git a/src/main/java/com/swyp8team2/post/domain/PostImage.java b/src/main/java/com/swyp8team2/post/domain/PostImage.java new file mode 100644 index 00000000..e24a760c --- /dev/null +++ b/src/main/java/com/swyp8team2/post/domain/PostImage.java @@ -0,0 +1,64 @@ +package com.swyp8team2.post.domain; + +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToOne; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import static com.swyp8team2.common.util.Validator.validateEmptyString; +import static com.swyp8team2.common.util.Validator.validateNull; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PostImage { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + private Post post; + + private String name; + + private Long imageFileId; + + private int voteCount; + + public PostImage(Long id, Post post, String name, Long imageFileId, int voteCount) { + this.id = id; + this.post = post; + this.name = name; + this.imageFileId = imageFileId; + this.voteCount = voteCount; + } + + public PostImage(String name, Long imageFileId, int voteCount) { + this.name = name; + this.imageFileId = imageFileId; + this.voteCount = voteCount; + } + + public static PostImage create(String name, Long imageFileId) { + return new PostImage(name, imageFileId, 0); + } + + public void setPost(Post post) { + validateNull(post); + this.post = post; + } + + public void increaseVoteCount() { + this.voteCount++; + } + + public void decreaseVoteCount() { + this.voteCount = this.voteCount == 0 ? 0 : this.voteCount - 1; + } +} diff --git a/src/main/java/com/swyp8team2/post/domain/PostRepository.java b/src/main/java/com/swyp8team2/post/domain/PostRepository.java new file mode 100644 index 00000000..0653564c --- /dev/null +++ b/src/main/java/com/swyp8team2/post/domain/PostRepository.java @@ -0,0 +1,34 @@ +package com.swyp8team2.post.domain; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface PostRepository extends JpaRepository { + + @Query(""" + SELECT p + FROM Post p + WHERE p.userId = :userId + AND (:postId IS NULL OR p.id < :postId) + ORDER BY p.createdAt DESC + """ + ) + Slice findByUserId(@Param("userId") Long userId, @Param("postId") Long postId, Pageable pageable); + + @Query(""" + SELECT p + FROM Post p + WHERE p.id IN :postIds + AND (:postId IS NULL OR p.id < :postId) + ORDER BY p.createdAt DESC + """ + ) + Slice findByIdIn(@Param("postIds") List postIds, @Param("postId") Long postId, Pageable pageable); +} diff --git a/src/main/java/com/swyp8team2/post/domain/Status.java b/src/main/java/com/swyp8team2/post/domain/Status.java new file mode 100644 index 00000000..bda22f18 --- /dev/null +++ b/src/main/java/com/swyp8team2/post/domain/Status.java @@ -0,0 +1,5 @@ +package com.swyp8team2.post.domain; + +public enum Status { + PROGRESS, CLOSED +} diff --git a/src/main/java/com/swyp8team2/post/presentation/PostController.java b/src/main/java/com/swyp8team2/post/presentation/PostController.java new file mode 100644 index 00000000..625897dd --- /dev/null +++ b/src/main/java/com/swyp8team2/post/presentation/PostController.java @@ -0,0 +1,108 @@ +package com.swyp8team2.post.presentation; + +import com.swyp8team2.auth.domain.UserInfo; +import com.swyp8team2.common.dto.CursorBasePaginatedResponse; +import com.swyp8team2.post.application.PostService; +import com.swyp8team2.post.presentation.dto.CreatePostRequest; +import com.swyp8team2.post.presentation.dto.CreatePostResponse; +import com.swyp8team2.post.presentation.dto.PostImageVoteStatusResponse; +import com.swyp8team2.post.presentation.dto.PostResponse; +import com.swyp8team2.post.presentation.dto.SimplePostResponse; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +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.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Optional; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/posts") +public class PostController { + + private final PostService postService; + + @PostMapping("") + public ResponseEntity createPost( + @Valid @RequestBody CreatePostRequest request, + @AuthenticationPrincipal UserInfo userInfo + ) { + + return ResponseEntity.ok(postService.create(userInfo.userId(), request)); + } + + @GetMapping("/{postId}") + public ResponseEntity findPost( + @PathVariable("postId") Long postId, + @AuthenticationPrincipal UserInfo userInfo + ) { + Long userId = Optional.ofNullable(userInfo) + .map(UserInfo::userId) + .orElse(null); + return ResponseEntity.ok(postService.findById(userId, postId)); + } + + @GetMapping("/shareUrl/{shareUrl}") + public ResponseEntity findPostByShareUrl( + @PathVariable("shareUrl") String shareUrl, + @AuthenticationPrincipal UserInfo userInfo + ) { + Long userId = Optional.ofNullable(userInfo) + .map(UserInfo::userId) + .orElse(null); + return ResponseEntity.ok(postService.findByShareUrl(userId, shareUrl)); + } + + @GetMapping("/{postId}/status") + public ResponseEntity> findVoteStatus( + @PathVariable("postId") Long postId + ) { + return ResponseEntity.ok(postService.findPostStatus(postId)); + } + + @PostMapping("/{postId}/close") + public ResponseEntity closePost( + @PathVariable("postId") Long postId, + @AuthenticationPrincipal UserInfo userInfo + ) { + postService.close(userInfo.userId(), postId); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/{postId}") + public ResponseEntity deletePost( + @PathVariable("postId") Long postId, + @AuthenticationPrincipal UserInfo userInfo + ) { + postService.delete(userInfo.userId(), postId); + return ResponseEntity.ok().build(); + } + + @GetMapping("/users/{userId}") + public ResponseEntity> findMyPosts( + @PathVariable("userId") Long userId, + @RequestParam(name = "cursor", required = false) @Min(0) Long cursor, + @RequestParam(name = "size", required = false, defaultValue = "10") @Min(1) int size + ) { + return ResponseEntity.ok(postService.findUserPosts(userId, cursor, size)); + } + + @GetMapping("/users/{userId}/voted") + public ResponseEntity> findVotedPosts( + @PathVariable("userId") Long userId, + @RequestParam(name = "cursor", required = false) @Min(0) Long cursor, + @RequestParam(name = "size", required = false, defaultValue = "10") @Min(1) int size + ) { + return ResponseEntity.ok(postService.findVotedPosts(userId, cursor, size)); + } +} diff --git a/src/main/java/com/swyp8team2/post/presentation/dto/AuthorDto.java b/src/main/java/com/swyp8team2/post/presentation/dto/AuthorDto.java new file mode 100644 index 00000000..f2cc0907 --- /dev/null +++ b/src/main/java/com/swyp8team2/post/presentation/dto/AuthorDto.java @@ -0,0 +1,17 @@ +package com.swyp8team2.post.presentation.dto; + +import com.swyp8team2.user.domain.User; + +public record AuthorDto( + Long id, + String nickname, + String profileUrl +) { + public static AuthorDto of(User user) { + return new AuthorDto( + user.getId(), + user.getNickname(), + user.getProfileUrl() + ); + } +} diff --git a/src/main/java/com/swyp8team2/post/presentation/dto/CreatePostRequest.java b/src/main/java/com/swyp8team2/post/presentation/dto/CreatePostRequest.java new file mode 100644 index 00000000..1efb3b7a --- /dev/null +++ b/src/main/java/com/swyp8team2/post/presentation/dto/CreatePostRequest.java @@ -0,0 +1,15 @@ +package com.swyp8team2.post.presentation.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; + +import java.util.List; + +public record CreatePostRequest( + @NotNull + String description, + + @Valid @NotNull + List images +) { +} diff --git a/src/main/java/com/swyp8team2/post/presentation/dto/CreatePostResponse.java b/src/main/java/com/swyp8team2/post/presentation/dto/CreatePostResponse.java new file mode 100644 index 00000000..172d42b3 --- /dev/null +++ b/src/main/java/com/swyp8team2/post/presentation/dto/CreatePostResponse.java @@ -0,0 +1,4 @@ +package com.swyp8team2.post.presentation.dto; + +public record CreatePostResponse(Long postId, String shareUrl) { +} diff --git a/src/main/java/com/swyp8team2/post/presentation/dto/PostImageRequestDto.java b/src/main/java/com/swyp8team2/post/presentation/dto/PostImageRequestDto.java new file mode 100644 index 00000000..15aeeeef --- /dev/null +++ b/src/main/java/com/swyp8team2/post/presentation/dto/PostImageRequestDto.java @@ -0,0 +1,9 @@ +package com.swyp8team2.post.presentation.dto; + +import jakarta.validation.constraints.NotNull; + +public record PostImageRequestDto( + @NotNull + Long imageFileId +) { +} diff --git a/src/main/java/com/swyp8team2/post/presentation/dto/PostImageResponse.java b/src/main/java/com/swyp8team2/post/presentation/dto/PostImageResponse.java new file mode 100644 index 00000000..0fd9881e --- /dev/null +++ b/src/main/java/com/swyp8team2/post/presentation/dto/PostImageResponse.java @@ -0,0 +1,10 @@ +package com.swyp8team2.post.presentation.dto; + +public record PostImageResponse( + Long id, + String imageName, + String imageUrl, + String thumbnailUrl, + boolean voted +) { +} diff --git a/src/main/java/com/swyp8team2/post/presentation/dto/PostImageVoteStatusResponse.java b/src/main/java/com/swyp8team2/post/presentation/dto/PostImageVoteStatusResponse.java new file mode 100644 index 00000000..56c0f57f --- /dev/null +++ b/src/main/java/com/swyp8team2/post/presentation/dto/PostImageVoteStatusResponse.java @@ -0,0 +1,9 @@ +package com.swyp8team2.post.presentation.dto; + +public record PostImageVoteStatusResponse( + Long id, + String imageName, + int voteCount, + String voteRatio +) { +} diff --git a/src/main/java/com/swyp8team2/post/presentation/dto/PostResponse.java b/src/main/java/com/swyp8team2/post/presentation/dto/PostResponse.java new file mode 100644 index 00000000..b41421eb --- /dev/null +++ b/src/main/java/com/swyp8team2/post/presentation/dto/PostResponse.java @@ -0,0 +1,32 @@ +package com.swyp8team2.post.presentation.dto; + +import com.swyp8team2.post.domain.Post; +import com.swyp8team2.post.domain.Status; +import com.swyp8team2.user.domain.User; + +import java.time.LocalDateTime; +import java.util.List; + +public record PostResponse( + Long id, + AuthorDto author, + String description, + List images, + String shareUrl, + boolean isAuthor, + Status status, + LocalDateTime createdAt +) { + public static PostResponse of(Post post, User user, List images, boolean isAuthor) { + return new PostResponse( + post.getId(), + AuthorDto.of(user), + post.getDescription(), + images, + post.getShareUrl(), + isAuthor, + post.getStatus(), + post.getCreatedAt() + ); + } +} diff --git a/src/main/java/com/swyp8team2/post/presentation/dto/SimplePostResponse.java b/src/main/java/com/swyp8team2/post/presentation/dto/SimplePostResponse.java new file mode 100644 index 00000000..2613a8e3 --- /dev/null +++ b/src/main/java/com/swyp8team2/post/presentation/dto/SimplePostResponse.java @@ -0,0 +1,28 @@ +package com.swyp8team2.post.presentation.dto; + +import com.swyp8team2.common.dto.CursorDto; +import com.swyp8team2.post.domain.Post; + +import java.time.LocalDateTime; + +public record SimplePostResponse( + long id, + String bestPickedImageUrl, + String shareUrl, + LocalDateTime createdAt +) implements CursorDto { + + public static SimplePostResponse of(Post post, String bestPickedImageUrl) { + return new SimplePostResponse( + post.getId(), + bestPickedImageUrl, + post.getShareUrl(), + post.getCreatedAt() + ); + } + + @Override + public long getId() { + return id; + } +} diff --git a/src/main/java/com/swyp8team2/user/application/UserService.java b/src/main/java/com/swyp8team2/user/application/UserService.java new file mode 100644 index 00000000..b97757fb --- /dev/null +++ b/src/main/java/com/swyp8team2/user/application/UserService.java @@ -0,0 +1,56 @@ +package com.swyp8team2.user.application; + +import com.swyp8team2.common.exception.BadRequestException; +import com.swyp8team2.common.exception.ErrorCode; +import com.swyp8team2.user.domain.NicknameAdjective; +import com.swyp8team2.user.domain.NicknameAdjectiveRepository; +import com.swyp8team2.user.domain.User; +import com.swyp8team2.user.domain.UserRepository; +import com.swyp8team2.user.presentation.dto.UserInfoResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class UserService { + + private final UserRepository userRepository; + private final NicknameAdjectiveRepository nicknameAdjectiveRepository; + + @Transactional + public Long createUser(String nickname, String profileImageUrl) { + User user = userRepository.save(User.create(getNickname(nickname), getProfileImage(profileImageUrl))); + return user.getId(); + } + + private String getProfileImage(String profileImageUrl) { + return Optional.ofNullable(profileImageUrl) + .orElse("https://t1.kakaocdn.net/account_images/default_profile.jpeg"); + } + + private String getNickname(String nickname) { + return Optional.ofNullable(nickname) + .orElseGet(() -> { + long randomIndex = (long)(Math.random() * 500); + Optional adjective = nicknameAdjectiveRepository.findNicknameAdjectiveById(randomIndex); + return adjective.map(NicknameAdjective::getAdjective) + .orElse("user_" + System.currentTimeMillis()); + }); + } + + @Transactional + public Long createGuest() { + User user = userRepository.save(User.createGuest()); + return user.getId(); + } + + public UserInfoResponse findById(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new BadRequestException(ErrorCode.USER_NOT_FOUND)); + return UserInfoResponse.of(user); + } +} diff --git a/src/main/java/com/swyp8team2/user/domain/NicknameAdjective.java b/src/main/java/com/swyp8team2/user/domain/NicknameAdjective.java new file mode 100644 index 00000000..3a535294 --- /dev/null +++ b/src/main/java/com/swyp8team2/user/domain/NicknameAdjective.java @@ -0,0 +1,26 @@ +package com.swyp8team2.user.domain; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.GenerationType; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "nickname_adjectives") +@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED) +public class NicknameAdjective { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String adjective; + + public NicknameAdjective(String adjective) { + this.adjective = adjective; + } +} diff --git a/src/main/java/com/swyp8team2/user/domain/NicknameAdjectiveRepository.java b/src/main/java/com/swyp8team2/user/domain/NicknameAdjectiveRepository.java new file mode 100644 index 00000000..13ce4e4c --- /dev/null +++ b/src/main/java/com/swyp8team2/user/domain/NicknameAdjectiveRepository.java @@ -0,0 +1,12 @@ +package com.swyp8team2.user.domain; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface NicknameAdjectiveRepository extends JpaRepository { + + Optional findNicknameAdjectiveById(Long id); +} diff --git a/src/main/java/com/swyp8team2/user/domain/Role.java b/src/main/java/com/swyp8team2/user/domain/Role.java new file mode 100644 index 00000000..3792a4c5 --- /dev/null +++ b/src/main/java/com/swyp8team2/user/domain/Role.java @@ -0,0 +1,5 @@ +package com.swyp8team2.user.domain; + +public enum Role { + GUEST, USER +} diff --git a/src/main/java/com/swyp8team2/user/domain/User.java b/src/main/java/com/swyp8team2/user/domain/User.java new file mode 100644 index 00000000..5686f490 --- /dev/null +++ b/src/main/java/com/swyp8team2/user/domain/User.java @@ -0,0 +1,54 @@ +package com.swyp8team2.user.domain; + +import com.swyp8team2.common.domain.BaseEntity; +import jakarta.persistence.Entity; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +import static com.swyp8team2.common.util.Validator.validateEmptyString; +import static com.swyp8team2.common.util.Validator.validateNull; + +@Getter +@Entity +@Table(name = "users") +@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED) +public class User extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String nickname; + + private String profileUrl; + + @Enumerated(jakarta.persistence.EnumType.STRING) + public Role role; + + public User(Long id, String nickname, String profileUrl, Role role) { + this.id = id; + this.nickname = nickname; + this.profileUrl = profileUrl; + this.role = role; + } + + public static User create(String nickname, String profileUrl) { + return new User(null, nickname, profileUrl, Role.USER); + } + + public static User createGuest() { + return new User( + null, + "guest_" + System.currentTimeMillis(), + "https://image.photopic.site/images-dev/resized_202502240006030.png", + Role.GUEST + ); + } +} diff --git a/src/main/java/com/swyp8team2/user/domain/UserRepository.java b/src/main/java/com/swyp8team2/user/domain/UserRepository.java new file mode 100644 index 00000000..a92bbdb2 --- /dev/null +++ b/src/main/java/com/swyp8team2/user/domain/UserRepository.java @@ -0,0 +1,8 @@ +package com.swyp8team2.user.domain; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface UserRepository extends JpaRepository { +} diff --git a/src/main/java/com/swyp8team2/user/presentation/UserController.java b/src/main/java/com/swyp8team2/user/presentation/UserController.java new file mode 100644 index 00000000..332679ed --- /dev/null +++ b/src/main/java/com/swyp8team2/user/presentation/UserController.java @@ -0,0 +1,32 @@ +package com.swyp8team2.user.presentation; + +import com.swyp8team2.auth.domain.UserInfo; +import com.swyp8team2.user.application.UserService; +import com.swyp8team2.user.presentation.dto.UserInfoResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/users") +public class UserController { + + private final UserService userService; + + @GetMapping("/{userId}") + public ResponseEntity findUserInfo(@PathVariable("userId") Long userId) { + return ResponseEntity.ok(userService.findById(userId)); + } + + @GetMapping("/me") + public ResponseEntity findMyInfo( + @AuthenticationPrincipal UserInfo userInfo + ) { + return ResponseEntity.ok(userService.findById(userInfo.userId())); + } +} diff --git a/src/main/java/com/swyp8team2/user/presentation/dto/UserInfoResponse.java b/src/main/java/com/swyp8team2/user/presentation/dto/UserInfoResponse.java new file mode 100644 index 00000000..13bf6e26 --- /dev/null +++ b/src/main/java/com/swyp8team2/user/presentation/dto/UserInfoResponse.java @@ -0,0 +1,13 @@ +package com.swyp8team2.user.presentation.dto; + +import com.swyp8team2.user.domain.User; + +public record UserInfoResponse( + Long id, + String nickname, + String profileUrl +) { + public static UserInfoResponse of(User user) { + return new UserInfoResponse(user.getId(), user.getNickname(), user.getProfileUrl()); + } +} diff --git a/src/main/java/com/swyp8team2/vote/application/VoteService.java b/src/main/java/com/swyp8team2/vote/application/VoteService.java new file mode 100644 index 00000000..e7f093d9 --- /dev/null +++ b/src/main/java/com/swyp8team2/vote/application/VoteService.java @@ -0,0 +1,58 @@ +package com.swyp8team2.vote.application; + +import com.swyp8team2.common.exception.BadRequestException; +import com.swyp8team2.common.exception.ErrorCode; +import com.swyp8team2.post.domain.Post; +import com.swyp8team2.post.domain.PostRepository; +import com.swyp8team2.user.domain.User; +import com.swyp8team2.user.domain.UserRepository; +import com.swyp8team2.vote.domain.Vote; +import com.swyp8team2.vote.domain.VoteRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class VoteService { + + private final VoteRepository voteRepository; + private final UserRepository userRepository; + private final PostRepository postRepository; + + @Transactional + public Long vote(Long voterId, Long postId, Long imageId) { + User voter = userRepository.findById(voterId) + .orElseThrow(() -> new BadRequestException(ErrorCode.USER_NOT_FOUND)); + deleteVoteIfExisting(postId, voter.getId()); + Vote vote = createVote(postId, imageId, voter.getId()); + return vote.getId(); + } + + private void deleteVoteIfExisting(Long postId, Long userId) { + voteRepository.findByUserIdAndPostId(userId, postId) + .ifPresent(vote -> { + voteRepository.delete(vote); + postRepository.findById(postId) + .orElseThrow(() -> new BadRequestException(ErrorCode.POST_NOT_FOUND)) + .cancelVote(vote.getPostImageId()); + }); + } + + private Vote createVote(Long postId, Long imageId, Long userId) { + Post post = postRepository.findById(postId) + .orElseThrow(() -> new BadRequestException(ErrorCode.POST_NOT_FOUND)); + post.validateProgress(); + Vote vote = voteRepository.save(Vote.of(post.getId(), imageId, userId)); + post.vote(imageId); + return vote; + } + + @Transactional + public Long guestVote(Long userId, Long postId, Long imageId) { + deleteVoteIfExisting(postId, userId); + Vote vote = createVote(postId, imageId, userId); + return vote.getId(); + } +} diff --git a/src/main/java/com/swyp8team2/vote/domain/Vote.java b/src/main/java/com/swyp8team2/vote/domain/Vote.java new file mode 100644 index 00000000..23bd38a9 --- /dev/null +++ b/src/main/java/com/swyp8team2/vote/domain/Vote.java @@ -0,0 +1,39 @@ +package com.swyp8team2.vote.domain; + +import com.swyp8team2.common.domain.BaseEntity; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "user_votes") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Vote extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private Long postId; + + private Long postImageId; + + private Long userId; + + public Vote(Long id, Long postId, Long postImageId, Long userId) { + this.id = id; + this.postId = postId; + this.postImageId = postImageId; + this.userId = userId; + } + + public static Vote of(Long postId, Long postImageId, Long userId) { + return new Vote(null, postId, postImageId, userId); + } +} diff --git a/src/main/java/com/swyp8team2/vote/domain/VoteRepository.java b/src/main/java/com/swyp8team2/vote/domain/VoteRepository.java new file mode 100644 index 00000000..05c2ccf5 --- /dev/null +++ b/src/main/java/com/swyp8team2/vote/domain/VoteRepository.java @@ -0,0 +1,14 @@ +package com.swyp8team2.vote.domain; + +import org.springframework.data.domain.Slice; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface VoteRepository extends JpaRepository { + Optional findByUserIdAndPostId(Long userId, Long postId); + + Slice findByUserId(Long userId); +} diff --git a/src/main/java/com/swyp8team2/vote/presentation/VoteController.java b/src/main/java/com/swyp8team2/vote/presentation/VoteController.java new file mode 100644 index 00000000..6f97d68f --- /dev/null +++ b/src/main/java/com/swyp8team2/vote/presentation/VoteController.java @@ -0,0 +1,64 @@ +package com.swyp8team2.vote.presentation; + +import com.swyp8team2.auth.domain.UserInfo; +import com.swyp8team2.common.presentation.CustomHeader; +import com.swyp8team2.vote.application.VoteService; +import com.swyp8team2.vote.presentation.dto.ChangeVoteRequest; +import com.swyp8team2.vote.presentation.dto.VoteRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/posts/{postId}/votes") +public class VoteController { + + private final VoteService voteService; + + @PostMapping("") + public ResponseEntity vote( + @PathVariable("postId") Long postId, + @Valid @RequestBody VoteRequest request, + @AuthenticationPrincipal UserInfo userInfo + ) { + voteService.vote(userInfo.userId(), postId, request.imageId()); + return ResponseEntity.ok().build(); + } + + @PostMapping("/guest") + public ResponseEntity guestVote( + @PathVariable("postId") Long postId, + @Valid @RequestBody VoteRequest request, + @AuthenticationPrincipal UserInfo userInfo + ) { + voteService.guestVote(userInfo.userId(), postId, request.imageId()); + return ResponseEntity.ok().build(); + } + + @PatchMapping("") + public ResponseEntity changeVote( + @PathVariable("postId") Long postId, + @Valid @RequestBody ChangeVoteRequest request, + @AuthenticationPrincipal UserInfo userInfo + ) { + return ResponseEntity.ok().build(); + } + + @PatchMapping("/guest") + public ResponseEntity changeGuestVote( + @PathVariable("postId") Long postId, + @RequestHeader(CustomHeader.GUEST_TOKEN) String guestId, + @Valid @RequestBody ChangeVoteRequest request + ) { + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/swyp8team2/vote/presentation/dto/ChangeVoteRequest.java b/src/main/java/com/swyp8team2/vote/presentation/dto/ChangeVoteRequest.java new file mode 100644 index 00000000..f9c102f8 --- /dev/null +++ b/src/main/java/com/swyp8team2/vote/presentation/dto/ChangeVoteRequest.java @@ -0,0 +1,9 @@ +package com.swyp8team2.vote.presentation.dto; + +import jakarta.validation.constraints.NotNull; + +public record ChangeVoteRequest( + @NotNull + Long imageId +) { +} diff --git a/src/main/java/com/swyp8team2/vote/presentation/dto/GuestVoteRequest.java b/src/main/java/com/swyp8team2/vote/presentation/dto/GuestVoteRequest.java new file mode 100644 index 00000000..60492cc1 --- /dev/null +++ b/src/main/java/com/swyp8team2/vote/presentation/dto/GuestVoteRequest.java @@ -0,0 +1,12 @@ +package com.swyp8team2.vote.presentation.dto; + +import jakarta.validation.constraints.NotNull; + +public record GuestVoteRequest( + @NotNull + Long postId, + + @NotNull + Long voteId +) { +} diff --git a/src/main/java/com/swyp8team2/vote/presentation/dto/VoteRequest.java b/src/main/java/com/swyp8team2/vote/presentation/dto/VoteRequest.java new file mode 100644 index 00000000..9c7b2adb --- /dev/null +++ b/src/main/java/com/swyp8team2/vote/presentation/dto/VoteRequest.java @@ -0,0 +1,9 @@ +package com.swyp8team2.vote.presentation.dto; + +import jakarta.validation.constraints.NotNull; + +public record VoteRequest( + @NotNull + Long imageId +) { +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index e69de29b..d75d118c 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -0,0 +1,21 @@ +server: + port: 8080 +spring: + profiles: + active: local + +--- + +spring: + config: + import: classpath:application-dev.yml + activate: + on-profile: dev + +--- + +spring: + config: + import: classpath:application-prod.yml + activate: + on-profile: prod diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html new file mode 100644 index 00000000..ab7a06bc --- /dev/null +++ b/src/main/resources/static/index.html @@ -0,0 +1,52 @@ + + + + + 멀티 파일 업로드 테스트 + + +

여러 파일 업로드 테스트

+ + + + + + + diff --git a/src/test/java/com/swyp8team2/auth/application/AuthServiceTest.java b/src/test/java/com/swyp8team2/auth/application/AuthServiceTest.java new file mode 100644 index 00000000..f2e4959e --- /dev/null +++ b/src/test/java/com/swyp8team2/auth/application/AuthServiceTest.java @@ -0,0 +1,71 @@ +package com.swyp8team2.auth.application; + +import com.swyp8team2.auth.application.jwt.JwtProvider; +import com.swyp8team2.auth.application.jwt.TokenPair; +import com.swyp8team2.auth.application.oauth.OAuthService; +import com.swyp8team2.auth.application.oauth.dto.OAuthUserInfo; +import com.swyp8team2.auth.domain.Provider; +import com.swyp8team2.auth.domain.SocialAccount; +import com.swyp8team2.auth.domain.SocialAccountRepository; +import com.swyp8team2.auth.presentation.dto.TokenResponse; +import com.swyp8team2.support.IntegrationTest; +import com.swyp8team2.user.domain.User; +import com.swyp8team2.user.domain.UserRepository; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import static org.assertj.core.api.Assertions.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; + +class AuthServiceTest extends IntegrationTest { + + @Autowired + AuthService authService; + + @MockitoBean + OAuthService oAuthService; + + @MockitoBean + JwtProvider jwtProvider; + + @Autowired + SocialAccountRepository socialAccountRepository; + + @Autowired + UserRepository userRepository; + + @Test + @DisplayName("OAuth 로그인하면 토큰 발급해야 하고 유저 정보 없는 경우 유저 생성") + void oAuthSignIn() throws Exception { + //given + OAuthUserInfo oAuthUserInfo = new OAuthUserInfo("socialId", "profileImageUrl", "nickname", Provider.KAKAO); + given(oAuthService.getUserInfo(anyString(), anyString())) + .willReturn(oAuthUserInfo); + TokenPair expectedTokenPair = new TokenPair("accessToken", "refreshToken"); + given(jwtProvider.createToken(any())) + .willReturn(expectedTokenPair); + + //when + TokenResponse tokenResponse = authService.oauthSignIn("code", "https://dev.photopic.site"); + + //then + TokenPair tokenPair = tokenResponse.tokenPair(); + SocialAccount socialAccount = socialAccountRepository.findBySocialIdAndProvider(oAuthUserInfo.socialId(), Provider.KAKAO).get(); + User user = userRepository.findById(socialAccount.getId()).get(); + assertAll( + () -> assertThat(tokenPair).isEqualTo(expectedTokenPair), + () -> assertThat(socialAccount.getUserId()).isNotNull(), + () -> assertThat(socialAccount.getSocialId()).isEqualTo(oAuthUserInfo.socialId()), + () -> assertThat(socialAccount.getProvider()).isEqualTo(oAuthUserInfo.provider()), + () -> assertThat(user.getNickname()).isEqualTo(oAuthUserInfo.nickname()), + () -> assertThat(user.getProfileUrl()).isEqualTo(oAuthUserInfo.profileImageUrl()) + ); + } +} diff --git a/src/test/java/com/swyp8team2/auth/application/JwtClaimTest.java b/src/test/java/com/swyp8team2/auth/application/JwtClaimTest.java new file mode 100644 index 00000000..ef7a31e5 --- /dev/null +++ b/src/test/java/com/swyp8team2/auth/application/JwtClaimTest.java @@ -0,0 +1,22 @@ +package com.swyp8team2.auth.application; + +import com.swyp8team2.auth.application.jwt.JwtClaim; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class JwtClaimTest { + + @Test + @DisplayName("JwtClaim 생성") + void idAsLong() { + // given + long givenId = 1; + + // when + JwtClaim jwtClaim = JwtClaim.from(givenId); + + // then + Assertions.assertThat(jwtClaim.idAsLong()).isEqualTo(givenId); + } +} diff --git a/src/test/java/com/swyp8team2/auth/application/JwtProviderTest.java b/src/test/java/com/swyp8team2/auth/application/JwtProviderTest.java new file mode 100644 index 00000000..d3a5fcae --- /dev/null +++ b/src/test/java/com/swyp8team2/auth/application/JwtProviderTest.java @@ -0,0 +1,87 @@ +package com.swyp8team2.auth.application; + +import com.swyp8team2.auth.application.jwt.JwtClaim; +import com.swyp8team2.auth.application.jwt.JwtProvider; +import com.swyp8team2.auth.application.jwt.TokenPair; +import com.swyp8team2.common.exception.ErrorCode; +import com.swyp8team2.common.exception.UnauthorizedException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.temporal.ChronoUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.spy; + +class JwtProviderTest { + + @Test + @DisplayName("jwt 토큰 생성") + void createToken() throws Exception { + //given + JwtProvider jwtProvider = new JwtProvider("2345asdfasdfsadfsdf243dfdsfsfssasdf", "issuer", Clock.systemDefaultZone()); + JwtClaim givenClaim = new JwtClaim(1L); + + //when + TokenPair tokenPair = jwtProvider.createToken(givenClaim); + JwtClaim jwtClaim = jwtProvider.parseToken(tokenPair.accessToken()); + + //then + assertAll( + () -> assertThat(tokenPair.accessToken()).isNotNull(), + () -> assertThat(tokenPair.refreshToken()).isNotNull(), + () -> assertThat(jwtClaim.idAsLong()).isEqualTo(givenClaim.idAsLong()) + ); + } + + @Test + @DisplayName("jwt 토큰 생성 - 만료 토큰") + void parseClaim_expiredToken() throws Exception { + //given + Clock clock = Clock.systemDefaultZone(); + Clock mockedClocked = spy(clock); + JwtProvider jwtProvider = new JwtProvider("2345asdfasdfsadfsdf243dfdsfsfssasdf", "issuer", mockedClocked); + given(mockedClocked.instant()) + .willReturn(clock.instant().minus(24, ChronoUnit.HOURS)); + + //when then + TokenPair tokenPair = jwtProvider.createToken(new JwtClaim(1L)); + assertThatThrownBy(() -> jwtProvider.parseToken(tokenPair.accessToken())) + .isInstanceOf(UnauthorizedException.class) + .hasMessage(ErrorCode.EXPIRED_TOKEN.getMessage()); + } + + @Test + @DisplayName("jwt 토큰 생성 - key가 다른 경우") + void parseClaim_differentKey() throws Exception { + //given + Clock clock = Clock.systemDefaultZone(); + JwtProvider jwtProvider = new JwtProvider("2345asdfasdfsadfsdf243dfdsfsfssasdf", "issuer", clock); + JwtProvider jwtProviderWithDifferentKey = new JwtProvider("1211qwerqwerqwer1111qwerqwerqwer", "issuer", clock); + + //when then + TokenPair tokenPair = jwtProviderWithDifferentKey.createToken(new JwtClaim(1L)); + assertThatThrownBy(() -> jwtProvider.parseToken(tokenPair.accessToken())) + .isInstanceOf(UnauthorizedException.class) + .hasMessage(ErrorCode.INVALID_TOKEN.getMessage()); + } + + @Test + @DisplayName("jwt 토큰 생성 - issuer가 다른 경우") + void parseClaim_differentIssuer() throws Exception { + //given + Clock clock = Clock.systemDefaultZone(); + JwtProvider jwtProvider = new JwtProvider("2345asdfasdfsadfsdf243dfdsfsfssasdf", "issuer", clock); + JwtProvider jwtProviderWithDifferentIssuer = new JwtProvider("2345asdfasdfsadfsdf243dfdsfsfssasdf", "asdf", clock); + + //when then + TokenPair tokenPair = jwtProviderWithDifferentIssuer.createToken(new JwtClaim(1L)); + assertThatThrownBy(() -> jwtProvider.parseToken(tokenPair.accessToken())) + .isInstanceOf(UnauthorizedException.class) + .hasMessage(ErrorCode.INVALID_TOKEN.getMessage()); + } +} diff --git a/src/test/java/com/swyp8team2/auth/application/JwtServiceTest.java b/src/test/java/com/swyp8team2/auth/application/JwtServiceTest.java new file mode 100644 index 00000000..40cefbdb --- /dev/null +++ b/src/test/java/com/swyp8team2/auth/application/JwtServiceTest.java @@ -0,0 +1,136 @@ +package com.swyp8team2.auth.application; + +import com.swyp8team2.auth.application.jwt.JwtClaim; +import com.swyp8team2.auth.application.jwt.JwtProvider; +import com.swyp8team2.auth.application.jwt.JwtService; +import com.swyp8team2.auth.application.jwt.TokenPair; +import com.swyp8team2.auth.domain.RefreshToken; +import com.swyp8team2.auth.domain.RefreshTokenRepository; +import com.swyp8team2.auth.presentation.dto.TokenResponse; +import com.swyp8team2.common.exception.BadRequestException; +import com.swyp8team2.common.exception.ErrorCode; +import com.swyp8team2.support.IntegrationTest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; + +class JwtServiceTest extends IntegrationTest { + + @Autowired + JwtService jwtService; + + @Autowired + RefreshTokenRepository refreshTokenRepository; + + @MockitoBean + JwtProvider jwtProvider; + + @Test + @DisplayName("새 토큰 발급하고 refresh token을 db에 저장해야 함") + void createToken() throws Exception { + //given + long givenUserId = 1L; + TokenPair expectedTokenPair = new TokenPair("accessToken", "refreshToken"); + given(jwtProvider.createToken(any(JwtClaim.class))) + .willReturn(expectedTokenPair); + + //when + TokenResponse tokenResponse = jwtService.createToken(givenUserId); + + //then + TokenPair tokenPair = tokenResponse.tokenPair(); + RefreshToken findRefreshToken = refreshTokenRepository.findByUserId(givenUserId).get(); + assertThat(tokenPair).isEqualTo(expectedTokenPair); + assertThat(findRefreshToken.getToken()).isEqualTo(expectedTokenPair.refreshToken()); + } + + @Test + @DisplayName("토큰 재발급하고 refresh token을 db에 갱신해야 함") + void reissue() throws Exception { + //given + long givenUserId = 1L; + String givenRefreshToken = "refreshToken"; + String newRefreshToken = "newRefreshToken"; + TokenPair expectedTokenPair = new TokenPair("newAccessToken", newRefreshToken); + given(jwtProvider.parseToken(any(String.class))) + .willReturn(new JwtClaim(givenUserId)); + given(jwtProvider.createToken(any(JwtClaim.class))) + .willReturn(expectedTokenPair); + refreshTokenRepository.save(new RefreshToken(givenUserId, givenRefreshToken)); + + //when + TokenResponse tokenResponse = jwtService.reissue(givenRefreshToken); + + //then + TokenPair tokenPair = tokenResponse.tokenPair(); + RefreshToken findRefreshToken = refreshTokenRepository.findByUserId(givenUserId).get(); + assertThat(tokenPair).isEqualTo(expectedTokenPair); + assertThat(findRefreshToken.getToken()).isEqualTo(newRefreshToken); + } + + @Test + @DisplayName("토큰 재발급 - refresh token이 존재하지 않는 경우") + void reissue_refreshTokenNotFound() throws Exception { + //given + given(jwtProvider.parseToken(any(String.class))) + .willReturn(new JwtClaim(1L)); + + //when + assertThatThrownBy(() -> jwtService.reissue("refreshToken")) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.REFRESH_TOKEN_NOT_FOUND.getMessage()); + } + + @Test + @DisplayName("토큰 재발급 - refresh token이 일치하지 않은 경우") + void reissue_refreshTokenMismatched() throws Exception { + //given + long givenUserId = 1L; + String givenRefreshToken = "refreshToken"; + given(jwtProvider.parseToken(any(String.class))) + .willReturn(new JwtClaim(givenUserId)); + given(jwtProvider.createToken(any(JwtClaim.class))) + .willReturn(new TokenPair("accessToken", "newRefreshToken")); + refreshTokenRepository.save(new RefreshToken(givenUserId, givenRefreshToken)); + + //when + assertThatThrownBy(() -> jwtService.reissue("mismatchToken")) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.REFRESH_TOKEN_MISMATCHED.getMessage()); + } + + @Test + @DisplayName("로그아웃하면 refresh token을 db에서 삭제해야 함") + void signOut() throws Exception { + //given + long givenUserId = 1L; + String givenRefreshToken = "refreshToken"; + refreshTokenRepository.save(new RefreshToken(givenUserId, givenRefreshToken)); + + //when + jwtService.signOut(givenUserId, givenRefreshToken); + + //then + assertThat(refreshTokenRepository.findByUserId(givenUserId)).isEmpty(); + } + + @Test + @DisplayName("로그아웃 - 유저의 refresh token이 아닌 경우") + void signOut_invalidRefreshToken() throws Exception { + //given + long givenUserId = 1L; + String givenRefreshToken = "refreshToken"; + refreshTokenRepository.save(new RefreshToken(givenUserId, givenRefreshToken)); + + //when then + assertThatThrownBy(() -> jwtService.signOut(givenUserId, "differentToken")) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.REFRESH_TOKEN_MISMATCHED.getMessage()); + } +} diff --git a/src/test/java/com/swyp8team2/auth/domain/RefreshTokenTest.java b/src/test/java/com/swyp8team2/auth/domain/RefreshTokenTest.java new file mode 100644 index 00000000..29c985c8 --- /dev/null +++ b/src/test/java/com/swyp8team2/auth/domain/RefreshTokenTest.java @@ -0,0 +1,39 @@ +package com.swyp8team2.auth.domain; + +import com.swyp8team2.common.exception.BadRequestException; +import com.swyp8team2.common.exception.ErrorCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.*; + +class RefreshTokenTest { + + @Test + @DisplayName("Refresh Token 교체") + void rotate() throws Exception { + //given + String token = "refreshToken"; + String newToken = "newRefreshToken"; + RefreshToken refreshToken = new RefreshToken(1L, token); + + //when + refreshToken.rotate(token, newToken); + + //then + assertThat(refreshToken.getToken()).isEqualTo(newToken); + } + + @Test + @DisplayName("Refresh Token 교체 - 현재 토큰과 다른 경우") + void rotate_() throws Exception { + //given + String newToken = "newRefreshToken"; + RefreshToken refreshToken = new RefreshToken(1L, "refreshToken"); + + //when then + assertThatThrownBy(() -> refreshToken.rotate("mismatchToken", newToken)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.REFRESH_TOKEN_MISMATCHED.getMessage()); + } +} diff --git a/src/test/java/com/swyp8team2/auth/domain/SocialAccountTest.java b/src/test/java/com/swyp8team2/auth/domain/SocialAccountTest.java new file mode 100644 index 00000000..f73cd9ad --- /dev/null +++ b/src/test/java/com/swyp8team2/auth/domain/SocialAccountTest.java @@ -0,0 +1,61 @@ +package com.swyp8team2.auth.domain; + +import com.swyp8team2.auth.application.oauth.dto.OAuthUserInfo; +import com.swyp8team2.common.exception.ErrorCode; +import com.swyp8team2.common.exception.InternalServerException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.*; + +class SocialAccountTest { + + + @Test + @DisplayName("SocialAccount Entity 생성") + void create() throws Exception { + //given + long givenUserId = 1L; + OAuthUserInfo oAuthUserInfo = new OAuthUserInfo( + "socialId", + "profileImageUrl", + "nickname", + Provider.KAKAO + ); + + //when + SocialAccount socialAccount = SocialAccount.create(givenUserId, oAuthUserInfo); + + //then + assertAll( + () -> assertThat(socialAccount.getUserId()).isEqualTo(givenUserId), + () -> assertThat(socialAccount.getSocialId()).isEqualTo(oAuthUserInfo.socialId()), + () -> assertThat(socialAccount.getProvider()).isEqualTo(oAuthUserInfo.provider()) + ); + } + + @Test + @DisplayName("SocialAccount Entity 생성 - 파라미터가 null인 경우") + void create_null() throws Exception { + //given + + //when then + assertAll( + () -> assertThatThrownBy(() -> SocialAccount.create(1L, null)) + .isInstanceOf(InternalServerException.class) + .hasMessage(ErrorCode.INVALID_INPUT_VALUE.getMessage()), + () -> assertThatThrownBy(() -> SocialAccount.create( + 1L, + new OAuthUserInfo(null, "profileImageUrl", "nickname", Provider.KAKAO) + )).isInstanceOf(InternalServerException.class) + .hasMessage(ErrorCode.INVALID_INPUT_VALUE.getMessage()), + () -> assertThatThrownBy(() -> SocialAccount.create( + 1L, + new OAuthUserInfo("socialId", "profileImageUrl", "nickname", null) + )).isInstanceOf(InternalServerException.class) + .hasMessage(ErrorCode.INVALID_INPUT_VALUE.getMessage()) + ); + } +} diff --git a/src/test/java/com/swyp8team2/auth/presentation/AuthControllerTest.java b/src/test/java/com/swyp8team2/auth/presentation/AuthControllerTest.java new file mode 100644 index 00000000..60390dfb --- /dev/null +++ b/src/test/java/com/swyp8team2/auth/presentation/AuthControllerTest.java @@ -0,0 +1,205 @@ +package com.swyp8team2.auth.presentation; + +import com.swyp8team2.auth.application.AuthService; +import com.swyp8team2.auth.application.jwt.TokenPair; +import com.swyp8team2.auth.presentation.dto.GuestTokenResponse; +import com.swyp8team2.auth.presentation.dto.OAuthSignInRequest; +import com.swyp8team2.auth.presentation.dto.AuthResponse; +import com.swyp8team2.auth.presentation.dto.TokenResponse; +import com.swyp8team2.common.exception.BadRequestException; +import com.swyp8team2.common.exception.ErrorCode; +import com.swyp8team2.common.exception.ErrorResponse; +import com.swyp8team2.common.presentation.CustomHeader; +import com.swyp8team2.support.RestDocsTest; +import com.swyp8team2.support.WithMockUserInfo; +import jakarta.servlet.http.Cookie; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithAnonymousUser; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.ArgumentMatchers.nullable; +import static org.mockito.BDDMockito.given; +import static org.springframework.restdocs.cookies.CookieDocumentation.cookieWithName; +import static org.springframework.restdocs.cookies.CookieDocumentation.requestCookies; +import static org.springframework.restdocs.cookies.CookieDocumentation.responseCookies; + +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +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.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.cookie; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class AuthControllerTest extends RestDocsTest { + + @Autowired + AuthService authService; + + @Test + @DisplayName("카카오 로그인 코드로 토큰 발급") + void kakaoOAuthSignIn() throws Exception { + //given + TokenPair expectedTokenPair = new TokenPair("accessToken", "refreshToken"); + AuthResponse response = new AuthResponse(expectedTokenPair.accessToken(), 1L); + given(authService.oauthSignIn(anyString(), anyString())) + .willReturn(new TokenResponse(expectedTokenPair, 1L)); + OAuthSignInRequest request = new OAuthSignInRequest("code", "https://dev.photopic.site"); + + //when then + mockMvc.perform(post("/auth/oauth2/code/kakao") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(response))) + .andExpect(cookie().value(CustomHeader.CustomCookie.REFRESH_TOKEN, expectedTokenPair.refreshToken())) + .andExpect(cookie().httpOnly(CustomHeader.CustomCookie.REFRESH_TOKEN, true)) + .andExpect(cookie().path(CustomHeader.CustomCookie.REFRESH_TOKEN, "/")) + .andExpect(cookie().secure(CustomHeader.CustomCookie.REFRESH_TOKEN, true)) + .andExpect(cookie().attribute(CustomHeader.CustomCookie.REFRESH_TOKEN, "SameSite", "None")) + .andExpect(cookie().maxAge(CustomHeader.CustomCookie.REFRESH_TOKEN, 60 * 60 * 24 * 14)) + .andDo(restDocs.document( + requestFields( + fieldWithPath("code").description("카카오 인증 코드"), + fieldWithPath("redirectUri").description("카카오 인증 redirect uri") + ), + responseFields( + fieldWithPath("accessToken").description("액세스 토큰"), + fieldWithPath("userId").description("유저 Id") + ), + responseCookies( + cookieWithName(CustomHeader.CustomCookie.REFRESH_TOKEN).description("리프레시 토큰") + ) + )); + } + + @Test + @WithAnonymousUser + @DisplayName("토큰 재발급") + void reissue() throws Exception { + //given + String newRefreshToken = "newRefreshToken"; + TokenPair tokenPair = new TokenPair("accessToken", newRefreshToken); + given(authService.reissue(anyString())) + .willReturn(new TokenResponse(tokenPair, 1L)); + AuthResponse response = new AuthResponse(tokenPair.accessToken(), 1L); + + //when then + mockMvc.perform(post("/auth/reissue") + .cookie(new Cookie(CustomHeader.CustomCookie.REFRESH_TOKEN, "refreshToken"))) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(response))) + .andExpect(cookie().value(CustomHeader.CustomCookie.REFRESH_TOKEN, newRefreshToken)) + .andExpect(cookie().httpOnly(CustomHeader.CustomCookie.REFRESH_TOKEN, true)) + .andExpect(cookie().path(CustomHeader.CustomCookie.REFRESH_TOKEN, "/")) + .andExpect(cookie().secure(CustomHeader.CustomCookie.REFRESH_TOKEN, true)) + .andExpect(cookie().attribute(CustomHeader.CustomCookie.REFRESH_TOKEN, "SameSite", "None")) + .andExpect(cookie().maxAge(CustomHeader.CustomCookie.REFRESH_TOKEN, 60 * 60 * 24 * 14)) + .andDo(restDocs.document( + requestCookies( + cookieWithName(CustomHeader.CustomCookie.REFRESH_TOKEN).description("리프레시 토큰") + ), + responseCookies( + cookieWithName(CustomHeader.CustomCookie.REFRESH_TOKEN).description("새 리프레시 토큰") + ), + responseFields( + fieldWithPath("accessToken").description("새 액세스 토큰"), + fieldWithPath("userId").description("유저 Id") + ) + )); + } + + @Test + @DisplayName("토큰 재발급 - 리프레시 토큰 헤더 없는 경우") + void reissue_invalidRefreshTokenHeader() throws Exception { + //given + ErrorResponse response = new ErrorResponse(ErrorCode.INVALID_REFRESH_TOKEN_HEADER); + + //when then + mockMvc.perform(post("/auth/reissue")) + .andExpect(status().isBadRequest()) + .andExpect(content().json(objectMapper.writeValueAsString(response))); + } + + @Test + @DisplayName("토큰 재발급 - 리프레시 토큰 헤더가 db에 없는 경우") + void reissue_refreshTokenNotFound() throws Exception { + //given + ErrorResponse response = new ErrorResponse(ErrorCode.REFRESH_TOKEN_NOT_FOUND); + given(authService.reissue(anyString())) + .willThrow(new BadRequestException(ErrorCode.REFRESH_TOKEN_NOT_FOUND)); + + //when then + mockMvc.perform(post("/auth/reissue") + .cookie(new Cookie(CustomHeader.CustomCookie.REFRESH_TOKEN, "refreshToken"))) + .andExpect(status().isBadRequest()) + .andExpect(content().json(objectMapper.writeValueAsString(response))); + } + + @Test + @DisplayName("토큰 재발급 - 리프레시 토큰 헤더가 db에 있는 값과 일치하지 않은 경우") + void reissue_refreshTokenMismatched() throws Exception { + //given + ErrorResponse response = new ErrorResponse(ErrorCode.REFRESH_TOKEN_MISMATCHED); + given(authService.reissue(anyString())) + .willThrow(new BadRequestException(ErrorCode.REFRESH_TOKEN_MISMATCHED)); + + //when then + mockMvc.perform(post("/auth/reissue") + .cookie(new Cookie(CustomHeader.CustomCookie.REFRESH_TOKEN, "refreshToken"))) + .andExpect(status().isBadRequest()) + .andExpect(content().json(objectMapper.writeValueAsString(response))); + } + + @Test + @DisplayName("게스트 토큰 발급") + void guestToken() throws Exception { + //given + String guestToken = "guestToken"; + given(authService.createGuestToken()) + .willReturn(guestToken); + + //when then + mockMvc.perform(post("/auth/guest/token")) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(new GuestTokenResponse(guestToken)))) + .andDo(restDocs.document( + responseFields( + fieldWithPath("guestToken").description("게스트 토큰") + ) + )); + } + + @Test + @WithMockUserInfo + @DisplayName("로그아웃") + void signOut() throws Exception { + //given + + //when then + mockMvc.perform(post("/auth/sign-out") + .cookie(new Cookie(CustomHeader.CustomCookie.REFRESH_TOKEN, "refreshToken")) + .header(HttpHeaders.AUTHORIZATION, "Bearer accessToken")) + .andExpect(status().isOk()) + .andExpect(cookie().httpOnly(CustomHeader.CustomCookie.REFRESH_TOKEN, true)) + .andExpect(cookie().path(CustomHeader.CustomCookie.REFRESH_TOKEN, "/")) + .andExpect(cookie().secure(CustomHeader.CustomCookie.REFRESH_TOKEN, true)) + .andExpect(cookie().attribute(CustomHeader.CustomCookie.REFRESH_TOKEN, "SameSite", "None")) + .andExpect(cookie().maxAge(CustomHeader.CustomCookie.REFRESH_TOKEN, 0)) + .andDo(restDocs.document( + requestHeaders(authorizationHeader()), + requestCookies( + cookieWithName(CustomHeader.CustomCookie.REFRESH_TOKEN).description("리프레시 토큰") + ), + responseCookies( + cookieWithName(CustomHeader.CustomCookie.REFRESH_TOKEN).description("리프레시 토큰") + ) + )); + } +} diff --git a/src/test/java/com/swyp8team2/auth/presentation/filter/HeaderTokenExtractorTest.java b/src/test/java/com/swyp8team2/auth/presentation/filter/HeaderTokenExtractorTest.java new file mode 100644 index 00000000..5165e34f --- /dev/null +++ b/src/test/java/com/swyp8team2/auth/presentation/filter/HeaderTokenExtractorTest.java @@ -0,0 +1,59 @@ +package com.swyp8team2.auth.presentation.filter; + +import com.swyp8team2.common.exception.ErrorCode; +import com.swyp8team2.common.exception.UnauthorizedException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.*; + +class HeaderTokenExtractorTest { + + HeaderTokenExtractor tokenExtractor; + + @BeforeEach + void setUp() { + tokenExtractor = new HeaderTokenExtractor(); + } + + @Test + @DisplayName("토큰을 추출해야 함") + void extractToken() throws Exception { + //given + String authorization = "Bearer token"; + + //when + String token = tokenExtractor.extractToken(authorization); + + //then + assertThat(token).isEqualTo("token"); + } + + @Test + @DisplayName("헤더가 null일 경우 예외가 발생해야 함") + void extractToken_null() throws Exception { + //given + String authorization = null; + + //when then + assertThatThrownBy(() -> tokenExtractor.extractToken(authorization)) + .isInstanceOf(UnauthorizedException.class) + .hasMessage(ErrorCode.INVALID_AUTH_HEADER.getMessage()); + } + + @Test + @DisplayName("헤더 형식이 잘못되었을 경우 예외가 발생해야 함") + void extractToken_notBearer() throws Exception { + //given + String authorization = "bearer token"; + + //when then + assertThatThrownBy(() -> tokenExtractor.extractToken(authorization)) + .isInstanceOf(UnauthorizedException.class) + .hasMessage(ErrorCode.INVALID_AUTH_HEADER.getMessage()); + } + +} diff --git a/src/test/java/com/swyp8team2/comment/application/CommentServiceTest.java b/src/test/java/com/swyp8team2/comment/application/CommentServiceTest.java new file mode 100644 index 00000000..65ca8fbe --- /dev/null +++ b/src/test/java/com/swyp8team2/comment/application/CommentServiceTest.java @@ -0,0 +1,125 @@ +package com.swyp8team2.comment.application; + +import com.swyp8team2.auth.domain.UserInfo; +import com.swyp8team2.comment.domain.Comment; +import com.swyp8team2.comment.domain.CommentRepository; +import com.swyp8team2.comment.presentation.dto.CommentResponse; +import com.swyp8team2.comment.presentation.dto.CreateCommentRequest; +import com.swyp8team2.common.dto.CursorBasePaginatedResponse; +import com.swyp8team2.common.exception.BadRequestException; +import com.swyp8team2.common.exception.ErrorCode; +import com.swyp8team2.user.domain.Role; +import com.swyp8team2.user.domain.User; +import com.swyp8team2.user.domain.UserRepository; +import com.swyp8team2.vote.domain.VoteRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.SliceImpl; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CommentServiceTest { + + @Mock + private CommentRepository commentRepository; + + @Mock + private UserRepository userRepository; + + @InjectMocks + private CommentService commentService; + + @Mock + private VoteRepository voteRepository; + + @Test + @DisplayName("댓글 생성") + void createComment() { + // given + Long postId = 1L; + CreateCommentRequest request = new CreateCommentRequest("테스트 댓글"); + UserInfo userInfo = new UserInfo(100L, Role.USER); + Comment comment = new Comment(postId, userInfo.userId(), request.content()); + + // when + when(commentRepository.save(any(Comment.class))).thenReturn(comment); + + // then + assertDoesNotThrow(() -> commentService.createComment(postId, request, userInfo)); + } + + @Test + @DisplayName("댓글 조회") + void findComments() { + // given + Long postId = 1L; + Long cursor = null; + int size = 2; + + Comment comment1 = new Comment(1L, postId, 100L, "첫 번째 댓글"); + Comment comment2 = new Comment(2L, postId, 100L, "두 번째 댓글"); + SliceImpl commentSlice = new SliceImpl<>(List.of(comment1, comment2), PageRequest.of(0, size), false); + User user = new User(100L, "닉네임","http://example.com/profile.png", Role.USER); + + // Mock 설정 + given(commentRepository.findByPostId(eq(postId), eq(cursor), any(PageRequest.class))).willReturn(commentSlice); + given(voteRepository.findByUserIdAndPostId(eq(user.getId()), eq(postId))).willReturn(Optional.empty()); + // 각 댓글마다 user_no=100L 이므로, findById(100L)만 호출됨 + given(userRepository.findById(100L)).willReturn(Optional.of(user)); + + // when + CursorBasePaginatedResponse response = commentService.findComments(user.getId(), postId, cursor, size); + + // then + assertThat(response.data()).hasSize(2); + + CommentResponse cr1 = response.data().get(0); + assertThat(cr1.commentId()).isEqualTo(1L); + assertThat(cr1.content()).isEqualTo("첫 번째 댓글"); + assertThat(cr1.author().nickname()).isEqualTo("닉네임"); + + CommentResponse cr2 = response.data().get(1); + assertThat(cr2.commentId()).isEqualTo(2L); + assertThat(cr2.content()).isEqualTo("두 번째 댓글"); + } + + @Test + @DisplayName("댓글 조회 - 유저 정보 없는 경우") + void findComments_userNotFound() { + // given + Long postId = 1L; + Long cursor = null; + int size = 2; + + Comment comment1 = new Comment(1L, postId, 100L, "첫 번째 댓글"); + Comment comment2 = new Comment(2L, postId, 100L, "두 번째 댓글"); + SliceImpl commentSlice = new SliceImpl<>( + List.of(comment1, comment2), + PageRequest.of(0, size), + false + ); + + given(commentRepository.findByPostId(eq(postId), eq(cursor), any(PageRequest.class))).willReturn(commentSlice); + given(userRepository.findById(100L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> commentService.findComments(1L, postId, cursor, size)) + .isInstanceOf(BadRequestException.class) + .hasMessage((ErrorCode.USER_NOT_FOUND.getMessage())); + } +} diff --git a/src/test/java/com/swyp8team2/comment/domain/CommentRepositoryTest.java b/src/test/java/com/swyp8team2/comment/domain/CommentRepositoryTest.java new file mode 100644 index 00000000..aaa6f9ca --- /dev/null +++ b/src/test/java/com/swyp8team2/comment/domain/CommentRepositoryTest.java @@ -0,0 +1,40 @@ +package com.swyp8team2.comment.domain; + +import com.swyp8team2.support.RepositoryTest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class CommentRepositoryTest extends RepositoryTest { + + @Autowired + private CommentRepository commentRepository; + + @Test + @DisplayName("댓글 조회") + void select_CommentUser() { + // given + Comment comment1 = new Comment(1L, 100L, "content1"); + Comment comment2 = new Comment(1L, 101L, "content2"); + Comment comment3 = new Comment(1L, 102L, "content3"); + commentRepository.saveAll(List.of(comment1, comment2, comment3)); + + // when + Slice result1 = commentRepository.findByPostId(1L, null, PageRequest.of(0, 10)); + + // then + assertThat(result1.getContent()).hasSize(3); + + // when2 + Slice result2 = commentRepository.findByPostId(1L, 1L, PageRequest.of(0, 10)); + + // then2 + assertThat(result2.getContent()).hasSize(2); + } +} \ No newline at end of file diff --git a/src/test/java/com/swyp8team2/comment/presentation/CommentControllerTest.java b/src/test/java/com/swyp8team2/comment/presentation/CommentControllerTest.java new file mode 100644 index 00000000..da30223a --- /dev/null +++ b/src/test/java/com/swyp8team2/comment/presentation/CommentControllerTest.java @@ -0,0 +1,157 @@ +package com.swyp8team2.comment.presentation; + +import com.swyp8team2.auth.domain.UserInfo; +import com.swyp8team2.comment.presentation.dto.AuthorDto; +import com.swyp8team2.comment.presentation.dto.CommentResponse; +import com.swyp8team2.comment.presentation.dto.CreateCommentRequest; +import com.swyp8team2.common.dto.CursorBasePaginatedResponse; +import com.swyp8team2.support.RestDocsTest; +import com.swyp8team2.support.WithMockUserInfo; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.security.test.context.support.WithAnonymousUser; + +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.*; +import static org.springframework.restdocs.request.RequestDocumentation.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class CommentControllerTest extends RestDocsTest { + + @Test + @WithMockUserInfo + @DisplayName("댓글 생성") + void createComment() throws Exception { + //given + Long postId = 1L; + CreateCommentRequest request = new CreateCommentRequest("content"); + + doNothing().when(commentService).createComment(eq(postId), any(CreateCommentRequest.class), any(UserInfo.class)); + + //when then + mockMvc.perform(post("/posts/{postId}/comments", "1") + .header(HttpHeaders.AUTHORIZATION, "Bearer token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andDo(restDocs.document( + requestHeaders(authorizationHeader()), + pathParameters( + parameterWithName("postId").description("게시글 Id") + ), + requestFields( + fieldWithPath("content").type(JsonFieldType.STRING).description("댓글 내용").attributes(constraints("최대 ?글자")) + ) + )); + + verify(commentService, times(1)).createComment(eq(postId), any(CreateCommentRequest.class), any(UserInfo.class)); + } + + @Test + @WithAnonymousUser + @DisplayName("댓글 조회") + void findComments() throws Exception { + //given + Long postId = 1L; + Long cursor = null; + int size = 10; + CommentResponse commentResponse = new CommentResponse( + 1L, + "댓글 내용", + new AuthorDto(100L, "닉네임", "http://example.com/profile.png"), + null, + LocalDateTime.now(), + false + ); + List commentList = Collections.singletonList(commentResponse); + + CursorBasePaginatedResponse response = + new CursorBasePaginatedResponse<>(null, false, commentList); + + when(commentService.findComments(eq(null), eq(postId), eq(cursor), eq(size))).thenReturn(response); + + //when + mockMvc.perform(get("/posts/{postId}/comments", "1")) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(response))) + .andDo(restDocs.document( + pathParameters( + parameterWithName("postId").description("게시글 Id") + ), + queryParameters(cursorQueryParams()), + responseFields( + fieldWithPath("nextCursor") + .type(JsonFieldType.NUMBER) + .optional() + .description("다음 조회 커서 값"), + fieldWithPath("hasNext") + .type(JsonFieldType.BOOLEAN) + .description("다음 페이지 존재 여부 (기본 값 10)"), + fieldWithPath("data[]") + .type(JsonFieldType.ARRAY) + .description("댓글 데이터"), + fieldWithPath("data[].commentId") + .type(JsonFieldType.NUMBER) + .description("댓글 Id"), + fieldWithPath("data[].content") + .type(JsonFieldType.STRING) + .description("댓글 내용"), + fieldWithPath("data[].author") + .type(JsonFieldType.OBJECT) + .description("작성자"), + fieldWithPath("data[].author.userId") + .type(JsonFieldType.NUMBER) + .description("작성자 id"), + fieldWithPath("data[].author.nickname") + .type(JsonFieldType.STRING) + .description("작성자 닉네임"), + fieldWithPath("data[].author.profileUrl") + .type(JsonFieldType.STRING) + .description("작성자 프로필 이미지 url"), + fieldWithPath("data[].voteImageId") + .type(JsonFieldType.NUMBER) + .optional() + .description("작성자가 투표한 이미지 Id (투표 없을 시 null)"), + fieldWithPath("data[].createdAt") + .type(JsonFieldType.STRING) + .description("댓글 작성일"), + fieldWithPath("data[].isAuthor") + .type(JsonFieldType.BOOLEAN) + .description("작성자 여부") + ) + )); + + verify(commentService, times(1)).findComments(eq(null), eq(postId), eq(cursor), eq(size)); + } + + @Test + @WithMockUserInfo + @DisplayName("댓글 삭제") + void deleteComment() throws Exception { + //given + + //when then + mockMvc.perform(delete("/posts/{postId}/comments/{commentId}", "1", "1") + .header(HttpHeaders.AUTHORIZATION, "Bearer token")) + .andExpect(status().isOk()) + .andDo(restDocs.document( + requestHeaders(authorizationHeader()), + pathParameters( + parameterWithName("postId").description("게시글 Id"), + parameterWithName("commentId").description("댓글 Id") + ) + )); + } +} diff --git a/src/test/java/com/swyp8team2/crypto/application/CryptoServiceTest.java b/src/test/java/com/swyp8team2/crypto/application/CryptoServiceTest.java new file mode 100644 index 00000000..89b9fd1e --- /dev/null +++ b/src/test/java/com/swyp8team2/crypto/application/CryptoServiceTest.java @@ -0,0 +1,75 @@ +package com.swyp8team2.crypto.application; + +import com.swyp8team2.common.exception.BadRequestException; +import com.swyp8team2.common.exception.ErrorCode; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.security.crypto.encrypt.AesBytesEncryptor; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; + +class CryptoServiceTest { + + CryptoService cryptoService; + + @BeforeEach + void setUp() throws Exception { + cryptoService = new CryptoService(new AesBytesEncryptor("asdfd", "1541235432")); + } + + @Test + @DisplayName("암호화 및 복호화") + void encryptAndDecrypt() { + // given + String plainText = "15411"; + + // when + String encryptedText = cryptoService.encrypt(plainText); + System.out.println("encryptedText = " + encryptedText); + String decryptedText = cryptoService.decrypt(encryptedText); + + // then + assertThat(decryptedText).isEqualTo(plainText); + } + + @Test + @DisplayName("암호화 및 복호화 - 다른 키") + void encryptAndDecrypt_differentKey() throws Exception { + // given + String plainText = "Hello, World!"; + CryptoService differentCryptoService = new CryptoService(new AesBytesEncryptor("different", "234562")); + String encryptedText = differentCryptoService.encrypt(plainText); + + // when then + assertThatThrownBy(() -> cryptoService.decrypt(encryptedText)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.INVALID_TOKEN.getMessage()); + } + + @Test + @DisplayName("복호화 - 이상한 토큰") + void decrypt_invalidToken() { + // given + String invalid = "invalidToken"; + + // when then + assertThatThrownBy(() -> cryptoService.decrypt(invalid)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.INVALID_TOKEN.getMessage()); + } + + @Test + @DisplayName("복호화 - empty string") + void decrypt_emptyString() { + // given + String invalid = ""; + + // when then + assertThatThrownBy(() -> cryptoService.decrypt(invalid)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.INVALID_TOKEN.getMessage()); + } +} diff --git a/src/test/java/com/swyp8team2/image/application/ImageServiceTest.java b/src/test/java/com/swyp8team2/image/application/ImageServiceTest.java new file mode 100644 index 00000000..c38daacf --- /dev/null +++ b/src/test/java/com/swyp8team2/image/application/ImageServiceTest.java @@ -0,0 +1,169 @@ +package com.swyp8team2.image.application; + +import com.swyp8team2.common.exception.BadRequestException; +import com.swyp8team2.common.exception.ErrorCode; +import com.swyp8team2.image.domain.ImageFile; +import com.swyp8team2.image.domain.ImageFileRepository; +import com.swyp8team2.image.presentation.dto.ImageFileDto; +import com.swyp8team2.image.presentation.dto.ImageFileResponse; +import com.swyp8team2.image.util.FileValidator; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class ImageServiceTest { + + @Mock + private R2Storage r2Storage; + + @Mock + private FileValidator fileValidator; + + @Mock + private ImageFileRepository imageFileRepository; + + @InjectMocks + private ImageService imageService; + + @Test + @DisplayName("ImageFile Entity 생성") + void createImageFile() { + // given + ImageFileDto dto = new ImageFileDto("test.jpg", "https://image.photopic.site/test.jpg", "https://image.photopic.site/thumb.jpg"); + ImageFile imageFile = ImageFile.create(dto); + + // when + ReflectionTestUtils.setField(imageFile, "id", 100L); + when(imageFileRepository.save(any(ImageFile.class))).thenReturn(imageFile); + Long id = imageService.createImageFile(dto); + + // then + assertEquals(100L, id); + } + + @Test + @DisplayName("ImageFile Entity 생성 - 파라미터가 null인 경우") + void createImageFile_null() { + // given + ImageFileDto dto = new ImageFileDto("test.jpg", null, null); + + // when + when(imageFileRepository.save(any(ImageFile.class))) + .thenThrow(new BadRequestException(ErrorCode.INVALID_ARGUMENT)); + + // then + assertThatThrownBy(() -> imageService.createImageFile(dto)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.INVALID_ARGUMENT.getMessage()); + + } + + @Test + @DisplayName("ImageFile Entity 생성 - 파라미터가 빈 값인 경우") + void createImageFile_emptyString() { + // given + ImageFileDto dto = new ImageFileDto("test.jpg", "", ""); + + // when + when(imageFileRepository.save(any(ImageFile.class))) + .thenThrow(new BadRequestException(ErrorCode.INVALID_ARGUMENT)); + + // then + assertThatThrownBy(() -> imageService.createImageFile(dto)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.INVALID_ARGUMENT.getMessage()); + + } + + @Test + @DisplayName("파일 업로드") + void uploadImageFile() { + // given + MockMultipartFile file1 = new MockMultipartFile( + "files", + "test1.jpg", + MediaType.IMAGE_JPEG_VALUE, + "dummy content".getBytes(StandardCharsets.UTF_8) + ); + MockMultipartFile file2 = new MockMultipartFile( + "files", + "test2.png", + MediaType.IMAGE_PNG_VALUE, + "dummy content".getBytes(StandardCharsets.UTF_8) + ); + + List imageFiles = List.of( + new ImageFileDto("test1.jpg", "https://image.photopic.site/test1.jpg", "https://image.photopic.site/thumb1.jpg"), + new ImageFileDto("test2.png", "https://image.photopic.site/test2.png", "https://image.photopic.site/thumb2.png") + ); + + doNothing().when(fileValidator).validate(file1, file2); + when(r2Storage.uploadImageFile(file1, file2)).thenReturn(imageFiles); + + AtomicLong idGenerator = new AtomicLong(1L); + when(imageFileRepository.save(any(ImageFile.class))).thenAnswer(invocation -> new ImageFile() { + @Override + public Long getId() { + return idGenerator.getAndIncrement(); + } + }); + + // when + ImageFileResponse response = imageService.uploadImageFile(file1, file2); + + // then + assertAll( + () -> assertNotNull(response), + () -> assertEquals(2, response.imageFileId().size()), + () -> assertThat(response.imageFileId().get(0)).isEqualTo(1L), + () -> assertThat(response.imageFileId().get(1)).isEqualTo(2L) + ); + } + + @Test + @DisplayName("파일 업로드 - IOException 발생") + void uploadImageFile_IOException() { + // given + MockMultipartFile file1 = new MockMultipartFile( + "files", + "test1.jpg", + MediaType.IMAGE_JPEG_VALUE, + "dummy content".getBytes(StandardCharsets.UTF_8) + ); + MockMultipartFile file2 = new MockMultipartFile( + "files", + "test2.png", + MediaType.IMAGE_PNG_VALUE, + "dummy content".getBytes(StandardCharsets.UTF_8) + ); + + doNothing().when(fileValidator).validate(file1, file2); + when(r2Storage.uploadImageFile(file1, file2)) + .thenThrow(new UncheckedIOException(new IOException(ErrorCode.SERVICE_UNAVAILABLE.getMessage()))); + + // when then + assertThatThrownBy(() -> imageService.uploadImageFile(file1, file2)) + .isInstanceOf(UncheckedIOException.class) + .hasMessageContaining(ErrorCode.SERVICE_UNAVAILABLE.getMessage()); + } +} \ No newline at end of file diff --git a/src/test/java/com/swyp8team2/image/presentation/ImageControllerTest.java b/src/test/java/com/swyp8team2/image/presentation/ImageControllerTest.java new file mode 100644 index 00000000..37c8b636 --- /dev/null +++ b/src/test/java/com/swyp8team2/image/presentation/ImageControllerTest.java @@ -0,0 +1,75 @@ +package com.swyp8team2.image.presentation; + +import com.swyp8team2.image.presentation.dto.ImageFileResponse; +import com.swyp8team2.support.RestDocsTest; +import com.swyp8team2.support.WithMockUserInfo; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +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.restdocs.snippet.Attributes.key; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class ImageControllerTest extends RestDocsTest { + + @Test + @WithMockUserInfo + @DisplayName("이미지 업로드") + void createImageFile() throws Exception { + //given + MockMultipartFile file1 = new MockMultipartFile( + "files", + "image.jpg", + MediaType.IMAGE_JPEG_VALUE, + "".getBytes(StandardCharsets.UTF_8) + ); + MockMultipartFile file2 = new MockMultipartFile( + "files", + "image.png", + MediaType.IMAGE_PNG_VALUE, + "".getBytes(StandardCharsets.UTF_8) + ); + ImageFileResponse response = new ImageFileResponse(List.of(1L, 2L)); + + // stub + when(imageService.uploadImageFile(file1, file2)).thenReturn(response); + + //when then + mockMvc.perform(MockMvcRequestBuilders.multipart("/image/upload") + .file(file1) + .file(file2) + .contentType(MediaType.MULTIPART_FORM_DATA) + .header(HttpHeaders.AUTHORIZATION, "Bearer token")) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(response))) + .andDo(restDocs.document( + requestHeaders(authorizationHeader()), + requestParts( + partWithName("files") + .description("투표 후보 이미지 파일") + .attributes(key("type").value("Array[File]")) + .attributes(constraints("최소 2개")) + ), + responseFields( + fieldWithPath("imageFileId") + .description("업로드된 이미지 파일") + .type(JsonFieldType.ARRAY) + ) + )); + + } +} \ No newline at end of file diff --git a/src/test/java/com/swyp8team2/image/util/FileValidatorTest.java b/src/test/java/com/swyp8team2/image/util/FileValidatorTest.java new file mode 100644 index 00000000..14fb3a6f --- /dev/null +++ b/src/test/java/com/swyp8team2/image/util/FileValidatorTest.java @@ -0,0 +1,102 @@ +package com.swyp8team2.image.util; + +import com.swyp8team2.common.exception.BadRequestException; +import com.swyp8team2.common.exception.ErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class FileValidatorTest { + + private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB + + private FileValidator fileValidator; + + @BeforeEach + void setUp() { + String allowedExtensions = "gif,jpg,jpeg,png"; + fileValidator = new FileValidator(allowedExtensions); + } + + @Test + @DisplayName("파일 유효성 체크 - 파일 크기 초과") + void validate_validFile_shouldPass() { + // given + byte[] largeContent = new byte[(int) (MAX_FILE_SIZE + 1)]; + MockMultipartFile file = new MockMultipartFile( + "file", + "test.jpg", + MediaType.IMAGE_JPEG_VALUE, + largeContent + ); + + // when then + assertThatThrownBy(() -> fileValidator.validate(file)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.EXCEED_MAX_FILE_SIZE.getMessage()); + } + + @Test + @DisplayName("파일 유효성 체크 - 지원하지 않는 확장자") + void validate_unsupportedExtension_shouldThrowException() { + // given + MockMultipartFile file = new MockMultipartFile( + "file", + "test.txt", + "text/plain", + "dummy content".getBytes(StandardCharsets.UTF_8) + ); + + // when then + assertThatThrownBy(() -> fileValidator.validate(file)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.UNSUPPORTED_FILE_EXTENSION.getMessage()); + } + + @Test + @DisplayName("파일 유효성 체크 - 확장자 누락") + void validate_missingFileExtension_shouldThrowException() { + // given + MockMultipartFile file = new MockMultipartFile( + "file", + "test", + "", + "dummy content".getBytes(StandardCharsets.UTF_8) + ); + + // when then + assertThatThrownBy(() -> fileValidator.validate(file)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.MISSING_FILE_EXTENSION.getMessage()); + } + + @Test + @DisplayName("파일 유효성 체크 - 여러 파일 중 하나가 유효성 실패") + void validate_multipleFiles_oneInvalid_shouldThrowException() { + // given + MockMultipartFile file1 = new MockMultipartFile( + "file", + "test.jpg", + "image/jpeg", + "dummy".getBytes(StandardCharsets.UTF_8) + ); + byte[] largeContent = new byte[(int) (MAX_FILE_SIZE + 1)]; + MockMultipartFile file2 = new MockMultipartFile( + "file", + "large.jpg", + "image/jpeg", + largeContent + ); + + // when then + assertThatThrownBy(() -> fileValidator.validate(file1, file2)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.EXCEED_MAX_FILE_SIZE.getMessage()); + } +} \ No newline at end of file diff --git a/src/test/java/com/swyp8team2/post/application/PostImageNameGeneratorTest.java b/src/test/java/com/swyp8team2/post/application/PostImageNameGeneratorTest.java new file mode 100644 index 00000000..626bb21f --- /dev/null +++ b/src/test/java/com/swyp8team2/post/application/PostImageNameGeneratorTest.java @@ -0,0 +1,32 @@ +package com.swyp8team2.post.application; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +class PostImageNameGeneratorTest { + + PostImageNameGenerator postImageNameGenerator; + + @BeforeEach + void setUp() { + postImageNameGenerator = new PostImageNameGenerator(); + } + + @Test + @DisplayName("이미지 이름 생성") + void generate() throws Exception { + //given + + //when + String generate1 = postImageNameGenerator.generate(); + String generate2 = postImageNameGenerator.generate(); + + //then + assertThat(generate1).isEqualTo("뽀또A"); + assertThat(generate2).isEqualTo("뽀또B"); + } +} diff --git a/src/test/java/com/swyp8team2/post/application/PostServiceTest.java b/src/test/java/com/swyp8team2/post/application/PostServiceTest.java new file mode 100644 index 00000000..b85b73ee --- /dev/null +++ b/src/test/java/com/swyp8team2/post/application/PostServiceTest.java @@ -0,0 +1,330 @@ +package com.swyp8team2.post.application; + +import com.swyp8team2.common.annotation.ShareUrlCryptoService; +import com.swyp8team2.common.exception.BadRequestException; +import com.swyp8team2.common.exception.ErrorCode; +import com.swyp8team2.crypto.application.CryptoService; +import com.swyp8team2.image.domain.ImageFile; +import com.swyp8team2.image.domain.ImageFileRepository; +import com.swyp8team2.post.domain.Post; +import com.swyp8team2.post.domain.PostImage; +import com.swyp8team2.post.domain.PostRepository; +import com.swyp8team2.post.domain.Status; +import com.swyp8team2.post.presentation.dto.CreatePostRequest; +import com.swyp8team2.post.presentation.dto.CreatePostResponse; +import com.swyp8team2.post.presentation.dto.PostResponse; +import com.swyp8team2.post.presentation.dto.PostImageRequestDto; +import com.swyp8team2.post.presentation.dto.PostImageResponse; +import com.swyp8team2.support.IntegrationTest; +import com.swyp8team2.user.domain.User; +import com.swyp8team2.user.domain.UserRepository; +import com.swyp8team2.vote.application.VoteService; +import com.swyp8team2.vote.domain.Vote; +import com.swyp8team2.vote.domain.VoteRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.ArrayList; +import java.util.List; + +import static com.swyp8team2.support.fixture.FixtureGenerator.createImageFile; +import static com.swyp8team2.support.fixture.FixtureGenerator.createPost; +import static com.swyp8team2.support.fixture.FixtureGenerator.createUser; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; + +class PostServiceTest extends IntegrationTest { + + @Autowired + PostService postService; + + @Autowired + PostRepository postRepository; + + @Autowired + UserRepository userRepository; + + @Autowired + ImageFileRepository imageFileRepository; + + @Autowired + VoteRepository voteRepository; + + @Autowired + VoteService voteService; + + @MockitoBean + @ShareUrlCryptoService + CryptoService shareUrlCryptoService; + + @Test + @DisplayName("게시글 작성") + void create() throws Exception { + //given + long userId = 1L; + CreatePostRequest request = new CreatePostRequest("description", List.of( + new PostImageRequestDto(1L), + new PostImageRequestDto(2L) + )); + String shareUrl = "shareUrl"; + given(shareUrlCryptoService.encrypt(any())) + .willReturn(shareUrl); + + //when + CreatePostResponse response = postService.create(userId, request); + + //then + Post post = postRepository.findById(response.postId()).get(); + List images = post.getImages(); + assertAll( + () -> assertThat(post.getDescription()).isEqualTo("description"), + () -> assertThat(post.getUserId()).isEqualTo(userId), + () -> assertThat(post.getShareUrl()).isEqualTo(shareUrl), + () -> assertThat(images).hasSize(2), + () -> assertThat(images.get(0).getImageFileId()).isEqualTo(1L), + () -> assertThat(images.get(0).getName()).isEqualTo("뽀또A"), + () -> assertThat(images.get(0).getVoteCount()).isEqualTo(0), + () -> assertThat(images.get(1).getImageFileId()).isEqualTo(2L), + () -> assertThat(images.get(1).getName()).isEqualTo("뽀또B"), + () -> assertThat(images.get(1).getVoteCount()).isEqualTo(0) + ); + } + + @Test + @DisplayName("게시글 작성 - 이미지가 2개 미만인 경우") + void create_invalidPostImageCount() throws Exception { + //given + long userId = 1L; + CreatePostRequest request = new CreatePostRequest("description", List.of( + new PostImageRequestDto(1L) + )); + + //when then + assertThatThrownBy(() -> postService.create(userId, request)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.INVALID_POST_IMAGE_COUNT.getMessage()); + } + + @Test + @DisplayName("게시글 작성 - 설명이 100자 넘어가는 경우") + void create_descriptionCountExceeded() throws Exception { + //given + long userId = 1L; + CreatePostRequest request = new CreatePostRequest("a".repeat(101), List.of( + new PostImageRequestDto(1L), + new PostImageRequestDto(2L) + )); + + //when then + assertThatThrownBy(() -> postService.create(userId, request)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.DESCRIPTION_LENGTH_EXCEEDED.getMessage()); + } + + @Test + @DisplayName("게시글 조회") + void findById() throws Exception { + //given + User user = userRepository.save(createUser(1)); + ImageFile imageFile1 = imageFileRepository.save(createImageFile(1)); + ImageFile imageFile2 = imageFileRepository.save(createImageFile(2)); + Post post = postRepository.save(createPost(user.getId(), imageFile1, imageFile2, 1)); + + //when + PostResponse response = postService.findById(user.getId(), post.getId()); + + //then + List votes = response.images(); + assertAll( + () -> assertThat(response.description()).isEqualTo(post.getDescription()), + () -> assertThat(response.id()).isEqualTo(post.getId()), + () -> assertThat(response.author().nickname()).isEqualTo(user.getNickname()), + () -> assertThat(response.author().profileUrl()).isEqualTo(user.getProfileUrl()), + () -> assertThat(response.shareUrl()).isEqualTo(post.getShareUrl()), + () -> assertThat(votes).hasSize(2), + () -> assertThat(votes.get(0).imageUrl()).isEqualTo(imageFile1.getImageUrl()), + () -> assertThat(votes.get(0).voted()).isFalse(), + () -> assertThat(votes.get(1).imageUrl()).isEqualTo(imageFile2.getImageUrl()), + () -> assertThat(votes.get(1).voted()).isFalse() + ); + } + + @Test + @DisplayName("유저가 작성한 게시글 조회 - 커서 null인 경우") + void findUserPosts() throws Exception { + //given + User user = userRepository.save(createUser(1)); + List posts = createPosts(user); + int size = 10; + + //when + var response = postService.findUserPosts(user.getId(), null, size); + + //then + assertAll( + () -> assertThat(response.data()).hasSize(size), + () -> assertThat(response.hasNext()).isTrue(), + () -> assertThat(response.nextCursor()).isEqualTo(posts.get(posts.size() - size).getId()) + ); + } + + @Test + @DisplayName("유저가 작성한 게시글 조회 - 커서 있는 경우") + void findUserPosts2() throws Exception { + //given + User user = userRepository.save(createUser(1)); + List posts = createPosts(user); + int size = 10; + + //when + var response = postService.findUserPosts(user.getId(), posts.get(3).getId(), size); + + //then + assertAll( + () -> assertThat(response.data()).hasSize(3), + () -> assertThat(response.hasNext()).isFalse(), + () -> assertThat(response.nextCursor()).isEqualTo(posts.get(0).getId()) + ); + } + + private List createPosts(User user) { + List posts = new ArrayList<>(); + for (int i = 0; i < 30; i += 2) { + ImageFile imageFile1 = imageFileRepository.save(createImageFile(i)); + ImageFile imageFile2 = imageFileRepository.save(createImageFile(i + 1)); + posts.add(postRepository.save(createPost(user.getId(), imageFile1, imageFile2, i))); + } + return posts; + } + + @Test + @DisplayName("유저가 투표한 게시글 조회 - 커서 null인 경우") + void findVotedPosts() throws Exception { + //given + User user = userRepository.save(createUser(1)); + List posts = createPosts(user); + for (int i = 0; i < 15; i++) { + Post post = posts.get(i); + voteRepository.save(Vote.of(post.getId(), post.getImages().get(0).getId(), user.getId())); + } + int size = 10; + + //when + var response = postService.findVotedPosts(user.getId(), null, size); + + //then + int 전체_15개에서_맨_마지막_데이터_인덱스 = posts.size() - size; + assertAll( + () -> assertThat(response.data()).hasSize(size), + () -> assertThat(response.hasNext()).isTrue(), + () -> assertThat(response.nextCursor()).isEqualTo(posts.get(전체_15개에서_맨_마지막_데이터_인덱스).getId()) + ); + } + + @Test + @DisplayName("투표 현황 조회") + void findPostStatus() throws Exception { + //given + User user = userRepository.save(createUser(1)); + ImageFile imageFile1 = imageFileRepository.save(createImageFile(1)); + ImageFile imageFile2 = imageFileRepository.save(createImageFile(2)); + Post post = postRepository.save(createPost(user.getId(), imageFile1, imageFile2, 1)); + voteService.vote(user.getId(), post.getId(), post.getImages().get(0).getId()); + + //when + var response = postService.findPostStatus(post.getId()); + + //then + assertAll( + () -> assertThat(response).hasSize(2), + () -> assertThat(response.get(0).id()).isEqualTo(post.getImages().get(0).getId()), + () -> assertThat(response.get(0).imageName()).isEqualTo(post.getImages().get(0).getName()), + () -> assertThat(response.get(0).voteCount()).isEqualTo(1), + () -> assertThat(response.get(0).voteRatio()).isEqualTo("100.0"), + () -> assertThat(response.get(1).id()).isEqualTo(post.getImages().get(1).getId()), + () -> assertThat(response.get(1).imageName()).isEqualTo(post.getImages().get(1).getName()), + () -> assertThat(response.get(1).voteCount()).isEqualTo(0), + () -> assertThat(response.get(1).voteRatio()).isEqualTo("0.0") + ); + } + + @Test + @DisplayName("투표 마감") + void close() throws Exception { + //given + User user = userRepository.save(createUser(1)); + ImageFile imageFile1 = imageFileRepository.save(createImageFile(1)); + ImageFile imageFile2 = imageFileRepository.save(createImageFile(2)); + Post post = postRepository.save(createPost(user.getId(), imageFile1, imageFile2, 1)); + + //when + post.close(user.getId()); + + //then + postRepository.findById(post.getId()).get(); + assertThat(post.getStatus()).isEqualTo(Status.CLOSED); + } + + @Test + @DisplayName("투표 마감 - 게시글 작성자가 아닐 경우") + void close_notPostAuthor() throws Exception { + //given + User user = userRepository.save(createUser(1)); + ImageFile imageFile1 = imageFileRepository.save(createImageFile(1)); + ImageFile imageFile2 = imageFileRepository.save(createImageFile(2)); + Post post = postRepository.save(createPost(user.getId(), imageFile1, imageFile2, 1)); + + //when then + assertThatThrownBy(() -> post.close(2L)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.NOT_POST_AUTHOR.getMessage()); + } + + @Test + @DisplayName("투표 마감 - 이미 마감된 게시글인 경우") + void close_alreadyClosed() throws Exception { + //given + User user = userRepository.save(createUser(1)); + ImageFile imageFile1 = imageFileRepository.save(createImageFile(1)); + ImageFile imageFile2 = imageFileRepository.save(createImageFile(2)); + Post post = postRepository.save(createPost(user.getId(), imageFile1, imageFile2, 1)); + post.close(user.getId()); + + //when then + assertThatThrownBy(() -> post.close(user.getId())) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.POST_ALREADY_CLOSED.getMessage()); + } + + @Test + @DisplayName("투표 마감 - 존재하지 않는 게시글일 경우") + void close_notFoundPost() throws Exception { + //given + + //when then + assertThatThrownBy(() -> postService.close(1L, 1L)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.POST_NOT_FOUND.getMessage()); + } + + @Test + @DisplayName("게시글 삭제") + void delete() throws Exception { + //given + User user = userRepository.save(createUser(1)); + ImageFile imageFile1 = imageFileRepository.save(createImageFile(1)); + ImageFile imageFile2 = imageFileRepository.save(createImageFile(2)); + Post post = postRepository.save(createPost(user.getId(), imageFile1, imageFile2, 1)); + + //when + postService.delete(user.getId(), post.getId()); + + //then + assertThat(postRepository.findById(post.getId())).isEmpty(); + } +} diff --git a/src/test/java/com/swyp8team2/post/application/RatioCalculatorTest.java b/src/test/java/com/swyp8team2/post/application/RatioCalculatorTest.java new file mode 100644 index 00000000..27e95e3d --- /dev/null +++ b/src/test/java/com/swyp8team2/post/application/RatioCalculatorTest.java @@ -0,0 +1,31 @@ +package com.swyp8team2.post.application; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import static org.assertj.core.api.Assertions.assertThat; + +class RatioCalculatorTest { + + RatioCalculator ratioCalculator; + + @BeforeEach + void setUp() { + ratioCalculator = new RatioCalculator(); + } + + @ParameterizedTest(name = "{index}: totalVoteCount={0}, voteCount={1} => result={2}") + @CsvSource({"3, 2, 66.7", "3, 1, 33.3", "4, 2, 50.0", "4, 3, 75.0", "0, 0, 0.0", "1, 0, 0.0", "1, 1, 100.0", "10, 7, 70.0", "10, 3, 30.0"}) + @DisplayName("비율 계산") + void calculate(int totalVoteCount, int voteCount, String result) throws Exception { + //given + + //when + String ratio = ratioCalculator.calculate(totalVoteCount, voteCount); + + //then + assertThat(ratio).isEqualTo(result); + } +} diff --git a/src/test/java/com/swyp8team2/post/domain/PostImageTest.java b/src/test/java/com/swyp8team2/post/domain/PostImageTest.java new file mode 100644 index 00000000..2d22b4bd --- /dev/null +++ b/src/test/java/com/swyp8team2/post/domain/PostImageTest.java @@ -0,0 +1,28 @@ +package com.swyp8team2.post.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +class PostImageTest { + + @Test + @DisplayName("게시글 이미지 생성") + void create() throws Exception { + //given + String name = "뽀또A"; + long imageFileId = 1L; + + //when + PostImage postImage = PostImage.create(name, imageFileId); + + //then + assertAll( + () -> assertThat(postImage.getName()).isEqualTo(name), + () -> assertThat(postImage.getImageFileId()).isEqualTo(imageFileId), + () -> assertThat(postImage.getVoteCount()).isEqualTo(0) + ); + } +} diff --git a/src/test/java/com/swyp8team2/post/domain/PostRepositoryTest.java b/src/test/java/com/swyp8team2/post/domain/PostRepositoryTest.java new file mode 100644 index 00000000..e5b5157b --- /dev/null +++ b/src/test/java/com/swyp8team2/post/domain/PostRepositoryTest.java @@ -0,0 +1,96 @@ +package com.swyp8team2.post.domain; + +import com.swyp8team2.image.domain.ImageFile; +import com.swyp8team2.support.RepositoryTest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; + +import java.util.ArrayList; +import java.util.List; + +import static com.swyp8team2.support.fixture.FixtureGenerator.createImageFile; +import static com.swyp8team2.support.fixture.FixtureGenerator.createPost; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +class PostRepositoryTest extends RepositoryTest { + + @Autowired + PostRepository postRepository; + + @Test + @DisplayName("유저가 작성한 게시글 조회 - 게시글이 15개일 경우 15번쨰부터 10개 조회해야 함") + void select_post_findByUserId1() throws Exception { + //given + long userId = 1L; + List posts = createPosts(userId); + int size = 10; + + //when + Slice res = postRepository.findByUserId(userId, null, PageRequest.ofSize(size)); + + //then + assertAll( + () -> assertThat(res.getContent().size()).isEqualTo(size), + () -> assertThat(res.getContent().get(0).getId()).isEqualTo(posts.get(posts.size() - 1).getId()), + () -> assertThat(res.getContent().get(1).getId()).isEqualTo(posts.get(posts.size() - 2).getId()), + () -> assertThat(res.getContent().get(2).getId()).isEqualTo(posts.get(posts.size() - 3).getId()), + () -> assertThat(res.hasNext()).isTrue() + ); + } + + @Test + @DisplayName("유저가 작성한 게시글 조회 - 15개 중에 커서가 5번째 게시글의 id면 4번째부터 0번째까지 조회해야 함") + void select_post_findByUserId2() throws Exception { + //given + long userId = 1L; + List posts = createPosts(userId); + int size = 10; + int cursorIndex = 5; + + //when + Slice res = postRepository.findByUserId(userId, posts.get(cursorIndex).getId(), PageRequest.ofSize(size)); + + //then + assertAll( + () -> assertThat(res.getContent().size()).isEqualTo(5), + () -> assertThat(res.getContent().get(0).getId()).isEqualTo(posts.get(cursorIndex - 1).getId()), + () -> assertThat(res.getContent().get(1).getId()).isEqualTo(posts.get(cursorIndex - 2).getId()), + () -> assertThat(res.getContent().get(2).getId()).isEqualTo(posts.get(cursorIndex - 3).getId()), + () -> assertThat(res.hasNext()).isFalse() + ); + } + + @Test + @DisplayName("id 리스트에 포함되는 게시글 조회") + void select_post_findByIdIn() throws Exception { + //given + List posts = createPosts(1L); + List postIds = List.of(posts.get(0).getId(), posts.get(1).getId(), posts.get(2).getId()); + + //when + Slice postSlice = postRepository.findByIdIn(postIds, null, PageRequest.ofSize(10)); + + //then + assertAll( + () -> assertThat(postSlice.getContent().size()).isEqualTo(postIds.size()), + () -> assertThat(postSlice.getContent().get(0).getId()).isEqualTo(postIds.get(2)), + () -> assertThat(postSlice.getContent().get(1).getId()).isEqualTo(postIds.get(1)), + () -> assertThat(postSlice.getContent().get(2).getId()).isEqualTo(postIds.get(0)), + () -> assertThat(postSlice.hasNext()).isFalse() + ); + } + + private List createPosts(long userId) { + List posts = new ArrayList<>(); + for (int i = 0; i < 30; i += 2) { + ImageFile imageFile1 = createImageFile(i); + ImageFile imageFile2 = createImageFile(i + 1); + posts.add(postRepository.save(createPost(userId, imageFile1, imageFile2, i))); + } + return posts; + } +} diff --git a/src/test/java/com/swyp8team2/post/domain/PostTest.java b/src/test/java/com/swyp8team2/post/domain/PostTest.java new file mode 100644 index 00000000..a41a2609 --- /dev/null +++ b/src/test/java/com/swyp8team2/post/domain/PostTest.java @@ -0,0 +1,127 @@ +package com.swyp8team2.post.domain; + +import com.swyp8team2.common.exception.BadRequestException; +import com.swyp8team2.common.exception.ErrorCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.*; + +class PostTest { + + @Test + @DisplayName("게시글 생성") + void create() throws Exception { + //given + long userId = 1L; + String description = "description"; + List postImages = List.of( + PostImage.create("뽀또A", 1L), + PostImage.create("뽀또B", 2L) + ); + + //when + Post post = Post.create(userId, description, postImages); + + //then + List images = post.getImages(); + assertAll( + () -> assertThat(post.getUserId()).isEqualTo(userId), + () -> assertThat(post.getDescription()).isEqualTo(description), + () -> assertThat(post.getStatus()).isEqualTo(Status.PROGRESS), + () -> assertThat(images).hasSize(2), + () -> assertThat(images.get(0).getName()).isEqualTo("뽀또A"), + () -> assertThat(images.get(0).getImageFileId()).isEqualTo(1L), + () -> assertThat(images.get(0).getVoteCount()).isEqualTo(0), + () -> assertThat(images.get(1).getName()).isEqualTo("뽀또B"), + () -> assertThat(images.get(1).getImageFileId()).isEqualTo(2L), + () -> assertThat(images.get(1).getVoteCount()).isEqualTo(0) + ); + } + + @Test + @DisplayName("게시글 생성 - 이미지가 2개 미만인 경우") + void create_invalidPostImageCount() throws Exception { + //given + List postImages = List.of( + PostImage.create("뽀또A", 1L) + ); + + //when then + assertThatThrownBy(() -> Post.create(1L, "description", postImages)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.INVALID_POST_IMAGE_COUNT.getMessage()); + } + + @Test + @DisplayName("게시글 생성 - 설명이 100자 넘어가는 경우") + void create_descriptionCountExceeded() throws Exception { + //given + String description = "a".repeat(101); + List postImages = List.of( + PostImage.create("뽀또A", 1L), + PostImage.create("뽀또B", 2L) + ); + + //when then + assertThatThrownBy(() -> Post.create(1L, description, postImages)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.DESCRIPTION_LENGTH_EXCEEDED.getMessage()); + } + + @Test + @DisplayName("투표 마감") + void close() throws Exception { + //given + long userId = 1L; + List postImages = List.of( + PostImage.create("뽀또A", 1L), + PostImage.create("뽀또B", 2L) + ); + Post post = new Post(null, userId, "description", Status.PROGRESS, postImages, "shareUrl"); + + //when + post.close(userId); + + //then + assertThat(post.getStatus()).isEqualTo(Status.CLOSED); + } + + @Test + @DisplayName("투표 마감 - 이미 마감된 게시글인 경우") + void close_alreadyClosed() throws Exception { + //given + long userId = 1L; + List postImages = List.of( + PostImage.create("뽀또A", 1L), + PostImage.create("뽀또B", 2L) + ); + Post post = new Post(null, userId, "description", Status.CLOSED, postImages, "shareUrl"); + + //when then + assertThatThrownBy(() -> post.close(userId)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.POST_ALREADY_CLOSED.getMessage()); + } + + @Test + @DisplayName("투표 마감 - 게시글 작성자가 아닌 경우") + void close_notPostAuthor() throws Exception { + //given + long userId = 1L; + List postImages = List.of( + PostImage.create("뽀또A", 1L), + PostImage.create("뽀또B", 2L) + ); + Post post = new Post(null, userId, "description", Status.PROGRESS, postImages, "shareUrl"); + + //when then + assertThatThrownBy(() -> post.close(2L)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.NOT_POST_AUTHOR.getMessage()); + } +} diff --git a/src/test/java/com/swyp8team2/post/presentation/PostControllerTest.java b/src/test/java/com/swyp8team2/post/presentation/PostControllerTest.java new file mode 100644 index 00000000..befad202 --- /dev/null +++ b/src/test/java/com/swyp8team2/post/presentation/PostControllerTest.java @@ -0,0 +1,379 @@ +package com.swyp8team2.post.presentation; + +import com.swyp8team2.common.dto.CursorBasePaginatedResponse; +import com.swyp8team2.post.domain.Status; +import com.swyp8team2.post.presentation.dto.AuthorDto; +import com.swyp8team2.post.presentation.dto.CreatePostRequest; +import com.swyp8team2.post.presentation.dto.CreatePostResponse; +import com.swyp8team2.post.presentation.dto.PostImageVoteStatusResponse; +import com.swyp8team2.post.presentation.dto.PostResponse; +import com.swyp8team2.post.presentation.dto.SimplePostResponse; +import com.swyp8team2.post.presentation.dto.PostImageRequestDto; +import com.swyp8team2.post.presentation.dto.PostImageResponse; +import com.swyp8team2.support.RestDocsTest; +import com.swyp8team2.support.WithMockUserInfo; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.security.test.context.support.WithAnonymousUser; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +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.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class PostControllerTest extends RestDocsTest { + + @Test + @WithMockUserInfo + @DisplayName("게시글 생성") + void createPost() throws Exception { + //given + CreatePostRequest request = new CreatePostRequest( + "제목", + List.of(new PostImageRequestDto(1L), new PostImageRequestDto(2L)) + ); + CreatePostResponse response = new CreatePostResponse(1L, "shareUrl"); + given(postService.create(any(), any())) + .willReturn(response); + + //when then + mockMvc.perform(post("/posts") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + .header(HttpHeaders.AUTHORIZATION, "Bearer token")) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(response))) + .andDo(restDocs.document( + requestHeaders(authorizationHeader()), + requestFields( + fieldWithPath("description") + .type(JsonFieldType.STRING) + .description("설명") + .attributes(constraints("0~100자 사이")), + fieldWithPath("images") + .type(JsonFieldType.ARRAY) + .description("투표 후보") + .attributes(constraints("최소 2개")), + fieldWithPath("images[].imageFileId") + .type(JsonFieldType.NUMBER) + .description("투표 후보 이미지 ID") + ), + responseFields( + fieldWithPath("postId") + .type(JsonFieldType.NUMBER) + .description("게시글 Id"), + fieldWithPath("shareUrl") + .type(JsonFieldType.STRING) + .description("게시글 공유 url") + ) + )); + } + + @Test + @WithAnonymousUser + @DisplayName("게시글 상세 조회") + void findPost() throws Exception { + //given + PostResponse response = new PostResponse( + 1L, + new AuthorDto( + 1L, + "author", + "https://image.photopic.site/profile-image" + ), + "description", + List.of( + new PostImageResponse(1L, "뽀또A", "https://image.photopic.site/image/1", "https://image.photopic.site/image/resize/1", true), + new PostImageResponse(2L, "뽀또B", "https://image.photopic.site/image/2", "https://image.photopic.site/image/resize/2", false) + ), + "https://photopic.site/shareurl", + true, + Status.PROGRESS, + LocalDateTime.of(2025, 2, 13, 12, 0) + ); + given(postService.findById(any(), any())) + .willReturn(response); + + //when then + mockMvc.perform(RestDocumentationRequestBuilders.get("/posts/{postId}", 1)) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(response))) + .andDo(restDocs.document( + pathParameters( + parameterWithName("postId").description("게시글 Id") + ), + responseFields( + fieldWithPath("id").type(JsonFieldType.NUMBER).description("게시글 Id"), + fieldWithPath("author").type(JsonFieldType.OBJECT).description("게시글 작성자 정보"), + fieldWithPath("author.id").type(JsonFieldType.NUMBER).description("게시글 작성자 유저 Id"), + fieldWithPath("author.nickname").type(JsonFieldType.STRING).description("게시글 작성자 닉네임"), + fieldWithPath("author.profileUrl").type(JsonFieldType.STRING).description("게시글 작성자 프로필 이미지"), + fieldWithPath("description").type(JsonFieldType.STRING).description("설명"), + fieldWithPath("images[]").type(JsonFieldType.ARRAY).description("투표 선택지 목록"), + fieldWithPath("images[].id").type(JsonFieldType.NUMBER).description("투표 선택지 Id"), + fieldWithPath("images[].imageName").type(JsonFieldType.STRING).description("사진 이름"), + fieldWithPath("images[].imageUrl").type(JsonFieldType.STRING).description("사진 이미지"), + fieldWithPath("images[].thumbnailUrl").type(JsonFieldType.STRING).description("확대 사진 이미지"), + fieldWithPath("images[].voted").type(JsonFieldType.BOOLEAN).description("투표 여부"), + fieldWithPath("shareUrl").type(JsonFieldType.STRING).description("게시글 공유 URL"), + fieldWithPath("createdAt").type(JsonFieldType.STRING).description("게시글 생성 시간"), + fieldWithPath("status").type(JsonFieldType.STRING).description("게시글 마감 여부 (PROGRESS, CLOSED)"), + fieldWithPath("isAuthor").type(JsonFieldType.BOOLEAN).description("게시글 작성자 여부") + ) + )); + } + + @Test + @WithAnonymousUser + @DisplayName("게시글 공유 url 상세 조회") + void findPost_shareUrl() throws Exception { + //given + PostResponse response = new PostResponse( + 1L, + new AuthorDto( + 1L, + "author", + "https://image.photopic.site/profile-image" + ), + "description", + List.of( + new PostImageResponse(1L, "뽀또A", "https://image.photopic.site/image/1", "https://image.photopic.site/image/resize/1", true), + new PostImageResponse(2L, "뽀또B", "https://image.photopic.site/image/2", "https://image.photopic.site/image/resize/2", false) + ), + "https://photopic.site/shareurl", + true, + Status.PROGRESS, + LocalDateTime.of(2025, 2, 13, 12, 0) + ); + given(postService.findByShareUrl(any(), any())) + .willReturn(response); + + //when then + mockMvc.perform(RestDocumentationRequestBuilders.get("/posts/shareUrl/{shareUrl}", "JNOfBVfcG2z89afSiRrOyQ")) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(response))) + .andDo(restDocs.document( + pathParameters( + parameterWithName("shareUrl").description("공유 url") + ), + responseFields( + fieldWithPath("id").type(JsonFieldType.NUMBER).description("게시글 Id"), + fieldWithPath("author").type(JsonFieldType.OBJECT).description("게시글 작성자 정보"), + fieldWithPath("author.id").type(JsonFieldType.NUMBER).description("게시글 작성자 유저 Id"), + fieldWithPath("author.nickname").type(JsonFieldType.STRING).description("게시글 작성자 닉네임"), + fieldWithPath("author.profileUrl").type(JsonFieldType.STRING).description("게시글 작성자 프로필 이미지"), + fieldWithPath("description").type(JsonFieldType.STRING).description("설명"), + fieldWithPath("images[]").type(JsonFieldType.ARRAY).description("투표 선택지 목록"), + fieldWithPath("images[].id").type(JsonFieldType.NUMBER).description("투표 선택지 Id"), + fieldWithPath("images[].imageName").type(JsonFieldType.STRING).description("사진 이름"), + fieldWithPath("images[].imageUrl").type(JsonFieldType.STRING).description("사진 이미지"), + fieldWithPath("images[].thumbnailUrl").type(JsonFieldType.STRING).description("확대 사진 이미지"), + fieldWithPath("images[].voted").type(JsonFieldType.BOOLEAN).description("투표 여부"), + fieldWithPath("shareUrl").type(JsonFieldType.STRING).description("게시글 공유 URL"), + fieldWithPath("createdAt").type(JsonFieldType.STRING).description("게시글 생성 시간"), + fieldWithPath("status").type(JsonFieldType.STRING).description("게시글 마감 여부 (PROGRESS, CLOSED)"), + fieldWithPath("isAuthor").type(JsonFieldType.BOOLEAN).description("게시글 작성자 여부") + ) + )); + } + + @Test + @WithMockUserInfo + @DisplayName("게시글 투표 상태 조회") + void findVoteStatus() throws Exception { + //given + var response = List.of( + new PostImageVoteStatusResponse(1L, "뽀또A", 2, "66.7"), + new PostImageVoteStatusResponse(2L, "뽀또B", 1, "33.3") + ); + given(postService.findPostStatus(1L)) + .willReturn(response); + + //when then + mockMvc.perform(RestDocumentationRequestBuilders.get("/posts/{postId}/status", 1) + .header(HttpHeaders.AUTHORIZATION, "Bearer token")) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(response))) + .andDo(restDocs.document( + requestHeaders(authorizationHeader()), + pathParameters( + parameterWithName("postId").description("게시글 Id") + ), + responseFields( + fieldWithPath("[]").type(JsonFieldType.ARRAY).description("투표 선택지 목록"), + fieldWithPath("[].id").type(JsonFieldType.NUMBER).description("이미지 Id"), + fieldWithPath("[].imageName").type(JsonFieldType.STRING).description("사진 이름"), + fieldWithPath("[].voteCount").type(JsonFieldType.NUMBER).description("투표 수"), + fieldWithPath("[].voteRatio").type(JsonFieldType.STRING).description("투표 비율") + ) + )); + } + + @Test + @WithMockUserInfo + @DisplayName("게시글 삭제") + void deletePost() throws Exception { + //given + + //when then + mockMvc.perform(RestDocumentationRequestBuilders.delete("/posts/{postId}", 1) + .header(HttpHeaders.AUTHORIZATION, "Bearer token")) + .andExpect(status().isOk()) + .andDo(restDocs.document( + requestHeaders(authorizationHeader()), + pathParameters( + parameterWithName("postId").description("게시글 Id") + ) + )); + verify(postService, times(1)).delete(any(), any()); + } + + @Test + @WithMockUserInfo + @DisplayName("유저가 작성한 게시글 조회") + void findMyPost() throws Exception { + //given + var response = new CursorBasePaginatedResponse<>( + 1L, + false, + List.of( + new SimplePostResponse( + 1L, + "https://image.photopic.site/1", + "https://photopic.site/shareurl", + LocalDateTime.of(2025, 2, 13, 12, 0) + ) + ) + ); + given(postService.findUserPosts(1L, null, 10)) + .willReturn(response); + + //when then + mockMvc.perform(RestDocumentationRequestBuilders.get("/posts/users/{userId}", 1) + .header(HttpHeaders.AUTHORIZATION, "Bearer token")) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(response))) + .andDo(restDocs.document( + pathParameters(parameterWithName("userId").description("유저 Id")), + requestHeaders(authorizationHeader()), + queryParameters(cursorQueryParams()), + responseFields( + fieldWithPath("nextCursor") + .type(JsonFieldType.NUMBER) + .optional() + .description("다음 조회 커서 값"), + fieldWithPath("hasNext") + .type(JsonFieldType.BOOLEAN) + .description("다음 페이지 존재 여부 (기본 값 10)"), + fieldWithPath("data[]") + .type(JsonFieldType.ARRAY) + .description("게시글 데이터"), + fieldWithPath("data[].id") + .type(JsonFieldType.NUMBER) + .description("게시글 Id"), + fieldWithPath("data[].bestPickedImageUrl") + .type(JsonFieldType.STRING) + .description("가장 많은 득표를 받은 이미지 URL"), + fieldWithPath("data[].shareUrl") + .type(JsonFieldType.STRING) + .description("게시글 공유 URL"), + fieldWithPath("data[].createdAt") + .type(JsonFieldType.STRING) + .description("게시글 생성 시간") + ) + )); + } + + @Test + @WithMockUserInfo + @DisplayName("유저가 참여한 게시글 조회") + void findVotedPost() throws Exception { + //given + var response = new CursorBasePaginatedResponse<>( + 1L, + false, + List.of( + new SimplePostResponse( + 1L, + "https://image.photopic.site/1", + "https://photopic.site/shareurl", + LocalDateTime.of(2025, 2, 13, 12, 0) + ) + ) + ); + given(postService.findVotedPosts(1L, null, 10)) + .willReturn(response); + + //when then + mockMvc.perform(RestDocumentationRequestBuilders.get("/posts/users/{userId}/voted", 1) + .header(HttpHeaders.AUTHORIZATION, "Bearer token")) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(response))) + .andDo(restDocs.document( + pathParameters(parameterWithName("userId").description("유저 Id")), + requestHeaders(authorizationHeader()), + queryParameters(cursorQueryParams()), + responseFields( + fieldWithPath("nextCursor") + .type(JsonFieldType.NUMBER) + .optional() + .description("다음 조회 커서 값"), + fieldWithPath("hasNext") + .type(JsonFieldType.BOOLEAN) + .description("다음 페이지 존재 여부 (기본 값 10)"), + fieldWithPath("data[]") + .type(JsonFieldType.ARRAY) + .description("게시글 데이터"), + fieldWithPath("data[].id") + .type(JsonFieldType.NUMBER) + .description("게시글 Id"), + fieldWithPath("data[].bestPickedImageUrl") + .type(JsonFieldType.STRING) + .description("가장 많은 득표를 받은 이미지 URL"), + fieldWithPath("data[].shareUrl") + .type(JsonFieldType.STRING) + .description("게시글 공유 URL"), + fieldWithPath("data[].createdAt") + .type(JsonFieldType.STRING) + .description("게시글 생성 시간") + ) + )); + } + + @Test + @WithMockUserInfo + @DisplayName("게시글 마감") + void closePost() throws Exception { + //given + + //when then + mockMvc.perform(RestDocumentationRequestBuilders.post("/posts/{postId}/close", 1) + .header(HttpHeaders.AUTHORIZATION, "Bearer token")) + .andExpect(status().isOk()) + .andDo(restDocs.document( + requestHeaders(authorizationHeader()), + pathParameters( + parameterWithName("postId").description("게시글 Id") + ) + )); + verify(postService, times(1)).close(any(), any()); + } +} diff --git a/src/test/java/com/swyp8team2/support/IntegrationTest.java b/src/test/java/com/swyp8team2/support/IntegrationTest.java new file mode 100644 index 00000000..357ab8e4 --- /dev/null +++ b/src/test/java/com/swyp8team2/support/IntegrationTest.java @@ -0,0 +1,9 @@ +package com.swyp8team2.support; + +import jakarta.transaction.Transactional; +import org.springframework.boot.test.context.SpringBootTest; + +@Transactional +@SpringBootTest +public abstract class IntegrationTest { +} diff --git a/src/test/java/com/swyp8team2/support/RepositoryTest.java b/src/test/java/com/swyp8team2/support/RepositoryTest.java new file mode 100644 index 00000000..9b3ded7e --- /dev/null +++ b/src/test/java/com/swyp8team2/support/RepositoryTest.java @@ -0,0 +1,10 @@ +package com.swyp8team2.support; + +import com.swyp8team2.common.config.JpaConfig; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; + +@DataJpaTest +@Import(JpaConfig.class) +public abstract class RepositoryTest { +} diff --git a/src/test/java/com/swyp8team2/support/RestDocsTest.java b/src/test/java/com/swyp8team2/support/RestDocsTest.java new file mode 100644 index 00000000..8e72a1e0 --- /dev/null +++ b/src/test/java/com/swyp8team2/support/RestDocsTest.java @@ -0,0 +1,50 @@ +package com.swyp8team2.support; + +import com.swyp8team2.common.presentation.CustomHeader; +import com.swyp8team2.support.config.RestDocsConfiguration; +import com.swyp8team2.support.config.TestSecurityConfig; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpHeaders; +import org.springframework.restdocs.RestDocumentationExtension; +import org.springframework.restdocs.headers.HeaderDescriptor; +import org.springframework.restdocs.mockmvc.RestDocumentationResultHandler; +import org.springframework.restdocs.request.ParameterDescriptor; +import org.springframework.restdocs.snippet.Attributes; + +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; + +@AutoConfigureRestDocs +@Import({RestDocsConfiguration.class, TestSecurityConfig.class}) +@ExtendWith(RestDocumentationExtension.class) +public abstract class RestDocsTest extends WebUnitTest { + + @Autowired + protected RestDocumentationResultHandler restDocs; + + protected static Attributes.Attribute constraints(String value) { + return new Attributes.Attribute("constraints", value); + } + + protected static HeaderDescriptor authorizationHeader() { + return headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer token"); + } + + protected static HeaderDescriptor guestHeader() { + return headerWithName(CustomHeader.GUEST_TOKEN).description("게스트 토큰"); + } + + protected static ParameterDescriptor[] cursorQueryParams() { + return new ParameterDescriptor[]{ + parameterWithName("cursor").optional().description("페이지 조회 커서 값"), + parameterWithName("size").optional().attributes(defaultValue("10")).description("페이지 크기 (기본 값 10)") + }; + } + + protected static Attributes.Attribute defaultValue(String value) { + return new Attributes.Attribute("defaultValue", value); + } +} diff --git a/src/test/java/com/swyp8team2/support/WebUnitTest.java b/src/test/java/com/swyp8team2/support/WebUnitTest.java new file mode 100644 index 00000000..aa1f1395 --- /dev/null +++ b/src/test/java/com/swyp8team2/support/WebUnitTest.java @@ -0,0 +1,51 @@ +package com.swyp8team2.support; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.swyp8team2.auth.application.AuthService; +import com.swyp8team2.auth.presentation.RefreshTokenCookieGenerator; +import com.swyp8team2.comment.application.CommentService; +import com.swyp8team2.common.exception.DiscordMessageSender; +import com.swyp8team2.image.application.ImageService; +import com.swyp8team2.post.application.PostService; +import com.swyp8team2.user.application.UserService; +import com.swyp8team2.vote.application.VoteService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest +@Import(RefreshTokenCookieGenerator.class) +public abstract class WebUnitTest { + + @Autowired + protected MockMvc mockMvc; + + @Autowired + protected ObjectMapper objectMapper; + + @Autowired + protected RefreshTokenCookieGenerator refreshTokenCookieGenerator; + + @MockitoBean + protected AuthService authService; + + @MockitoBean + protected ImageService imageService; + + @MockitoBean + protected PostService postService; + + @MockitoBean + protected VoteService voteService; + + @MockitoBean + protected CommentService commentService; + + @MockitoBean + protected UserService userService; + + @MockitoBean + protected DiscordMessageSender discordMessageSender; +} diff --git a/src/test/java/com/swyp8team2/support/WithMockUserInfo.java b/src/test/java/com/swyp8team2/support/WithMockUserInfo.java new file mode 100644 index 00000000..5b42ff3e --- /dev/null +++ b/src/test/java/com/swyp8team2/support/WithMockUserInfo.java @@ -0,0 +1,15 @@ +package com.swyp8team2.support; + +import com.swyp8team2.support.security.TestSecurityContextFactory; +import com.swyp8team2.user.domain.Role; +import org.springframework.security.test.context.support.WithSecurityContext; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +@Retention(RetentionPolicy.RUNTIME) +@WithSecurityContext(factory = TestSecurityContextFactory.class) +public @interface WithMockUserInfo { + long userId() default 1L; + Role role() default Role.USER; +} diff --git a/src/test/java/com/swyp8team2/support/config/RestDocsConfiguration.java b/src/test/java/com/swyp8team2/support/config/RestDocsConfiguration.java new file mode 100644 index 00000000..8848b5a8 --- /dev/null +++ b/src/test/java/com/swyp8team2/support/config/RestDocsConfiguration.java @@ -0,0 +1,37 @@ +package com.swyp8team2.support.config; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation; +import org.springframework.restdocs.mockmvc.RestDocumentationResultHandler; + +import static org.springframework.restdocs.operation.preprocess.Preprocessors.modifyHeaders; +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; + +@TestConfiguration +public class RestDocsConfiguration { + + @Bean + public RestDocumentationResultHandler restDocumentationResultHandler() { + return MockMvcRestDocumentation.document( + "{class-name}/{method-name}", + preprocessRequest( + modifyHeaders() + .remove("Content-Length") + .remove("Host"), + prettyPrint()), + preprocessResponse( + modifyHeaders() + .remove("Content-Length") + .remove("X-Content-Type-Options") + .remove("X-XSS-Protection") + .remove("Cache-Control") + .remove("Pragma") + .remove("Expires") + .remove("X-Frame-Options"), + prettyPrint()) + ); + } +} diff --git a/src/test/java/com/swyp8team2/support/config/TestSecurityConfig.java b/src/test/java/com/swyp8team2/support/config/TestSecurityConfig.java new file mode 100644 index 00000000..2fd6de4a --- /dev/null +++ b/src/test/java/com/swyp8team2/support/config/TestSecurityConfig.java @@ -0,0 +1,44 @@ +package com.swyp8team2.support.config; + +import com.swyp8team2.user.domain.Role; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.servlet.handler.HandlerMappingIntrospector; + +import static com.swyp8team2.common.config.SecurityConfig.getGuestTokenRequestList; +import static com.swyp8team2.common.config.SecurityConfig.getWhiteList; + +@TestConfiguration +public class TestSecurityConfig { + + @Bean + public SecurityFilterChain securityFilterChain( + HttpSecurity http, + HandlerMappingIntrospector introspect + ) throws Exception { + http + .httpBasic(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .logout(AbstractHttpConfigurer::disable) + .csrf(AbstractHttpConfigurer::disable) + .headers(headers -> + headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin) + .httpStrictTransportSecurity(HeadersConfigurer.HstsConfig::disable)) + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + + .authorizeHttpRequests(authorize -> + authorize +// .requestMatchers(getWhiteList(introspect)).permitAll() +// .requestMatchers(getGuestTokenRequestList(introspect)) +// .hasAnyRole(Role.USER.name(), Role.GUEST.name()) + .anyRequest().permitAll() + ); + return http.build(); + } +} diff --git a/src/test/java/com/swyp8team2/support/fixture/FixtureGenerator.java b/src/test/java/com/swyp8team2/support/fixture/FixtureGenerator.java new file mode 100644 index 00000000..a11bf052 --- /dev/null +++ b/src/test/java/com/swyp8team2/support/fixture/FixtureGenerator.java @@ -0,0 +1,37 @@ +package com.swyp8team2.support.fixture; + +import com.swyp8team2.image.domain.ImageFile; +import com.swyp8team2.image.presentation.dto.ImageFileDto; +import com.swyp8team2.post.domain.Post; +import com.swyp8team2.post.domain.PostImage; +import com.swyp8team2.user.domain.User; + +import java.util.List; + +public abstract class FixtureGenerator { + + public static Post createPost(Long userId, ImageFile imageFile1, ImageFile imageFile2, int key) { + return Post.create( + userId, + "description" + key, + List.of( + PostImage.create("뽀또A", imageFile1.getId()), + PostImage.create("뽀또B", imageFile2.getId()) + ) + ); + } + + public static User createUser(int key) { + return User.create("nickname" + key, "profileUrl" + key); + } + + public static ImageFile createImageFile(int key) { + return ImageFile.create( + new ImageFileDto( + "originalFileName" + key, + "imageUrl" + key, + "thumbnailUrl" + key + ) + ); + } +} diff --git a/src/test/java/com/swyp8team2/support/security/TestSecurityContextFactory.java b/src/test/java/com/swyp8team2/support/security/TestSecurityContextFactory.java new file mode 100644 index 00000000..e6a06d4a --- /dev/null +++ b/src/test/java/com/swyp8team2/support/security/TestSecurityContextFactory.java @@ -0,0 +1,30 @@ +package com.swyp8team2.support.security; + +import com.swyp8team2.auth.domain.UserInfo; +import com.swyp8team2.support.WithMockUserInfo; +import com.swyp8team2.user.domain.Role; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.test.context.support.WithSecurityContextFactory; + +import java.util.Collections; + +public class TestSecurityContextFactory implements WithSecurityContextFactory { + + @Override + public SecurityContext createSecurityContext(WithMockUserInfo annotation) { + long userId = annotation.userId(); + UserInfo userInfo = new UserInfo(userId, Role.USER); + + Authentication auth = new UsernamePasswordAuthenticationToken( + userInfo, + null, + Collections.emptyList() + ); + SecurityContext context = SecurityContextHolder.getContext(); + context.setAuthentication(auth); + return context; + } +} diff --git a/src/test/java/com/swyp8team2/user/application/UserServiceTest.java b/src/test/java/com/swyp8team2/user/application/UserServiceTest.java new file mode 100644 index 00000000..ce483924 --- /dev/null +++ b/src/test/java/com/swyp8team2/user/application/UserServiceTest.java @@ -0,0 +1,48 @@ +package com.swyp8team2.user.application; + +import com.swyp8team2.support.IntegrationTest; +import com.swyp8team2.user.domain.NicknameAdjective; +import com.swyp8team2.user.domain.NicknameAdjectiveRepository; +import com.swyp8team2.user.domain.User; +import com.swyp8team2.user.domain.UserRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +class UserServiceTest extends IntegrationTest { + + @Autowired + UserRepository userRepository; + + @Autowired + NicknameAdjectiveRepository nicknameAdjectiveRepository; + + @Autowired + UserService userService; + + @Test + void createUser() { + // given + User user = User.create(null, "https://image.com/1"); + + for (int i = 0; i < 250; i++) { + nicknameAdjectiveRepository.save(new NicknameAdjective("호기심 많은 뽀또")); + nicknameAdjectiveRepository.save(new NicknameAdjective("배려 깊은 뽀또")); + } + + // when + Long userId = userService.createUser(user.getNickname(), user.getProfileUrl()); + Optional returnUser = userRepository.findById(userId); + + // when then + assertAll( + () -> assertThat(returnUser.get().getNickname()).isNotNull(), + () -> assertThat(returnUser.get().getNickname()).contains("뽀또") + ); + + } +} \ No newline at end of file diff --git a/src/test/java/com/swyp8team2/user/domain/UserTest.java b/src/test/java/com/swyp8team2/user/domain/UserTest.java new file mode 100644 index 00000000..104c44f7 --- /dev/null +++ b/src/test/java/com/swyp8team2/user/domain/UserTest.java @@ -0,0 +1,22 @@ +package com.swyp8team2.user.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class UserTest { + + @Test + @DisplayName("user Entity 생성") + void create() throws Exception { + //given + String nickname = "nickname"; + + //when + User user = User.create(nickname, "email"); + + //then + assertThat(user.getNickname()).isEqualTo(nickname); + } +} diff --git a/src/test/java/com/swyp8team2/user/presentation/UserControllerTest.java b/src/test/java/com/swyp8team2/user/presentation/UserControllerTest.java new file mode 100644 index 00000000..8490a66a --- /dev/null +++ b/src/test/java/com/swyp8team2/user/presentation/UserControllerTest.java @@ -0,0 +1,73 @@ +package com.swyp8team2.user.presentation; + +import com.swyp8team2.support.RestDocsTest; +import com.swyp8team2.support.WithMockUserInfo; +import com.swyp8team2.user.presentation.dto.UserInfoResponse; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; +import org.springframework.security.test.context.support.WithMockUser; + +import static org.mockito.BDDMockito.given; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.payload.JsonFieldType.NUMBER; +import static org.springframework.restdocs.payload.JsonFieldType.STRING; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class UserControllerTest extends RestDocsTest { + + @Test + @WithMockUserInfo + @DisplayName("유저 정보 조회") + void findUserInfo() throws Exception { + //given + UserInfoResponse response = new UserInfoResponse(1L, "nickname", "https://image.com/profile-image"); + given(userService.findById(1L)) + .willReturn(response); + + //when then + mockMvc.perform(RestDocumentationRequestBuilders.get("/users/{userId}", "1")) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(response))) + .andDo(restDocs.document( + pathParameters( + parameterWithName("userId").description("유저 아이디") + ), + responseFields( + fieldWithPath("id").description("유저 아이디").type(NUMBER), + fieldWithPath("nickname").description("닉네임").type(STRING), + fieldWithPath("profileUrl").description("프로필 이미지 URL").type(STRING) + ) + )); + } + + @Test + @WithMockUserInfo + @DisplayName("본인 정보 조회") + void findMe() throws Exception { + //given + UserInfoResponse response = new UserInfoResponse(1L, "nickname", "https://image.com/profile-image"); + given(userService.findById(1L)) + .willReturn(response); + + //when then + mockMvc.perform(RestDocumentationRequestBuilders.get("/users/me") + .header(HttpHeaders.AUTHORIZATION, "Bearer access-token")) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(response))) + .andDo(restDocs.document( + requestHeaders(authorizationHeader()), + responseFields( + fieldWithPath("id").description("유저 아이디").type(NUMBER), + fieldWithPath("nickname").description("닉네임").type(STRING), + fieldWithPath("profileUrl").description("프로필 이미지 URL").type(STRING) + ) + )); + } +} diff --git a/src/test/java/com/swyp8team2/vote/application/VoteServiceTest.java b/src/test/java/com/swyp8team2/vote/application/VoteServiceTest.java new file mode 100644 index 00000000..fff0993c --- /dev/null +++ b/src/test/java/com/swyp8team2/vote/application/VoteServiceTest.java @@ -0,0 +1,194 @@ +package com.swyp8team2.vote.application; + +import com.swyp8team2.common.exception.BadRequestException; +import com.swyp8team2.common.exception.ErrorCode; +import com.swyp8team2.image.domain.ImageFile; +import com.swyp8team2.image.domain.ImageFileRepository; +import com.swyp8team2.post.domain.Post; +import com.swyp8team2.post.domain.PostImage; +import com.swyp8team2.post.domain.PostRepository; +import com.swyp8team2.post.domain.Status; +import com.swyp8team2.support.IntegrationTest; +import com.swyp8team2.user.domain.User; +import com.swyp8team2.user.domain.UserRepository; +import com.swyp8team2.vote.domain.Vote; +import com.swyp8team2.vote.domain.VoteRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.util.List; + +import static com.swyp8team2.support.fixture.FixtureGenerator.createImageFile; +import static com.swyp8team2.support.fixture.FixtureGenerator.createPost; +import static com.swyp8team2.support.fixture.FixtureGenerator.createUser; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.*; + +class VoteServiceTest extends IntegrationTest { + + @Autowired + VoteService voteService; + + @Autowired + UserRepository userRepository; + + @Autowired + VoteRepository voteRepository; + + @Autowired + PostRepository postRepository; + + @Autowired + ImageFileRepository imageFileRepository; + + @Test + @DisplayName("투표하기") + void vote() { + // given + User user = userRepository.save(createUser(1)); + ImageFile imageFile1 = imageFileRepository.save(createImageFile(1)); + ImageFile imageFile2 = imageFileRepository.save(createImageFile(2)); + Post post = postRepository.save(createPost(user.getId(), imageFile1, imageFile2, 1)); + + // when + Long voteId = voteService.vote(user.getId(), post.getId(), post.getImages().get(0).getId()); + + // then + Vote vote = voteRepository.findById(voteId).get(); + Post findPost = postRepository.findById(post.getId()).get(); + assertAll( + () -> assertThat(vote.getUserId()).isEqualTo(user.getId()), + () -> assertThat(vote.getPostId()).isEqualTo(post.getId()), + () -> assertThat(vote.getPostImageId()).isEqualTo(post.getImages().get(0).getId()), + () -> assertThat(findPost.getImages().get(0).getVoteCount()).isEqualTo(1) + ); + } + + @Test + @DisplayName("투표하기 - 다른 이미지로 투표 변경한 경우") + void vote_change() { + // given + User user = userRepository.save(createUser(1)); + ImageFile imageFile1 = imageFileRepository.save(createImageFile(1)); + ImageFile imageFile2 = imageFileRepository.save(createImageFile(2)); + Post post = postRepository.save(createPost(user.getId(), imageFile1, imageFile2, 1)); + voteService.vote(user.getId(), post.getId(), post.getImages().get(0).getId()); + + // when + Long voteId = voteService.vote(user.getId(), post.getId(), post.getImages().get(1).getId()); + + // then + Vote vote = voteRepository.findById(voteId).get(); + Post findPost = postRepository.findById(post.getId()).get(); + assertAll( + () -> assertThat(vote.getUserId()).isEqualTo(user.getId()), + () -> assertThat(vote.getPostId()).isEqualTo(post.getId()), + () -> assertThat(vote.getPostImageId()).isEqualTo(post.getImages().get(1).getId()), + () -> assertThat(findPost.getImages().get(0).getVoteCount()).isEqualTo(0), + () -> assertThat(findPost.getImages().get(1).getVoteCount()).isEqualTo(1) + ); + } + + @Test + @DisplayName("투표하기 - 투표 마감된 경우") + void vote_alreadyClosed() { + // given + User user = userRepository.save(createUser(1)); + ImageFile imageFile1 = imageFileRepository.save(createImageFile(1)); + ImageFile imageFile2 = imageFileRepository.save(createImageFile(2)); + Post post = postRepository.save(new Post( + null, + user.getId(), + "description", + Status.CLOSED, + List.of( + PostImage.create("뽀또A", imageFile1.getId()), + PostImage.create("뽀또B", imageFile2.getId()) + ), + "shareUrl" + )); + + // when + assertThatThrownBy(() -> voteService.vote(user.getId(), post.getId(), post.getImages().get(0).getId())) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.POST_ALREADY_CLOSED.getMessage()); + } + + @Test + @DisplayName("게스트 투표하기") + void guestVote() { + // given + User user = userRepository.save(createUser(1)); + Long guestId = user.getId() + 1L; + ImageFile imageFile1 = imageFileRepository.save(createImageFile(1)); + ImageFile imageFile2 = imageFileRepository.save(createImageFile(2)); + Post post = postRepository.save(createPost(user.getId(), imageFile1, imageFile2, 1)); + + // when + Long voteId = voteService.guestVote(guestId, post.getId(), post.getImages().get(0).getId()); + + // then + Vote vote = voteRepository.findById(voteId).get(); + Post findPost = postRepository.findById(post.getId()).get(); + assertAll( + () -> assertThat(vote.getUserId()).isEqualTo(guestId), + () -> assertThat(vote.getPostId()).isEqualTo(post.getId()), + () -> assertThat(vote.getPostImageId()).isEqualTo(post.getImages().get(0).getId()), + () -> assertThat(findPost.getImages().get(0).getVoteCount()).isEqualTo(1) + ); + } + + @Test + @DisplayName("게스트 투표하기 - 다른 이미지로 투표 변경한 경우") + void guestVote_change() { + // given + User user = userRepository.save(createUser(1)); + Long guestId = user.getId() + 1L; + ImageFile imageFile1 = imageFileRepository.save(createImageFile(1)); + ImageFile imageFile2 = imageFileRepository.save(createImageFile(2)); + Post post = postRepository.save(createPost(user.getId(), imageFile1, imageFile2, 1)); + voteService.guestVote(guestId, post.getId(), post.getImages().get(0).getId()); + + // when + Long voteId = voteService.guestVote(guestId, post.getId(), post.getImages().get(1).getId()); + + // then + Vote vote = voteRepository.findById(voteId).get(); + Post findPost = postRepository.findById(post.getId()).get(); + assertAll( + () -> assertThat(vote.getUserId()).isEqualTo(guestId), + () -> assertThat(vote.getPostId()).isEqualTo(post.getId()), + () -> assertThat(vote.getPostImageId()).isEqualTo(post.getImages().get(1).getId()), + () -> assertThat(findPost.getImages().get(0).getVoteCount()).isEqualTo(0), + () -> assertThat(findPost.getImages().get(1).getVoteCount()).isEqualTo(1) + ); + } + + @Test + @DisplayName("게스트 투표하기 - 투표 마감된 경우") + void guestVote_alreadyClosed() { + // given + User user = userRepository.save(createUser(1)); + Long guestId = user.getId() + 1L; + ImageFile imageFile1 = imageFileRepository.save(createImageFile(1)); + ImageFile imageFile2 = imageFileRepository.save(createImageFile(2)); + Post post = postRepository.save(new Post( + null, + user.getId(), + "description", + Status.CLOSED, + List.of( + PostImage.create("뽀또A", imageFile1.getId()), + PostImage.create("뽀또B", imageFile2.getId()) + ), + "shareUrl" + )); + + // when + assertThatThrownBy(() -> voteService.guestVote(guestId, post.getId(), post.getImages().get(0).getId())) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.POST_ALREADY_CLOSED.getMessage()); + } +} diff --git a/src/test/java/com/swyp8team2/vote/presentation/VoteControllerTest.java b/src/test/java/com/swyp8team2/vote/presentation/VoteControllerTest.java new file mode 100644 index 00000000..c1c9d502 --- /dev/null +++ b/src/test/java/com/swyp8team2/vote/presentation/VoteControllerTest.java @@ -0,0 +1,134 @@ +package com.swyp8team2.vote.presentation; + +import com.swyp8team2.common.presentation.CustomHeader; +import com.swyp8team2.support.RestDocsTest; +import com.swyp8team2.support.WithMockUserInfo; +import com.swyp8team2.user.domain.Role; +import com.swyp8team2.vote.presentation.dto.ChangeVoteRequest; +import com.swyp8team2.vote.presentation.dto.VoteRequest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.restdocs.payload.JsonFieldType; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class VoteControllerTest extends RestDocsTest { + + @Test + @WithMockUserInfo + @DisplayName("투표") + void vote() throws Exception { + //given + VoteRequest request = new VoteRequest(1L); + + //when test + mockMvc.perform(post("/posts/{postId}/votes", "1") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + .header(HttpHeaders.AUTHORIZATION, "Bearer token")) + .andExpect(status().isOk()) + .andDo(restDocs.document( + requestHeaders(authorizationHeader()), + pathParameters( + parameterWithName("postId").description("게시글 Id") + ), + requestFields( + fieldWithPath("imageId") + .type(JsonFieldType.NUMBER) + .description("투표 후보 Id") + ) + )); + verify(voteService, times(1)).vote(any(), any(), any()); + } + + @Test + @WithMockUserInfo(role = Role.GUEST) + @DisplayName("게스트 투표") + void guestVote() throws Exception { + //given + VoteRequest request = new VoteRequest(1L); + + //when test + mockMvc.perform(post("/posts/{postId}/votes/guest", "1") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + .header(CustomHeader.GUEST_TOKEN, "guestToken")) + .andExpect(status().isOk()) + .andDo(restDocs.document( + requestHeaders(guestHeader()), + pathParameters( + parameterWithName("postId").description("게시글 Id") + ), + requestFields( + fieldWithPath("imageId") + .type(JsonFieldType.NUMBER) + .description("투표 후보 Id") + ) + )); + verify(voteService, times(1)).guestVote(any(), any(), any()); + } + + @Test + @WithMockUserInfo + @DisplayName("투표 변경") + void changeVote() throws Exception { + //given + ChangeVoteRequest request = new ChangeVoteRequest(1L); + + //when + mockMvc.perform(patch("/posts/{postId}/votes", "1") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + .header(HttpHeaders.AUTHORIZATION, "Bearer token")) + .andExpect(status().isOk()) + .andDo(restDocs.document( + requestHeaders(authorizationHeader()), + pathParameters( + parameterWithName("postId").description("변경할 게시글 Id") + ), + requestFields( + fieldWithPath("imageId") + .type(JsonFieldType.NUMBER) + .description("변경할 투표 이미지 Id") + ) + )); + } + + @Test + @WithMockUserInfo(role = Role.GUEST) + @DisplayName("게스트 투표 변경") + void guestChangeVote() throws Exception { + //given + ChangeVoteRequest request = new ChangeVoteRequest(1L); + + //when + mockMvc.perform(patch("/posts/{postId}/votes/guest", "1") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + .header(CustomHeader.GUEST_TOKEN, "guestToken")) + .andExpect(status().isOk()) + .andDo(restDocs.document( + requestHeaders(guestHeader()), + pathParameters( + parameterWithName("postId").description("변경활 게시글 Id") + ), + requestFields( + fieldWithPath("imageId") + .type(JsonFieldType.NUMBER) + .description("변경할 투표 이미지 Id") + ) + )); + } +} diff --git a/src/test/resources/org/springframework/restdocs/templates/query-parameters.snippet b/src/test/resources/org/springframework/restdocs/templates/query-parameters.snippet new file mode 100644 index 00000000..f8d07c43 --- /dev/null +++ b/src/test/resources/org/springframework/restdocs/templates/query-parameters.snippet @@ -0,0 +1,12 @@ +|=== + |파라미터|필수값|기본값|제약조건|설명 + +{{#parameters}} + |{{#tableCellContent}}`+{{name}}+`{{/tableCellContent}} + |{{#tableCellContent}}{{^optional}}true{{/optional}}{{/tableCellContent}} + |{{#tableCellContent}}{{#defaultValue}}{{.}}{{/defaultValue}}{{/tableCellContent}} + |{{#tableCellContent}}{{#constraint}}{{.}}{{/constraint}}{{/tableCellContent}} + |{{#tableCellContent}}{{description}}{{/tableCellContent}} +{{/parameters}} + +|=== \ No newline at end of file diff --git a/src/test/resources/org/springframework/restdocs/templates/request-fields.snippet b/src/test/resources/org/springframework/restdocs/templates/request-fields.snippet new file mode 100644 index 00000000..58729b5d --- /dev/null +++ b/src/test/resources/org/springframework/restdocs/templates/request-fields.snippet @@ -0,0 +1,12 @@ +|=== +|필드명|타입|필수값|조건|설명 + +{{#fields}} + |{{#tableCellContent}}`+{{path}}+`{{/tableCellContent}} + |{{#tableCellContent}}`+{{type}}+`{{/tableCellContent}} + |{{#tableCellContent}}{{^optional}}true{{/optional}}{{/tableCellContent}} + |{{#tableCellContent}}{{#constraints}}{{.}}{{/constraints}}{{/tableCellContent}} + |{{#tableCellContent}}{{description}}{{/tableCellContent}} +{{/fields}} + +|=== \ No newline at end of file diff --git a/src/test/resources/org/springframework/restdocs/templates/request-parts.snippet b/src/test/resources/org/springframework/restdocs/templates/request-parts.snippet new file mode 100644 index 00000000..46f44bdf --- /dev/null +++ b/src/test/resources/org/springframework/restdocs/templates/request-parts.snippet @@ -0,0 +1,12 @@ +|=== +|필드명|타입|필수값|조건|설명 + +{{#requestParts}} + |{{#tableCellContent}}`+{{name}}+`{{/tableCellContent}} + |{{#tableCellContent}}`+{{type}}+`{{/tableCellContent}} + |{{#tableCellContent}}{{^optional}}true{{/optional}}{{/tableCellContent}} + |{{#tableCellContent}}{{#constraints}}{{.}}{{/constraints}}{{/tableCellContent}} + |{{#tableCellContent}}{{description}}{{/tableCellContent}} +{{/requestParts}} + +|=== \ No newline at end of file diff --git a/src/test/resources/org/springframework/restdocs/templates/response-fields.snippet b/src/test/resources/org/springframework/restdocs/templates/response-fields.snippet new file mode 100644 index 00000000..f63b0ed6 --- /dev/null +++ b/src/test/resources/org/springframework/restdocs/templates/response-fields.snippet @@ -0,0 +1,11 @@ +|=== +|필드명|타입|필수값|설명 + +{{#fields}} + |{{#tableCellContent}}`+{{path}}+`{{/tableCellContent}} + |{{#tableCellContent}}`+{{type}}+`{{/tableCellContent}} + |{{#tableCellContent}}{{^optional}}true{{/optional}}{{/tableCellContent}} + |{{#tableCellContent}}{{description}}{{/tableCellContent}} +{{/fields}} + +|=== \ No newline at end of file