diff --git a/src/docs/asciidoc/record-query.adoc b/src/docs/asciidoc/record-query.adoc index 26d5fa11..669d6ef5 100644 --- a/src/docs/asciidoc/record-query.adoc +++ b/src/docs/asciidoc/record-query.adoc @@ -24,8 +24,18 @@ include::{snippets}/record-controller-test/특정_책_기록_감정_필터_조 include::{snippets}/record-controller-test/특정_책_기록_감정_필터_조회_성공/http-response.adoc[opts=optional] include::{snippets}/record-controller-test/특정_책_기록_감정_필터_조회_성공/response-fields.adoc[opts=optional] +=== 기록 개별 조회 +기록 ID로 단건 기록을 조회합니다. +이미지가 연결된 기록은 저장된 key를 기준으로 조회 시점에 presigned GET URL로 변환된 `imgUrls` 로 내려갑니다. + +include::{snippets}/record-controller-test/기록_개별_조회_성공/http-request.adoc[opts=optional] +include::{snippets}/record-controller-test/기록_개별_조회_성공/request-headers.adoc[opts=optional] +include::{snippets}/record-controller-test/기록_개별_조회_성공/path-parameters.adoc[opts=optional] +include::{snippets}/record-controller-test/기록_개별_조회_성공/http-response.adoc[opts=optional] +include::{snippets}/record-controller-test/기록_개별_조회_성공/response-fields.adoc[opts=optional] + === 독서 기록 감정별 개수 조회 -사용자가 작성한 전체 독서 기록 수와 감정별 기록 수를 함께 반환합니다. +특정 책의 전체 기록 수와 감정별 기록 수를 함께 반환합니다. include::{snippets}/record-controller-test/독서_기록_감정별_개수_조회_성공/http-request.adoc[opts=optional] include::{snippets}/record-controller-test/독서_기록_감정별_개수_조회_성공/request-headers.adoc[opts=optional] diff --git a/src/main/java/app/nook/library/domain/Library.java b/src/main/java/app/nook/library/domain/Library.java index f2482a25..235f1032 100644 --- a/src/main/java/app/nook/library/domain/Library.java +++ b/src/main/java/app/nook/library/domain/Library.java @@ -7,10 +7,7 @@ import app.nook.timeline.domain.Timeline; import app.nook.user.domain.User; import jakarta.persistence.*; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; +import lombok.*; import java.util.ArrayList; import java.util.List; @@ -19,8 +16,8 @@ @Entity @Getter -@NoArgsConstructor -@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) @Table( name = "library", uniqueConstraints = { diff --git a/src/main/java/app/nook/record/controller/RecordController.java b/src/main/java/app/nook/record/controller/RecordController.java index 09f0c697..518be67d 100644 --- a/src/main/java/app/nook/record/controller/RecordController.java +++ b/src/main/java/app/nook/record/controller/RecordController.java @@ -46,11 +46,6 @@ public ApiResponse> getU RecordListCursorCodec.decode(cursor), order ); - - if (response.getItems().isEmpty()) { - return ApiResponse.onSuccess(null, SuccessCode.NO_CONTENT); - } - return ApiResponse.onSuccess(response, SuccessCode.OK); } @@ -64,20 +59,16 @@ public ApiResponse> getBookRec ) { CursorResponse response = recordQueryService.getBookRecords(user, bookId, size, cursor, emotion); - - if (response.getItems().isEmpty()) { - return ApiResponse.onSuccess(null, SuccessCode.NO_CONTENT); - } - return ApiResponse.onSuccess(response, SuccessCode.OK); } - @GetMapping("/emotions") + @GetMapping("/emotions/{bookId}") public ApiResponse getRecordEmotionCounts( - @CurrentUser User user + @CurrentUser User user, + @PathVariable Long bookId ) { return ApiResponse.onSuccess( - recordQueryService.getRecordEmotionCounts(user), + recordQueryService.getRecordEmotionCounts(user,bookId), SuccessCode.OK ); } @@ -121,4 +112,16 @@ public ApiResponse countRecords( ); } + @GetMapping("/{recordId}") + public ApiResponse getRecordDetail( + @CurrentUser User user, + @PathVariable Long recordId + ) { + return ApiResponse.onSuccess( + recordQueryService.getRecordDetail(user, recordId), + SuccessCode.OK + ); + } + + } diff --git a/src/main/java/app/nook/record/domain/enums/Emotion.java b/src/main/java/app/nook/record/domain/enums/Emotion.java index 0e4dc049..c00f0981 100644 --- a/src/main/java/app/nook/record/domain/enums/Emotion.java +++ b/src/main/java/app/nook/record/domain/enums/Emotion.java @@ -2,6 +2,6 @@ public enum Emotion { - FUN, EMPATHIZING, USEFUL, COMPLICATED, SAD, UNCOMFORTABLE + FUN, EMPATHIZING, USEFUL, COMPLICATED, SAD, UNCOMFORTABLE, EMPTY } diff --git a/src/main/java/app/nook/record/dto/BookRecordDto.java b/src/main/java/app/nook/record/dto/BookRecordDto.java index 6aef9b37..3e15fd45 100644 --- a/src/main/java/app/nook/record/dto/BookRecordDto.java +++ b/src/main/java/app/nook/record/dto/BookRecordDto.java @@ -30,7 +30,7 @@ public record RecordItemDto( ){} public record RecordEmotionDto( - Emotion emotion, + String emotion, long recordCount ){} diff --git a/src/main/java/app/nook/record/repository/RecordQueryRepository.java b/src/main/java/app/nook/record/repository/RecordQueryRepository.java index 8f06587d..736bd603 100644 --- a/src/main/java/app/nook/record/repository/RecordQueryRepository.java +++ b/src/main/java/app/nook/record/repository/RecordQueryRepository.java @@ -15,5 +15,5 @@ public List findRecordsByCursor( public List findBookRecordsByCursor( Long userId, Long bookId, Long cursor, Emotion emotion, int size ); - public BookRecordDto.RecordEmotionCountResponse countRecordsByEmotion(Long userId); + public BookRecordDto.RecordEmotionCountResponse countRecordsByEmotion(Long userId, Long bookId); } diff --git a/src/main/java/app/nook/record/repository/RecordQueryRepositoryImpl.java b/src/main/java/app/nook/record/repository/RecordQueryRepositoryImpl.java index f4ec3ff4..e8c364c9 100644 --- a/src/main/java/app/nook/record/repository/RecordQueryRepositoryImpl.java +++ b/src/main/java/app/nook/record/repository/RecordQueryRepositoryImpl.java @@ -284,22 +284,28 @@ public List findBookRecordsByCursor( } // 감상별 독서 기록 개수 조회 - public BookRecordDto.RecordEmotionCountResponse countRecordsByEmotion(Long userId) { + public BookRecordDto.RecordEmotionCountResponse countRecordsByEmotion(Long userId, Long bookId) { Long totalCount = Optional.ofNullable( queryFactory .select(record.count()) .from(record) - .where(record.library.user.id.eq(userId)) + .where( + record.library.user.id.eq(userId), + record.library.book.id.eq(bookId) + ) .fetchOne() ).orElse(0L); List emotionCounts = queryFactory .select(Projections.constructor(BookRecordDto.RecordEmotionDto.class, - record.emotion, + record.emotion.stringValue(), record.count() )) .from(record) - .where(record.library.user.id.eq(userId)) + .where( + record.library.user.id.eq(userId), + record.library.book.id.eq(bookId) + ) .groupBy(record.emotion) .fetch(); diff --git a/src/main/java/app/nook/record/repository/RecordRepository.java b/src/main/java/app/nook/record/repository/RecordRepository.java index e7d0eab1..bec12ea3 100644 --- a/src/main/java/app/nook/record/repository/RecordRepository.java +++ b/src/main/java/app/nook/record/repository/RecordRepository.java @@ -43,4 +43,7 @@ List findRecentByLibraryId( @EntityGraph(attributePaths = "images") Optional findWithImagesById(Long id); + + @EntityGraph(attributePaths = {"images", "library", "library.user"}) + Optional findWithDetailById(Long id); } diff --git a/src/main/java/app/nook/record/service/RecordCommandService.java b/src/main/java/app/nook/record/service/RecordCommandService.java index df1b79af..e34ef4dd 100644 --- a/src/main/java/app/nook/record/service/RecordCommandService.java +++ b/src/main/java/app/nook/record/service/RecordCommandService.java @@ -10,6 +10,7 @@ import app.nook.library.repository.LibraryRepository; import app.nook.record.domain.Record; import app.nook.record.domain.RecordImage; +import app.nook.record.domain.enums.Emotion; import app.nook.record.dto.RecordRequestDto; import app.nook.record.dto.RecordResponseDto; import app.nook.record.dto.RecordUpdateRequestDto; @@ -73,7 +74,7 @@ public void createRecord( Record newRecord = Record.create( library, - requestDto.emotion(), + normalizeEmotion(requestDto.emotion()), requestDto.content() ); @@ -104,7 +105,7 @@ public void updateRecord( throw new CustomException(RecordErrorCode.RECORD_NOT_AUTHORIZED); } - record.update(requestDto.content(), requestDto.emotion()); + record.update(requestDto.content(), normalizeEmotion(requestDto.emotion())); // 이미지 업데이트 시에 동기화 처리 syncRecordImages(record, requestedImageKeys); @@ -150,6 +151,10 @@ private List filterImageKeys(List imageKeys) { .toList(); } + private Emotion normalizeEmotion(Emotion emotion) { + return emotion == null ? Emotion.EMPTY : emotion; + } + // 기록 이미지 저장 private void saveRecordImages(Record record, List imageKeys) { for (int index = 0; index < imageKeys.size(); index++) { diff --git a/src/main/java/app/nook/record/service/RecordQueryService.java b/src/main/java/app/nook/record/service/RecordQueryService.java index 52942623..6598e7ea 100644 --- a/src/main/java/app/nook/record/service/RecordQueryService.java +++ b/src/main/java/app/nook/record/service/RecordQueryService.java @@ -13,6 +13,7 @@ import app.nook.record.domain.enums.SortType; import app.nook.record.dto.BookRecordDto; import app.nook.record.dto.RecordListCursor; +import app.nook.record.exception.RecordErrorCode; import app.nook.record.repository.RecordRepository; import app.nook.record.util.RecordListCursorCodec; import app.nook.r2.service.PresignedUrlService; @@ -21,7 +22,10 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.Arrays; +import java.util.EnumMap; import java.util.List; +import java.util.Map; @Service @RequiredArgsConstructor @@ -78,8 +82,33 @@ public CursorResponse getUserRecords( } // 독서 기록 감상별 개수 조회 - public BookRecordDto.RecordEmotionCountResponse getRecordEmotionCounts(User user) { - return recordRepository.countRecordsByEmotion(user.getId()); + public BookRecordDto.RecordEmotionCountResponse getRecordEmotionCounts(User user, Long bookId) { + if (!bookRepository.existsById(bookId)) { + throw new CustomException(BookErrorCode.BOOK_NOT_FOUND); + } + + if (!libraryRepository.existsByUserIdAndBookId(user.getId(), bookId)) { + throw new CustomException(LibraryErrorCode.BOOK_NOT_EXIST); + } + + BookRecordDto.RecordEmotionCountResponse response = recordRepository.countRecordsByEmotion(user.getId(), bookId); + + Map emotionCountMap = new EnumMap<>(Emotion.class); + response.emotionCounts().forEach(item -> emotionCountMap.put(Emotion.valueOf(item.emotion()), item.recordCount())); + + List normalizedEmotionCounts = Arrays.stream(Emotion.values()) + .filter(emotion -> emotion != Emotion.EMPTY) + .map(emotion -> new BookRecordDto.RecordEmotionDto( + emotion.name(), + emotionCountMap.getOrDefault(emotion, 0L) + )) + .toList(); + + List emotionCountsWithAll = new java.util.ArrayList<>(); + emotionCountsWithAll.add(new BookRecordDto.RecordEmotionDto("ALL", response.totalCount())); + emotionCountsWithAll.addAll(normalizedEmotionCounts); + + return new BookRecordDto.RecordEmotionCountResponse(response.totalCount(), emotionCountsWithAll); } // 해당 책의 독서 기록 목록 조회 @@ -146,10 +175,24 @@ private Emotion parseEmotionFilter(String emotion) { } try { - return Emotion.valueOf(emotion.trim().toUpperCase()); + Emotion parsedEmotion = Emotion.valueOf(emotion.trim().toUpperCase()); + if (parsedEmotion == Emotion.EMPTY) { + throw new CustomException(CommonErrorCode.INVALID_REQUEST); + } + return parsedEmotion; } catch (IllegalArgumentException exception) { throw new CustomException(CommonErrorCode.INVALID_REQUEST); } } + public BookRecordDto.RecordItemDto getRecordDetail(User user, Long recordId) { + Record record = recordRepository.findWithDetailById(recordId) + .orElseThrow(() -> new CustomException(RecordErrorCode.RECORD_NOT_FOUND)); + + if (!record.getLibrary().getUser().getId().equals(user.getId())) { + throw new CustomException(RecordErrorCode.RECORD_NOT_AUTHORIZED); + } + + return recordConverter.toRecordItemDto(user.getId(), record); + } } diff --git a/src/main/java/app/nook/user/controller/AuthController.java b/src/main/java/app/nook/user/controller/AuthController.java index a93926b0..4ea05945 100644 --- a/src/main/java/app/nook/user/controller/AuthController.java +++ b/src/main/java/app/nook/user/controller/AuthController.java @@ -71,6 +71,16 @@ public ApiResponse devSignUp( ); } + @PostMapping("/reissue") + public ApiResponse reissueAccessToken( + @Valid @RequestBody UserDTO.TokenReissueRequest request + ) { + return ApiResponse.onSuccess( + userService.reissueAccessToken(request.getRefreshToken()), + SuccessCode.OK + ); + } + // 현재 로그인한 유저 확인 @GetMapping("/me") public ApiResponse user( diff --git a/src/main/java/app/nook/user/dto/UserDTO.java b/src/main/java/app/nook/user/dto/UserDTO.java index bd9678ce..391811f4 100644 --- a/src/main/java/app/nook/user/dto/UserDTO.java +++ b/src/main/java/app/nook/user/dto/UserDTO.java @@ -1,6 +1,9 @@ package app.nook.user.dto; import com.fasterxml.jackson.annotation.JsonInclude; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -17,13 +20,19 @@ public static class LoginResponse { private String email; private String nickName; private String accessToken; + private String refreshToken; } @AllArgsConstructor @Getter @NoArgsConstructor public static class DevLoginRequest{ + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "올바르지 않은 이메일 형식입니다.") private String email; + + @NotBlank(message = "닉네임은 필수입니다.") + @Pattern(regexp = "^[a-zA-Z0-9가-힣]{2,20}$", message = "닉네임은 2~20자의 영문, 숫자, 한글만 사용할 수 있습니다.") private String nickName; } @@ -31,7 +40,25 @@ public static class DevLoginRequest{ @Getter @NoArgsConstructor public static class DevSignUpRequest { + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "올바르지 않은 이메일 형식입니다.") private String email; + + @NotBlank(message = "닉네임은 필수입니다.") + @Pattern(regexp = "^[a-zA-Z0-9가-힣]{2,20}$", message = "닉네임은 2~20자의 영문, 숫자, 한글만 사용할 수 있습니다.") private String nickName; } + + @AllArgsConstructor + @Getter + @NoArgsConstructor + public static class TokenReissueRequest { + @NotBlank(message = "리프레시토큰은 필수입니다.") + private String refreshToken; + } + + public record TokenReissueResponse( + String accessToken, + String refreshToken + ) {} } diff --git a/src/main/java/app/nook/user/filter/JwtFilter.java b/src/main/java/app/nook/user/filter/JwtFilter.java index e1eae478..aff06864 100644 --- a/src/main/java/app/nook/user/filter/JwtFilter.java +++ b/src/main/java/app/nook/user/filter/JwtFilter.java @@ -4,14 +4,12 @@ import app.nook.global.response.AuthErrorCode; import app.nook.user.domain.User; import app.nook.user.jwt.JwtProvider; -import app.nook.user.redis.TokenRedis; -import app.nook.user.redis.TokenRedisRepository; import app.nook.user.repository.UserRepository; import app.nook.user.service.CustomUserDetails; +import io.jsonwebtoken.JwtException; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpHeaders; @@ -30,12 +28,11 @@ public class JwtFilter extends OncePerRequestFilter { private final JwtProvider jwtProvider; - private final TokenRedisRepository tokenRedisRepository; private final UserRepository userRepository; @Override protected void doFilterInternal(HttpServletRequest request, - HttpServletResponse response, + jakarta.servlet.http.HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String accessToken = resolveAccessToken(request); @@ -45,16 +42,17 @@ protected void doFilterInternal(HttpServletRequest request, // 만료되지 않으면 SecurityContext 에 저장 setAuthentication(accessToken); } else if (jwtProvider.isExpiredToken(accessToken)) { - // 만료 시 재발급 - reissueAccessToken(accessToken, response); + log.warn("[TOKEN] 만료된 access token. /auth/reissue API 사용 필요"); } else { - log.warn("[TOKEN] 유효하지 않은 access token. 재발급 시도 생략"); + log.warn("[TOKEN] 유효하지 않은 access token"); + throw new JwtException("유효하지 않은 access token"); } } filterChain.doFilter(request, response); } + // 엑세스 토큰 처리 private String resolveAccessToken(HttpServletRequest request) { String authorization = request.getHeader(HttpHeaders.AUTHORIZATION); if (StringUtils.hasText(authorization) && authorization.startsWith(JwtProvider.BEARER_PREFIX)) { @@ -63,9 +61,14 @@ private String resolveAccessToken(HttpServletRequest request) { return null; } + // 엑세스토큰으로 인증 객체 생성, authentication 객체를 SecurityContext에 저장 private void setAuthentication(String accessToken) { - String email = jwtProvider.extractEmail(accessToken); - User user = userRepository.findByEmail(email) + Number userIdClaim = jwtProvider.parseClaims(accessToken).get("userId", Number.class); + if (userIdClaim == null) { + throw new CustomException(AuthErrorCode.UNAUTHORIZED); + } + + User user = userRepository.findById(userIdClaim.longValue()) .orElseThrow(() -> new CustomException(AuthErrorCode.USER_NOT_FOUND)); CustomUserDetails userDetails = new CustomUserDetails(user); @@ -75,41 +78,4 @@ private void setAuthentication(String accessToken) { ); SecurityContextHolder.getContext().setAuthentication(auth); } - - // 토큰 재발급 - private void reissueAccessToken(String expiredAccessToken, HttpServletResponse response) { - try { - Number userIdClaim = jwtProvider.parseClaims(expiredAccessToken).get("userId", Number.class); - if (userIdClaim == null) { - return; - } - Long userId = userIdClaim.longValue(); - - tokenRedisRepository.findById(userId).ifPresentOrElse(tokenRedis -> { - String refreshToken = tokenRedis.getRefreshToken(); - if (!jwtProvider.validateToken(refreshToken)) { - log.warn("[TOKEN] 리프레시 토큰 무효. accessToken 재발급 실패"); - return; - } - - User user = userRepository.findById(userId) - .orElseThrow(() -> new CustomException(AuthErrorCode.USER_NOT_FOUND)); - - String newAccessToken = jwtProvider.createAccessToken(user); - - // 새로운 액세스 토큰 발급 시 리프레시 토큰도 함께 재발급 - TokenRedis newTokenRedis = TokenRedis.builder() - .id(user.getId()) - .refreshToken(refreshToken) - .build(); - tokenRedisRepository.save(newTokenRedis); - - response.setHeader(HttpHeaders.AUTHORIZATION, JwtProvider.BEARER_PREFIX + newAccessToken); - log.info("[TOKEN] 토큰 만료, 새로운 토큰 발급 ={}", newAccessToken); - setAuthentication(newAccessToken); - }, () -> log.warn("[TOKEN] 저장된 리프레시 토큰 없음. accessToken 재발급 실패")); - } catch (Exception e) { - log.warn("[TOKEN] access token 재발급 실패"); - } - } } diff --git a/src/main/java/app/nook/user/jwt/JwtProvider.java b/src/main/java/app/nook/user/jwt/JwtProvider.java index 5b8d9b6d..2b115928 100644 --- a/src/main/java/app/nook/user/jwt/JwtProvider.java +++ b/src/main/java/app/nook/user/jwt/JwtProvider.java @@ -38,7 +38,6 @@ public void init() { public String createAccessToken(User user) { Date now = new Date(); return Jwts.builder() - .setSubject(user.getEmail()) .claim("userId", user.getId()) .claim("role", user.getRole().toString()) .setIssuedAt(now) @@ -109,11 +108,4 @@ public Claims parseClaims(String token) { return e.getClaims(); } } - - /** - * 토큰에서 이메일 추출 - */ - public String extractEmail(String token) { - return parseClaims(token).getSubject(); - } } diff --git a/src/main/java/app/nook/user/oauth/OAuthService.java b/src/main/java/app/nook/user/oauth/OAuthService.java index ccba33c1..df7a20e8 100644 --- a/src/main/java/app/nook/user/oauth/OAuthService.java +++ b/src/main/java/app/nook/user/oauth/OAuthService.java @@ -93,6 +93,7 @@ public UserDTO.LoginResponse login( .email(user.getEmail()) .nickName(user.getNickName()) .accessToken(accessToken) + .refreshToken(refreshToken) .build(); } diff --git a/src/main/java/app/nook/user/redis/TokenRedis.java b/src/main/java/app/nook/user/redis/TokenRedis.java index 27c8a862..ba559cf6 100644 --- a/src/main/java/app/nook/user/redis/TokenRedis.java +++ b/src/main/java/app/nook/user/redis/TokenRedis.java @@ -18,8 +18,9 @@ public class TokenRedis { @Id private Long id; + @Indexed private String refreshToken; @Indexed private String accessToken; -} \ No newline at end of file +} diff --git a/src/main/java/app/nook/user/redis/TokenRedisRepository.java b/src/main/java/app/nook/user/redis/TokenRedisRepository.java index 0c285f68..fba95952 100644 --- a/src/main/java/app/nook/user/redis/TokenRedisRepository.java +++ b/src/main/java/app/nook/user/redis/TokenRedisRepository.java @@ -6,6 +6,7 @@ public interface TokenRedisRepository extends CrudRepository { Optional findByAccessToken(String accessToken); + Optional findByRefreshToken(String refreshToken); void deleteById(Long userId); -} \ No newline at end of file +} diff --git a/src/main/java/app/nook/user/service/UserService.java b/src/main/java/app/nook/user/service/UserService.java index 68aee230..c8a7e42c 100644 --- a/src/main/java/app/nook/user/service/UserService.java +++ b/src/main/java/app/nook/user/service/UserService.java @@ -83,6 +83,38 @@ public UserDTO.LoginResponse getThisUser(User user) { .build(); } + @Transactional + public UserDTO.TokenReissueResponse reissueAccessToken(String refreshToken) { + if (!jwtProvider.validateToken(refreshToken)) { + if (jwtProvider.isExpiredToken(refreshToken)) { + throw new CustomException(AuthErrorCode.TOKEN_EXPIRED); + } + throw new CustomException(AuthErrorCode.INVALID_TOKEN); + } + + TokenRedis tokenRedis = tokenRedisRepository.findByRefreshToken(refreshToken) + .orElseThrow(() -> { + log.warn("[TOKEN REISSUE REUSE DETECTED] refresh token was valid but not found in Redis. Possible rotated token reuse."); + throw new CustomException(AuthErrorCode.INVALID_TOKEN); + }); + + User user = userRepository.findById(tokenRedis.getId()) + .orElseThrow(() -> new CustomException(AuthErrorCode.USER_NOT_FOUND)); + + String newAccessToken = jwtProvider.createAccessToken(user); + String newRefreshToken = jwtProvider.createRefreshToken(); + + tokenRedisRepository.save( + TokenRedis.builder() + .id(user.getId()) + .refreshToken(newRefreshToken) + .accessToken(newAccessToken) + .build() + ); + + return new UserDTO.TokenReissueResponse(newAccessToken, newRefreshToken); + } + private void validateDevUserRole(User user) { // DEV 로그인은 USER 권한만 허용 — 다른 역할은 존재하지 않는 것과 동일하게 처리 if (user.getRole() != UserRole.USER) { @@ -107,6 +139,7 @@ private UserDTO.LoginResponse issueTokens(User user) { .email(user.getEmail()) .nickName(user.getNickName()) .accessToken(accessToken) + .refreshToken(refreshToken) .build(); } } diff --git a/src/test/java/app/nook/controller/focus/FocusControllerTest.java b/src/test/java/app/nook/controller/focus/FocusControllerTest.java index 0390edf2..d45ba8bd 100644 --- a/src/test/java/app/nook/controller/focus/FocusControllerTest.java +++ b/src/test/java/app/nook/controller/focus/FocusControllerTest.java @@ -1,60 +1,62 @@ package app.nook.controller.focus; +import app.nook.focus.controller.FocusController; import app.nook.focus.dto.FocusRequestDto; import app.nook.focus.dto.FocusResponseDto; import app.nook.focus.service.FocusService; -import app.nook.global.common.AbstractRestDocsTests; +import app.nook.global.common.AbstractWebMvcRestDocsTests; +import app.nook.global.common.security.WithCustomUser; +import app.nook.global.config.WebSecurityConfig; import app.nook.global.docs.ApiResponseSnippet; import app.nook.user.domain.User; -import app.nook.user.domain.enums.UserRole; -import app.nook.user.service.CustomUserDetails; +import app.nook.user.filter.JwtExceptionFilter; +import app.nook.user.filter.JwtFilter; import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; import org.springframework.http.MediaType; import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.test.util.ReflectionTestUtils; import java.time.LocalDateTime; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; 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.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -public class FocusControllerTest extends AbstractRestDocsTests { +@WebMvcTest( + controllers = FocusController.class, + excludeFilters = @ComponentScan.Filter( + type = FilterType.ASSIGNABLE_TYPE, + classes = { + WebSecurityConfig.class, + JwtFilter.class, + JwtExceptionFilter.class + } + ) +) +public class FocusControllerTest extends AbstractWebMvcRestDocsTests { @MockitoBean private FocusService focusService; + @MockitoBean + private app.nook.focus.service.ThemeService themeService; + @Autowired private ObjectMapper objectMapper; - private CustomUserDetails userDetails; - private User user; - - @BeforeEach - void setUpUser() { - user = User.builder() - .email("test@example.com") - .nickName("테스터") - .provider("google") - .providerId("provider-id") - .role(UserRole.USER) - .build(); - - ReflectionTestUtils.setField(user, "id", 1L); - userDetails = new CustomUserDetails(user); - } - @Test + @WithCustomUser @DisplayName("포커스 시작 성공") void 포커스_시작_성공() throws Exception { // given @@ -76,8 +78,7 @@ void setUpUser() { // when & then mockMvc.perform( post("/api/v1/focuses/start") - .header("Authorization", "Bearer test-access-token") - .with(user(userDetails)) + .header(AUTH_HEADER, AUTH_TOKEN) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request)) ) @@ -104,6 +105,7 @@ void setUpUser() { } @Test + @WithCustomUser @DisplayName("포커스 종료 성공") void 포커스_종료_성공() throws Exception { // given @@ -125,13 +127,12 @@ void setUpUser() { "FINISHED" ); - given(focusService.endFocus(eq(1L), eq(request))).willReturn(response); + given(focusService.endFocus(anyLong(), eq(request))).willReturn(response); // when & then mockMvc.perform( post("/api/v1/focuses/end") - .header("Authorization", "Bearer test-access-token") - .with(user(userDetails)) + .header(AUTH_HEADER, AUTH_TOKEN) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request)) ) @@ -158,4 +159,4 @@ void setUpUser() { ) )); } -} \ No newline at end of file +} diff --git a/src/test/java/app/nook/controller/focus/FocusThemeControllerTest.java b/src/test/java/app/nook/controller/focus/FocusThemeControllerTest.java index c41576b4..599a5d74 100644 --- a/src/test/java/app/nook/controller/focus/FocusThemeControllerTest.java +++ b/src/test/java/app/nook/controller/focus/FocusThemeControllerTest.java @@ -1,17 +1,20 @@ package app.nook.controller.focus; +import app.nook.focus.controller.FocusController; import app.nook.focus.dto.FocusResponseDto; import app.nook.focus.service.ThemeService; -import app.nook.global.common.AbstractRestDocsTests; +import app.nook.global.common.AbstractWebMvcRestDocsTests; +import app.nook.global.common.security.WithCustomUser; +import app.nook.global.config.WebSecurityConfig; import app.nook.global.docs.ApiResponseSnippet; -import app.nook.user.domain.User; -import app.nook.user.domain.enums.UserRole; -import app.nook.user.service.CustomUserDetails; -import org.junit.jupiter.api.BeforeEach; +import app.nook.user.filter.JwtExceptionFilter; +import app.nook.user.filter.JwtFilter; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.test.util.ReflectionTestUtils; import java.util.List; @@ -19,30 +22,29 @@ import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -public class FocusThemeControllerTest extends AbstractRestDocsTests { +@WebMvcTest( + controllers = FocusController.class, + excludeFilters = @ComponentScan.Filter( + type = FilterType.ASSIGNABLE_TYPE, + classes = { + WebSecurityConfig.class, + JwtFilter.class, + JwtExceptionFilter.class + } + ) +) +public class FocusThemeControllerTest extends AbstractWebMvcRestDocsTests { @MockitoBean private ThemeService themeService; - private CustomUserDetails userDetails; - - @BeforeEach - void setUpUser() { - User user = User.builder() - .email("test@example.com") - .nickName("테스터") - .provider("google") - .providerId("provider-id") - .role(UserRole.USER) - .build(); - ReflectionTestUtils.setField(user, "id", 1L); - userDetails = new CustomUserDetails(user); - } + @MockitoBean + private app.nook.focus.service.FocusService focusService; @Test + @WithCustomUser @DisplayName("포커스 테마 목록 조회 성공") void 포커스_테마목록_조회_성공() throws Exception { // given @@ -60,8 +62,7 @@ void setUpUser() { // when & then mockMvc.perform( get("/api/v1/focuses/themes") - .header("Authorization", "Bearer test-access-token") - .with(user(userDetails)) + .header(AUTH_HEADER, AUTH_TOKEN) ) .andExpect(status().isOk()) .andDo(documentWithAuth( diff --git a/src/test/java/app/nook/controller/library/LibraryControllerTest.java b/src/test/java/app/nook/controller/library/LibraryControllerTest.java index 80396398..dcbfaa3d 100644 --- a/src/test/java/app/nook/controller/library/LibraryControllerTest.java +++ b/src/test/java/app/nook/controller/library/LibraryControllerTest.java @@ -73,6 +73,7 @@ class LibraryControllerTest extends AbstractWebMvcRestDocsTests { ObjectMapper objectMapper; @Test + @DisplayName("서재에 책 등록 성공") @WithCustomUser void 서재_책_등록_성공() throws Exception { willDoNothing().given(libraryCommandService).registerBook(anyLong(), anyLong()); @@ -94,6 +95,7 @@ class LibraryControllerTest extends AbstractWebMvcRestDocsTests { } @Test + @DisplayName("서재에서 책 삭제 성공") @WithCustomUser void 서재_책_삭제_성공() throws Exception { willDoNothing().given(libraryCommandService).deleteByBookId(anyLong(), anyLong()); @@ -115,6 +117,7 @@ class LibraryControllerTest extends AbstractWebMvcRestDocsTests { } @Test + @DisplayName("서재 책 상태변경 성공") @WithCustomUser void 서재_책_상태변경_성공() throws Exception { ReadingStatusRequestDto request = new ReadingStatusRequestDto(1L, ReadingStatus.READING); @@ -161,6 +164,7 @@ class LibraryControllerTest extends AbstractWebMvcRestDocsTests { } @Test + @DisplayName("서재 상태별 책 조회 성공") @WithCustomUser void 서재_상태별_책_조회_성공() throws Exception { LibraryViewDto.UserStatusBookItem item = diff --git a/src/test/java/app/nook/controller/record/RecordControllerTest.java b/src/test/java/app/nook/controller/record/RecordControllerTest.java index 46ceebfd..84b2ba87 100644 --- a/src/test/java/app/nook/controller/record/RecordControllerTest.java +++ b/src/test/java/app/nook/controller/record/RecordControllerTest.java @@ -21,6 +21,7 @@ import app.nook.user.filter.JwtExceptionFilter; import app.nook.user.filter.JwtFilter; import com.fasterxml.jackson.databind.ObjectMapper; +import org.hamcrest.Matchers; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -174,7 +175,7 @@ class GetUserRecords { @Test @WithCustomUser - void 독서_기록_목록_조회_데이터없음_204() throws Exception { + void 독서_기록_목록_조회_빈응답() throws Exception { // given CursorResponse response = CursorResponse.of( List.of(), @@ -190,15 +191,23 @@ class GetUserRecords { .header(AUTH_HEADER, AUTH_TOKEN)) .andExpect(status().isOk()) .andExpect(jsonPath("$.isSuccess").value(true)) - .andExpect(jsonPath("$.code").value("SUCCESS-204")) + .andExpect(jsonPath("$.code").value("SUCCESS-200")) + .andExpect(jsonPath("$.result.items").isArray()) + .andExpect(jsonPath("$.result.items").isEmpty()) + .andExpect(jsonPath("$.result.nextCursor").value(Matchers.nullValue())) + .andExpect(jsonPath("$.result.hasNext").value(false)) .andDo(documentWithAuth( - "record-controller-test/독서_기록_목록_조회_데이터없음_204", + "record-controller-test/독서_기록_목록_조회_빈응답", queryParameters( parameterWithName("cursor").description("다음 페이지 조회에 사용할 단일 커서 문자열. 첫 요청이면 전달하지 않음").optional(), parameterWithName("size").description("한 번에 조회할 독서 기록 묶음 수. 기본값은 20").optional(), parameterWithName("order").description("정렬 기준. RECENT_RECORDED(최근 독서 기록 순), OLDEST_RECORDED(오래된 기록 순), RECORD_COUNT_ASC(기록 개수 적은 순), RECORD_COUNT_DESC(기록 개수 많은 순) 중 하나 사용").optional() ), - responseFields(ApiResponseSnippet.commonResponseFieldsWithNullableResult()) + responseFields(ApiResponseSnippet.withResult( + fieldWithPath("result.items").type(JsonFieldType.ARRAY).description("조회된 독서 기록 목록. 빈 결과면 빈 배열"), + fieldWithPath("result.nextCursor").type(JsonFieldType.NULL).description("다음 페이지 조회 커서. 빈 결과면 null").optional(), + fieldWithPath("result.hasNext").type(JsonFieldType.BOOLEAN).description("다음 페이지 존재 여부") + )) )); } } @@ -272,7 +281,7 @@ class GetBookRecords { @Test @WithCustomUser - void 특정_책_기록_감정_필터_조회_데이터없음_204() throws Exception { + void 특정_책_기록_감정_필터_조회_빈응답() throws Exception { // given CursorResponse response = CursorResponse.of( List.of(), @@ -288,9 +297,13 @@ class GetBookRecords { .header(AUTH_HEADER, AUTH_TOKEN) .param("emotion", "ALL")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value("SUCCESS-204")) + .andExpect(jsonPath("$.code").value("SUCCESS-200")) + .andExpect(jsonPath("$.result.items").isArray()) + .andExpect(jsonPath("$.result.items").isEmpty()) + .andExpect(jsonPath("$.result.nextCursor").value(Matchers.nullValue())) + .andExpect(jsonPath("$.result.hasNext").value(false)) .andDo(documentWithAuth( - "record-controller-test/특정_책_기록_감정_필터_조회_데이터없음_204", + "record-controller-test/특정_책_기록_감정_필터_조회_빈응답", pathParameters( parameterWithName("bookId").description("기록을 조회할 도서 ID") ), @@ -299,7 +312,11 @@ class GetBookRecords { parameterWithName("size").description("한 번에 조회할 기록 수. 기본값은 20").optional(), parameterWithName("emotion").description("감정 필터. ALL, FUN, EMPATHIZING, USEFUL, COMPLICATED, SAD, UNCOMFORTABLE 중 하나 사용. ALL이면 전체 반환").optional() ), - responseFields(ApiResponseSnippet.commonResponseFieldsWithNullableResult()) + responseFields(ApiResponseSnippet.withResult( + fieldWithPath("result.items").type(JsonFieldType.ARRAY).description("조회된 기록 목록. 빈 결과면 빈 배열"), + fieldWithPath("result.nextCursor").type(JsonFieldType.NULL).description("다음 페이지 조회 커서. 빈 결과면 null").optional(), + fieldWithPath("result.hasNext").type(JsonFieldType.BOOLEAN).description("다음 페이지 존재 여부") + )) )); } } @@ -315,29 +332,39 @@ class GetRecordEmotionCounts { BookRecordDto.RecordEmotionCountResponse response = new BookRecordDto.RecordEmotionCountResponse( 7L, List.of( - new BookRecordDto.RecordEmotionDto(Emotion.FUN, 3L), - new BookRecordDto.RecordEmotionDto(Emotion.EMPATHIZING, 2L), - new BookRecordDto.RecordEmotionDto(Emotion.USEFUL, 2L) + new BookRecordDto.RecordEmotionDto("ALL", 7L), + new BookRecordDto.RecordEmotionDto("FUN", 3L), + new BookRecordDto.RecordEmotionDto("EMPATHIZING", 2L), + new BookRecordDto.RecordEmotionDto("USEFUL", 2L), + new BookRecordDto.RecordEmotionDto("COMPLICATED", 0L), + new BookRecordDto.RecordEmotionDto("SAD", 0L), + new BookRecordDto.RecordEmotionDto("UNCOMFORTABLE", 0L) ) ); - given(recordQueryService.getRecordEmotionCounts(any())) + given(recordQueryService.getRecordEmotionCounts(any(), anyLong())) .willReturn(response); // when & then - mockMvc.perform(get("/api/v1/records/emotions") + mockMvc.perform(get("/api/v1/records/emotions/{bookId}", 101L) .header(AUTH_HEADER, AUTH_TOKEN)) .andExpect(status().isOk()) .andExpect(jsonPath("$.result.totalCount").value(7)) - .andExpect(jsonPath("$.result.emotionCounts[0].emotion").value("FUN")) - .andExpect(jsonPath("$.result.emotionCounts[0].recordCount").value(3)) + .andExpect(jsonPath("$.result.emotionCounts[0].emotion").value("ALL")) + .andExpect(jsonPath("$.result.emotionCounts[0].recordCount").value(7)) + .andExpect(jsonPath("$.result.emotionCounts[1].emotion").value("FUN")) + .andExpect(jsonPath("$.result.emotionCounts[1].recordCount").value(3)) + .andExpect(jsonPath("$.result.emotionCounts.length()").value(7)) .andDo(documentWithAuth( "record-controller-test/독서_기록_감정별_개수_조회_성공", + pathParameters( + parameterWithName("bookId").description("감정별 기록 개수를 조회할 도서 ID") + ), responseFields(ApiResponseSnippet.withResult( - fieldWithPath("result.totalCount").type(JsonFieldType.NUMBER).description("사용자가 작성한 전체 독서 기록 수"), - fieldWithPath("result.emotionCounts").type(JsonFieldType.ARRAY).description("감정별로 집계한 독서 기록 개수 목록"), - fieldWithPath("result.emotionCounts[].emotion").type(JsonFieldType.STRING).description("기록에 저장된 감정 값. FUN, EMPATHIZING, USEFUL, COMPLICATED, SAD, UNCOMFORTABLE 중 하나"), - fieldWithPath("result.emotionCounts[].recordCount").type(JsonFieldType.NUMBER).description("해당 감정으로 작성된 독서 기록 수") + fieldWithPath("result.totalCount").type(JsonFieldType.NUMBER).description("해당 도서에 대해 사용자가 작성한 전체 독서 기록 수. emotion 미입력으로 EMPTY 저장된 기록도 ALL(totalCount)에는 포함되지만 개별 감정 버킷에는 포함되지 않음"), + fieldWithPath("result.emotionCounts").type(JsonFieldType.ARRAY).description("해당 도서의 기록을 집계한 목록. ALL은 전체 기록 수를 뜻하고, 나머지 감정은 기록이 없어도 0으로 포함"), + fieldWithPath("result.emotionCounts[].emotion").type(JsonFieldType.STRING).description("집계 기준 값. ALL, FUN, EMPATHIZING, USEFUL, COMPLICATED, SAD, UNCOMFORTABLE 중 하나. 요청에서 emotion 미입력 시 EMPTY로 저장되지만 EMPTY 값은 별도 집계 버킷으로 제공되지 않음"), + fieldWithPath("result.emotionCounts[].recordCount").type(JsonFieldType.NUMBER).description("해당 기준으로 집계된 기록 수") )) )); } @@ -378,7 +405,7 @@ class Success { ), requestFields( fieldWithPath("content").type(JsonFieldType.STRING).description("기록 내용"), - fieldWithPath("emotion").type(JsonFieldType.STRING).description("기록 감정 값. FUN, EMPATHIZING, USEFUL, COMPLICATED, SAD, UNCOMFORTABLE 중 하나"), + fieldWithPath("emotion").type(JsonFieldType.STRING).description("기록 감정 값. FUN, EMPATHIZING, USEFUL, COMPLICATED, SAD, UNCOMFORTABLE, EMPTY 중 하나. 미입력 시 EMPTY로 저장").optional(), fieldWithPath("imageKeys").type(JsonFieldType.ARRAY).description("업로드 완료된 이미지 key 목록").optional(), fieldWithPath("imageKeys[]").description("업로드 완료된 record 이미지 key") ), @@ -415,42 +442,124 @@ class Failure { @Nested class UpdateRecord { - @Test - @WithCustomUser - void 기록_수정_성공() throws Exception { - // given - RecordUpdateRequestDto request = new RecordUpdateRequestDto( - "수정된 기록 내용입니다.", - Emotion.EMPATHIZING, - List.of( - "record/users/1/first.png", - "record/users/1/second.png" - ) - ); + @DisplayName("성공") + @Nested + class Success { - willDoNothing().given(recordCommandService).updateRecord(any(), anyLong(), any()); + @Test + @WithCustomUser + void 기록_수정_성공() throws Exception { + // given + RecordUpdateRequestDto request = new RecordUpdateRequestDto( + "수정된 기록 내용입니다.", + Emotion.EMPATHIZING, + List.of( + "record/users/1/first.png", + "record/users/1/second.png" + ) + ); - // when & then - mockMvc.perform(put("/api/v1/records/{recordId}", 10L) - .header(AUTH_HEADER, AUTH_TOKEN) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.isSuccess").value(true)) - .andExpect(jsonPath("$.code").value("SUCCESS-200")) - .andDo(documentWithAuth( - "record-controller-test/기록_수정_성공", - pathParameters( - parameterWithName("recordId").description("수정할 기록 ID") - ), - requestFields( - fieldWithPath("content").type(JsonFieldType.STRING).description("수정할 기록 내용"), - fieldWithPath("emotion").type(JsonFieldType.STRING).description("수정할 기록 감정 값. FUN, EMPATHIZING, USEFUL, COMPLICATED, SAD, UNCOMFORTABLE 중 하나"), - fieldWithPath("imageKeys").type(JsonFieldType.ARRAY).description("수정 후 최종 이미지 key 목록").optional(), - fieldWithPath("imageKeys[]").description("업로드 완료된 record 이미지 key") - ), - responseFields(ApiResponseSnippet.commonResponseFieldsWithNullableResult()) - )); + willDoNothing().given(recordCommandService).updateRecord(any(), anyLong(), any()); + + // when & then + mockMvc.perform(put("/api/v1/records/{recordId}", 10L) + .header(AUTH_HEADER, AUTH_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.code").value("SUCCESS-200")) + .andDo(documentWithAuth( + "record-controller-test/기록_수정_성공", + pathParameters( + parameterWithName("recordId").description("수정할 기록 ID") + ), + requestFields( + fieldWithPath("content").type(JsonFieldType.STRING).description("수정할 기록 내용"), + fieldWithPath("emotion").type(JsonFieldType.STRING).description("수정할 기록 감정 값. FUN, EMPATHIZING, USEFUL, COMPLICATED, SAD, UNCOMFORTABLE, EMPTY 중 하나. 미입력 시 EMPTY로 저장").optional(), + fieldWithPath("imageKeys").type(JsonFieldType.ARRAY).description("수정 후 최종 이미지 key 목록").optional(), + fieldWithPath("imageKeys[]").description("업로드 완료된 record 이미지 key") + ), + responseFields(ApiResponseSnippet.commonResponseFieldsWithNullableResult()) + )); + } + } + + @DisplayName("실패") + @Nested + class Failure { + + @Test + @WithCustomUser + void 기록_수정_실패_기록없음() throws Exception { + // given + RecordUpdateRequestDto request = new RecordUpdateRequestDto( + "수정된 기록 내용입니다.", + Emotion.EMPATHIZING, + List.of() + ); + + willThrow(new CustomException(RecordErrorCode.RECORD_NOT_FOUND)) + .given(recordCommandService).updateRecord(any(), anyLong(), any()); + + // when & then + mockMvc.perform(put("/api/v1/records/{recordId}", 999L) + .header(AUTH_HEADER, AUTH_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.isSuccess").value(false)) + .andExpect(jsonPath("$.code").value("RECORD-404")) + .andDo(documentWithAuth( + "record-controller-test/기록_수정_실패_기록없음", + pathParameters( + parameterWithName("recordId").description("수정하려는 기록 ID. 존재하지 않으면 실패") + ), + requestFields( + fieldWithPath("content").type(JsonFieldType.STRING).description("수정할 기록 내용"), + fieldWithPath("emotion").type(JsonFieldType.STRING).description("수정할 기록 감정 값. FUN, EMPATHIZING, USEFUL, COMPLICATED, SAD, UNCOMFORTABLE, EMPTY 중 하나. 미입력 시 EMPTY로 저장").optional(), + fieldWithPath("imageKeys").type(JsonFieldType.ARRAY).description("수정 후 최종 이미지 key 목록").optional(), + fieldWithPath("imageKeys[]").description("업로드 완료된 record 이미지 key").optional() + ), + responseFields(ApiResponseSnippet.commonResponseFieldsWithNullableResult()) + )); + } + + @Test + @WithCustomUser + void 기록_수정_실패_권한없음() throws Exception { + // given + RecordUpdateRequestDto request = new RecordUpdateRequestDto( + "수정된 기록 내용입니다.", + Emotion.EMPATHIZING, + List.of() + ); + + willThrow(new CustomException(RecordErrorCode.RECORD_NOT_AUTHORIZED)) + .given(recordCommandService).updateRecord(any(), anyLong(), any()); + + // when & then + mockMvc.perform(put("/api/v1/records/{recordId}", 10L) + .header(AUTH_HEADER, AUTH_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.isSuccess").value(false)) + .andExpect(jsonPath("$.code").value("RECORD-403")) + .andDo(documentWithAuth( + "record-controller-test/기록_수정_실패_권한없음", + pathParameters( + parameterWithName("recordId").description("수정하려는 기록 ID. 본인 기록이 아니면 실패") + ), + requestFields( + fieldWithPath("content").type(JsonFieldType.STRING).description("수정할 기록 내용"), + fieldWithPath("emotion").type(JsonFieldType.STRING).description("수정할 기록 감정 값. FUN, EMPATHIZING, USEFUL, COMPLICATED, SAD, UNCOMFORTABLE, EMPTY 중 하나. 미입력 시 EMPTY로 저장").optional(), + fieldWithPath("imageKeys").type(JsonFieldType.ARRAY).description("수정 후 최종 이미지 key 목록").optional(), + fieldWithPath("imageKeys[]").description("업로드 완료된 record 이미지 key").optional() + ), + responseFields(ApiResponseSnippet.commonResponseFieldsWithNullableResult()) + )); + } } } @@ -503,6 +612,90 @@ class Failure { } } + @DisplayName("기록 개별 조회") + @Nested + class GetRecordDetail { + + @DisplayName("성공") + @Nested + class Success { + + @Test + @WithCustomUser + void 기록_개별_조회_성공() throws Exception { + // given + BookRecordDto.RecordItemDto response = new BookRecordDto.RecordItemDto( + 1L, + "가장 인상 깊었던 구절을 적어둔 기록", + List.of( + "https://example.com/records/1-1.png", + "https://example.com/records/1-2.png" + ), + Emotion.FUN, + java.time.LocalDate.of(2026, 4, 10) + ); + + given(recordQueryService.getRecordDetail(any(), anyLong())).willReturn(response); + + // when & then + mockMvc.perform(get("/api/v1/records/{recordId}", 1L) + .header(AUTH_HEADER, AUTH_TOKEN)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.recordId").value(1)) + .andExpect(jsonPath("$.result.content").value("가장 인상 깊었던 구절을 적어둔 기록")) + .andExpect(jsonPath("$.result.emotion").value("FUN")) + .andDo(documentWithAuth( + "record-controller-test/기록_개별_조회_성공", + pathParameters( + parameterWithName("recordId").description("조회할 기록 ID") + ), + responseFields(ApiResponseSnippet.withResult( + fieldWithPath("result.recordId").type(JsonFieldType.NUMBER).description("기록 ID"), + fieldWithPath("result.content").type(JsonFieldType.STRING).description("기록 내용"), + fieldWithPath("result.imgUrls").type(JsonFieldType.ARRAY).description("기록에 연결된 이미지 조회 URL 목록. 저장된 key를 기준으로 조회 시점에 presigned GET URL로 변환"), + fieldWithPath("result.emotion").type(JsonFieldType.STRING).description("기록 감정 값. EMPTY, FUN, EMPATHIZING, USEFUL, COMPLICATED, SAD, UNCOMFORTABLE 중 하나"), + fieldWithPath("result.createdDate").type(JsonFieldType.STRING).description("기록 작성 날짜 (yyyy-MM-dd)") + )) + )); + } + } + + @DisplayName("실패") + @Nested + class Failure { + + @Test + @WithCustomUser + void 기록_개별_조회_실패_기록없음() throws Exception { + // given + given(recordQueryService.getRecordDetail(any(), anyLong())) + .willThrow(new CustomException(RecordErrorCode.RECORD_NOT_FOUND)); + + // when & then + mockMvc.perform(get("/api/v1/records/{recordId}", 999L) + .header(AUTH_HEADER, AUTH_TOKEN)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.isSuccess").value(false)) + .andExpect(jsonPath("$.code").value("RECORD-404")); + } + + @Test + @WithCustomUser + void 기록_개별_조회_실패_권한없음() throws Exception { + // given + given(recordQueryService.getRecordDetail(any(), anyLong())) + .willThrow(new CustomException(RecordErrorCode.RECORD_NOT_AUTHORIZED)); + + // when & then + mockMvc.perform(get("/api/v1/records/{recordId}", 1L) + .header(AUTH_HEADER, AUTH_TOKEN)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.isSuccess").value(false)) + .andExpect(jsonPath("$.code").value("RECORD-403")); + } + } + } + @DisplayName("기록 전체 개수 조회") @Nested class CountRecords { diff --git a/src/test/java/app/nook/controller/user/AuthControllerTest.java b/src/test/java/app/nook/controller/user/AuthControllerTest.java index 87b24d6b..8f07eeb5 100644 --- a/src/test/java/app/nook/controller/user/AuthControllerTest.java +++ b/src/test/java/app/nook/controller/user/AuthControllerTest.java @@ -68,6 +68,7 @@ class AuthControllerTest extends AbstractWebMvcRestDocsTests { .email("jiwon@kakao.com") .nickName("jiwon") .accessToken("test-access-token") + .refreshToken("test-refresh-token") .build(); given(oAuthService.login(any(), any())) @@ -92,7 +93,8 @@ class AuthControllerTest extends AbstractWebMvcRestDocsTests { fieldWithPath("result.id").description("사용자ID"), fieldWithPath("result.email").description("이메일"), fieldWithPath("result.nickName").description("닉네임"), - fieldWithPath("result.accessToken").description("엑세스 토큰") + fieldWithPath("result.accessToken").description("엑세스 토큰"), + fieldWithPath("result.refreshToken").description("리프레시 토큰") ) ) )); @@ -110,6 +112,7 @@ class AuthControllerTest extends AbstractWebMvcRestDocsTests { .email("dev@test.com") .nickName("DEV_USER") .accessToken("test-access-token") + .refreshToken("test-refresh-token") .build(); given(userService.devLogin(any())) @@ -134,7 +137,8 @@ class AuthControllerTest extends AbstractWebMvcRestDocsTests { fieldWithPath("result.id").description("사용자ID"), fieldWithPath("result.email").description("이메일"), fieldWithPath("result.nickName").description("닉네임"), - fieldWithPath("result.accessToken").description("엑세스 토큰") + fieldWithPath("result.accessToken").description("엑세스 토큰"), + fieldWithPath("result.refreshToken").description("리프레시 토큰") ) ) )); @@ -224,6 +228,48 @@ class AuthControllerTest extends AbstractWebMvcRestDocsTests { .andExpect(status().isConflict()); } + @Test + void 토큰_재발급_성공() throws Exception { + UserDTO.TokenReissueRequest request = new UserDTO.TokenReissueRequest("valid-refresh-token"); + UserDTO.TokenReissueResponse response = new UserDTO.TokenReissueResponse("new-access-token", "new-refresh-token"); + + given(userService.reissueAccessToken(any())) + .willReturn(response); + + mockMvc.perform( + post("/api/v1/auth/reissue") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ) + .andExpect(status().isOk()) + .andDo(restDocs.document( + requestFields( + fieldWithPath("refreshToken").description("새 access token 발급에 사용할 refresh token") + ), + responseFields( + ApiResponseSnippet.withResult( + fieldWithPath("result.accessToken").description("재발급된 access token"), + fieldWithPath("result.refreshToken").description("재발급된 refresh token") + ) + ) + )); + } + + @Test + void 토큰_재발급_실패_만료된_리프레시토큰() throws Exception { + UserDTO.TokenReissueRequest request = new UserDTO.TokenReissueRequest("expired-refresh-token"); + + given(userService.reissueAccessToken(any())) + .willThrow(new CustomException(AuthErrorCode.TOKEN_EXPIRED)); + + mockMvc.perform( + post("/api/v1/auth/reissue") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ) + .andExpect(status().isUnauthorized()); + } + @Test void 내정보_조회_성공() throws Exception { // given diff --git a/src/test/java/app/nook/record/repository/RecordRepositoryTest.java b/src/test/java/app/nook/record/repository/RecordRepositoryTest.java index a66d9c29..d771bd78 100644 --- a/src/test/java/app/nook/record/repository/RecordRepositoryTest.java +++ b/src/test/java/app/nook/record/repository/RecordRepositoryTest.java @@ -135,23 +135,26 @@ class QueryDslQuery { persistRecord(library, Emotion.FUN, "재미1", LocalDateTime.of(2026, 4, 1, 10, 0)); persistRecord(library, Emotion.FUN, "재미2", LocalDateTime.of(2026, 4, 2, 10, 0)); persistRecord(library, Emotion.SAD, "슬픔", LocalDateTime.of(2026, 4, 3, 10, 0)); + Book otherBook = persistBook("다른 책", "다른 작가"); + Library otherBookLibrary = persistLibrary(user, otherBook); + persistRecord(otherBookLibrary, Emotion.FUN, "다른 책 기록", LocalDateTime.of(2026, 4, 3, 11, 0)); persistRecord(otherLibrary, Emotion.USEFUL, "다른 사용자", LocalDateTime.of(2026, 4, 4, 10, 0)); em.flush(); em.clear(); // when - BookRecordDto.RecordEmotionCountResponse result = recordRepository.countRecordsByEmotion(user.getId()); + BookRecordDto.RecordEmotionCountResponse result = recordRepository.countRecordsByEmotion(user.getId(), book.getId()); // then assertThat(result.totalCount()).isEqualTo(3L); assertThat(result.emotionCounts()).hasSize(2); assertThat(result.emotionCounts()) .anySatisfy(item -> { - assertThat(item.emotion()).isEqualTo(Emotion.FUN); + assertThat(item.emotion()).isEqualTo("FUN"); assertThat(item.recordCount()).isEqualTo(2L); }) .anySatisfy(item -> { - assertThat(item.emotion()).isEqualTo(Emotion.SAD); + assertThat(item.emotion()).isEqualTo("SAD"); assertThat(item.recordCount()).isEqualTo(1L); }); } diff --git a/src/test/java/app/nook/record/service/RecordQueryServiceTest.java b/src/test/java/app/nook/record/service/RecordQueryServiceTest.java index 3806fd40..3f0fb500 100644 --- a/src/test/java/app/nook/record/service/RecordQueryServiceTest.java +++ b/src/test/java/app/nook/record/service/RecordQueryServiceTest.java @@ -212,22 +212,70 @@ class GetRecordEmotionCounts { void 감상별_개수_조회() { // given User user = UserFixture.user(); + Long bookId = 10L; List emotionCounts = List.of( - new BookRecordDto.RecordEmotionDto(Emotion.FUN, 5L), - new BookRecordDto.RecordEmotionDto(Emotion.SAD, 3L) + new BookRecordDto.RecordEmotionDto("FUN", 5L), + new BookRecordDto.RecordEmotionDto("SAD", 3L) ); - given(recordRepository.countRecordsByEmotion(1L)) + given(bookRepository.existsById(bookId)).willReturn(true); + given(libraryRepository.existsByUserIdAndBookId(user.getId(), bookId)).willReturn(true); + given(recordRepository.countRecordsByEmotion(1L, bookId)) .willReturn(new BookRecordDto.RecordEmotionCountResponse(8L, emotionCounts)); // when - BookRecordDto.RecordEmotionCountResponse result = recordQueryService.getRecordEmotionCounts(user); + BookRecordDto.RecordEmotionCountResponse result = recordQueryService.getRecordEmotionCounts(user, bookId); // then assertThat(result.totalCount()).isEqualTo(8L); - assertThat(result.emotionCounts()).hasSize(2); - assertThat(result.emotionCounts().get(0).emotion()).isEqualTo(Emotion.FUN); - assertThat(result.emotionCounts().get(1).emotion()).isEqualTo(Emotion.SAD); + assertThat(result.emotionCounts()).hasSize(Emotion.values().length); + assertThat(result.emotionCounts().get(0).emotion()).isEqualTo("ALL"); + assertThat(result.emotionCounts().get(0).recordCount()).isEqualTo(8L); + assertThat(result.emotionCounts().get(1).emotion()).isEqualTo("FUN"); + assertThat(result.emotionCounts().get(1).recordCount()).isEqualTo(5L); + assertThat(result.emotionCounts().get(2).emotion()).isEqualTo("EMPATHIZING"); + assertThat(result.emotionCounts().get(2).recordCount()).isZero(); + assertThat(result.emotionCounts().get(5).emotion()).isEqualTo("SAD"); + assertThat(result.emotionCounts().get(5).recordCount()).isEqualTo(3L); + assertThat(result.emotionCounts().get(6).emotion()).isEqualTo("UNCOMFORTABLE"); + assertThat(result.emotionCounts().get(6).recordCount()).isZero(); + assertThat(result.emotionCounts()).noneMatch(item -> item.emotion().equals("EMPTY")); + verify(recordRepository).countRecordsByEmotion(1L, bookId); + } + + @Test + @DisplayName("실패 - 존재하지 않는 책이면 예외를 던진다") + void 감상별_개수_조회_실패_책없음() { + // given + User user = UserFixture.user(); + given(bookRepository.existsById(10L)).willReturn(false); + + // when + CustomException exception = assertThrows( + CustomException.class, + () -> recordQueryService.getRecordEmotionCounts(user, 10L) + ); + + // then + assertThat(exception.getErrorCode()).isEqualTo(BookErrorCode.BOOK_NOT_FOUND); + } + + @Test + @DisplayName("실패 - 서재에 없는 책이면 예외를 던진다") + void 감상별_개수_조회_실패_서재없음() { + // given + User user = UserFixture.user(); + given(bookRepository.existsById(10L)).willReturn(true); + given(libraryRepository.existsByUserIdAndBookId(user.getId(), 10L)).willReturn(false); + + // when + CustomException exception = assertThrows( + CustomException.class, + () -> recordQueryService.getRecordEmotionCounts(user, 10L) + ); + + // then + assertThat(exception.getErrorCode()).isEqualTo(LibraryErrorCode.BOOK_NOT_EXIST); } } @@ -475,6 +523,24 @@ class Failure { // then assertThat(exception.getErrorCode().getCode()).isEqualTo("COMMON-002"); } + + @Test + @DisplayName("실패 - EMPTY 감정 필터면 예외를 던진다") + void 도서별_기록조회_실패_empty감정필터() { + // given + User user = UserFixture.user(); + given(bookRepository.existsById(10L)).willReturn(true); + given(libraryRepository.existsByUserIdAndBookId(user.getId(), 10L)).willReturn(true); + + // when + CustomException exception = assertThrows( + CustomException.class, + () -> recordQueryService.getBookRecords(user, 10L, 10, null, "EMPTY") + ); + + // then + assertThat(exception.getErrorCode().getCode()).isEqualTo("COMMON-002"); + } } } diff --git a/src/test/java/app/nook/record/service/RecordServiceTest.java b/src/test/java/app/nook/record/service/RecordServiceTest.java index 534fa444..5466994d 100644 --- a/src/test/java/app/nook/record/service/RecordServiceTest.java +++ b/src/test/java/app/nook/record/service/RecordServiceTest.java @@ -145,6 +145,29 @@ class CreateRecord { verify(timelineCommandService).appendRecordCreated(any(Record.class), eq(1)); } + @Test + @DisplayName("성공 - emotion이 null이면 EMPTY로 저장한다") + void 기록_생성_성공_emotion_null이면_empty로저장() { + // given + User user = UserFixture.user(); + Book book = BookFixture.book(); + Library library = LibraryFixture.library(user, book); + RecordRequestDto request = new RecordRequestDto("내용", null, List.of()); + ArgumentCaptor recordCaptor = ArgumentCaptor.forClass(Record.class); + + given(bookRepository.findById(10L)).willReturn(Optional.of(book)); + given(libraryRepository.findByUserAndBook(user, book)).willReturn(Optional.of(library)); + given(libraryRepository.findByIdAndUserIdForUpdate(20L, 1L)).willReturn(Optional.of(library)); + given(recordRepository.countByLibraryIdAndUserId(20L, 1L)).willReturn(0L); + + // when + recordService.createRecord(user, 10L, request); + + // then + verify(recordRepository).save(recordCaptor.capture()); + assertThat(recordCaptor.getValue().getEmotion()).isEqualTo(Emotion.EMPTY); + } + @Test @DisplayName("실패 - 존재하지 않는 책이면 예외를 던진다") void 기록_생성_실패_책없음() { @@ -378,6 +401,29 @@ class Success { // 타임라인에는 수정이므로 새 이미지 개수는 0으로 전달 verify(timelineCommandService, never()).appendRecordCreated(any(), anyInt()); } + + @Test + @DisplayName("성공 - emotion이 null이면 EMPTY로 수정한다") + void 기록_수정_성공_emotion_null이면_empty로수정() { + // given + User user = UserFixture.user(); + Book book = BookFixture.book(); + Library library = LibraryFixture.library(user, book); + Record record = record(library, Emotion.FUN, "재미있는 책이었다."); + + RecordUpdateRequestDto request = new RecordUpdateRequestDto( + "유용한 책이었다.", null, List.of() + ); + + given(recordRepository.findById(1L)).willReturn(Optional.of(record)); + + // when + recordService.updateRecord(user, 1L, request); + + // then + assertThat(record.getContent()).isEqualTo("유용한 책이었다."); + assertThat(record.getEmotion()).isEqualTo(Emotion.EMPTY); + } } @Nested diff --git a/src/test/java/app/nook/user/filter/JwtFilterTest.java b/src/test/java/app/nook/user/filter/JwtFilterTest.java new file mode 100644 index 00000000..4781fea3 --- /dev/null +++ b/src/test/java/app/nook/user/filter/JwtFilterTest.java @@ -0,0 +1,133 @@ +package app.nook.user.filter; + +import app.nook.user.domain.User; +import app.nook.user.domain.enums.UserRole; +import app.nook.user.jwt.JwtProvider; +import app.nook.user.repository.UserRepository; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import jakarta.servlet.FilterChain; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpHeaders; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class JwtFilterTest { + + @Mock + private JwtProvider jwtProvider; + + @Mock + private UserRepository userRepository; + + @Mock + private FilterChain filterChain; + + @InjectMocks + private JwtFilter jwtFilter; + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Nested + class DoFilterInternal { + + @Test + void 유효한_액세스토큰이면_인증정보를_저장한다() throws Exception { + String accessToken = "valid-access-token"; + + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader(HttpHeaders.AUTHORIZATION, JwtProvider.BEARER_PREFIX + accessToken); + MockHttpServletResponse response = new MockHttpServletResponse(); + + Claims claims = Jwts.claims(); + claims.put("userId", 1L); + + User user = User.builder() + .email("user@test.com") + .nickName("tester") + .role(UserRole.USER) + .provider("DEV") + .providerId("dev-1") + .build(); + ReflectionTestUtils.setField(user, "id", 1L); + + given(jwtProvider.validateToken(accessToken)).willReturn(true); + given(jwtProvider.parseClaims(accessToken)).willReturn(claims); + given(userRepository.findById(1L)).willReturn(Optional.of(user)); + + jwtFilter.doFilter(request, response, filterChain); + + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNotNull(); + verify(filterChain).doFilter(request, response); + } + + @Test + void 유효하지않고_만료되지도_않은_토큰이면_JwtException을_던진다() throws Exception { + String invalidAccessToken = "invalid-access-token"; + + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader(HttpHeaders.AUTHORIZATION, JwtProvider.BEARER_PREFIX + invalidAccessToken); + MockHttpServletResponse response = new MockHttpServletResponse(); + + given(jwtProvider.validateToken(invalidAccessToken)).willReturn(false); + given(jwtProvider.isExpiredToken(invalidAccessToken)).willReturn(false); + + assertThatThrownBy(() -> jwtFilter.doFilter(request, response, filterChain)) + .isInstanceOf(io.jsonwebtoken.JwtException.class); + + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + verify(filterChain, never()).doFilter(request, response); + } + + @Test + void 만료된_액세스토큰이면_인증정보를_저장하지_않고_재발급도_하지않는다() throws Exception { + String expiredAccessToken = "expired-access-token"; + + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader(HttpHeaders.AUTHORIZATION, JwtProvider.BEARER_PREFIX + expiredAccessToken); + MockHttpServletResponse response = new MockHttpServletResponse(); + + given(jwtProvider.validateToken(expiredAccessToken)).willReturn(false); + given(jwtProvider.isExpiredToken(expiredAccessToken)).willReturn(true); + + jwtFilter.doFilter(request, response, filterChain); + + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + assertThat(response.getHeader(HttpHeaders.AUTHORIZATION)).isNull(); + verify(userRepository, never()).findById(org.mockito.ArgumentMatchers.anyLong()); + verify(filterChain).doFilter(request, response); + } + + @Test + void Authorization_헤더가_없으면_그대로_필터체인을_진행한다() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + jwtFilter.doFilter(request, response, filterChain); + + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + verify(filterChain).doFilter(request, response); + } + } +} diff --git a/src/test/java/app/nook/user/service/UserServiceTest.java b/src/test/java/app/nook/user/service/UserServiceTest.java index 2bb0350a..3fa64e93 100644 --- a/src/test/java/app/nook/user/service/UserServiceTest.java +++ b/src/test/java/app/nook/user/service/UserServiceTest.java @@ -24,6 +24,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) @@ -63,6 +64,7 @@ class UserServiceTest { assertThat(response.getEmail()).isEqualTo("dev@test.com"); assertThat(response.getNickName()).isEqualTo("DEV_USER"); assertThat(response.getAccessToken()).isEqualTo("access-token"); + assertThat(response.getRefreshToken()).isEqualTo("refresh-token"); ArgumentCaptor tokenCaptor = ArgumentCaptor.forClass(TokenRedis.class); verify(tokenRedisRepository).save(tokenCaptor.capture()); @@ -141,4 +143,85 @@ class UserServiceTest { assertThat(ex.getErrorCode()).isEqualTo(AuthErrorCode.EMAIL_DUPLICATE); } + + @Test + void reissueAccessToken_성공() { + String refreshToken = "refresh-token"; + String newAccessToken = "new-access-token"; + + User user = User.builder() + .email("dev@test.com") + .nickName("DEV_USER") + .role(UserRole.USER) + .provider("DEV") + .providerId("dev-1") + .build(); + ReflectionTestUtils.setField(user, "id", 1L); + + String newRefreshToken = "new-refresh-token"; + + given(jwtProvider.validateToken(refreshToken)).willReturn(true); + given(tokenRedisRepository.findByRefreshToken(refreshToken)) + .willReturn(Optional.of(TokenRedis.builder() + .id(1L) + .refreshToken(refreshToken) + .accessToken("old-access-token") + .build())); + given(userRepository.findById(1L)).willReturn(Optional.of(user)); + given(jwtProvider.createAccessToken(user)).willReturn(newAccessToken); + given(jwtProvider.createRefreshToken()).willReturn(newRefreshToken); + + UserDTO.TokenReissueResponse response = userService.reissueAccessToken(refreshToken); + + assertThat(response.accessToken()).isEqualTo(newAccessToken); + assertThat(response.refreshToken()).isEqualTo(newRefreshToken); + + ArgumentCaptor tokenCaptor = ArgumentCaptor.forClass(TokenRedis.class); + verify(tokenRedisRepository).save(tokenCaptor.capture()); + assertThat(tokenCaptor.getValue().getRefreshToken()).isEqualTo(newRefreshToken); + assertThat(tokenCaptor.getValue().getAccessToken()).isEqualTo(newAccessToken); + } + + @Test + void reissueAccessToken_만료된토큰_예외() { + String refreshToken = "expired-refresh-token"; + given(jwtProvider.validateToken(refreshToken)).willReturn(false); + given(jwtProvider.isExpiredToken(refreshToken)).willReturn(true); + + CustomException ex = assertThrows( + CustomException.class, + () -> userService.reissueAccessToken(refreshToken) + ); + + assertThat(ex.getErrorCode()).isEqualTo(AuthErrorCode.TOKEN_EXPIRED); + } + + @Test + void reissueAccessToken_레디스에없는토큰_예외() { + String refreshToken = "valid-refresh-token"; + given(jwtProvider.validateToken(refreshToken)).willReturn(true); + given(tokenRedisRepository.findByRefreshToken(refreshToken)).willReturn(Optional.empty()); + + CustomException ex = assertThrows( + CustomException.class, + () -> userService.reissueAccessToken(refreshToken) + ); + + assertThat(ex.getErrorCode()).isEqualTo(AuthErrorCode.INVALID_TOKEN); + verify(tokenRedisRepository, never()).save(any()); + } + + @Test + void reissueAccessToken_유효하지않은토큰_예외() { + String invalidToken = "tampered-token"; + given(jwtProvider.validateToken(invalidToken)).willReturn(false); + given(jwtProvider.isExpiredToken(invalidToken)).willReturn(false); + + CustomException ex = assertThrows( + CustomException.class, + () -> userService.reissueAccessToken(invalidToken) + ); + + assertThat(ex.getErrorCode()).isEqualTo(AuthErrorCode.INVALID_TOKEN); + } }