diff --git a/.gitignore b/.gitignore index 653e7c70..5425fc8d 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,5 @@ out/ .DS_Store application*.yml + +/src/main/generated/ \ No newline at end of file diff --git a/build.gradle b/build.gradle index f036af26..54b51a47 100644 --- a/build.gradle +++ b/build.gradle @@ -40,9 +40,8 @@ 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' + implementation 'software.amazon.awssdk:lambda:2.30.18' + implementation 'com.twelvemonkeys.imageio:imageio-webp:3.9.4' // gson implementation 'com.google.code.gson:gson:2.8.6' @@ -62,6 +61,7 @@ dependencies { testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } +// rest docs ext { snippetsDir = file('build/generated-snippets') } @@ -73,7 +73,7 @@ asciidoctor { dependsOn test } -task copyDocument(type: Copy) { +tasks.register('copyDocument', Copy) { dependsOn asciidoctor doFirst { delete file('src/main/resources/static/docs') diff --git a/server-config b/server-config index a9f449f3..95ed10ad 160000 --- a/server-config +++ b/server-config @@ -1 +1 @@ -Subproject commit a9f449f36c6328393160acb9014cbbac33b9d82d +Subproject commit 95ed10ad85caba6bf8a623d60088547ae3bd3205 diff --git a/src/docs/asciidoc/auth.adoc b/src/docs/asciidoc/auth.adoc index bf0d05db..fcf60714 100644 --- a/src/docs/asciidoc/auth.adoc +++ b/src/docs/asciidoc/auth.adoc @@ -6,17 +6,34 @@ operation::auth-controller-test/kakao-o-auth-sign-in[snippets='http-request,curl-request,request-fields,http-response,response-cookies,response-fields'] +[[게스트-로그인]] +=== `POST` 게스트 로그인 + +``` +1. 리프레시 토큰이 있는 경우 + +토큰 재발급 시도 (토큰이 잘못된 경우 400에러 발생) + +2. 리프레시 토큰이 없는 경우 + +게스트 계정 생성 +``` + +operation::auth-controller-test/guest-sign-in[snippets='http-request,curl-request,request-cookies,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'] + +[[회원탈퇴]] +=== `POST` 회원탈퇴 (미구현) + +operation::auth-controller-test/withdraw[snippets='http-request,curl-request,request-cookies,request-headers,http-response'] \ No newline at end of file diff --git a/src/docs/asciidoc/comments.adoc b/src/docs/asciidoc/comments.adoc index 1491db53..3282c5e6 100644 --- a/src/docs/asciidoc/comments.adoc +++ b/src/docs/asciidoc/comments.adoc @@ -11,7 +11,12 @@ operation::comment-controller-test/create-comment[snippets='http-request,curl-re operation::comment-controller-test/find-comments[snippets='http-request,curl-request,path-parameters,http-response,response-fields'] +[[댓글-수정]] +=== `POST` 댓글 수정 + +operation::comment-controller-test/update-comment[snippets='http-request,curl-request,path-parameters,request-headers,request-fields,http-response'] + [[댓글-삭제]] -=== 댓글 삭제 (미구현) +=== `DELETE` 댓글 삭제 operation::comment-controller-test/delete-comment[snippets='http-request,curl-request,path-parameters,request-headers,http-response'] diff --git a/src/docs/asciidoc/index.adoc b/src/docs/asciidoc/index.adoc index be2279b8..91db8505 100644 --- a/src/docs/asciidoc/index.adoc +++ b/src/docs/asciidoc/index.adoc @@ -28,6 +28,7 @@ HTTP 상태 코드 본 REST API에서 사용하는 HTTP 상태 코드는 가능 | `200 OK`| 요청을 성공적으로 처리함 | `400 Bad Request`| 잘못된 요청을 보낸 경우. 응답 본문에 더 오류에 대한 정보가 담겨있음 | `401 Unauthorization`| 인증에 실패한 경우. 응답 본문에 더 오류에 대한 정보가 담겨있음 +| `403 Forbidden`| 권한이 없는 경우. 응답 본문에 더 오류에 대한 정보가 담겨있음 | `404 Not Found`| 요청한 리소스가 없음. | `500 Internal Server Error`| 서버 내부 오류가 발생한 경우. | `503 Service Unavailable`| 서버가 요청을 처리할 준비가 되지 않은 경우. @@ -67,25 +68,26 @@ Authorization: Bearer accessToken ``` user1 -eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjEiLCJpYXQiOjE3NDAyOTQyMzEsImlzcyI6InN3eXA4dGVhbTIiLCJleHAiOjMzMjc2Mjk0MjMxfQ.gqA245tRiBQB9owKRWIpX1we1T362R-xDTt4YT9AhRY +eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjEiLCJyb2xlIjoiVVNFUiIsImlhdCI6MTc0MTA2MTc2NSwiaXNzIjoic3d5cDh0ZWFtMiIsImV4cCI6MzMyNzcwNjE3NjV9.3o2uNN3IuGZ-uLrAPdkHBBHF9kk9KALlP373eF27HI4 user2 -eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjIiLCJpYXQiOjE3NDA0NDM0ODIsImlzcyI6InN3eXA4dGVhbTIiLCJleHAiOjMzMjc2NDQzNDgyfQ.2sTlCtSHb4eGzhlL6WlRT6xvJLtvipnHp6EAmC4j1UQ +eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjIiLCJyb2xlIjoiVVNFUiIsImlhdCI6MTc0MTA2MjkxMiwiaXNzIjoic3d5cDh0ZWFtMiIsImV4cCI6MzMyNzcwNjI5MTJ9.eC4oUp9ROb6udMarevZQcImTWojcL_3kkY1YgatpuJg ``` [[인증-예외]] === 인증 예외 ``` -인증 토큰관련 예외가 발생하면 다음과 같은 에러 코드와 함께 401 상태 코드를 응답함 +인증 토큰관련 예외가 발생하면 다음과 같은 에러 코드와 함께 상태 코드를 응답함 ``` |=== -| 에러 코드 | 용례 -|`EXPIRED_TOKEN`| 토큰이 만료되었을 경우 -|`INVALID_TOKEN`| 잘못된 형식의 토큰인 경우 -|`INVALID_AUTH_HEADER`| Authorization 헤더가 존재하지 않거나 Bearer 형식이 아닌 경우 +| 에러 코드 | 상태 코드 | 용례 +|`EXPIRED_TOKEN`| 401 | 토큰이 만료되었을 경우 +|`INVALID_TOKEN`| 401 | 잘못된 형식의 토큰인 경우 +|`INVALID_AUTH_HEADER`| 401 | Authorization 헤더가 존재하지 않거나 Bearer 형식이 아닌 경우 +|`FORBIDDEN`| 403 | 권한이 없는 경우 (ex, 게스트가 투표 생성) |=== 예시 diff --git a/src/docs/asciidoc/posts.adoc b/src/docs/asciidoc/posts.adoc index 7087793d..d288b5b1 100644 --- a/src/docs/asciidoc/posts.adoc +++ b/src/docs/asciidoc/posts.adoc @@ -16,14 +16,6 @@ operation::post-controller-test/find-post[snippets='http-request,curl-request,pa 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` 유저가 작성한 게시글 조회 @@ -34,6 +26,16 @@ operation::post-controller-test/find-my-post[snippets='http-request,curl-request 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/toggle-status-post[snippets='http-request,curl-request,path-parameters,request-headers,http-response'] + +[[게시글-투표-수정]] +=== `POST` 게시글 투표 수정 (미구현) + +operation::post-controller-test/update-post[snippets='http-request,curl-request,path-parameters,request-headers,http-response'] + [[게시글-투표-마감]] === `POST` 게시글 투표 마감 @@ -43,3 +45,8 @@ operation::post-controller-test/close-post[snippets='http-request,curl-request,p === `DELETE` 게시글 삭제 operation::post-controller-test/delete-post[snippets='http-request,curl-request,path-parameters,request-headers,http-response'] + +[[피드-조회]] +=== `GET` 피드 조회 + +operation::post-controller-test/find-feed[snippets='http-request,curl-request,query-parameters,request-headers,http-response,response-fields'] \ No newline at end of file diff --git a/src/docs/asciidoc/votes.adoc b/src/docs/asciidoc/votes.adoc index 98b1cdc6..98fb98bd 100644 --- a/src/docs/asciidoc/votes.adoc +++ b/src/docs/asciidoc/votes.adoc @@ -1,22 +1,17 @@ [[투표-API]] == 투표 API +[[투표-현황-조회]] +=== `GET` 투표 현황 조회 + +operation::vote-controller-test/find-vote-status[snippets='http-request,curl-request,request-headers,path-parameters,http-response,response-fields'] + [[투표]] === `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로 통일) +[[투표-취소]] +=== `DELETE` 투표 취소 -// operation::vote-controller-test/guest-change-vote[snippets='http-request,curl-request,request-headers,request-fields,http-response'] +operation::vote-controller-test/cancel-vote[snippets='http-request,curl-request,path-parameters,request-headers,http-response'] diff --git a/src/main/java/com/swyp8team2/auth/application/AuthService.java b/src/main/java/com/swyp8team2/auth/application/AuthService.java index 24b8c3fc..a015170a 100644 --- a/src/main/java/com/swyp8team2/auth/application/AuthService.java +++ b/src/main/java/com/swyp8team2/auth/application/AuthService.java @@ -1,5 +1,6 @@ package com.swyp8team2.auth.application; +import com.swyp8team2.auth.application.jwt.JwtClaim; import com.swyp8team2.auth.application.jwt.JwtService; import com.swyp8team2.auth.application.oauth.OAuthService; import com.swyp8team2.auth.application.oauth.dto.OAuthUserInfo; @@ -7,42 +8,41 @@ 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 com.swyp8team2.user.domain.Role; +import com.swyp8team2.user.domain.User; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.Objects; + +@Slf4j @Service +@Transactional +@RequiredArgsConstructor 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( + SocialAccount socialAccount = getSocialAccount(oAuthUserInfo); + + TokenResponse response = jwtService.createToken(new JwtClaim(socialAccount.getUserId(), Role.USER)); + log.debug("oauthSignIn userId: {} tokenPair: {}", response.userId(), response.tokenPair()); + return response; + } + + private SocialAccount getSocialAccount(OAuthUserInfo oAuthUserInfo) { + return socialAccountRepository.findBySocialIdAndProvider( oAuthUserInfo.socialId(), Provider.KAKAO ).orElseGet(() -> createUser(oAuthUserInfo)); - return jwtService.createToken(socialAccount.getUserId()); } private SocialAccount createUser(OAuthUserInfo oAuthUserInfo) { @@ -50,19 +50,25 @@ private SocialAccount createUser(OAuthUserInfo oAuthUserInfo) { 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)); + TokenResponse response = jwtService.reissue(refreshToken); + log.debug("reissue userId: {} tokenPair: {}", response.userId(), response.tokenPair()); + return response; } - @Transactional public void signOut(Long userId, String refreshToken) { jwtService.signOut(userId, refreshToken); } + + public TokenResponse guestSignIn(String refreshToken) { + TokenResponse response; + if (Objects.isNull(refreshToken)) { + User user = userService.createGuest(); + response = jwtService.createToken(new JwtClaim(user.getId(), user.getRole())); + } else { + response = jwtService.reissue(refreshToken); + } + log.debug("guestSignIn userId: {} tokenPair: {}", response.userId(), response.tokenPair()); + return response; + } } diff --git a/src/main/java/com/swyp8team2/auth/application/jwt/JwtClaim.java b/src/main/java/com/swyp8team2/auth/application/jwt/JwtClaim.java index 149aa64d..6ae7cc87 100644 --- a/src/main/java/com/swyp8team2/auth/application/jwt/JwtClaim.java +++ b/src/main/java/com/swyp8team2/auth/application/jwt/JwtClaim.java @@ -1,17 +1,22 @@ package com.swyp8team2.auth.application.jwt; +import com.swyp8team2.user.domain.Role; + public class JwtClaim { public static final String ID = "id"; + public static final String ROLE = "role"; private final String id; + private final Role role; - public JwtClaim(long id) { + public JwtClaim(long id, Role role) { this.id = String.valueOf(id); + this.role = role; } - public static JwtClaim from(long id) { - return new JwtClaim(id); + public static JwtClaim from(long id, Role role) { + return new JwtClaim(id, role); } public Long idAsLong() { @@ -21,4 +26,8 @@ public Long idAsLong() { public String id() { return id; } + + public Role role() { + return role; + } } \ 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 index 343f285b..96121e2f 100644 --- a/src/main/java/com/swyp8team2/auth/application/jwt/JwtProvider.java +++ b/src/main/java/com/swyp8team2/auth/application/jwt/JwtProvider.java @@ -3,6 +3,7 @@ import com.swyp8team2.common.exception.ErrorCode; import com.swyp8team2.common.exception.InternalServerException; import com.swyp8team2.common.exception.UnauthorizedException; +import com.swyp8team2.user.domain.Role; import io.jsonwebtoken.Claims; import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.JwtException; @@ -63,6 +64,7 @@ private String createToken(JwtClaim claim, long expiration) { return Jwts.builder() .claim(JwtClaim.ID, claim.id()) + .claim(JwtClaim.ROLE, claim.role()) .setIssuedAt(Date.from(now)) .setIssuer(issuer) .setExpiration(Date.from(expiredAt)) @@ -80,15 +82,14 @@ public JwtClaim parseToken(String token) { Claims claims = parser.parseClaimsJws(token) .getBody(); String userId = (String) claims.get(JwtClaim.ID); - return new JwtClaim(Long.parseLong(userId)); + Role role = Role.valueOf((String) claims.get(JwtClaim.ROLE)); + return new JwtClaim(Long.parseLong(userId), role); } catch (ExpiredJwtException e) { log.trace("Expired Jwt Token: {}", e.getMessage()); throw new UnauthorizedException(ErrorCode.EXPIRED_TOKEN); - } catch (JwtException e) { + } catch (Exception 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 index 75228fbd..5c178d8d 100644 --- a/src/main/java/com/swyp8team2/auth/application/jwt/JwtService.java +++ b/src/main/java/com/swyp8team2/auth/application/jwt/JwtService.java @@ -19,16 +19,14 @@ public class JwtService { 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())); + public TokenResponse createToken(JwtClaim claim) { + TokenPair tokenPair = jwtProvider.createToken(claim); + RefreshToken refreshToken = refreshTokenRepository.findByUserId(claim.idAsLong()) + .orElseGet(() -> new RefreshToken(claim.idAsLong(), 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); + return new TokenResponse(tokenPair, claim.idAsLong(), claim.role()); } @Transactional @@ -37,12 +35,10 @@ public TokenResponse reissue(String refreshToken) { RefreshToken findRefreshToken = refreshTokenRepository.findByUserId(claim.idAsLong()) .orElseThrow(() -> new BadRequestException(ErrorCode.REFRESH_TOKEN_NOT_FOUND)); - TokenPair tokenPair = jwtProvider.createToken(new JwtClaim(claim.idAsLong())); + TokenPair tokenPair = jwtProvider.createToken(new JwtClaim(claim.idAsLong(), claim.role())); findRefreshToken.rotate(refreshToken, tokenPair.refreshToken()); - log.debug("reissue userId: {} accessToken: {} refreshToken: {}", - claim.id(), tokenPair.accessToken(), tokenPair.refreshToken()); - return new TokenResponse(tokenPair, claim.idAsLong()); + return new TokenResponse(tokenPair, claim.idAsLong(), claim.role()); } @Transactional diff --git a/src/main/java/com/swyp8team2/auth/presentation/AuthController.java b/src/main/java/com/swyp8team2/auth/presentation/AuthController.java index f0c6eeb4..33493d43 100644 --- a/src/main/java/com/swyp8team2/auth/presentation/AuthController.java +++ b/src/main/java/com/swyp8team2/auth/presentation/AuthController.java @@ -4,7 +4,6 @@ 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; @@ -42,7 +41,19 @@ public ResponseEntity kakaoOAuthSignIn( TokenPair tokenPair = tokenResponse.tokenPair(); Cookie cookie = refreshTokenCookieGenerator.createCookie(tokenPair.refreshToken()); response.addCookie(cookie); - return ResponseEntity.ok(new AuthResponse(tokenPair.accessToken(), tokenResponse.userId())); + return ResponseEntity.ok(new AuthResponse(tokenPair.accessToken(), tokenResponse.userId(), tokenResponse.role())); + } + + @PostMapping("/guest/sign-in") + public ResponseEntity guestSignIn( + @CookieValue(name = CustomHeader.CustomCookie.REFRESH_TOKEN, required = false) String refreshToken, + HttpServletResponse response + ) { + TokenResponse tokenResponse = authService.guestSignIn(refreshToken); + TokenPair tokenPair = tokenResponse.tokenPair(); + Cookie cookie = refreshTokenCookieGenerator.createCookie(tokenPair.refreshToken()); + response.addCookie(cookie); + return ResponseEntity.ok(new AuthResponse(tokenPair.accessToken(), tokenResponse.userId(), tokenResponse.role())); } @PostMapping("/reissue") @@ -57,13 +68,13 @@ public ResponseEntity reissue( 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)); + return ResponseEntity.ok( + new AuthResponse( + tokenPair.accessToken(), + tokenResponse.userId(), + tokenResponse.role() + ) + ); } @PostMapping("/sign-out") @@ -79,4 +90,11 @@ public ResponseEntity signOut( authService.signOut(userInfo.userId(), refreshToken); return ResponseEntity.ok().build(); } + + @PostMapping("/withdraw") + public ResponseEntity withdraw( + @AuthenticationPrincipal UserInfo userInfo + ) { + return ResponseEntity.ok().build(); + } } diff --git a/src/main/java/com/swyp8team2/auth/presentation/dto/AuthResponse.java b/src/main/java/com/swyp8team2/auth/presentation/dto/AuthResponse.java index 65d2c20a..08048542 100644 --- a/src/main/java/com/swyp8team2/auth/presentation/dto/AuthResponse.java +++ b/src/main/java/com/swyp8team2/auth/presentation/dto/AuthResponse.java @@ -1,4 +1,4 @@ package com.swyp8team2.auth.presentation.dto; -public record AuthResponse(String accessToken, Long userId) { +public record AuthResponse(String accessToken, Long userId, com.swyp8team2.user.domain.Role role) { } diff --git a/src/main/java/com/swyp8team2/auth/presentation/dto/TokenResponse.java b/src/main/java/com/swyp8team2/auth/presentation/dto/TokenResponse.java index dad3501e..dc29f892 100644 --- a/src/main/java/com/swyp8team2/auth/presentation/dto/TokenResponse.java +++ b/src/main/java/com/swyp8team2/auth/presentation/dto/TokenResponse.java @@ -1,9 +1,10 @@ package com.swyp8team2.auth.presentation.dto; import com.swyp8team2.auth.application.jwt.TokenPair; +import com.swyp8team2.user.domain.Role; public record TokenResponse( TokenPair tokenPair, - Long userId -) { -} + Long userId, + Role role +) { } diff --git a/src/main/java/com/swyp8team2/auth/presentation/filter/CustomAccessDenialHandler.java b/src/main/java/com/swyp8team2/auth/presentation/filter/CustomAccessDenialHandler.java new file mode 100644 index 00000000..c024d97c --- /dev/null +++ b/src/main/java/com/swyp8team2/auth/presentation/filter/CustomAccessDenialHandler.java @@ -0,0 +1,33 @@ +package com.swyp8team2.auth.presentation.filter; + +import com.swyp8team2.common.exception.ForbiddenException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.web.servlet.HandlerExceptionResolver; + +import java.util.Objects; + +public class CustomAccessDenialHandler implements AccessDeniedHandler { + + private final HandlerExceptionResolver exceptionResolver; + + public CustomAccessDenialHandler( + @Qualifier("handlerExceptionResolver") HandlerExceptionResolver exceptionResolver + ) { + this.exceptionResolver = exceptionResolver; + } + + @Override + public void handle( + HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException + ) { + if (Objects.nonNull(accessDeniedException)) { + exceptionResolver.resolveException(request, response, null, new ForbiddenException()); + } + } +} diff --git a/src/main/java/com/swyp8team2/auth/presentation/filter/GuestAuthFilter.java b/src/main/java/com/swyp8team2/auth/presentation/filter/GuestAuthFilter.java deleted file mode 100644 index defa35bb..00000000 --- a/src/main/java/com/swyp8team2/auth/presentation/filter/GuestAuthFilter.java +++ /dev/null @@ -1,66 +0,0 @@ -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()) && - !matcher.match("/posts/shareUrl/{shareUrl}", 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/JwtAuthFilter.java b/src/main/java/com/swyp8team2/auth/presentation/filter/JwtAuthFilter.java index b7f16f08..5829e4b2 100644 --- a/src/main/java/com/swyp8team2/auth/presentation/filter/JwtAuthFilter.java +++ b/src/main/java/com/swyp8team2/auth/presentation/filter/JwtAuthFilter.java @@ -32,20 +32,23 @@ public class JwtAuthFilter extends OncePerRequestFilter { protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { try { + log.debug("JwtAuthFilter.doFilterInternal start"); String authorization = request.getHeader(HttpHeaders.AUTHORIZATION); JwtClaim claim = jwtProvider.parseToken(headerTokenExtractor.extractToken(authorization)); - Authentication authentication = getAuthentication(claim.idAsLong()); + Authentication authentication = getAuthentication(claim); SecurityContextHolder.getContext().setAuthentication(authentication); } catch (ApplicationException e) { + log.debug("JwtAuthFilter.doFilterInternal application exception {}", e.getMessage()); request.setAttribute(EXCEPTION_KEY, e); } finally { + log.debug("JwtAuthFilter.doFilterInternal end"); doFilter(request, response, filterChain); } } - private Authentication getAuthentication(long userId) { - UserInfo userInfo = new UserInfo(userId, Role.USER); + private Authentication getAuthentication(JwtClaim claim) { + UserInfo userInfo = new UserInfo(claim.idAsLong(), claim.role()); return new UsernamePasswordAuthenticationToken(userInfo, null, userInfo.getAuthorities()); } } diff --git a/src/main/java/com/swyp8team2/comment/application/CommentService.java b/src/main/java/com/swyp8team2/comment/application/CommentService.java index bfe61db3..85147560 100644 --- a/src/main/java/com/swyp8team2/comment/application/CommentService.java +++ b/src/main/java/com/swyp8team2/comment/application/CommentService.java @@ -4,10 +4,11 @@ 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.comment.presentation.dto.CommentRequest; import com.swyp8team2.common.dto.CursorBasePaginatedResponse; import com.swyp8team2.common.exception.BadRequestException; import com.swyp8team2.common.exception.ErrorCode; +import com.swyp8team2.common.exception.ForbiddenException; import com.swyp8team2.user.domain.User; import com.swyp8team2.user.domain.UserRepository; import com.swyp8team2.vote.domain.Vote; @@ -19,6 +20,9 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; +import java.util.stream.Collectors; + @Service @Transactional(readOnly = true) @RequiredArgsConstructor @@ -30,7 +34,7 @@ public class CommentService { private final VoteRepository voteRepository; @Transactional - public void createComment(Long postId, CreateCommentRequest request, UserInfo userInfo) { + public void createComment(Long postId, CommentRequest request, UserInfo userInfo) { Comment comment = new Comment(postId, userInfo.userId(), request.content()); commentRepository.save(comment); } @@ -45,9 +49,34 @@ public CursorBasePaginatedResponse findComments(Long userId, Lo 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()) + List votes = voteRepository.findByUserIdAndPostId(userId, comment.getPostId()); + List voteImageIds = votes.stream() .map(Vote::getPostImageId) - .orElse(null); - return CommentResponse.of(comment, author, author.getId().equals(userId), voteImageId); + .collect(Collectors.toList()); + return CommentResponse.of(comment, author, author.getId().equals(userId), voteImageIds); + } + + @Transactional + public void updateComment(Long commentId, CommentRequest request, UserInfo userInfo) { + Comment comment = commentRepository.findByIdAndNotDeleted(commentId) + .orElseThrow(() -> new BadRequestException(ErrorCode.COMMENT_NOT_FOUND)); + + if (!comment.getUserNo().equals(userInfo.userId())) { + throw new ForbiddenException(); + } + + comment.updateComment(request.content()); + } + + @Transactional + public void deleteComment(Long commentId, UserInfo userInfo) { + Comment comment = commentRepository.findByIdAndNotDeleted(commentId) + .orElseThrow(() -> new BadRequestException(ErrorCode.COMMENT_NOT_FOUND)); + + if (!comment.getUserNo().equals(userInfo.userId())) { + throw new ForbiddenException(); + } + + comment.delete(); } } diff --git a/src/main/java/com/swyp8team2/comment/domain/Comment.java b/src/main/java/com/swyp8team2/comment/domain/Comment.java index 686c4d38..c02b1ebe 100644 --- a/src/main/java/com/swyp8team2/comment/domain/Comment.java +++ b/src/main/java/com/swyp8team2/comment/domain/Comment.java @@ -49,4 +49,9 @@ public Comment(Long postId, Long userNo, String content) { this.userNo = userNo; this.content = content; } + + public void updateComment(String content) { + validateEmptyString(content); + 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 index 0dfbfbb9..6f0e5d47 100644 --- a/src/main/java/com/swyp8team2/comment/domain/CommentRepository.java +++ b/src/main/java/com/swyp8team2/comment/domain/CommentRepository.java @@ -7,6 +7,9 @@ import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import java.util.List; +import java.util.Optional; + @Repository public interface CommentRepository extends JpaRepository { @@ -14,6 +17,7 @@ public interface CommentRepository extends JpaRepository { SELECT c FROM Comment c WHERE c.postId = :postId + AND c.deleted = false AND (:cursor is null or c.id > :cursor) ORDER BY c.createdAt ASC """) @@ -23,4 +27,13 @@ Slice findByPostId( Pageable pageable ); + @Query(""" + SELECT c + FROM Comment c + WHERE c.id = :commentId + AND c.deleted = false + """) + Optional findByIdAndNotDeleted(@Param("commentId") Long commentId); + + List findByPostIdAndDeletedFalse(Long postId); } diff --git a/src/main/java/com/swyp8team2/comment/presentation/CommentController.java b/src/main/java/com/swyp8team2/comment/presentation/CommentController.java index b727fb2b..e57d691f 100644 --- a/src/main/java/com/swyp8team2/comment/presentation/CommentController.java +++ b/src/main/java/com/swyp8team2/comment/presentation/CommentController.java @@ -2,9 +2,8 @@ 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.comment.presentation.dto.CommentRequest; import com.swyp8team2.common.dto.CursorBasePaginatedResponse; import jakarta.validation.Valid; import jakarta.validation.constraints.Min; @@ -20,8 +19,6 @@ 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 @@ -34,7 +31,7 @@ public class CommentController { @PostMapping("") public ResponseEntity createComment( @PathVariable("postId") Long postId, - @Valid @RequestBody CreateCommentRequest request, + @Valid @RequestBody CommentRequest request, @AuthenticationPrincipal UserInfo userInfo ) { commentService.createComment(postId, request, userInfo); @@ -52,12 +49,24 @@ public ResponseEntity> selectCommen return ResponseEntity.ok(commentService.findComments(userId, postId, cursor, size)); } + @PostMapping("/{commentId}") + public ResponseEntity updateComment( + @PathVariable("postId") Long postId, + @PathVariable("commentId") Long commentId, + @Valid @RequestBody CommentRequest request, + @AuthenticationPrincipal UserInfo userInfo + ) { + commentService.updateComment(commentId, request, userInfo); + return ResponseEntity.ok().build(); + } + @DeleteMapping("/{commentId}") public ResponseEntity deleteComment( @PathVariable("postId") Long postId, @PathVariable("commentId") Long commentId, @AuthenticationPrincipal UserInfo userInfo ) { + commentService.deleteComment(commentId, userInfo); return ResponseEntity.ok().build(); } } diff --git a/src/main/java/com/swyp8team2/comment/presentation/dto/CreateCommentRequest.java b/src/main/java/com/swyp8team2/comment/presentation/dto/CommentRequest.java similarity index 80% rename from src/main/java/com/swyp8team2/comment/presentation/dto/CreateCommentRequest.java rename to src/main/java/com/swyp8team2/comment/presentation/dto/CommentRequest.java index 624a0fed..5abf1633 100644 --- a/src/main/java/com/swyp8team2/comment/presentation/dto/CreateCommentRequest.java +++ b/src/main/java/com/swyp8team2/comment/presentation/dto/CommentRequest.java @@ -2,7 +2,7 @@ import jakarta.validation.constraints.NotEmpty; -public record CreateCommentRequest( +public record CommentRequest( @NotEmpty String content ) { diff --git a/src/main/java/com/swyp8team2/comment/presentation/dto/CommentResponse.java b/src/main/java/com/swyp8team2/comment/presentation/dto/CommentResponse.java index 89848202..4a238493 100644 --- a/src/main/java/com/swyp8team2/comment/presentation/dto/CommentResponse.java +++ b/src/main/java/com/swyp8team2/comment/presentation/dto/CommentResponse.java @@ -6,12 +6,13 @@ import com.swyp8team2.user.domain.User; import java.time.LocalDateTime; +import java.util.List; public record CommentResponse( Long commentId, String content, AuthorDto author, - Long voteImageId, + List voteImageId, LocalDateTime createdAt, boolean isAuthor ) implements CursorDto { @@ -22,7 +23,7 @@ public long getId() { return commentId; } - public static CommentResponse of(Comment comment, User user, boolean isAuthor, Long voteImageId) { + public static CommentResponse of(Comment comment, User user, boolean isAuthor, List voteImageId) { return new CommentResponse(comment.getId(), comment.getContent(), new AuthorDto(user.getId(), user.getNickname(), user.getProfileUrl()), diff --git a/src/main/java/com/swyp8team2/common/annotation/GuestTokenCryptoService.java b/src/main/java/com/swyp8team2/common/annotation/GuestTokenCryptoService.java deleted file mode 100644 index 90a6e2db..00000000 --- a/src/main/java/com/swyp8team2/common/annotation/GuestTokenCryptoService.java +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index 82be1bc7..00000000 --- a/src/main/java/com/swyp8team2/common/annotation/ShareUrlCryptoService.java +++ /dev/null @@ -1,16 +0,0 @@ -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/CryptoConfig.java b/src/main/java/com/swyp8team2/common/config/CryptoConfig.java deleted file mode 100644 index 7e722dfd..00000000 --- a/src/main/java/com/swyp8team2/common/config/CryptoConfig.java +++ /dev/null @@ -1,39 +0,0 @@ -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/SecurityConfig.java b/src/main/java/com/swyp8team2/common/config/SecurityConfig.java index f283fa95..9bfdfd1f 100644 --- a/src/main/java/com/swyp8team2/common/config/SecurityConfig.java +++ b/src/main/java/com/swyp8team2/common/config/SecurityConfig.java @@ -1,12 +1,10 @@ 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.CustomAccessDenialHandler; 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; @@ -33,14 +31,11 @@ public class SecurityConfig { private final HandlerExceptionResolver handlerExceptionResolver; - private final CryptoService cryptoService; public SecurityConfig( - @Qualifier("handlerExceptionResolver") HandlerExceptionResolver handlerExceptionResolver, - @GuestTokenCryptoService CryptoService cryptoService + @Qualifier("handlerExceptionResolver") HandlerExceptionResolver handlerExceptionResolver ) { this.handlerExceptionResolver = handlerExceptionResolver; - this.cryptoService = cryptoService; } @Bean @@ -88,39 +83,41 @@ public SecurityFilterChain securityFilterChain( .authorizeHttpRequests(authorize -> authorize .requestMatchers(getWhiteList(introspect)).permitAll() - .requestMatchers(getGuestTokenRequestList(introspect)) + .requestMatchers(getGuestAllowedList(introspect)) .hasAnyRole(Role.USER.name(), Role.GUEST.name()) - .anyRequest().authenticated()) + .anyRequest().hasRole(Role.USER.name())) .addFilterBefore( new JwtAuthFilter(jwtProvider, new HeaderTokenExtractor()), UsernamePasswordAuthenticationFilter.class) - .addFilterAfter( - new GuestAuthFilter(cryptoService), - JwtAuthFilter.class - ) .exceptionHandling(exception -> exception.authenticationEntryPoint( - new JwtAuthenticationEntryPoint(handlerExceptionResolver))); + new JwtAuthenticationEntryPoint(handlerExceptionResolver)) + .accessDeniedHandler((request, response, accessDeniedException) -> + new CustomAccessDenialHandler(handlerExceptionResolver) + .handle(request, response, accessDeniedException) + ) + ); 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.POST, "/auth/oauth2/code/kakao"), + mvc.pattern(HttpMethod.POST, "/auth/guest/sign-in"), + mvc.pattern(HttpMethod.POST, "/auth/reissue"), 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) { + public static MvcRequestMatcher[] getGuestAllowedList(HandlerMappingIntrospector introspect) { MvcRequestMatcher.Builder mvc = new MvcRequestMatcher.Builder(introspect); return new MvcRequestMatcher[]{ - mvc.pattern("/posts/{postId}/votes/guest"), + mvc.pattern(HttpMethod.POST, "/posts/{postId}/votes"), + mvc.pattern(HttpMethod.DELETE, "/votes/{voteId}"), + mvc.pattern(HttpMethod.GET, "/users/me"), }; } } diff --git a/src/main/java/com/swyp8team2/common/dev/DataInitConfig.java b/src/main/java/com/swyp8team2/common/dev/DataInitConfig.java index 59bd22d8..561a4c99 100644 --- a/src/main/java/com/swyp8team2/common/dev/DataInitConfig.java +++ b/src/main/java/com/swyp8team2/common/dev/DataInitConfig.java @@ -12,7 +12,7 @@ public class DataInitConfig { private final DataInitializer dataInitializer; -// @PostConstruct + @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 index 2328c4ef..21b6f01b 100644 --- a/src/main/java/com/swyp8team2/common/dev/DataInitializer.java +++ b/src/main/java/com/swyp8team2/common/dev/DataInitializer.java @@ -1,26 +1,25 @@ package com.swyp8team2.common.dev; +import com.swyp8team2.auth.application.jwt.JwtClaim; 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.post.application.ShareUrlService; 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.post.domain.Scope; +import com.swyp8team2.post.domain.VoteType; 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; @@ -37,7 +36,7 @@ public class DataInitializer { private final UserRepository userRepository; private final ImageFileRepository imageFileRepository; private final PostRepository postRepository; - private final CryptoService shaereUrlCryptoService; + private final ShareUrlService shaereUrlShareUrlService; private final JwtService jwtService; private final VoteService voteService; private final CommentRepository commentRepository; @@ -47,7 +46,7 @@ public DataInitializer( UserRepository userRepository, ImageFileRepository imageFileRepository, PostRepository postRepository, - @ShareUrlCryptoService CryptoService shaereUrlCryptoService, + ShareUrlService shaereUrlShareUrlService, JwtService jwtService, VoteService voteService, CommentRepository commentRepository @@ -56,7 +55,7 @@ public DataInitializer( this.userRepository = userRepository; this.imageFileRepository = imageFileRepository; this.postRepository = postRepository; - this.shaereUrlCryptoService = shaereUrlCryptoService; + this.shaereUrlShareUrlService = shaereUrlShareUrlService; this.jwtService = jwtService; this.voteService = voteService; this.commentRepository = commentRepository; @@ -70,7 +69,7 @@ public void init() { } 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()); + TokenResponse tokenResponse = jwtService.createToken(new JwtClaim(testUser.getId(), testUser.getRole())); TokenPair tokenPair = tokenResponse.tokenPair(); System.out.println("accessToken = " + tokenPair.accessToken()); System.out.println("refreshToken = " + tokenPair.refreshToken()); @@ -83,8 +82,8 @@ public void init() { 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()))); + Post post = postRepository.save(Post.create(user.getId(), "description" + j, List.of(PostImage.create("뽀또A", imageFile1.getId()), PostImage.create("뽀또B", imageFile2.getId())), Scope.PUBLIC, VoteType.SINGLE)); + post.setShareUrl(shaereUrlShareUrlService.encrypt(String.valueOf(post.getId()))); posts.add(post); } diff --git a/src/main/java/com/swyp8team2/common/domain/BaseEntity.java b/src/main/java/com/swyp8team2/common/domain/BaseEntity.java index 1e75cd58..fb55f796 100644 --- a/src/main/java/com/swyp8team2/common/domain/BaseEntity.java +++ b/src/main/java/com/swyp8team2/common/domain/BaseEntity.java @@ -1,5 +1,6 @@ package com.swyp8team2.common.domain; +import jakarta.persistence.Column; import jakarta.persistence.EntityListeners; import jakarta.persistence.MappedSuperclass; import lombok.Getter; @@ -17,9 +18,11 @@ public abstract class BaseEntity { @CreatedBy + @Column(updatable = false) private Long createdBy; @CreatedDate + @Column(updatable = false) private LocalDateTime createdAt; @LastModifiedBy @@ -30,4 +33,9 @@ public abstract class BaseEntity { private boolean deleted = false; private LocalDateTime deletedAt; + + public void delete() { + this.deleted = true; + this.deletedAt = LocalDateTime.now(); + } } diff --git a/src/main/java/com/swyp8team2/common/exception/ApplicationControllerAdvice.java b/src/main/java/com/swyp8team2/common/exception/ApplicationControllerAdvice.java index 4dc5fd55..1de6b396 100644 --- a/src/main/java/com/swyp8team2/common/exception/ApplicationControllerAdvice.java +++ b/src/main/java/com/swyp8team2/common/exception/ApplicationControllerAdvice.java @@ -54,29 +54,27 @@ public ResponseEntity invalidArgument(Exception e) { .body(new ErrorResponse(ErrorCode.INVALID_ARGUMENT)); } - @ExceptionHandler({HttpRequestMethodNotSupportedException.class, MethodArgumentTypeMismatchException.class}) - public ResponseEntity notFound(HttpRequestMethodNotSupportedException e) { + @ExceptionHandler({ + HttpRequestMethodNotSupportedException.class, + MethodArgumentTypeMismatchException.class, + NoResourceFoundException.class + }) + public ResponseEntity notFound(Exception 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(); + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(new ErrorResponse(ErrorCode.NOT_FOUND)); } - @ExceptionHandler(AuthenticationException.class) public ResponseEntity handle(AuthenticationException e) { - log.info(e.getMessage()); + log.debug(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(ForbiddenException.class) + public ResponseEntity handle(ForbiddenException e) { + log.debug(e.getMessage()); + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(new ErrorResponse(ErrorCode.FORBIDDEN)); } @ExceptionHandler(Exception.class) diff --git a/src/main/java/com/swyp8team2/common/exception/ErrorCode.java b/src/main/java/com/swyp8team2/common/exception/ErrorCode.java index 6ee4f4b3..bf26eccb 100644 --- a/src/main/java/com/swyp8team2/common/exception/ErrorCode.java +++ b/src/main/java/com/swyp8team2/common/exception/ErrorCode.java @@ -7,36 +7,44 @@ @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("파일 이름이 너무 김"), + 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("이미 마감된 게시글입니다."), + FILE_NAME_TOO_LONG("파일 이름이 너무 깁니다."), + ACCESS_DENIED_VOTE_STATUS("투표 현황 조회 권한이 없습니다."), + COMMENT_NOT_FOUND("존재하지 않는 댓글입니다."), + VOTE_NOT_FOUND("존재하지 않는 투표입니다."), + NOT_VOTER("투표자가 아닙니다."), //401 - EXPIRED_TOKEN("토큰 만료"), - INVALID_TOKEN("유효하지 않은 토큰"), - INVALID_AUTH_HEADER("잘못된 인증 헤더"), - OAUTH_LOGIN_FAILED("소셜 로그인 실패"), + EXPIRED_TOKEN("토큰이 만료됐습니다."), + INVALID_TOKEN("유효하지 않은 토큰입니다."), + INVALID_AUTH_HEADER("잘못된 인증 헤더입니다."), + + //403 + FORBIDDEN("권한 없음"), + + //404 + NOT_FOUND("리소스를 찾을 수 없음"), //500 - INTERNAL_SERVER_ERROR("서버 내부 오류"), - INVALID_INPUT_VALUE("잘못된 입력 값"), - SOCIAL_AUTHENTICATION_FAILED("소셜 로그인 실패"), + 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이 이미 존재"), + IMAGE_FILE_NOT_FOUND("존재하지 않는 이미지입니다."), + POST_IMAGE_NOT_FOUND("게시글 이미지가 없습니다."), + SHARE_URL_ALREADY_EXISTS("공유 URL이 이미 존재합니다."), //503 SERVICE_UNAVAILABLE("서비스 이용 불가"), diff --git a/src/main/java/com/swyp8team2/common/exception/ForbiddenException.java b/src/main/java/com/swyp8team2/common/exception/ForbiddenException.java new file mode 100644 index 00000000..0e39910f --- /dev/null +++ b/src/main/java/com/swyp8team2/common/exception/ForbiddenException.java @@ -0,0 +1,8 @@ +package com.swyp8team2.common.exception; + +public class ForbiddenException extends ApplicationException { + + public ForbiddenException() { + super(ErrorCode.FORBIDDEN); + } +} diff --git a/src/main/java/com/swyp8team2/common/presentation/HttpLoggingFilter.java b/src/main/java/com/swyp8team2/common/presentation/HttpLoggingFilter.java index 1794ab22..c5ee8834 100644 --- a/src/main/java/com/swyp8team2/common/presentation/HttpLoggingFilter.java +++ b/src/main/java/com/swyp8team2/common/presentation/HttpLoggingFilter.java @@ -10,6 +10,8 @@ import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; import org.slf4j.MDC; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Component; @@ -29,6 +31,7 @@ import java.util.stream.Stream; @Slf4j +@Order(Ordered.HIGHEST_PRECEDENCE) @Component public class HttpLoggingFilter extends OncePerRequestFilter { diff --git a/src/main/java/com/swyp8team2/image/application/R2Storage.java b/src/main/java/com/swyp8team2/image/application/R2Storage.java index 8222f2b0..df824f99 100644 --- a/src/main/java/com/swyp8team2/image/application/R2Storage.java +++ b/src/main/java/com/swyp8team2/image/application/R2Storage.java @@ -1,37 +1,49 @@ package com.swyp8team2.image.application; -import com.sksamuel.scrimage.ImmutableImage; -import com.swyp8team2.common.exception.BadRequestException; +import com.fasterxml.jackson.databind.ObjectMapper; import com.swyp8team2.common.exception.ErrorCode; +import com.swyp8team2.common.exception.InternalServerException; 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.SdkBytes; import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.lambda.LambdaClient; +import software.amazon.awssdk.services.lambda.model.InvokeRequest; +import software.amazon.awssdk.services.lambda.model.InvokeResponse; 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 javax.imageio.IIOImage; +import javax.imageio.IIOException; +import javax.imageio.ImageWriteParam; +import javax.imageio.ImageWriter; +import javax.imageio.stream.FileImageOutputStream; +import java.awt.Graphics2D; +import java.awt.Color; import java.awt.image.BufferedImage; import java.io.File; import java.io.IOException; +import java.nio.file.Files; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Optional; +import java.util.Base64; @Component @Slf4j @RequiredArgsConstructor public class R2Storage { + private static final String CONVERT_EXTENSION = ".jpeg"; + @Value("${file.endpoint}") private String imageDomainUrl; @@ -41,126 +53,128 @@ public class R2Storage { @Value("${r2.bucket.path}") private String filePath; - @Value("${r2.bucket.resize-path}") - private String resizedFilePath; - - @Value("${r2.bucket.resize-height}") - private int resizeHeight; + @Value("${aws.lambda-arn}") + private String lambdaFunctionName; private final S3Client s3Client; + private final LambdaClient lambdaClient; 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); + String originFilename = file.getOriginalFilename(); + String fileExtension = originFilename.substring(originFilename.lastIndexOf(".")); + Map metadata = new HashMap<>(); + String realFileName = getRealFileName(filePath, i, CONVERT_EXTENSION); - int splitIndex = originFileName.lastIndexOf("/"); - String imageType = originFileName.substring(splitIndex + 1).toLowerCase(); + File tempFile = File.createTempFile("upload_", originFilename); + file.transferTo(tempFile); + switch(fileExtension) { + case ".heic", ".heif" -> { + convertHeicToJpg(tempFile, originFilename, realFileName); + } case ".png", ".gif" -> { + metadata.put("Content-Type", "image/jpeg"); + s3PutObject(convertToJpg(tempFile), realFileName, metadata); + } default -> { + realFileName = getRealFileName(filePath, i, fileExtension); + s3PutObject(tempFile, realFileName, metadata); + } + } - 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); + imageFiles.add(new ImageFileDto(originFilename, imageUrl, imageUrl)); + deleteTempFile(tempFile); } return imageFiles; } catch (IOException e) { - log.error("Failed to create temp file", e); + log.error("Failed to upload 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); + private void convertHeicToJpg(File sourceFile, String originFilename, String realFileName) throws IOException { + byte[] fileContent = Files.readAllBytes(sourceFile.toPath()); + String base64Content = Base64.getEncoder().encodeToString(fileContent); - int splitIndex = realFileName.lastIndexOf("/") + 1; - realFileName = realFileName.substring(splitIndex); - String dstKey = resizedFilePath + realFileName; - String imageType = realFileName.substring(realFileName.lastIndexOf(".") + 1).toLowerCase(); + Map payload = new HashMap<>(); + payload.put("fileContent", base64Content); + payload.put("originFilename", originFilename); + payload.put("key", realFileName); - File tempFile = File.createTempFile("resized_", "." + imageType); - ImageIO.write(resizedImage, imageType, tempFile); + ObjectMapper objectMapper = new ObjectMapper(); + String payloadJson = objectMapper.writeValueAsString(payload); + InvokeRequest invokeRequest = InvokeRequest.builder() + .functionName(lambdaFunctionName) + .payload(SdkBytes.fromUtf8String(payloadJson)) + .build(); - s3PutObject(tempFile, dstKey, imageType); - deleteTempFile(tempFile); + InvokeResponse response = lambdaClient.invoke(invokeRequest); + String responseJson = response.payload().asUtf8String(); + Map responseMap = objectMapper.readValue(responseJson, Map.class); - return imageDomainUrl + dstKey; - } catch (IOException e) { - log.error("Failed to create temp file", e); - throw new ServiceUnavailableException(ErrorCode.SERVICE_UNAVAILABLE); + if (responseMap.containsKey("errorMessage")) { + log.error("Lambda service error, {}", responseMap.get("errorMessage")); + throw new InternalServerException(ErrorCode.INTERNAL_SERVER_ERROR); } } - 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 convertToJpg(File sourceFile) throws IOException { + BufferedImage image = ImageIO.read(sourceFile); + File jpgFile = File.createTempFile("converted_", ".jpeg"); + + ImageWriter writer = ImageIO.getImageWritersByFormatName("jpeg").next(); + try (FileImageOutputStream output = new FileImageOutputStream(jpgFile)) { + writer.setOutput(output); + ImageWriteParam param = writer.getDefaultWriteParam(); + param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT); + param.setCompressionQuality(0.9f); + + writer.write(null, new IIOImage(image, null, null), param); + writer.dispose(); + return jpgFile; + } catch (IIOException e) { + log.error("Failed to convert image to jpg", e); + + // 알파 채널 처리를 위해 새 RGB 이미지 생성 (알파 채널 제거) + BufferedImage rgbImage = new BufferedImage( + image.getWidth(), + image.getHeight(), + BufferedImage.TYPE_INT_RGB + ); + + // 원본 이미지를 새 RGB 이미지에 그림 + // 흰색 배경 설정 + Graphics2D graphics = rgbImage.createGraphics(); + graphics.setColor(Color.WHITE); + graphics.fillRect(0, 0, rgbImage.getWidth(), rgbImage.getHeight()); + graphics.drawImage(image, 0, 0, null); + graphics.dispose(); + + try { + ImageIO.write(rgbImage, "jpeg", jpgFile); + return jpgFile; + } catch (IOException io) { + log.error("Error in JPG conversion: {}", io.getMessage()); + throw io; + } + } } - private File s3PutObject(File file, String realFileName, String imageType) { - Map metadata = getMetadataMap(imageType); + private void s3PutObject(File file, String realFileName, Map metadata) { 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; + s3Client.putObject(objectRequest, RequestBody.fromFile(file)); } - private String getRealFileName(String originFileName, String filePath, int sequence) { - String objectType = originFileName.substring(originFileName.lastIndexOf(".")).toLowerCase(); - return filePath + DateTime.getCurrentTimestamp() + sequence + objectType; + private String getRealFileName(String filePath, int sequence, String extension) { + return filePath + DateTime.getCurrentTimestamp() + sequence + extension; } private void deleteTempFile(File tempFile) { diff --git a/src/main/java/com/swyp8team2/image/config/S3Config.java b/src/main/java/com/swyp8team2/image/config/S3Config.java index 3e65b030..91dee5e2 100644 --- a/src/main/java/com/swyp8team2/image/config/S3Config.java +++ b/src/main/java/com/swyp8team2/image/config/S3Config.java @@ -8,6 +8,7 @@ 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.lambda.LambdaClient; import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.S3Configuration; @@ -25,6 +26,12 @@ public class S3Config { @Value("${r2.endpoint}") private String endpoint; + @Value("${aws.access-key}") + private String awsAccessKey; + + @Value("${aws.secret-key}") + private String awsSecretKey; + @Bean public S3Client s3Client() { return S3Client.builder() @@ -39,4 +46,12 @@ public S3Client s3Client() { .responseChecksumValidation(ResponseChecksumValidation.WHEN_REQUIRED) .build(); } + @Bean + public LambdaClient lambdaClient() { + return LambdaClient.builder() + .region(Region.AP_NORTHEAST_2) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(awsAccessKey, awsSecretKey))) + .build(); + } } diff --git a/src/main/java/com/swyp8team2/image/domain/ImageFileRepository.java b/src/main/java/com/swyp8team2/image/domain/ImageFileRepository.java index c0ae2259..f277f9d5 100644 --- a/src/main/java/com/swyp8team2/image/domain/ImageFileRepository.java +++ b/src/main/java/com/swyp8team2/image/domain/ImageFileRepository.java @@ -3,6 +3,9 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.List; + @Repository public interface ImageFileRepository extends JpaRepository { + List findByIdIn(List bestPickedImageIds); } diff --git a/src/main/java/com/swyp8team2/image/util/FileValidator.java b/src/main/java/com/swyp8team2/image/util/FileValidator.java index 32a00c82..850ca2bc 100644 --- a/src/main/java/com/swyp8team2/image/util/FileValidator.java +++ b/src/main/java/com/swyp8team2/image/util/FileValidator.java @@ -36,6 +36,10 @@ private void validate(MultipartFile file) { } String originalFilename = file.getOriginalFilename(); + if (originalFilename.length() > 100) { + throw new BadRequestException(ErrorCode.FILE_NAME_TOO_LONG); + } + String ext = Optional.of(originalFilename) .filter(name -> name.contains(".")) .map(name -> name.substring(name.lastIndexOf('.') + 1)) diff --git a/src/main/java/com/swyp8team2/post/application/PostCommandService.java b/src/main/java/com/swyp8team2/post/application/PostCommandService.java new file mode 100644 index 00000000..e56bd5c3 --- /dev/null +++ b/src/main/java/com/swyp8team2/post/application/PostCommandService.java @@ -0,0 +1,64 @@ +package com.swyp8team2.post.application; + +import com.swyp8team2.common.exception.BadRequestException; +import com.swyp8team2.common.exception.ErrorCode; +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 lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@Transactional +@RequiredArgsConstructor +public class PostCommandService { + + private final PostRepository postRepository; + private final ShareUrlService shareUrlShareUrlService; + + public CreatePostResponse create(Long userId, CreatePostRequest request) { + List postImages = createPostImages(request); + Post post = Post.create(userId, request.description(), postImages, request.scope(), request.voteType()); + Post save = postRepository.save(post); + save.setShareUrl(shareUrlShareUrlService.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(); + } + + @Transactional + public void delete(Long userId, Long postId) { + Post post = postRepository.findById(postId) + .orElseThrow(() -> new BadRequestException(ErrorCode.POST_NOT_FOUND)); + if (!post.isAuthor(userId)) { + throw new BadRequestException(ErrorCode.NOT_POST_AUTHOR); + } + 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); + } + + @Transactional + public void toggleScope(Long userId, Long postId) { + Post post = postRepository.findById(postId) + .orElseThrow(() -> new BadRequestException(ErrorCode.POST_NOT_FOUND)); + post.toggleScope(userId); + } +} diff --git a/src/main/java/com/swyp8team2/post/application/PostQueryService.java b/src/main/java/com/swyp8team2/post/application/PostQueryService.java new file mode 100644 index 00000000..19eb7e4d --- /dev/null +++ b/src/main/java/com/swyp8team2/post/application/PostQueryService.java @@ -0,0 +1,137 @@ +package com.swyp8team2.post.application; + +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.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.PostImageRepository; +import com.swyp8team2.post.domain.PostRepository; +import com.swyp8team2.post.presentation.dto.FeedResponse; +import com.swyp8team2.post.presentation.dto.PostImageResponse; +import com.swyp8team2.post.presentation.dto.PostResponse; +import com.swyp8team2.post.presentation.dto.SimplePostResponse; +import com.swyp8team2.post.presentation.dto.AuthorDto; +import com.swyp8team2.post.presentation.dto.FeedDto; +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.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class PostQueryService { + + private final PostRepository postRepository; + private final PostImageRepository postImageRepository; + private final UserRepository userRepository; + private final ImageFileRepository imageFileRepository; + private final VoteRepository voteRepository; + private final ShareUrlService shareUrlShareUrlService; + + public PostResponse findByShareUrl(Long userId, String shareUrl) { + String decrypt = shareUrlShareUrlService.decrypt(shareUrl); + return findById(userId, Long.valueOf(decrypt)); + } + + public PostResponse findById(Long userId, Long postId) { + Post post = postRepository.findByIdFetchPostImage(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, post); + boolean isAuthor = post.getUserId().equals(userId); + return PostResponse.of(post, author, votes, isAuthor); + } + + private List createPostImageResponse(Long userId, Post post) { + List images = post.getImages(); + return images.stream() + .map(image -> createVoteResponseDto(image, userId)) + .toList(); + } + + private PostImageResponse createVoteResponseDto(PostImage image, Long userId) { + ImageFile imageFile = imageFileRepository.findById(image.getImageFileId()) + .orElseThrow(() -> new InternalServerException(ErrorCode.IMAGE_FILE_NOT_FOUND)); + return new PostImageResponse( + image.getId(), + image.getName(), + imageFile.getImageUrl(), + imageFile.getThumbnailUrl(), + getVoteId(image, userId) + ); + } + + private Long getVoteId(PostImage image, Long userId) { + return voteRepository.findByUserIdAndPostImageId(userId, image.getId()) + .map(Vote::getId) + .orElse(null); + } + + public CursorBasePaginatedResponse findUserPosts(Long userId, Long cursor, int size) { + Slice postSlice = postRepository.findByUserId(userId, cursor, PageRequest.ofSize(size)); + return getCursorPaginatedResponse(postSlice); + } + + public CursorBasePaginatedResponse findVotedPosts(Long userId, Long cursor, int size) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new BadRequestException(ErrorCode.USER_NOT_FOUND)); + List votedPostIds = voteRepository.findByUserId(user.getId()) + .map(Vote::getPostId) + .toList(); + Slice votedPostSlice = postRepository.findByIdIn(votedPostIds, cursor, PageRequest.ofSize(size)); + + return getCursorPaginatedResponse(votedPostSlice); + } + + private CursorBasePaginatedResponse getCursorPaginatedResponse(Slice postSlice) { + List bestPickedImageIds = postSlice.getContent().stream() + .map(Post::getBestPickedImage) + .map(PostImage::getImageFileId) + .toList(); + List imageIds = imageFileRepository.findByIdIn(bestPickedImageIds); + + List responseContent = postSlice.getContent().stream() + .map(post -> getSimplePostResponse(post, imageIds)) + .toList(); + + return CursorBasePaginatedResponse.of(new SliceImpl<>( + responseContent, + postSlice.getPageable(), + postSlice.hasNext() + )); + } + + private SimplePostResponse getSimplePostResponse(Post post, List imageIds) { + ImageFile bestPickedImage = imageIds.stream() + .filter(imageFile -> imageFile.getId().equals(post.getBestPickedImage().getImageFileId())) + .findFirst() + .orElseThrow(() -> new InternalServerException(ErrorCode.IMAGE_FILE_NOT_FOUND)); + return SimplePostResponse.of(post, bestPickedImage.getThumbnailUrl()); + } + + public CursorBasePaginatedResponse findFeed(Long userId, Long cursor, int size) { + Slice postSlice = postRepository.findFeedByScopeWithUser(userId, cursor, PageRequest.ofSize(size)); + return CursorBasePaginatedResponse.of(postSlice.map(post -> createFeedResponse(userId, post))); + } + + private FeedResponse createFeedResponse(Long userId, FeedDto dto) { + AuthorDto author = new AuthorDto(dto.postUserId(), dto.nickname(), dto.profileUrl()); + List postImages = postImageRepository.findByPostId(userId, dto.postId()); + boolean isAuthor = dto.postUserId().equals(userId); + return FeedResponse.of(dto, author, postImages, isAuthor); + } +} diff --git a/src/main/java/com/swyp8team2/post/application/PostService.java b/src/main/java/com/swyp8team2/post/application/PostService.java index f48b9c13..057a0adf 100644 --- a/src/main/java/com/swyp8team2/post/application/PostService.java +++ b/src/main/java/com/swyp8team2/post/application/PostService.java @@ -1,175 +1,60 @@ 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.FeedResponse; 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 lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.List; -import java.util.Objects; - @Service @Transactional(readOnly = true) +@RequiredArgsConstructor 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; - } + private final PostCommandService postCommandService; + private final PostQueryService postQueryService; @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()); + return postCommandService.create(userId, request); } - 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); + @Transactional + public void delete(Long userId, Long postId) { + postCommandService.delete(userId, postId); } - private List createPostImageResponse(Long userId, Long postId, Post post) { - List images = post.getImages(); - return images.stream() - .map(image -> createVoteResponseDto(image, userId, postId)) - .toList(); + @Transactional + public void close(Long userId, Long postId) { + postCommandService.close(userId, postId); } - 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 - ); + @Transactional + public void toggleScope(Long userId, Long postId) { + postCommandService.toggleScope(userId, postId); } - 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 PostResponse findById(Long userId, Long postId) { + return postQueryService.findById(userId, postId); } 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()); + return postQueryService.findUserPosts(userId, cursor, size); } 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(); + return postQueryService.findVotedPosts(userId, cursor, size); } - 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) { + return postQueryService.findByShareUrl(userId, shareUrl); } - public PostResponse findByShareUrl(Long userId, String shareUrl) { - String decrypt = shareUrlCryptoService.decrypt(shareUrl); - return findById(userId, Long.valueOf(decrypt)); + public CursorBasePaginatedResponse findFeed(Long userId, Long cursor, int size) { + return postQueryService.findFeed(userId, cursor, size); } -} +} \ No newline at end of file diff --git a/src/main/java/com/swyp8team2/crypto/application/CryptoService.java b/src/main/java/com/swyp8team2/post/application/ShareUrlService.java similarity index 77% rename from src/main/java/com/swyp8team2/crypto/application/CryptoService.java rename to src/main/java/com/swyp8team2/post/application/ShareUrlService.java index 4ff34182..b16bb5e0 100644 --- a/src/main/java/com/swyp8team2/crypto/application/CryptoService.java +++ b/src/main/java/com/swyp8team2/post/application/ShareUrlService.java @@ -1,4 +1,4 @@ -package com.swyp8team2.crypto.application; +package com.swyp8team2.post.application; import com.swyp8team2.common.exception.BadRequestException; import com.swyp8team2.common.exception.ErrorCode; @@ -6,17 +6,26 @@ import io.seruco.encoding.base62.Base62; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.security.crypto.encrypt.AesBytesEncryptor; +import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; import java.nio.charset.StandardCharsets; @Slf4j -@RequiredArgsConstructor -public class CryptoService { +@Service +public class ShareUrlService { private final AesBytesEncryptor encryptor; + public ShareUrlService( + @Value("${crypto.secret-key.share-url}") String shareUrlSymmetricKey, + @Value("${crypto.salt}") String salt + ) { + this.encryptor = new AesBytesEncryptor(shareUrlSymmetricKey, salt); + } + public String encrypt(String data) { try { byte[] encrypt = encryptor.encrypt(data.getBytes(StandardCharsets.UTF_8)); diff --git a/src/main/java/com/swyp8team2/post/domain/Post.java b/src/main/java/com/swyp8team2/post/domain/Post.java index 79ff5700..74bc69f8 100644 --- a/src/main/java/com/swyp8team2/post/domain/Post.java +++ b/src/main/java/com/swyp8team2/post/domain/Post.java @@ -39,21 +39,37 @@ public class Post extends BaseEntity { @Enumerated(EnumType.STRING) private Status status; + @Enumerated(EnumType.STRING) + private Scope scope; + @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) { + private VoteType voteType; + + public Post( + Long id, + Long userId, + String description, + Status status, + Scope scope, + List images, + String shareUrl, + VoteType voteType + ) { validateDescription(description); validatePostImages(images); this.id = id; this.description = description; this.userId = userId; this.status = status; + this.scope = scope; this.images = images; images.forEach(image -> image.setPost(this)); this.shareUrl = shareUrl; + this.voteType = voteType; } private void validatePostImages(List images) { @@ -68,8 +84,8 @@ private void validateDescription(String description) { } } - public static Post create(Long userId, String description, List images) { - return new Post(null, userId, description, Status.PROGRESS, images, null); + public static Post create(Long userId, String description, List images, Scope scope, VoteType voteType) { + return new Post(null, userId, description, Status.PROGRESS, scope, images, null, voteType); } public PostImage getBestPickedImage() { @@ -95,17 +111,17 @@ public void cancelVote(Long imageId) { } public void close(Long userId) { - validateOwner(userId); + if (!isAuthor(userId)) { + throw new BadRequestException(ErrorCode.NOT_POST_AUTHOR); + } 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 boolean isAuthor(Long userId) { + return this.userId.equals(userId); } public void validateProgress() { @@ -120,4 +136,11 @@ public void setShareUrl(String shareUrl) { } this.shareUrl = shareUrl; } + + public void toggleScope(Long userId) { + if (!isAuthor(userId)) { + throw new BadRequestException(ErrorCode.NOT_POST_AUTHOR); + } + this.scope = scope.equals(Scope.PRIVATE) ? Scope.PUBLIC : Scope.PRIVATE; + } } diff --git a/src/main/java/com/swyp8team2/post/domain/PostImageRepository.java b/src/main/java/com/swyp8team2/post/domain/PostImageRepository.java new file mode 100644 index 00000000..c580bb05 --- /dev/null +++ b/src/main/java/com/swyp8team2/post/domain/PostImageRepository.java @@ -0,0 +1,29 @@ +package com.swyp8team2.post.domain; + +import com.swyp8team2.post.presentation.dto.PostImageResponse; +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 PostImageRepository extends JpaRepository { + + @Query(""" + SELECT new com.swyp8team2.post.presentation.dto.PostImageResponse( + pi.id, + pi.name, + i.imageUrl, + i.thumbnailUrl, + (SELECT v.id FROM Vote v WHERE v.postImageId = pi.id AND v.userId = :userId) + ) + FROM PostImage pi + INNER JOIN ImageFile i ON pi.imageFileId = i.id + WHERE pi.post.id = :postId + ORDER BY pi.id ASC + """ + ) + List findByPostId(@Param("userId") Long userId, @Param("postId") Long postId); +} diff --git a/src/main/java/com/swyp8team2/post/domain/PostRepository.java b/src/main/java/com/swyp8team2/post/domain/PostRepository.java index 0653564c..5eb8a0b5 100644 --- a/src/main/java/com/swyp8team2/post/domain/PostRepository.java +++ b/src/main/java/com/swyp8team2/post/domain/PostRepository.java @@ -1,5 +1,6 @@ package com.swyp8team2.post.domain; +import com.swyp8team2.post.presentation.dto.FeedDto; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; @@ -8,6 +9,7 @@ import org.springframework.stereotype.Repository; import java.util.List; +import java.util.Optional; @Repository public interface PostRepository extends JpaRepository { @@ -17,7 +19,7 @@ public interface PostRepository extends JpaRepository { FROM Post p WHERE p.userId = :userId AND (:postId IS NULL OR p.id < :postId) - ORDER BY p.createdAt DESC + ORDER BY p.id DESC """ ) Slice findByUserId(@Param("userId") Long userId, @Param("postId") Long postId, Pageable pageable); @@ -27,8 +29,39 @@ public interface PostRepository extends JpaRepository { FROM Post p WHERE p.id IN :postIds AND (:postId IS NULL OR p.id < :postId) - ORDER BY p.createdAt DESC + ORDER BY p.id DESC """ ) Slice findByIdIn(@Param("postIds") List postIds, @Param("postId") Long postId, Pageable pageable); + + @Query(""" + SELECT p + FROM Post p + JOIN FETCH p.images + WHERE p.id = :postId + """ + ) + Optional findByIdFetchPostImage(@Param("postId") Long postId); + + @Query(""" + SELECT new com.swyp8team2.post.presentation.dto.FeedDto( + p.id, + p.status , + p.description , + p.shareUrl , + p.userId , + u.nickname, + u.profileUrl, + cast((select count(distinct v.id) from Vote v where p.id = v.postId) as long), + cast((select count(*) from Comment c where p.id = c.postId and c.deleted = false) as long) + ) + FROM Post p + INNER JOIN User u on p.userId = u.id + WHERE p.deleted = false + AND p.scope = 'PUBLIC' + AND (:postId IS NULL OR p.id < :postId) + ORDER BY p.createdAt DESC + """ + ) + Slice findFeedByScopeWithUser(@Param("userId") Long userId, @Param("postId") Long postId, Pageable pageable); } diff --git a/src/main/java/com/swyp8team2/post/domain/Scope.java b/src/main/java/com/swyp8team2/post/domain/Scope.java new file mode 100644 index 00000000..31cda011 --- /dev/null +++ b/src/main/java/com/swyp8team2/post/domain/Scope.java @@ -0,0 +1,5 @@ +package com.swyp8team2.post.domain; + +public enum Scope { + PUBLIC, PRIVATE +} diff --git a/src/main/java/com/swyp8team2/post/domain/VoteType.java b/src/main/java/com/swyp8team2/post/domain/VoteType.java new file mode 100644 index 00000000..43ae8f03 --- /dev/null +++ b/src/main/java/com/swyp8team2/post/domain/VoteType.java @@ -0,0 +1,5 @@ +package com.swyp8team2.post.domain; + +public enum VoteType { + SINGLE, MULTIPLE +} diff --git a/src/main/java/com/swyp8team2/post/presentation/PostController.java b/src/main/java/com/swyp8team2/post/presentation/PostController.java index 625897dd..489431f5 100644 --- a/src/main/java/com/swyp8team2/post/presentation/PostController.java +++ b/src/main/java/com/swyp8team2/post/presentation/PostController.java @@ -5,9 +5,10 @@ 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.UpdatePostRequest; import com.swyp8team2.post.presentation.dto.SimplePostResponse; +import com.swyp8team2.post.presentation.dto.FeedResponse; import jakarta.validation.Valid; import jakarta.validation.constraints.Min; import lombok.RequiredArgsConstructor; @@ -22,7 +23,6 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import java.util.List; import java.util.Optional; @RestController @@ -37,21 +37,9 @@ 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, @@ -63,11 +51,22 @@ public ResponseEntity findPostByShareUrl( return ResponseEntity.ok(postService.findByShareUrl(userId, shareUrl)); } - @GetMapping("/{postId}/status") - public ResponseEntity> findVoteStatus( - @PathVariable("postId") Long postId + @PostMapping("/{postId}/scope") + public ResponseEntity toggleScopePost( + @PathVariable("postId") Long postId, + @AuthenticationPrincipal UserInfo userInfo ) { - return ResponseEntity.ok(postService.findPostStatus(postId)); + postService.toggleScope(userInfo.userId(), postId); + return ResponseEntity.ok().build(); + } + + @PostMapping("/{postId}/update") + public ResponseEntity updatePost( + @PathVariable("postId") Long postId, + @Valid @RequestBody UpdatePostRequest request, + @AuthenticationPrincipal UserInfo userInfo + ) { + return ResponseEntity.ok().build(); } @PostMapping("/{postId}/close") @@ -105,4 +104,13 @@ public ResponseEntity> findVoted ) { return ResponseEntity.ok(postService.findVotedPosts(userId, cursor, size)); } + + @GetMapping("/feed") + public ResponseEntity> findFeed( + @RequestParam(name = "cursor", required = false) @Min(0) Long cursor, + @RequestParam(name = "size", required = false, defaultValue = "10") @Min(1) int size, + @AuthenticationPrincipal UserInfo userInfo + ) { + return ResponseEntity.ok(postService.findFeed(userInfo.userId(), cursor, size)); + } } diff --git a/src/main/java/com/swyp8team2/post/presentation/dto/CreatePostRequest.java b/src/main/java/com/swyp8team2/post/presentation/dto/CreatePostRequest.java index 1efb3b7a..5697ae28 100644 --- a/src/main/java/com/swyp8team2/post/presentation/dto/CreatePostRequest.java +++ b/src/main/java/com/swyp8team2/post/presentation/dto/CreatePostRequest.java @@ -1,5 +1,7 @@ package com.swyp8team2.post.presentation.dto; +import com.swyp8team2.post.domain.Scope; +import com.swyp8team2.post.domain.VoteType; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; @@ -10,6 +12,12 @@ public record CreatePostRequest( String description, @Valid @NotNull - List images + List images, + + @NotNull + Scope scope, + + @NotNull + VoteType voteType ) { } diff --git a/src/main/java/com/swyp8team2/post/presentation/dto/FeedDto.java b/src/main/java/com/swyp8team2/post/presentation/dto/FeedDto.java new file mode 100644 index 00000000..276f87f3 --- /dev/null +++ b/src/main/java/com/swyp8team2/post/presentation/dto/FeedDto.java @@ -0,0 +1,15 @@ +package com.swyp8team2.post.presentation.dto; + +import com.swyp8team2.post.domain.Status; + +public record FeedDto( + Long postId, + Status status, + String description, + String shareUrl, + Long postUserId, + String nickname, + String profileUrl, + Long participantCount, + Long commentCount) { +} \ No newline at end of file diff --git a/src/main/java/com/swyp8team2/post/presentation/dto/FeedResponse.java b/src/main/java/com/swyp8team2/post/presentation/dto/FeedResponse.java new file mode 100644 index 00000000..45212cdb --- /dev/null +++ b/src/main/java/com/swyp8team2/post/presentation/dto/FeedResponse.java @@ -0,0 +1,39 @@ +package com.swyp8team2.post.presentation.dto; + +import com.swyp8team2.common.dto.CursorDto; +import com.swyp8team2.post.domain.Status; + +import java.util.List; + +public record FeedResponse( + Long id, + AuthorDto author, + List images, + Status status, + String description, + String shareUrl, + boolean isAuthor, + Long participantCount, + Long commentCount + +) implements CursorDto { + + public static FeedResponse of(FeedDto feedDto, AuthorDto author, List images, boolean isAuthor) { + return new FeedResponse( + feedDto.postId(), + author, + images, + feedDto.status(), + feedDto.description(), + feedDto.shareUrl(), + isAuthor, + feedDto.participantCount(), + feedDto.commentCount() + ); + } + + @Override + public long getId() { + return id; + } +} diff --git a/src/main/java/com/swyp8team2/post/presentation/dto/PostImageResponse.java b/src/main/java/com/swyp8team2/post/presentation/dto/PostImageResponse.java index 0fd9881e..7eff9968 100644 --- a/src/main/java/com/swyp8team2/post/presentation/dto/PostImageResponse.java +++ b/src/main/java/com/swyp8team2/post/presentation/dto/PostImageResponse.java @@ -5,6 +5,6 @@ public record PostImageResponse( String imageName, String imageUrl, String thumbnailUrl, - boolean voted + Long voteId ) { } diff --git a/src/main/java/com/swyp8team2/post/presentation/dto/UpdatePostRequest.java b/src/main/java/com/swyp8team2/post/presentation/dto/UpdatePostRequest.java new file mode 100644 index 00000000..bf3b9b79 --- /dev/null +++ b/src/main/java/com/swyp8team2/post/presentation/dto/UpdatePostRequest.java @@ -0,0 +1,9 @@ +package com.swyp8team2.post.presentation.dto; + +import jakarta.validation.constraints.NotNull; + +public record UpdatePostRequest( + @NotNull + String description +) { +} diff --git a/src/main/java/com/swyp8team2/user/application/NicknameGenerator.java b/src/main/java/com/swyp8team2/user/application/NicknameGenerator.java new file mode 100644 index 00000000..508a4546 --- /dev/null +++ b/src/main/java/com/swyp8team2/user/application/NicknameGenerator.java @@ -0,0 +1,20 @@ +package com.swyp8team2.user.application; + +import com.swyp8team2.user.domain.NicknameAdjectiveRepository; +import com.swyp8team2.user.domain.Role; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class NicknameGenerator { + + private final NicknameAdjectiveRepository nicknameAdjectiveRepository; + + public String generate(Role role) { + long randomIndex = (long)(Math.random() * 500); + return nicknameAdjectiveRepository.findNicknameAdjectiveById(randomIndex) + .map(adjective -> adjective.getAdjective() + " " + role.getNickname()) + .orElse("user_" + System.currentTimeMillis()); + } +} diff --git a/src/main/java/com/swyp8team2/user/application/UserService.java b/src/main/java/com/swyp8team2/user/application/UserService.java index b97757fb..e008f3f2 100644 --- a/src/main/java/com/swyp8team2/user/application/UserService.java +++ b/src/main/java/com/swyp8team2/user/application/UserService.java @@ -2,11 +2,11 @@ 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.Role; import com.swyp8team2.user.domain.User; import com.swyp8team2.user.domain.UserRepository; import com.swyp8team2.user.presentation.dto.UserInfoResponse; +import com.swyp8team2.user.presentation.dto.UserMyInfoResponse; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -19,7 +19,7 @@ public class UserService { private final UserRepository userRepository; - private final NicknameAdjectiveRepository nicknameAdjectiveRepository; + private final NicknameGenerator nicknameGenerator; @Transactional public Long createUser(String nickname, String profileImageUrl) { @@ -27,25 +27,19 @@ public Long createUser(String nickname, String 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()); - }); + .orElseGet(() -> nicknameGenerator.generate(Role.USER)); + } + + private String getProfileImage(String profileImageUrl) { + return Optional.ofNullable(profileImageUrl) + .orElse(User.DEFAULT_PROFILE_URL); } @Transactional - public Long createGuest() { - User user = userRepository.save(User.createGuest()); - return user.getId(); + public User createGuest() { + return userRepository.save(User.createGuest(nicknameGenerator.generate(Role.GUEST))); } public UserInfoResponse findById(Long userId) { @@ -53,4 +47,10 @@ public UserInfoResponse findById(Long userId) { .orElseThrow(() -> new BadRequestException(ErrorCode.USER_NOT_FOUND)); return UserInfoResponse.of(user); } + + public UserMyInfoResponse findByMe(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new BadRequestException(ErrorCode.USER_NOT_FOUND)); + return UserMyInfoResponse.of(user); + } } diff --git a/src/main/java/com/swyp8team2/user/domain/Role.java b/src/main/java/com/swyp8team2/user/domain/Role.java index 3792a4c5..83f409d8 100644 --- a/src/main/java/com/swyp8team2/user/domain/Role.java +++ b/src/main/java/com/swyp8team2/user/domain/Role.java @@ -1,5 +1,12 @@ package com.swyp8team2.user.domain; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor public enum Role { - GUEST, USER + GUEST("낫또"), USER("뽀또"); + + private final String nickname; } diff --git a/src/main/java/com/swyp8team2/user/domain/User.java b/src/main/java/com/swyp8team2/user/domain/User.java index 5686f490..ff9f52ed 100644 --- a/src/main/java/com/swyp8team2/user/domain/User.java +++ b/src/main/java/com/swyp8team2/user/domain/User.java @@ -10,17 +10,14 @@ 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 { + public static final String DEFAULT_PROFILE_URL = "https://image.photopic.site/default_profile.png"; + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @@ -43,11 +40,11 @@ public static User create(String nickname, String profileUrl) { return new User(null, nickname, profileUrl, Role.USER); } - public static User createGuest() { + public static User createGuest(String nickname) { return new User( null, - "guest_" + System.currentTimeMillis(), - "https://image.photopic.site/images-dev/resized_202502240006030.png", + nickname, + DEFAULT_PROFILE_URL, Role.GUEST ); } diff --git a/src/main/java/com/swyp8team2/user/presentation/UserController.java b/src/main/java/com/swyp8team2/user/presentation/UserController.java index 332679ed..3f81d259 100644 --- a/src/main/java/com/swyp8team2/user/presentation/UserController.java +++ b/src/main/java/com/swyp8team2/user/presentation/UserController.java @@ -3,6 +3,7 @@ import com.swyp8team2.auth.domain.UserInfo; import com.swyp8team2.user.application.UserService; import com.swyp8team2.user.presentation.dto.UserInfoResponse; +import com.swyp8team2.user.presentation.dto.UserMyInfoResponse; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -24,9 +25,9 @@ public ResponseEntity findUserInfo(@PathVariable("userId") Lon } @GetMapping("/me") - public ResponseEntity findMyInfo( + public ResponseEntity findMyInfo( @AuthenticationPrincipal UserInfo userInfo ) { - return ResponseEntity.ok(userService.findById(userInfo.userId())); + return ResponseEntity.ok(userService.findByMe(userInfo.userId())); } } diff --git a/src/main/java/com/swyp8team2/user/presentation/dto/UserMyInfoResponse.java b/src/main/java/com/swyp8team2/user/presentation/dto/UserMyInfoResponse.java new file mode 100644 index 00000000..7aafd096 --- /dev/null +++ b/src/main/java/com/swyp8team2/user/presentation/dto/UserMyInfoResponse.java @@ -0,0 +1,15 @@ +package com.swyp8team2.user.presentation.dto; + +import com.swyp8team2.user.domain.Role; +import com.swyp8team2.user.domain.User; + +public record UserMyInfoResponse( + Long id, + String nickname, + String profileImageUrl, + Role role +) { + public static UserMyInfoResponse of(User user) { + return new UserMyInfoResponse(user.getId(), user.getNickname(), user.getProfileUrl(), user.getRole()); + } +} diff --git a/src/main/java/com/swyp8team2/post/application/RatioCalculator.java b/src/main/java/com/swyp8team2/vote/application/RatioCalculator.java similarity index 93% rename from src/main/java/com/swyp8team2/post/application/RatioCalculator.java rename to src/main/java/com/swyp8team2/vote/application/RatioCalculator.java index 1f4c8ec1..3b7e954b 100644 --- a/src/main/java/com/swyp8team2/post/application/RatioCalculator.java +++ b/src/main/java/com/swyp8team2/vote/application/RatioCalculator.java @@ -1,4 +1,4 @@ -package com.swyp8team2.post.application; +package com.swyp8team2.vote.application; import org.springframework.stereotype.Component; diff --git a/src/main/java/com/swyp8team2/vote/application/VoteService.java b/src/main/java/com/swyp8team2/vote/application/VoteService.java index e7f093d9..d3f69e12 100644 --- a/src/main/java/com/swyp8team2/vote/application/VoteService.java +++ b/src/main/java/com/swyp8team2/vote/application/VoteService.java @@ -3,7 +3,10 @@ import com.swyp8team2.common.exception.BadRequestException; import com.swyp8team2.common.exception.ErrorCode; import com.swyp8team2.post.domain.Post; +import com.swyp8team2.post.domain.PostImage; import com.swyp8team2.post.domain.PostRepository; +import com.swyp8team2.post.domain.VoteType; +import com.swyp8team2.vote.presentation.dto.PostImageVoteStatusResponse; import com.swyp8team2.user.domain.User; import com.swyp8team2.user.domain.UserRepository; import com.swyp8team2.vote.domain.Vote; @@ -12,6 +15,10 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; + @Service @Transactional(readOnly = true) @RequiredArgsConstructor @@ -20,39 +27,82 @@ public class VoteService { private final VoteRepository voteRepository; private final UserRepository userRepository; private final PostRepository postRepository; + private final RatioCalculator ratioCalculator; @Transactional public Long vote(Long voterId, Long postId, Long imageId) { + Optional existsVote = voteRepository.findByUserIdAndPostImageId(voterId, imageId); + if (existsVote.isPresent()) { + return existsVote.get().getId(); + } + Post post = postRepository.findById(postId) + .orElseThrow(() -> new BadRequestException(ErrorCode.POST_NOT_FOUND)); + post.validateProgress(); + User voter = userRepository.findById(voterId) .orElseThrow(() -> new BadRequestException(ErrorCode.USER_NOT_FOUND)); - deleteVoteIfExisting(postId, voter.getId()); - Vote vote = createVote(postId, imageId, voter.getId()); + + VoteType voteType = post.getVoteType(); + if (VoteType.SINGLE.equals(voteType)) { + deleteVoteIfExisting(post, voter.getId()); + } + Vote vote = createVote(post, 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 void deleteVoteIfExisting(Post post, Long userId) { + List votes = voteRepository.findByUserIdAndPostId(userId, post.getId()); + for (Vote vote : votes) { + voteRepository.delete(vote); + post.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(); + private Vote createVote(Post post, Long imageId, Long userId) { 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(); + public void cancelVote(Long userId, Long voteId) { + Vote vote = voteRepository.findById(voteId) + .orElseThrow(() -> new BadRequestException(ErrorCode.VOTE_NOT_FOUND)); + if (!vote.isVoter(userId)) { + throw new BadRequestException(ErrorCode.NOT_VOTER); + } + voteRepository.delete(vote); + Post post = postRepository.findById(vote.getPostId()) + .orElseThrow(() -> new BadRequestException(ErrorCode.POST_NOT_FOUND)); + post.cancelVote(vote.getPostImageId()); + } + + public List findVoteStatus(Long userId, Long postId) { + Post post = postRepository.findByIdFetchPostImage(postId) + .orElseThrow(() -> new BadRequestException(ErrorCode.POST_NOT_FOUND)); + validateVoteStatus(userId, post); + 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); + }) + .sorted(Comparator.comparingInt(PostImageVoteStatusResponse::voteCount).reversed()) + .toList(); + } + + private void validateVoteStatus(Long userId, Post post) { + List votes = voteRepository.findByUserIdAndPostId(userId, post.getId()); + if (!(post.isAuthor(userId) || !votes.isEmpty())) { + throw new BadRequestException(ErrorCode.ACCESS_DENIED_VOTE_STATUS); + } + } + + private int getTotalVoteCount(List images) { + int totalVoteCount = 0; + for (PostImage image : images) { + totalVoteCount += image.getVoteCount(); + } + return totalVoteCount; } } diff --git a/src/main/java/com/swyp8team2/vote/domain/Vote.java b/src/main/java/com/swyp8team2/vote/domain/Vote.java index 23bd38a9..30d3928a 100644 --- a/src/main/java/com/swyp8team2/vote/domain/Vote.java +++ b/src/main/java/com/swyp8team2/vote/domain/Vote.java @@ -36,4 +36,8 @@ public Vote(Long id, Long postId, Long postImageId, Long userId) { public static Vote of(Long postId, Long postImageId, Long userId) { return new Vote(null, postId, postImageId, userId); } + + public boolean isVoter(Long userId) { + return this.userId.equals(userId); + } } diff --git a/src/main/java/com/swyp8team2/vote/domain/VoteRepository.java b/src/main/java/com/swyp8team2/vote/domain/VoteRepository.java index 05c2ccf5..53c697a7 100644 --- a/src/main/java/com/swyp8team2/vote/domain/VoteRepository.java +++ b/src/main/java/com/swyp8team2/vote/domain/VoteRepository.java @@ -4,11 +4,18 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.List; import java.util.Optional; @Repository public interface VoteRepository extends JpaRepository { - Optional findByUserIdAndPostId(Long userId, Long postId); + List findByUserIdAndPostId(Long userId, Long postId); Slice findByUserId(Long userId); + + Optional findByUserIdAndPostImageId(Long voterId, Long imageId); + + Optional findByIdAndUserId(Long voteId, Long userId); + + List findByPostIdAndDeletedFalse(Long postId); } diff --git a/src/main/java/com/swyp8team2/vote/presentation/VoteController.java b/src/main/java/com/swyp8team2/vote/presentation/VoteController.java index 6f97d68f..058e0bc7 100644 --- a/src/main/java/com/swyp8team2/vote/presentation/VoteController.java +++ b/src/main/java/com/swyp8team2/vote/presentation/VoteController.java @@ -1,30 +1,29 @@ package com.swyp8team2.vote.presentation; import com.swyp8team2.auth.domain.UserInfo; -import com.swyp8team2.common.presentation.CustomHeader; +import com.swyp8team2.vote.presentation.dto.PostImageVoteStatusResponse; 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.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.RequestHeader; -import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import java.util.List; + @RestController @RequiredArgsConstructor -@RequestMapping("/posts/{postId}/votes") public class VoteController { private final VoteService voteService; - @PostMapping("") + @PostMapping("/posts/{postId}/votes") public ResponseEntity vote( @PathVariable("postId") Long postId, @Valid @RequestBody VoteRequest request, @@ -34,31 +33,20 @@ public ResponseEntity vote( return ResponseEntity.ok().build(); } - @PostMapping("/guest") - public ResponseEntity guestVote( - @PathVariable("postId") Long postId, - @Valid @RequestBody VoteRequest request, + @DeleteMapping("/votes/{voteId}") + public ResponseEntity cancelVote( + @PathVariable("voteId") Long voteId, @AuthenticationPrincipal UserInfo userInfo ) { - voteService.guestVote(userInfo.userId(), postId, request.imageId()); + voteService.cancelVote(userInfo.userId(), voteId); return ResponseEntity.ok().build(); } - @PatchMapping("") - public ResponseEntity changeVote( + @GetMapping("/posts/{postId}/votes/status") + public ResponseEntity> findVoteStatus( @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(); + return ResponseEntity.ok(voteService.findVoteStatus(userInfo.userId(), postId)); } } diff --git a/src/main/java/com/swyp8team2/post/presentation/dto/PostImageVoteStatusResponse.java b/src/main/java/com/swyp8team2/vote/presentation/dto/PostImageVoteStatusResponse.java similarity index 75% rename from src/main/java/com/swyp8team2/post/presentation/dto/PostImageVoteStatusResponse.java rename to src/main/java/com/swyp8team2/vote/presentation/dto/PostImageVoteStatusResponse.java index 56c0f57f..86f2e4b0 100644 --- a/src/main/java/com/swyp8team2/post/presentation/dto/PostImageVoteStatusResponse.java +++ b/src/main/java/com/swyp8team2/vote/presentation/dto/PostImageVoteStatusResponse.java @@ -1,4 +1,4 @@ -package com.swyp8team2.post.presentation.dto; +package com.swyp8team2.vote.presentation.dto; public record PostImageVoteStatusResponse( Long id, diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html deleted file mode 100644 index ab7a06bc..00000000 --- a/src/main/resources/static/index.html +++ /dev/null @@ -1,52 +0,0 @@ - - - - - 멀티 파일 업로드 테스트 - - -

여러 파일 업로드 테스트

- - - - - - - diff --git a/src/test/java/com/swyp8team2/auth/application/JwtClaimTest.java b/src/test/java/com/swyp8team2/auth/application/JwtClaimTest.java index ef7a31e5..fd0d5ffa 100644 --- a/src/test/java/com/swyp8team2/auth/application/JwtClaimTest.java +++ b/src/test/java/com/swyp8team2/auth/application/JwtClaimTest.java @@ -1,10 +1,13 @@ package com.swyp8team2.auth.application; import com.swyp8team2.auth.application.jwt.JwtClaim; +import com.swyp8team2.user.domain.Role; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; + class JwtClaimTest { @Test @@ -12,11 +15,13 @@ class JwtClaimTest { void idAsLong() { // given long givenId = 1; + Role givenRole = Role.GUEST; // when - JwtClaim jwtClaim = JwtClaim.from(givenId); + JwtClaim jwtClaim = JwtClaim.from(givenId, givenRole); // then - Assertions.assertThat(jwtClaim.idAsLong()).isEqualTo(givenId); + assertThat(jwtClaim.idAsLong()).isEqualTo(givenId); + assertThat(jwtClaim.role()).isEqualTo(givenRole); } } diff --git a/src/test/java/com/swyp8team2/auth/application/JwtProviderTest.java b/src/test/java/com/swyp8team2/auth/application/JwtProviderTest.java index d3a5fcae..0d2da3e7 100644 --- a/src/test/java/com/swyp8team2/auth/application/JwtProviderTest.java +++ b/src/test/java/com/swyp8team2/auth/application/JwtProviderTest.java @@ -5,6 +5,7 @@ import com.swyp8team2.auth.application.jwt.TokenPair; import com.swyp8team2.common.exception.ErrorCode; import com.swyp8team2.common.exception.UnauthorizedException; +import com.swyp8team2.user.domain.Role; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -24,7 +25,7 @@ class JwtProviderTest { void createToken() throws Exception { //given JwtProvider jwtProvider = new JwtProvider("2345asdfasdfsadfsdf243dfdsfsfssasdf", "issuer", Clock.systemDefaultZone()); - JwtClaim givenClaim = new JwtClaim(1L); + JwtClaim givenClaim = new JwtClaim(1L, Role.USER); //when TokenPair tokenPair = jwtProvider.createToken(givenClaim); @@ -49,7 +50,7 @@ void parseClaim_expiredToken() throws Exception { .willReturn(clock.instant().minus(24, ChronoUnit.HOURS)); //when then - TokenPair tokenPair = jwtProvider.createToken(new JwtClaim(1L)); + TokenPair tokenPair = jwtProvider.createToken(new JwtClaim(1L, Role.USER)); assertThatThrownBy(() -> jwtProvider.parseToken(tokenPair.accessToken())) .isInstanceOf(UnauthorizedException.class) .hasMessage(ErrorCode.EXPIRED_TOKEN.getMessage()); @@ -64,7 +65,7 @@ void parseClaim_differentKey() throws Exception { JwtProvider jwtProviderWithDifferentKey = new JwtProvider("1211qwerqwerqwer1111qwerqwerqwer", "issuer", clock); //when then - TokenPair tokenPair = jwtProviderWithDifferentKey.createToken(new JwtClaim(1L)); + TokenPair tokenPair = jwtProviderWithDifferentKey.createToken(new JwtClaim(1L, Role.USER)); assertThatThrownBy(() -> jwtProvider.parseToken(tokenPair.accessToken())) .isInstanceOf(UnauthorizedException.class) .hasMessage(ErrorCode.INVALID_TOKEN.getMessage()); @@ -79,7 +80,7 @@ void parseClaim_differentIssuer() throws Exception { JwtProvider jwtProviderWithDifferentIssuer = new JwtProvider("2345asdfasdfsadfsdf243dfdsfsfssasdf", "asdf", clock); //when then - TokenPair tokenPair = jwtProviderWithDifferentIssuer.createToken(new JwtClaim(1L)); + TokenPair tokenPair = jwtProviderWithDifferentIssuer.createToken(new JwtClaim(1L, Role.USER)); 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 index 40cefbdb..d33ce4ba 100644 --- a/src/test/java/com/swyp8team2/auth/application/JwtServiceTest.java +++ b/src/test/java/com/swyp8team2/auth/application/JwtServiceTest.java @@ -10,6 +10,7 @@ import com.swyp8team2.common.exception.BadRequestException; import com.swyp8team2.common.exception.ErrorCode; import com.swyp8team2.support.IntegrationTest; +import com.swyp8team2.user.domain.Role; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -41,7 +42,7 @@ void createToken() throws Exception { .willReturn(expectedTokenPair); //when - TokenResponse tokenResponse = jwtService.createToken(givenUserId); + TokenResponse tokenResponse = jwtService.createToken(new JwtClaim(givenUserId, Role.USER)); //then TokenPair tokenPair = tokenResponse.tokenPair(); @@ -59,7 +60,7 @@ void reissue() throws Exception { String newRefreshToken = "newRefreshToken"; TokenPair expectedTokenPair = new TokenPair("newAccessToken", newRefreshToken); given(jwtProvider.parseToken(any(String.class))) - .willReturn(new JwtClaim(givenUserId)); + .willReturn(new JwtClaim(givenUserId, Role.USER)); given(jwtProvider.createToken(any(JwtClaim.class))) .willReturn(expectedTokenPair); refreshTokenRepository.save(new RefreshToken(givenUserId, givenRefreshToken)); @@ -79,7 +80,7 @@ void reissue() throws Exception { void reissue_refreshTokenNotFound() throws Exception { //given given(jwtProvider.parseToken(any(String.class))) - .willReturn(new JwtClaim(1L)); + .willReturn(new JwtClaim(1L, Role.USER)); //when assertThatThrownBy(() -> jwtService.reissue("refreshToken")) @@ -94,7 +95,7 @@ void reissue_refreshTokenMismatched() throws Exception { long givenUserId = 1L; String givenRefreshToken = "refreshToken"; given(jwtProvider.parseToken(any(String.class))) - .willReturn(new JwtClaim(givenUserId)); + .willReturn(new JwtClaim(givenUserId, Role.USER)); given(jwtProvider.createToken(any(JwtClaim.class))) .willReturn(new TokenPair("accessToken", "newRefreshToken")); refreshTokenRepository.save(new RefreshToken(givenUserId, givenRefreshToken)); diff --git a/src/test/java/com/swyp8team2/auth/presentation/AuthControllerTest.java b/src/test/java/com/swyp8team2/auth/presentation/AuthControllerTest.java index 60390dfb..cee695b9 100644 --- a/src/test/java/com/swyp8team2/auth/presentation/AuthControllerTest.java +++ b/src/test/java/com/swyp8team2/auth/presentation/AuthControllerTest.java @@ -2,7 +2,6 @@ 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; @@ -12,6 +11,7 @@ import com.swyp8team2.common.presentation.CustomHeader; import com.swyp8team2.support.RestDocsTest; import com.swyp8team2.support.WithMockUserInfo; +import com.swyp8team2.user.domain.Role; import jakarta.servlet.http.Cookie; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -20,9 +20,9 @@ import org.springframework.http.MediaType; import org.springframework.security.test.context.support.WithAnonymousUser; +import static org.mockito.ArgumentMatchers.any; 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; @@ -47,9 +47,9 @@ class AuthControllerTest extends RestDocsTest { void kakaoOAuthSignIn() throws Exception { //given TokenPair expectedTokenPair = new TokenPair("accessToken", "refreshToken"); - AuthResponse response = new AuthResponse(expectedTokenPair.accessToken(), 1L); + AuthResponse response = new AuthResponse(expectedTokenPair.accessToken(), 1L, Role.USER); given(authService.oauthSignIn(anyString(), anyString())) - .willReturn(new TokenResponse(expectedTokenPair, 1L)); + .willReturn(new TokenResponse(expectedTokenPair, 1L, Role.USER)); OAuthSignInRequest request = new OAuthSignInRequest("code", "https://dev.photopic.site"); //when then @@ -71,7 +71,46 @@ void kakaoOAuthSignIn() throws Exception { ), responseFields( fieldWithPath("accessToken").description("액세스 토큰"), - fieldWithPath("userId").description("유저 Id") + fieldWithPath("userId").description("유저 Id"), + fieldWithPath("role").description("유저 권한") + ), + responseCookies( + cookieWithName(CustomHeader.CustomCookie.REFRESH_TOKEN).description("리프레시 토큰") + ) + )); + } + + @Test + @DisplayName("게스트 로그인") + void guestSignIn() throws Exception { + //given + TokenPair expectedTokenPair = new TokenPair("accessToken", "refreshToken"); + AuthResponse response = new AuthResponse(expectedTokenPair.accessToken(), 1L, Role.USER); + given(authService.guestSignIn(any())) + .willReturn(new TokenResponse(expectedTokenPair, 1L, Role.USER)); + + //when then + mockMvc.perform(post("/auth/guest/sign-in") + .contentType(MediaType.APPLICATION_JSON) + .cookie(new Cookie(CustomHeader.CustomCookie.REFRESH_TOKEN, "refreshToken"))) + .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( + requestCookies( + cookieWithName(CustomHeader.CustomCookie.REFRESH_TOKEN) + .optional() + .description("리프레시 토큰") + ), + responseFields( + fieldWithPath("accessToken").description("액세스 토큰"), + fieldWithPath("userId").description("유저 Id"), + fieldWithPath("role").description("유저 권한") ), responseCookies( cookieWithName(CustomHeader.CustomCookie.REFRESH_TOKEN).description("리프레시 토큰") @@ -87,8 +126,8 @@ void reissue() throws Exception { 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); + .willReturn(new TokenResponse(tokenPair, 1L, Role.USER)); + AuthResponse response = new AuthResponse(tokenPair.accessToken(), 1L, Role.USER); //when then mockMvc.perform(post("/auth/reissue") @@ -110,7 +149,8 @@ void reissue() throws Exception { ), responseFields( fieldWithPath("accessToken").description("새 액세스 토큰"), - fieldWithPath("userId").description("유저 Id") + fieldWithPath("userId").description("유저 Id"), + fieldWithPath("role").description("유저 권한") ) )); } @@ -157,25 +197,6 @@ void reissue_refreshTokenMismatched() throws Exception { .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("로그아웃") @@ -202,4 +223,19 @@ void signOut() throws Exception { ) )); } + + @Test + @WithMockUserInfo + @DisplayName("회원탈퇴") + void withdraw() throws Exception { + //given + + //when then + mockMvc.perform(post("/auth/withdraw") + .header(HttpHeaders.AUTHORIZATION, "Bearer accessToken")) + .andExpect(status().isOk()) + .andDo(restDocs.document( + requestHeaders(authorizationHeader()) + )); + } } diff --git a/src/test/java/com/swyp8team2/comment/application/CommentServiceTest.java b/src/test/java/com/swyp8team2/comment/application/CommentServiceTest.java index 65ca8fbe..4649e88e 100644 --- a/src/test/java/com/swyp8team2/comment/application/CommentServiceTest.java +++ b/src/test/java/com/swyp8team2/comment/application/CommentServiceTest.java @@ -4,13 +4,15 @@ 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.comment.presentation.dto.CommentRequest; import com.swyp8team2.common.dto.CursorBasePaginatedResponse; import com.swyp8team2.common.exception.BadRequestException; import com.swyp8team2.common.exception.ErrorCode; +import com.swyp8team2.common.exception.ForbiddenException; import com.swyp8team2.user.domain.Role; 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; @@ -24,9 +26,10 @@ import java.util.List; import java.util.Optional; +import static java.util.Optional.empty; 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.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.eq; @@ -52,7 +55,7 @@ class CommentServiceTest { void createComment() { // given Long postId = 1L; - CreateCommentRequest request = new CreateCommentRequest("테스트 댓글"); + CommentRequest request = new CommentRequest("테스트 댓글"); UserInfo userInfo = new UserInfo(100L, Role.USER); Comment comment = new Comment(postId, userInfo.userId(), request.content()); @@ -75,10 +78,14 @@ void findComments() { 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); + List votes = List.of( + Vote.of(1L, 100L, 1L), + Vote.of(1L, 101L, 1L) + ); // Mock 설정 given(commentRepository.findByPostId(eq(postId), eq(cursor), any(PageRequest.class))).willReturn(commentSlice); - given(voteRepository.findByUserIdAndPostId(eq(user.getId()), eq(postId))).willReturn(Optional.empty()); + given(voteRepository.findByUserIdAndPostId(eq(user.getId()), eq(postId))).willReturn(votes); // 각 댓글마다 user_no=100L 이므로, findById(100L)만 호출됨 given(userRepository.findById(100L)).willReturn(Optional.of(user)); @@ -115,11 +122,111 @@ void findComments_userNotFound() { ); given(commentRepository.findByPostId(eq(postId), eq(cursor), any(PageRequest.class))).willReturn(commentSlice); - given(userRepository.findById(100L)).willReturn(Optional.empty()); + given(userRepository.findById(100L)).willReturn(empty()); // when & then assertThatThrownBy(() -> commentService.findComments(1L, postId, cursor, size)) .isInstanceOf(BadRequestException.class) .hasMessage((ErrorCode.USER_NOT_FOUND.getMessage())); } + + @Test + @DisplayName("댓글 수정") + void updateComment() { + // given + Long postId = 1L; + UserInfo userInfo = new UserInfo(100L, Role.USER); + Comment comment = new Comment(1L, postId, 100L, "첫 번째 댓글"); + CommentRequest request = new CommentRequest("수정 댓글"); + when(commentRepository.findByIdAndNotDeleted(1L)).thenReturn(Optional.of(comment)); + + // when + commentService.updateComment(1L, request, userInfo); + + // then + assertAll( + () -> assertThat(comment.getId()).isEqualTo(1L), + () -> assertThat(comment.getContent()).isEqualTo("수정 댓글") + ); + } + + @Test + @DisplayName("댓글 수정 - 존재하지 않은 댓글") + void updateComment_commentNotFound() { + // given + CommentRequest request = new CommentRequest("수정 댓글"); + UserInfo userInfo = new UserInfo(100L, Role.USER); + when(commentRepository.findByIdAndNotDeleted(1L)).thenReturn(Optional.empty()); + + // when then + assertThatThrownBy(() -> commentService.updateComment(1L, request, userInfo)) + .isInstanceOf(BadRequestException.class) + .hasMessage((ErrorCode.COMMENT_NOT_FOUND.getMessage())); + } + + @Test + @DisplayName("댓글 수정 - 권한 없는 사용자") + void updateComment_forbiddenException() { + // given + Long postId = 1L; + UserInfo userInfo = new UserInfo(100L, Role.USER); + Comment comment = new Comment(1L, postId, 110L, "첫 번째 댓글"); + CommentRequest request = new CommentRequest("수정 댓글"); + when(commentRepository.findByIdAndNotDeleted(1L)).thenReturn(Optional.of(comment)); + + // when then + assertAll( + () -> assertThatThrownBy(() -> commentService.updateComment(1L, request, userInfo)) + .isInstanceOf(ForbiddenException.class), + () -> assertThat(comment.getContent()).isEqualTo("첫 번째 댓글") + ); + } + + @Test + @DisplayName("댓글 삭제") + void deleteComment() { + // given + Long postId = 1L; + UserInfo userInfo = new UserInfo(100L, Role.USER); + Comment comment = new Comment(1L, postId, 100L, "첫 번째 댓글"); + when(commentRepository.findByIdAndNotDeleted(1L)).thenReturn(Optional.of(comment)); + + // when + commentService.deleteComment(1L, userInfo); + + // then + assertAll( + () -> assertTrue(comment.isDeleted()), + () -> assertNotNull(comment.getDeletedAt()) + ); + } + + @Test + @DisplayName("댓글 삭제 - 존재하지 않는 댓글") + void deleteComment_commentNotFound() { + // given + UserInfo userInfo = new UserInfo(100L, Role.USER); + when(commentRepository.findByIdAndNotDeleted(1L)).thenReturn(Optional.empty()); + + // when then + assertThatThrownBy(() -> commentService.deleteComment(1L, userInfo)) + .isInstanceOf(BadRequestException.class) + .hasMessage((ErrorCode.COMMENT_NOT_FOUND.getMessage())); + } + + @Test + @DisplayName("댓글 삭제 - 권한 없는 사용자") + void deleteComment_forbiddenException() { + // given + Long postId = 1L; + UserInfo userInfo = new UserInfo(100L, Role.USER); + Comment comment = new Comment(1L, postId, 110L, "첫 번째 댓글"); + when(commentRepository.findByIdAndNotDeleted(1L)).thenReturn(Optional.of(comment)); + + // when then + assertThatThrownBy(() -> commentService.deleteComment(1L, userInfo)) + .isInstanceOf(ForbiddenException.class); + assertFalse(comment.isDeleted()); + assertNull(comment.getDeletedAt()); + } } diff --git a/src/test/java/com/swyp8team2/comment/domain/CommentRepositoryTest.java b/src/test/java/com/swyp8team2/comment/domain/CommentRepositoryTest.java index aaa6f9ca..d4e3a366 100644 --- a/src/test/java/com/swyp8team2/comment/domain/CommentRepositoryTest.java +++ b/src/test/java/com/swyp8team2/comment/domain/CommentRepositoryTest.java @@ -30,11 +30,20 @@ void select_CommentUser() { // then assertThat(result1.getContent()).hasSize(3); + } + + @Test + @DisplayName("댓글 조회 - 단일 조회") + void select_CommentById() { + // given + Comment comment = new Comment(1L, 100L, "content"); + commentRepository.save(comment); - // when2 - Slice result2 = commentRepository.findByPostId(1L, 1L, PageRequest.of(0, 10)); + // when + Comment selectComment = commentRepository.findByIdAndNotDeleted(1L) + .orElse(new Comment(2L, 2L, 101L, "content")); - // then2 - assertThat(result2.getContent()).hasSize(2); + // then + assertThat(comment.getId()).isEqualTo(selectComment.getId()); } } \ 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 index da30223a..0eda57c8 100644 --- a/src/test/java/com/swyp8team2/comment/presentation/CommentControllerTest.java +++ b/src/test/java/com/swyp8team2/comment/presentation/CommentControllerTest.java @@ -3,7 +3,7 @@ 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.comment.presentation.dto.CommentRequest; import com.swyp8team2.common.dto.CursorBasePaginatedResponse; import com.swyp8team2.support.RestDocsTest; import com.swyp8team2.support.WithMockUserInfo; @@ -36,9 +36,9 @@ class CommentControllerTest extends RestDocsTest { void createComment() throws Exception { //given Long postId = 1L; - CreateCommentRequest request = new CreateCommentRequest("content"); + CommentRequest request = new CommentRequest("content"); - doNothing().when(commentService).createComment(eq(postId), any(CreateCommentRequest.class), any(UserInfo.class)); + doNothing().when(commentService).createComment(eq(postId), any(CommentRequest.class), any(UserInfo.class)); //when then mockMvc.perform(post("/posts/{postId}/comments", "1") @@ -56,7 +56,7 @@ void createComment() throws Exception { ) )); - verify(commentService, times(1)).createComment(eq(postId), any(CreateCommentRequest.class), any(UserInfo.class)); + verify(commentService, times(1)).createComment(eq(postId), any(CommentRequest.class), any(UserInfo.class)); } @Test @@ -71,7 +71,7 @@ void findComments() throws Exception { 1L, "댓글 내용", new AuthorDto(100L, "닉네임", "http://example.com/profile.png"), - null, + List.of(1L, 2L), LocalDateTime.now(), false ); @@ -120,8 +120,8 @@ void findComments() throws Exception { fieldWithPath("data[].author.profileUrl") .type(JsonFieldType.STRING) .description("작성자 프로필 이미지 url"), - fieldWithPath("data[].voteImageId") - .type(JsonFieldType.NUMBER) + fieldWithPath("data[].voteImageId[]") + .type(JsonFieldType.ARRAY) .optional() .description("작성자가 투표한 이미지 Id (투표 없을 시 null)"), fieldWithPath("data[].createdAt") @@ -136,11 +136,41 @@ void findComments() throws Exception { verify(commentService, times(1)).findComments(eq(null), eq(postId), eq(cursor), eq(size)); } + @Test + @WithMockUserInfo + @DisplayName("댓글 수정") + void updateComment() throws Exception { + //given + CommentRequest request = new CommentRequest("수정 댓글"); + + //when then + mockMvc.perform(post("/posts/{postId}/comments/{commentId}", "1", "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"), + parameterWithName("commentId").description("댓글 Id") + ), + requestFields( + fieldWithPath("content") + .type(JsonFieldType.STRING) + .description("댓글 내용") + .attributes(constraints("최대 ?글자")) + ) + )); + } + @Test @WithMockUserInfo @DisplayName("댓글 삭제") void deleteComment() throws Exception { //given + Long commentId = 1L; + doNothing().when(commentService).deleteComment(eq(commentId), any(UserInfo.class)); //when then mockMvc.perform(delete("/posts/{postId}/comments/{commentId}", "1", "1") @@ -153,5 +183,7 @@ void deleteComment() throws Exception { parameterWithName("commentId").description("댓글 Id") ) )); + + verify(commentService, times(1)).deleteComment(eq(commentId), any(UserInfo.class)); } } diff --git a/src/test/java/com/swyp8team2/image/util/FileValidatorTest.java b/src/test/java/com/swyp8team2/image/util/FileValidatorTest.java index 14fb3a6f..17aecc86 100644 --- a/src/test/java/com/swyp8team2/image/util/FileValidatorTest.java +++ b/src/test/java/com/swyp8team2/image/util/FileValidatorTest.java @@ -20,13 +20,13 @@ class FileValidatorTest { @BeforeEach void setUp() { - String allowedExtensions = "gif,jpg,jpeg,png"; + String allowedExtensions = "gif,jpg,jpeg,png,webp,heic,heif"; fileValidator = new FileValidator(allowedExtensions); } @Test @DisplayName("파일 유효성 체크 - 파일 크기 초과") - void validate_validFile_shouldPass() { + void validate_exceedMaxFileSize() { // given byte[] largeContent = new byte[(int) (MAX_FILE_SIZE + 1)]; MockMultipartFile file = new MockMultipartFile( @@ -44,7 +44,7 @@ void validate_validFile_shouldPass() { @Test @DisplayName("파일 유효성 체크 - 지원하지 않는 확장자") - void validate_unsupportedExtension_shouldThrowException() { + void validate_unsupportedFileExtension() { // given MockMultipartFile file = new MockMultipartFile( "file", @@ -59,14 +59,32 @@ void validate_unsupportedExtension_shouldThrowException() { .hasMessage(ErrorCode.UNSUPPORTED_FILE_EXTENSION.getMessage()); } + @Test + @DisplayName("파일 유효성 체크 - 파일명 너무 김") + void validate_fileNameTooLong() { + // given + String filename = new String(new char[101])+".jpeg"; + MockMultipartFile file = new MockMultipartFile( + "file", + filename, + "", + "dummy content".getBytes(StandardCharsets.UTF_8) + ); + + // when then + assertThatThrownBy(() -> fileValidator.validate(file)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.FILE_NAME_TOO_LONG.getMessage()); + } + @Test @DisplayName("파일 유효성 체크 - 확장자 누락") - void validate_missingFileExtension_shouldThrowException() { + void validate_missingFileExtension() { // given MockMultipartFile file = new MockMultipartFile( "file", "test", - "", + "text/plain", "dummy content".getBytes(StandardCharsets.UTF_8) ); @@ -78,7 +96,7 @@ void validate_missingFileExtension_shouldThrowException() { @Test @DisplayName("파일 유효성 체크 - 여러 파일 중 하나가 유효성 실패") - void validate_multipleFiles_oneInvalid_shouldThrowException() { + void validate_multipleFilesOneInvalid() { // given MockMultipartFile file1 = new MockMultipartFile( "file", diff --git a/src/test/java/com/swyp8team2/post/application/PostServiceTest.java b/src/test/java/com/swyp8team2/post/application/PostCommandServiceTest.java similarity index 50% rename from src/test/java/com/swyp8team2/post/application/PostServiceTest.java rename to src/test/java/com/swyp8team2/post/application/PostCommandServiceTest.java index b85b73ee..3e523419 100644 --- a/src/test/java/com/swyp8team2/post/application/PostServiceTest.java +++ b/src/test/java/com/swyp8team2/post/application/PostCommandServiceTest.java @@ -1,26 +1,16 @@ 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.domain.*; 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; @@ -34,11 +24,11 @@ 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.junit.jupiter.api.Assertions.assertAll; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; -class PostServiceTest extends IntegrationTest { +public class PostCommandServiceTest extends IntegrationTest { @Autowired PostService postService; @@ -52,27 +42,25 @@ class PostServiceTest extends IntegrationTest { @Autowired ImageFileRepository imageFileRepository; - @Autowired - VoteRepository voteRepository; - - @Autowired - VoteService voteService; - @MockitoBean - @ShareUrlCryptoService - CryptoService shareUrlCryptoService; + ShareUrlService shareUrlShareUrlService; @Test @DisplayName("게시글 작성") void create() throws Exception { //given long userId = 1L; - CreatePostRequest request = new CreatePostRequest("description", List.of( - new PostImageRequestDto(1L), - new PostImageRequestDto(2L) - )); + CreatePostRequest request = new CreatePostRequest( + "description", + List.of( + new PostImageRequestDto(1L), + new PostImageRequestDto(2L) + ), + Scope.PRIVATE, + VoteType.SINGLE + ); String shareUrl = "shareUrl"; - given(shareUrlCryptoService.encrypt(any())) + given(shareUrlShareUrlService.encrypt(any())) .willReturn(shareUrl); //when @@ -85,6 +73,8 @@ void create() throws Exception { () -> assertThat(post.getDescription()).isEqualTo("description"), () -> assertThat(post.getUserId()).isEqualTo(userId), () -> assertThat(post.getShareUrl()).isEqualTo(shareUrl), + () -> assertThat(post.getStatus()).isEqualTo(Status.PROGRESS), + () -> assertThat(post.getVoteType()).isEqualTo(VoteType.SINGLE), () -> assertThat(images).hasSize(2), () -> assertThat(images.get(0).getImageFileId()).isEqualTo(1L), () -> assertThat(images.get(0).getName()).isEqualTo("뽀또A"), @@ -100,10 +90,14 @@ void create() throws Exception { void create_invalidPostImageCount() throws Exception { //given long userId = 1L; - CreatePostRequest request = new CreatePostRequest("description", List.of( - new PostImageRequestDto(1L) - )); - + CreatePostRequest request = new CreatePostRequest( + "description", + List.of( + new PostImageRequestDto(1L) + ), + Scope.PRIVATE, + VoteType.SINGLE + ); //when then assertThatThrownBy(() -> postService.create(userId, request)) .isInstanceOf(BadRequestException.class) @@ -115,10 +109,15 @@ void create_invalidPostImageCount() throws Exception { void create_descriptionCountExceeded() throws Exception { //given long userId = 1L; - CreatePostRequest request = new CreatePostRequest("a".repeat(101), List.of( - new PostImageRequestDto(1L), - new PostImageRequestDto(2L) - )); + CreatePostRequest request = new CreatePostRequest( + "a".repeat(101), + List.of( + new PostImageRequestDto(1L), + new PostImageRequestDto(2L) + ), + Scope.PRIVATE, + VoteType.SINGLE + ); //when then assertThatThrownBy(() -> postService.create(userId, request)) @@ -126,133 +125,6 @@ void create_descriptionCountExceeded() throws Exception { .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 { @@ -260,7 +132,7 @@ void close() throws Exception { 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 post = postRepository.save(createPost(user.getId(), Scope.PRIVATE, imageFile1, imageFile2, 1)); //when post.close(user.getId()); @@ -277,7 +149,7 @@ void close_notPostAuthor() throws Exception { 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 post = postRepository.save(createPost(user.getId(), Scope.PRIVATE, imageFile1, imageFile2, 1)); //when then assertThatThrownBy(() -> post.close(2L)) @@ -292,7 +164,7 @@ void close_alreadyClosed() throws Exception { 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 post = postRepository.save(createPost(user.getId(), Scope.PRIVATE, imageFile1, imageFile2, 1)); post.close(user.getId()); //when then @@ -319,7 +191,7 @@ void delete() throws Exception { 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 post = postRepository.save(createPost(user.getId(), Scope.PRIVATE, imageFile1, imageFile2, 1)); //when postService.delete(user.getId(), post.getId()); @@ -327,4 +199,15 @@ void delete() throws Exception { //then assertThat(postRepository.findById(post.getId())).isEmpty(); } + + 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(), Scope.PRIVATE, imageFile1, imageFile2, i))); + } + return posts; + } + } diff --git a/src/test/java/com/swyp8team2/post/application/PostQueryServiceTest.java b/src/test/java/com/swyp8team2/post/application/PostQueryServiceTest.java new file mode 100644 index 00000000..81569469 --- /dev/null +++ b/src/test/java/com/swyp8team2/post/application/PostQueryServiceTest.java @@ -0,0 +1,197 @@ +package com.swyp8team2.post.application; + +import com.swyp8team2.comment.domain.Comment; +import com.swyp8team2.comment.domain.CommentRepository; +import com.swyp8team2.common.dto.CursorBasePaginatedResponse; +import com.swyp8team2.image.domain.ImageFile; +import com.swyp8team2.image.domain.ImageFileRepository; +import com.swyp8team2.post.domain.Post; +import com.swyp8team2.post.domain.PostRepository; +import com.swyp8team2.post.domain.Scope; +import com.swyp8team2.post.presentation.dto.FeedResponse; +import com.swyp8team2.post.presentation.dto.PostImageResponse; +import com.swyp8team2.post.presentation.dto.PostResponse; +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.ArrayList; +import java.util.List; + +import static com.swyp8team2.support.fixture.FixtureGenerator.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +class PostQueryServiceTest extends IntegrationTest { + + @Autowired + PostService postService; + + @Autowired + PostRepository postRepository; + + @Autowired + UserRepository userRepository; + + @Autowired + ImageFileRepository imageFileRepository; + + @Autowired + VoteRepository voteRepository; + + @Autowired + CommentRepository commentRepository; + + @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(), Scope.PRIVATE, 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).voteId()).isNull(), + () -> assertThat(votes.get(1).imageUrl()).isEqualTo(imageFile2.getImageUrl()), + () -> assertThat(votes.get(1).voteId()).isNull() + ); + } + + @Test + @DisplayName("유저가 작성한 게시글 조회 - 커서 null인 경우") + void findUserPosts() throws Exception { + //given + User user = userRepository.save(createUser(1)); + List posts = createPosts(user, Scope.PRIVATE); + 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, Scope.PRIVATE); + 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, Scope scope) { + 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(), scope, imageFile1, imageFile2, i))); + } + return posts; + } + + @Test + @DisplayName("유저가 투표한 게시글 조회 - 커서 null인 경우") + void findVotedPosts() throws Exception { + //given + User user = userRepository.save(createUser(1)); + List posts = createPosts(user, Scope.PRIVATE); + 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("피드 조회 - 내 게시글 1개, 공개 게시글 15개, 투표 10개, 댓글 20개") + void findFeed() throws Exception { + //given + int size = 20; + User user1 = userRepository.save(createUser(1)); + User user2 = userRepository.save(createUser(2)); + ImageFile imageFile1 = imageFileRepository.save(createImageFile(1)); + ImageFile imageFile2 = imageFileRepository.save(createImageFile(2)); + + List publicPosts = createPosts(user2, Scope.PUBLIC); + List privatePosts = createPosts(user2, Scope.PRIVATE); + Post myPost = postRepository.save(createPost(user1.getId(), Scope.PUBLIC, imageFile1, imageFile2, 1)); + + createVotes(user1, publicPosts.getFirst()); + createComments(user1, publicPosts.getFirst()); + + List publicPostVotes = voteRepository.findByPostIdAndDeletedFalse(publicPosts.getFirst().getId()); + List publicPostComments = commentRepository.findByPostIdAndDeletedFalse(publicPosts.getFirst().getId()); + + //when + CursorBasePaginatedResponse response = postService.findFeed(user1.getId(), null, size); + + //then + assertAll( + () -> assertThat(response.data().size()).isEqualTo(16), + () -> assertThat(response.data().getLast().participantCount()).isEqualTo(publicPostVotes.size()), + () -> assertThat(response.data().getLast().commentCount()).isEqualTo(publicPostComments.size()), + () -> assertThat(response.data().getLast().isAuthor()).isFalse(), + () -> assertThat(response.data().getFirst().isAuthor()).isTrue() + ); + } + + private void createVotes(User user, Post post) { + for (int i = 0; i < 5; i++) { + ImageFile imageFile1 = imageFileRepository.save(createImageFile(1)); + ImageFile imageFile2 = imageFileRepository.save(createImageFile(2)); + voteRepository.save(createVote(user.getId(), post.getId(), imageFile1.getId())); + voteRepository.save(createVote(user.getId(), post.getId(), imageFile2.getId())); + } + } + + private void createComments(User user, Post post) { + for (int i = 0; i < 20; i++) { + commentRepository.save(createComment(user.getId(), post.getId())); + } + } +} diff --git a/src/test/java/com/swyp8team2/crypto/application/CryptoServiceTest.java b/src/test/java/com/swyp8team2/post/application/ShareUrlServiceTest.java similarity index 64% rename from src/test/java/com/swyp8team2/crypto/application/CryptoServiceTest.java rename to src/test/java/com/swyp8team2/post/application/ShareUrlServiceTest.java index 89b9fd1e..64549dfc 100644 --- a/src/test/java/com/swyp8team2/crypto/application/CryptoServiceTest.java +++ b/src/test/java/com/swyp8team2/post/application/ShareUrlServiceTest.java @@ -1,23 +1,20 @@ -package com.swyp8team2.crypto.application; +package com.swyp8team2.post.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 { +class ShareUrlServiceTest { - CryptoService cryptoService; + ShareUrlService shareUrlService; @BeforeEach void setUp() throws Exception { - cryptoService = new CryptoService(new AesBytesEncryptor("asdfd", "1541235432")); + shareUrlService = new ShareUrlService("asdfd", "1541235432"); } @Test @@ -27,9 +24,9 @@ void encryptAndDecrypt() { String plainText = "15411"; // when - String encryptedText = cryptoService.encrypt(plainText); + String encryptedText = shareUrlService.encrypt(plainText); System.out.println("encryptedText = " + encryptedText); - String decryptedText = cryptoService.decrypt(encryptedText); + String decryptedText = shareUrlService.decrypt(encryptedText); // then assertThat(decryptedText).isEqualTo(plainText); @@ -40,11 +37,11 @@ void encryptAndDecrypt() { void encryptAndDecrypt_differentKey() throws Exception { // given String plainText = "Hello, World!"; - CryptoService differentCryptoService = new CryptoService(new AesBytesEncryptor("different", "234562")); - String encryptedText = differentCryptoService.encrypt(plainText); + ShareUrlService differentShareUrlService = new ShareUrlService("different", "234562"); + String encryptedText = differentShareUrlService.encrypt(plainText); // when then - assertThatThrownBy(() -> cryptoService.decrypt(encryptedText)) + assertThatThrownBy(() -> shareUrlService.decrypt(encryptedText)) .isInstanceOf(BadRequestException.class) .hasMessage(ErrorCode.INVALID_TOKEN.getMessage()); } @@ -56,7 +53,7 @@ void decrypt_invalidToken() { String invalid = "invalidToken"; // when then - assertThatThrownBy(() -> cryptoService.decrypt(invalid)) + assertThatThrownBy(() -> shareUrlService.decrypt(invalid)) .isInstanceOf(BadRequestException.class) .hasMessage(ErrorCode.INVALID_TOKEN.getMessage()); } @@ -68,7 +65,7 @@ void decrypt_emptyString() { String invalid = ""; // when then - assertThatThrownBy(() -> cryptoService.decrypt(invalid)) + assertThatThrownBy(() -> shareUrlService.decrypt(invalid)) .isInstanceOf(BadRequestException.class) .hasMessage(ErrorCode.INVALID_TOKEN.getMessage()); } diff --git a/src/test/java/com/swyp8team2/post/domain/PostRepositoryTest.java b/src/test/java/com/swyp8team2/post/domain/PostRepositoryTest.java index e5b5157b..6e05019c 100644 --- a/src/test/java/com/swyp8team2/post/domain/PostRepositoryTest.java +++ b/src/test/java/com/swyp8team2/post/domain/PostRepositoryTest.java @@ -1,7 +1,10 @@ package com.swyp8team2.post.domain; import com.swyp8team2.image.domain.ImageFile; +import com.swyp8team2.post.presentation.dto.FeedDto; import com.swyp8team2.support.RepositoryTest; +import com.swyp8team2.user.domain.User; +import com.swyp8team2.user.domain.UserRepository; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -11,8 +14,7 @@ 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.*; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.*; @@ -21,12 +23,15 @@ class PostRepositoryTest extends RepositoryTest { @Autowired PostRepository postRepository; + @Autowired + UserRepository userRepository; + @Test @DisplayName("유저가 작성한 게시글 조회 - 게시글이 15개일 경우 15번쨰부터 10개 조회해야 함") void select_post_findByUserId1() throws Exception { //given long userId = 1L; - List posts = createPosts(userId); + List posts = createPosts(userId, Scope.PRIVATE); int size = 10; //when @@ -47,7 +52,7 @@ void select_post_findByUserId1() throws Exception { void select_post_findByUserId2() throws Exception { //given long userId = 1L; - List posts = createPosts(userId); + List posts = createPosts(userId, Scope.PRIVATE); int size = 10; int cursorIndex = 5; @@ -68,7 +73,7 @@ void select_post_findByUserId2() throws Exception { @DisplayName("id 리스트에 포함되는 게시글 조회") void select_post_findByIdIn() throws Exception { //given - List posts = createPosts(1L); + List posts = createPosts(1L, Scope.PRIVATE); List postIds = List.of(posts.get(0).getId(), posts.get(1).getId(), posts.get(2).getId()); //when @@ -84,13 +89,34 @@ void select_post_findByIdIn() throws Exception { ); } - private List createPosts(long userId) { + private List createPosts(long userId, Scope scope) { 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))); + posts.add(postRepository.save(createPost(userId, scope, imageFile1, imageFile2, i))); } return posts; } + + @Test + @DisplayName("피드 조회") + void select_post_findByScopeAndDeletedFalse() { + //given + User user1 = userRepository.save(createUser(1)); + User user2 = userRepository.save(createUser(2)); + List myPosts = createPosts(user1.getId(), Scope.PRIVATE); + List privatePosts = createPosts(user2.getId(), Scope.PRIVATE); + List publicPosts = createPosts(user2.getId(), Scope.PUBLIC); + int size = 10; + + //when + Slice res = postRepository.findFeedByScopeWithUser(1L, null, PageRequest.ofSize(size)); + + //then + assertAll( + () -> assertThat(res.getContent().size()).isEqualTo(size), + () -> assertThat(res.hasNext()).isTrue() + ); + } } diff --git a/src/test/java/com/swyp8team2/post/domain/PostTest.java b/src/test/java/com/swyp8team2/post/domain/PostTest.java index a41a2609..4ebdb10d 100644 --- a/src/test/java/com/swyp8team2/post/domain/PostTest.java +++ b/src/test/java/com/swyp8team2/post/domain/PostTest.java @@ -25,7 +25,7 @@ void create() throws Exception { ); //when - Post post = Post.create(userId, description, postImages); + Post post = Post.create(userId, description, postImages, Scope.PRIVATE, VoteType.SINGLE); //then List images = post.getImages(); @@ -52,7 +52,7 @@ void create_invalidPostImageCount() throws Exception { ); //when then - assertThatThrownBy(() -> Post.create(1L, "description", postImages)) + assertThatThrownBy(() -> Post.create(1L, "description", postImages, Scope.PRIVATE, VoteType.SINGLE)) .isInstanceOf(BadRequestException.class) .hasMessage(ErrorCode.INVALID_POST_IMAGE_COUNT.getMessage()); } @@ -68,7 +68,7 @@ void create_descriptionCountExceeded() throws Exception { ); //when then - assertThatThrownBy(() -> Post.create(1L, description, postImages)) + assertThatThrownBy(() -> Post.create(1L, description, postImages, Scope.PRIVATE, VoteType.SINGLE)) .isInstanceOf(BadRequestException.class) .hasMessage(ErrorCode.DESCRIPTION_LENGTH_EXCEEDED.getMessage()); } @@ -82,7 +82,7 @@ void close() throws Exception { PostImage.create("뽀또A", 1L), PostImage.create("뽀또B", 2L) ); - Post post = new Post(null, userId, "description", Status.PROGRESS, postImages, "shareUrl"); + Post post = new Post(null, userId, "description", Status.PROGRESS, Scope.PRIVATE, postImages, "shareUrl", VoteType.SINGLE); //when post.close(userId); @@ -100,7 +100,7 @@ void close_alreadyClosed() throws Exception { PostImage.create("뽀또A", 1L), PostImage.create("뽀또B", 2L) ); - Post post = new Post(null, userId, "description", Status.CLOSED, postImages, "shareUrl"); + Post post = new Post(null, userId, "description", Status.CLOSED, Scope.PRIVATE, postImages, "shareUrl", VoteType.SINGLE); //when then assertThatThrownBy(() -> post.close(userId)) @@ -117,11 +117,89 @@ void close_notPostAuthor() throws Exception { PostImage.create("뽀또A", 1L), PostImage.create("뽀또B", 2L) ); - Post post = new Post(null, userId, "description", Status.PROGRESS, postImages, "shareUrl"); + Post post = new Post(null, userId, "description", Status.PROGRESS, Scope.PRIVATE, postImages, "shareUrl", VoteType.SINGLE); //when then assertThatThrownBy(() -> post.close(2L)) .isInstanceOf(BadRequestException.class) .hasMessage(ErrorCode.NOT_POST_AUTHOR.getMessage()); } + + @Test + @DisplayName("게시글 공개 범위 수정") + void toggleScope() 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, Scope.PRIVATE, postImages, "shareUrl", VoteType.SINGLE); + + //when then + post.toggleScope(userId); + assertThat(post.getScope()).isEqualTo(Scope.PUBLIC); + + //when then + post.toggleScope(userId); + assertThat(post.getScope()).isEqualTo(Scope.PRIVATE); + } + + @Test + @DisplayName("게시글 공개 범위 수정 - 게시글 작성자가 아닌 경우") + void toggleScope_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, Scope.PRIVATE, postImages, "shareUrl", VoteType.SINGLE); + + //when then + assertThatThrownBy(() -> post.toggleScope(2L)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.NOT_POST_AUTHOR.getMessage()); + } + + @Test + @DisplayName("게시글 베스트 픽 조회") + void getBestPickedImage() 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, Scope.PRIVATE, postImages, "shareUrl", VoteType.SINGLE); + post.getImages().get(0).increaseVoteCount(); + post.getImages().get(0).increaseVoteCount(); + post.getImages().get(1).increaseVoteCount(); + + //when + PostImage bestPickedImage = post.getBestPickedImage(); + + //then + assertThat(bestPickedImage.getName()).isEqualTo("뽀또A"); + } + + @Test + @DisplayName("게시글 베스트 픽 조회 - 동일 투표수인 경우 첫 번째 이미지가 선택됨") + void getBestPickedImage_saveVoteCount() 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, Scope.PRIVATE, postImages, "shareUrl", VoteType.SINGLE); + post.getImages().get(0).increaseVoteCount(); + post.getImages().get(1).increaseVoteCount(); + + //when + PostImage bestPickedImage = post.getBestPickedImage(); + + //then + assertThat(bestPickedImage.getName()).isEqualTo("뽀또A"); + } } diff --git a/src/test/java/com/swyp8team2/post/presentation/PostControllerTest.java b/src/test/java/com/swyp8team2/post/presentation/PostControllerTest.java index befad202..bef91803 100644 --- a/src/test/java/com/swyp8team2/post/presentation/PostControllerTest.java +++ b/src/test/java/com/swyp8team2/post/presentation/PostControllerTest.java @@ -1,15 +1,10 @@ package com.swyp8team2.post.presentation; import com.swyp8team2.common.dto.CursorBasePaginatedResponse; +import com.swyp8team2.post.domain.Scope; 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.post.domain.VoteType; +import com.swyp8team2.post.presentation.dto.*; import com.swyp8team2.support.RestDocsTest; import com.swyp8team2.support.WithMockUserInfo; import org.junit.jupiter.api.DisplayName; @@ -24,11 +19,10 @@ import java.util.List; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.*; 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; @@ -48,7 +42,9 @@ void createPost() throws Exception { //given CreatePostRequest request = new CreatePostRequest( "제목", - List.of(new PostImageRequestDto(1L), new PostImageRequestDto(2L)) + List.of(new PostImageRequestDto(1L), new PostImageRequestDto(2L)), + Scope.PRIVATE, + VoteType.SINGLE ); CreatePostResponse response = new CreatePostResponse(1L, "shareUrl"); given(postService.create(any(), any())) @@ -74,7 +70,13 @@ void createPost() throws Exception { .attributes(constraints("최소 2개")), fieldWithPath("images[].imageFileId") .type(JsonFieldType.NUMBER) - .description("투표 후보 이미지 ID") + .description("투표 후보 이미지 ID"), + fieldWithPath("scope") + .type(JsonFieldType.STRING) + .description("투표 공개범위 (PRIVATE, PUBLIC)"), + fieldWithPath("voteType") + .type(JsonFieldType.STRING) + .description("투표 방식 (SINGLE, MULTIPLE)") ), responseFields( fieldWithPath("postId") @@ -86,61 +88,7 @@ void createPost() throws Exception { ) )); } - - @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 상세 조회") @@ -155,8 +103,8 @@ void findPost_shareUrl() throws Exception { ), "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) + new PostImageResponse(1L, "뽀또A", "https://image.photopic.site/image/1", "https://image.photopic.site/image/resize/1", 1L), + new PostImageResponse(2L, "뽀또B", "https://image.photopic.site/image/2", "https://image.photopic.site/image/resize/2", null) ), "https://photopic.site/shareurl", true, @@ -186,7 +134,7 @@ void findPost_shareUrl() throws Exception { 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("images[].voteId").type(JsonFieldType.NUMBER).optional().description("투표 Id (투표 안 한 경우 null)"), fieldWithPath("shareUrl").type(JsonFieldType.STRING).description("게시글 공유 URL"), fieldWithPath("createdAt").type(JsonFieldType.STRING).description("게시글 생성 시간"), fieldWithPath("status").type(JsonFieldType.STRING).description("게시글 마감 여부 (PROGRESS, CLOSED)"), @@ -195,38 +143,6 @@ void findPost_shareUrl() throws Exception { )); } - @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("게시글 삭제") @@ -358,6 +274,55 @@ void findVotedPost() throws Exception { )); } + @Test + @WithMockUserInfo + @DisplayName("게시글 공개 범위 변경") + void toggleStatusPost() throws Exception { + //given + Long postId = 1L; + doNothing().when(postService).toggleScope(any(), eq(postId)); + + //when then + mockMvc.perform(post("/posts/{postId}/scope", 1) + .header(HttpHeaders.AUTHORIZATION, "Bearer token")) + .andExpect(status().isOk()) + .andDo(restDocs.document( + requestHeaders(authorizationHeader()), + pathParameters( + parameterWithName("postId").description("게시글 Id") + ) + )); + + verify(postService, times(1)).toggleScope(any(), eq(postId)); + } + + @Test + @WithMockUserInfo + @DisplayName("게시글 수정") + void updatePost() throws Exception { + //given + UpdatePostRequest request = new UpdatePostRequest("설명"); + + //when then + mockMvc.perform(post("/posts/{postId}/update", 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("description") + .type(JsonFieldType.STRING) + .description("설명") + .attributes(constraints("0~100자 사이")) + ) + )); + } + @Test @WithMockUserInfo @DisplayName("게시글 마감") @@ -376,4 +341,68 @@ void closePost() throws Exception { )); verify(postService, times(1)).close(any(), any()); } + + @Test + @WithMockUserInfo + @DisplayName("피드 조회") + void findFeed() throws Exception { + //given + var response = new CursorBasePaginatedResponse<> ( + 1L, + false, + List.of( + new FeedResponse( + 1L, + new AuthorDto( + 1L, + "author", + "https://image.photopic.site/profile-image" + ), + List.of( + new PostImageResponse(1L, "뽀또A", "https://image.photopic.site/image/1", "https://image.photopic.site/image/resize/1", 1L), + new PostImageResponse(2L, "뽀또B", "https://image.photopic.site/image/2", "https://image.photopic.site/image/resize/2", null) + ), + Status.PROGRESS, + "description", + "anioefw78f329jcs9", + true, + 1L, + 2L + ) + ) + ); + given(postService.findFeed(1L, null, 10)).willReturn(response); + + //when then + mockMvc.perform(RestDocumentationRequestBuilders.get("/posts/feed") + .header(HttpHeaders.AUTHORIZATION, "Bearer token")) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(response))) + .andDo(restDocs.document( + 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[].author").type(JsonFieldType.OBJECT).description("게시글 작성자 정보"), + fieldWithPath("data[].author.id").type(JsonFieldType.NUMBER).description("게시글 작성자 유저 ID"), + fieldWithPath("data[].author.nickname").type(JsonFieldType.STRING).description("게시글 작성자 닉네임"), + fieldWithPath("data[].author.profileUrl").type(JsonFieldType.STRING).description("게시글 작성자 프로필 이미지"), + fieldWithPath("data[].images[]").type(JsonFieldType.ARRAY).description("투표 선택지 목록"), + fieldWithPath("data[].images[].id").type(JsonFieldType.NUMBER).description("투표 선택지 Id"), + fieldWithPath("data[].images[].imageName").type(JsonFieldType.STRING).description("사진 이름"), + fieldWithPath("data[].images[].imageUrl").type(JsonFieldType.STRING).description("사진 이미지"), + fieldWithPath("data[].images[].thumbnailUrl").type(JsonFieldType.STRING).description("나중에 없어질 예정"), + fieldWithPath("data[].images[].voteId").type(JsonFieldType.NUMBER).optional().description("투표 Id (투표 안 한 경우 null)"), + fieldWithPath("data[].status").type(JsonFieldType.STRING).description("게시글 마감 여부 (PROGRESS, CLOSED)"), + fieldWithPath("data[].description").type(JsonFieldType.STRING).description("설명"), + fieldWithPath("data[].shareUrl").type(JsonFieldType.STRING).description("게시글 공유 URL"), + fieldWithPath("data[].isAuthor").type(JsonFieldType.BOOLEAN).description("게시글 작성자 여부"), + fieldWithPath("data[].participantCount").type(JsonFieldType.NUMBER).description("투표 참여자 수"), + fieldWithPath("data[].commentCount").type(JsonFieldType.NUMBER).description("투표 댓글 수") + ) + )); + } } diff --git a/src/test/java/com/swyp8team2/support/RepositoryTest.java b/src/test/java/com/swyp8team2/support/RepositoryTest.java index 9b3ded7e..08df0a0a 100644 --- a/src/test/java/com/swyp8team2/support/RepositoryTest.java +++ b/src/test/java/com/swyp8team2/support/RepositoryTest.java @@ -4,7 +4,7 @@ import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.context.annotation.Import; -@DataJpaTest @Import(JpaConfig.class) +@DataJpaTest public abstract class RepositoryTest { } diff --git a/src/test/java/com/swyp8team2/support/config/TestSecurityConfig.java b/src/test/java/com/swyp8team2/support/config/TestSecurityConfig.java index 2fd6de4a..63961e27 100644 --- a/src/test/java/com/swyp8team2/support/config/TestSecurityConfig.java +++ b/src/test/java/com/swyp8team2/support/config/TestSecurityConfig.java @@ -10,9 +10,6 @@ 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 { diff --git a/src/test/java/com/swyp8team2/support/fixture/FixtureGenerator.java b/src/test/java/com/swyp8team2/support/fixture/FixtureGenerator.java index a11bf052..2a3ebe58 100644 --- a/src/test/java/com/swyp8team2/support/fixture/FixtureGenerator.java +++ b/src/test/java/com/swyp8team2/support/fixture/FixtureGenerator.java @@ -1,23 +1,55 @@ package com.swyp8team2.support.fixture; +import com.swyp8team2.comment.domain.Comment; 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.post.domain.Scope; +import com.swyp8team2.post.domain.VoteType; import com.swyp8team2.user.domain.User; +import com.swyp8team2.vote.domain.Vote; import java.util.List; +import java.util.stream.Collectors; public abstract class FixtureGenerator { - public static Post createPost(Long userId, ImageFile imageFile1, ImageFile imageFile2, int key) { + public static Post createPost(Long userId, Scope scope, ImageFile imageFile1, ImageFile imageFile2, int key) { return Post.create( userId, "description" + key, List.of( PostImage.create("뽀또A", imageFile1.getId()), PostImage.create("뽀또B", imageFile2.getId()) - ) + ), + scope, + VoteType.SINGLE + ); + } + + public static Post createPost(Long userId, Scope scope, List imageFiles, int key) { + return Post.create( + userId, + "description" + key, + imageFiles.stream() + .map(imageFile -> PostImage.create("뽀또"+key, imageFile.getId())) + .toList(), + scope, + VoteType.SINGLE + ); + } + + public static Post createMultiplePost(Long userId, Scope scope, ImageFile imageFile1, ImageFile imageFile2, int key) { + return Post.create( + userId, + "description" + key, + List.of( + PostImage.create("뽀또A", imageFile1.getId()), + PostImage.create("뽀또B", imageFile2.getId()) + ), + scope, + VoteType.MULTIPLE ); } @@ -34,4 +66,12 @@ public static ImageFile createImageFile(int key) { ) ); } + + public static Vote createVote(Long userId, Long postId, Long imageId) { + return Vote.of(userId, postId, imageId); + } + + public static Comment createComment(Long userId, Long postId) { + return new Comment(userId, postId, "내용"); + } } diff --git a/src/test/java/com/swyp8team2/user/application/NicknameGeneratorTest.java b/src/test/java/com/swyp8team2/user/application/NicknameGeneratorTest.java new file mode 100644 index 00000000..39f13de1 --- /dev/null +++ b/src/test/java/com/swyp8team2/user/application/NicknameGeneratorTest.java @@ -0,0 +1,59 @@ +package com.swyp8team2.user.application; + +import com.swyp8team2.user.domain.NicknameAdjective; +import com.swyp8team2.user.domain.NicknameAdjectiveRepository; +import com.swyp8team2.user.domain.Role; +import com.swyp8team2.user.domain.User; +import org.assertj.core.api.Assertions; +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 java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +class NicknameGeneratorTest { + + @InjectMocks + NicknameGenerator nicknameGenerator; + + @Mock + NicknameAdjectiveRepository nicknameAdjectiveRepository; + + @Test + @DisplayName("닉네임 생성 테스트") + void generate() throws Exception { + //given + Role role = Role.USER; + given(nicknameAdjectiveRepository.findNicknameAdjectiveById(any())) + .willReturn(Optional.of(new NicknameAdjective("호기심 많은"))); + + //when + String nickname = nicknameGenerator.generate(role); + + //then + Assertions.assertThat(nickname).isEqualTo("호기심 많은 뽀또"); + } + + @Test + @DisplayName("닉네임 생성 테스트 - 게스트") + void generate_guest() throws Exception { + //given + Role role = Role.GUEST; + given(nicknameAdjectiveRepository.findNicknameAdjectiveById(any())) + .willReturn(Optional.of(new NicknameAdjective("호기심 많은"))); + + //when + String nickname = nicknameGenerator.generate(role); + + //then + Assertions.assertThat(nickname).isEqualTo("호기심 많은 낫또"); + } +} diff --git a/src/test/java/com/swyp8team2/user/presentation/UserControllerTest.java b/src/test/java/com/swyp8team2/user/presentation/UserControllerTest.java index 8490a66a..02906b05 100644 --- a/src/test/java/com/swyp8team2/user/presentation/UserControllerTest.java +++ b/src/test/java/com/swyp8team2/user/presentation/UserControllerTest.java @@ -2,7 +2,9 @@ import com.swyp8team2.support.RestDocsTest; import com.swyp8team2.support.WithMockUserInfo; +import com.swyp8team2.user.domain.Role; import com.swyp8team2.user.presentation.dto.UserInfoResponse; +import com.swyp8team2.user.presentation.dto.UserMyInfoResponse; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.http.HttpHeaders; @@ -52,8 +54,8 @@ void findUserInfo() throws Exception { @DisplayName("본인 정보 조회") void findMe() throws Exception { //given - UserInfoResponse response = new UserInfoResponse(1L, "nickname", "https://image.com/profile-image"); - given(userService.findById(1L)) + UserMyInfoResponse response = new UserMyInfoResponse(1L, "nickname", "https://image.com/profile-image", Role.USER); + given(userService.findByMe(1L)) .willReturn(response); //when then @@ -66,7 +68,8 @@ void findMe() throws Exception { responseFields( fieldWithPath("id").description("유저 아이디").type(NUMBER), fieldWithPath("nickname").description("닉네임").type(STRING), - fieldWithPath("profileUrl").description("프로필 이미지 URL").type(STRING) + fieldWithPath("profileImageUrl").description("프로필 이미지 URL").type(STRING), + fieldWithPath("role").description("유저 권한").type(STRING) ) )); } diff --git a/src/test/java/com/swyp8team2/post/application/RatioCalculatorTest.java b/src/test/java/com/swyp8team2/vote/application/RatioCalculatorTest.java similarity index 95% rename from src/test/java/com/swyp8team2/post/application/RatioCalculatorTest.java rename to src/test/java/com/swyp8team2/vote/application/RatioCalculatorTest.java index 27e95e3d..8502377d 100644 --- a/src/test/java/com/swyp8team2/post/application/RatioCalculatorTest.java +++ b/src/test/java/com/swyp8team2/vote/application/RatioCalculatorTest.java @@ -1,4 +1,4 @@ -package com.swyp8team2.post.application; +package com.swyp8team2.vote.application; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; diff --git a/src/test/java/com/swyp8team2/vote/application/VoteServiceTest.java b/src/test/java/com/swyp8team2/vote/application/VoteServiceTest.java index fff0993c..c05f5dca 100644 --- a/src/test/java/com/swyp8team2/vote/application/VoteServiceTest.java +++ b/src/test/java/com/swyp8team2/vote/application/VoteServiceTest.java @@ -4,10 +4,7 @@ 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.post.domain.*; import com.swyp8team2.support.IntegrationTest; import com.swyp8team2.user.domain.User; import com.swyp8team2.user.domain.UserRepository; @@ -20,6 +17,7 @@ import java.util.List; import static com.swyp8team2.support.fixture.FixtureGenerator.createImageFile; +import static com.swyp8team2.support.fixture.FixtureGenerator.createMultiplePost; import static com.swyp8team2.support.fixture.FixtureGenerator.createPost; import static com.swyp8team2.support.fixture.FixtureGenerator.createUser; import static org.assertj.core.api.Assertions.assertThat; @@ -44,13 +42,13 @@ class VoteServiceTest extends IntegrationTest { ImageFileRepository imageFileRepository; @Test - @DisplayName("투표하기") - void vote() { + @DisplayName("단일 투표하기") + void singleVote() { // 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 post = postRepository.save(createPost(user.getId(), Scope.PRIVATE, imageFile1, imageFile2, 1)); // when Long voteId = voteService.vote(user.getId(), post.getId(), post.getImages().get(0).getId()); @@ -67,13 +65,13 @@ void vote() { } @Test - @DisplayName("투표하기 - 다른 이미지로 투표 변경한 경우") - void vote_change() { + @DisplayName("단일 투표하기 - 다른 이미지로 투표 변경한 경우") + void singleVote_change() { // given - User user = userRepository.save(createUser(1)); + User user = userRepository.save(createUser(2)); ImageFile imageFile1 = imageFileRepository.save(createImageFile(1)); ImageFile imageFile2 = imageFileRepository.save(createImageFile(2)); - Post post = postRepository.save(createPost(user.getId(), imageFile1, imageFile2, 1)); + Post post = postRepository.save(createPost(user.getId(), Scope.PRIVATE, imageFile1, imageFile2, 1)); voteService.vote(user.getId(), post.getId(), post.getImages().get(0).getId()); // when @@ -91,6 +89,37 @@ void vote_change() { ); } + @Test + @DisplayName("복수 투표하기") + void multipleVote() { + // given + User user = userRepository.save(createUser(1)); + ImageFile imageFile1 = imageFileRepository.save(createImageFile(1)); + ImageFile imageFile2 = imageFileRepository.save(createImageFile(2)); + Post post = postRepository.save(createMultiplePost(user.getId(), Scope.PRIVATE, imageFile1, imageFile2, 1)); + + // when + Long voteId1 = voteService.vote(user.getId(), post.getId(), post.getImages().get(0).getId()); + Long voteId2 = voteService.vote(user.getId(), post.getId(), post.getImages().get(1).getId()); + + // then + Vote vote1 = voteRepository.findById(voteId1).get(); + Vote vote2 = voteRepository.findById(voteId2).get(); + Post findPost = postRepository.findById(post.getId()).get(); + assertAll( + () -> assertThat(vote1.getUserId()).isEqualTo(user.getId()), + () -> assertThat(vote1.getPostId()).isEqualTo(post.getId()), + () -> assertThat(vote1.getPostImageId()).isEqualTo(post.getImages().get(0).getId()), + + () -> assertThat(vote2.getUserId()).isEqualTo(user.getId()), + () -> assertThat(vote2.getPostId()).isEqualTo(post.getId()), + () -> assertThat(vote2.getPostImageId()).isEqualTo(post.getImages().get(1).getId()), + + () -> assertThat(findPost.getImages().get(0).getVoteCount()).isEqualTo(1), + () -> assertThat(findPost.getImages().get(1).getVoteCount()).isEqualTo(1) + ); + } + @Test @DisplayName("투표하기 - 투표 마감된 경우") void vote_alreadyClosed() { @@ -99,16 +128,18 @@ void vote_alreadyClosed() { 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" - )); + null, + user.getId(), + "description", + Status.CLOSED, + Scope.PRIVATE, + List.of( + PostImage.create("뽀또A", imageFile1.getId()), + PostImage.create("뽀또B", imageFile2.getId()) + ), + "shareUrl", + VoteType.SINGLE + )); // when assertThatThrownBy(() -> voteService.vote(user.getId(), post.getId(), post.getImages().get(0).getId())) @@ -117,78 +148,118 @@ void vote_alreadyClosed() { } @Test - @DisplayName("게스트 투표하기") - void guestVote() { + @DisplayName("투표 취소") + void cancelVote() { // 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)); + Post post = postRepository.save(createPost(user.getId(), Scope.PRIVATE, imageFile1, imageFile2, 1)); + Long voteId = voteService.vote(user.getId(), post.getId(), post.getImages().get(0).getId()); // when - Long voteId = voteService.guestVote(guestId, post.getId(), post.getImages().get(0).getId()); + voteService.cancelVote(user.getId(), voteId); // then - Vote vote = voteRepository.findById(voteId).get(); + boolean res = voteRepository.findById(voteId).isEmpty(); 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) + () -> assertThat(res).isEqualTo(true), + () -> assertThat(findPost.getImages().get(0).getVoteCount()).isEqualTo(0) ); } @Test - @DisplayName("게스트 투표하기 - 다른 이미지로 투표 변경한 경우") - void guestVote_change() { + @DisplayName("투표 취소 - 투표자가 아닌 경우") + void cancelVote_notVoter() { // 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()); + Post post = postRepository.save(createPost(user.getId(), Scope.PRIVATE, imageFile1, imageFile2, 1)); + Long voteId = voteService.vote(user.getId(), post.getId(), post.getImages().get(0).getId()); - // when - Long voteId = voteService.guestVote(guestId, post.getId(), post.getImages().get(1).getId()); + // when then + assertThatThrownBy(() -> voteService.cancelVote(2L, voteId)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.NOT_VOTER.getMessage()); + } - // then - Vote vote = voteRepository.findById(voteId).get(); - Post findPost = postRepository.findById(post.getId()).get(); + @Test + @DisplayName("투표 현황 조회") + void findVoteStatus() throws Exception { + //given + User user = userRepository.save(createUser(1)); + ImageFile imageFile1 = imageFileRepository.save(createImageFile(1)); + ImageFile imageFile2 = imageFileRepository.save(createImageFile(2)); + ImageFile imageFile3 = imageFileRepository.save(createImageFile(3)); + Post post = postRepository.save(createPost(user.getId(), Scope.PRIVATE, List.of(imageFile1, imageFile2, imageFile3), 1)); + voteService.vote(user.getId(), post.getId(), post.getImages().get(1).getId()); + + //when + var response = voteService.findVoteStatus(user.getId(), post.getId()); + + //then 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) + () -> assertThat(response).hasSize(3), + () -> assertThat(response.get(0).id()).isEqualTo(post.getImages().get(1).getId()), + () -> assertThat(response.get(0).imageName()).isEqualTo(post.getImages().get(1).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(0).getId()), + () -> assertThat(response.get(1).imageName()).isEqualTo(post.getImages().get(0).getName()), + () -> assertThat(response.get(1).voteCount()).isEqualTo(0), + () -> assertThat(response.get(1).voteRatio()).isEqualTo("0.0"), + + () -> assertThat(response.get(2).id()).isEqualTo(post.getImages().get(2).getId()), + () -> assertThat(response.get(2).imageName()).isEqualTo(post.getImages().get(2).getName()), + () -> assertThat(response.get(2).voteCount()).isEqualTo(0), + () -> assertThat(response.get(2).voteRatio()).isEqualTo("0.0") ); } @Test - @DisplayName("게스트 투표하기 - 투표 마감된 경우") - void guestVote_alreadyClosed() { - // given - User user = userRepository.save(createUser(1)); - Long guestId = user.getId() + 1L; + @DisplayName("투표 현황 조회 - 투표한 사람인 경우") + void findVoteStatus_voteUser() throws Exception { + //given + User author = userRepository.save(createUser(1)); + User voter = userRepository.save(createUser(2)); 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" - )); + Post post = postRepository.save(createPost(author.getId(), Scope.PRIVATE, imageFile1, imageFile2, 1)); + voteService.vote(voter.getId(), post.getId(), post.getImages().get(0).getId()); - // when - assertThatThrownBy(() -> voteService.guestVote(guestId, post.getId(), post.getImages().get(0).getId())) + //when + var response = voteService.findVoteStatus(voter.getId(), 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 findVoteStatus_notAuthorAndVoter() throws Exception { + //given + User author = userRepository.save(createUser(1)); + ImageFile imageFile1 = imageFileRepository.save(createImageFile(1)); + ImageFile imageFile2 = imageFileRepository.save(createImageFile(2)); + Post post = postRepository.save(createPost(author.getId(), Scope.PRIVATE, imageFile1, imageFile2, 1)); + + //when + assertThatThrownBy(() -> voteService.findVoteStatus(2L, post.getId())) .isInstanceOf(BadRequestException.class) - .hasMessage(ErrorCode.POST_ALREADY_CLOSED.getMessage()); + .hasMessage(ErrorCode.ACCESS_DENIED_VOTE_STATUS.getMessage()); } + } diff --git a/src/test/java/com/swyp8team2/vote/presentation/VoteControllerTest.java b/src/test/java/com/swyp8team2/vote/presentation/VoteControllerTest.java index c1c9d502..f323ef9c 100644 --- a/src/test/java/com/swyp8team2/vote/presentation/VoteControllerTest.java +++ b/src/test/java/com/swyp8team2/vote/presentation/VoteControllerTest.java @@ -1,27 +1,32 @@ package com.swyp8team2.vote.presentation; -import com.swyp8team2.common.presentation.CustomHeader; +import com.swyp8team2.vote.presentation.dto.PostImageVoteStatusResponse; 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.mockmvc.RestDocumentationRequestBuilders; import org.springframework.restdocs.payload.JsonFieldType; +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.delete; 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.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 VoteControllerTest extends RestDocsTest { @@ -54,80 +59,52 @@ void vote() throws Exception { } @Test - @WithMockUserInfo(role = Role.GUEST) - @DisplayName("게스트 투표") - void guestVote() throws Exception { + @WithMockUserInfo + @DisplayName("투표 취소") + void cancelVote() 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")) + mockMvc.perform(delete("/votes/{voteId}", "1") + .header(HttpHeaders.AUTHORIZATION, "Bearer token")) .andExpect(status().isOk()) .andDo(restDocs.document( - requestHeaders(guestHeader()), + requestHeaders(authorizationHeader()), pathParameters( - parameterWithName("postId").description("게시글 Id") - ), - requestFields( - fieldWithPath("imageId") - .type(JsonFieldType.NUMBER) - .description("투표 후보 Id") + parameterWithName("voteId").description("투표 Id") ) )); - verify(voteService, times(1)).guestVote(any(), any(), any()); + verify(voteService, times(1)).cancelVote(any(), any()); } @Test @WithMockUserInfo - @DisplayName("투표 변경") - void changeVote() throws Exception { + @DisplayName("게시글 투표 상태 조회") + void findVoteStatus() throws Exception { //given - ChangeVoteRequest request = new ChangeVoteRequest(1L); + var response = List.of( + new PostImageVoteStatusResponse(1L, "뽀또A", 2, "66.7"), + new PostImageVoteStatusResponse(2L, "뽀또B", 1, "33.3") + ); + given(voteService.findVoteStatus(1L, 1L)) + .willReturn(response); - //when - mockMvc.perform(patch("/posts/{postId}/votes", "1") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request)) + //when then + mockMvc.perform(RestDocumentationRequestBuilders.get("/posts/{postId}/votes/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") - ), - 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") + parameterWithName("postId").description("게시글 Id") ), - requestFields( - fieldWithPath("imageId") - .type(JsonFieldType.NUMBER) - .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("투표 비율") ) )); } diff --git a/src/test/resources/org/springframework/restdocs/templates/request-cookies.snippet b/src/test/resources/org/springframework/restdocs/templates/request-cookies.snippet new file mode 100644 index 00000000..2c412000 --- /dev/null +++ b/src/test/resources/org/springframework/restdocs/templates/request-cookies.snippet @@ -0,0 +1,10 @@ +|=== + |파라미터|필수값|설명 + +{{#cookies}} + |{{#tableCellContent}}`+{{name}}+`{{/tableCellContent}} + |{{#tableCellContent}}{{^optional}}true{{/optional}}{{/tableCellContent}} + |{{#tableCellContent}}{{description}}{{/tableCellContent}} +{{/cookies}} + +|=== \ No newline at end of file