From 79beabeb9350d94a4fa859d1abc361387cec2bc0 Mon Sep 17 00:00:00 2001 From: ljw42b <80021912+JiwonLee42@users.noreply.github.com> Date: Tue, 5 May 2026 01:02:43 +0900 Subject: [PATCH 1/9] =?UTF-8?q?[FEAT]=20=EA=B0=9C=EB=B3=84=20=EB=8F=85?= =?UTF-8?q?=EC=84=9C=EA=B8=B0=EB=A1=9D=EC=9C=BC=EB=A1=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EB=B0=8F=20EMPTY=20=EC=84=A0=ED=83=9D=EA=B0=92=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/docs/asciidoc/record-query.adoc | 2 +- .../record/controller/RecordController.java | 17 +- .../app/nook/record/domain/enums/Emotion.java | 2 +- .../repository/RecordQueryRepository.java | 2 +- .../repository/RecordQueryRepositoryImpl.java | 12 +- .../record/service/RecordCommandService.java | 9 +- .../record/service/RecordQueryService.java | 34 ++- .../record/RecordControllerTest.java | 202 +++++++++++++----- .../repository/RecordRepositoryTest.java | 5 +- .../service/RecordQueryServiceTest.java | 72 ++++++- .../record/service/RecordServiceTest.java | 46 ++++ 11 files changed, 326 insertions(+), 77 deletions(-) diff --git a/src/docs/asciidoc/record-query.adoc b/src/docs/asciidoc/record-query.adoc index 26d5fa1..5eb5ab4 100644 --- a/src/docs/asciidoc/record-query.adoc +++ b/src/docs/asciidoc/record-query.adoc @@ -25,7 +25,7 @@ include::{snippets}/record-controller-test/특정_책_기록_감정_필터_조 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/record/controller/RecordController.java b/src/main/java/app/nook/record/controller/RecordController.java index 09f0c69..1e0d30a 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 ); } 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 0e4dc04..c00f098 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/repository/RecordQueryRepository.java b/src/main/java/app/nook/record/repository/RecordQueryRepository.java index 8f06587..736bd60 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 f4ec3ff..c9b5324 100644 --- a/src/main/java/app/nook/record/repository/RecordQueryRepositoryImpl.java +++ b/src/main/java/app/nook/record/repository/RecordQueryRepositoryImpl.java @@ -284,12 +284,15 @@ 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); @@ -299,7 +302,10 @@ public BookRecordDto.RecordEmotionCountResponse countRecordsByEmotion(Long userI 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/service/RecordCommandService.java b/src/main/java/app/nook/record/service/RecordCommandService.java index df1b79a..e34ef4d 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 5294262..7598771 100644 --- a/src/main/java/app/nook/record/service/RecordQueryService.java +++ b/src/main/java/app/nook/record/service/RecordQueryService.java @@ -21,7 +21,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 +81,29 @@ 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(item.emotion(), item.recordCount())); + + List normalizedEmotionCounts = Arrays.stream(Emotion.values()) + .filter(emotion -> emotion != Emotion.EMPTY) + .map(emotion -> new BookRecordDto.RecordEmotionDto( + emotion, + emotionCountMap.getOrDefault(emotion, 0L) + )) + .toList(); + + return new BookRecordDto.RecordEmotionCountResponse(response.totalCount(), normalizedEmotionCounts); } // 해당 책의 독서 기록 목록 조회 @@ -146,7 +170,11 @@ 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); } diff --git a/src/test/java/app/nook/controller/record/RecordControllerTest.java b/src/test/java/app/nook/controller/record/RecordControllerTest.java index 46ceebf..1722a11 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("다음 페이지 존재 여부") + )) )); } } @@ -317,25 +334,32 @@ class GetRecordEmotionCounts { List.of( new BookRecordDto.RecordEmotionDto(Emotion.FUN, 3L), new BookRecordDto.RecordEmotionDto(Emotion.EMPATHIZING, 2L), - new BookRecordDto.RecordEmotionDto(Emotion.USEFUL, 2L) + new BookRecordDto.RecordEmotionDto(Emotion.USEFUL, 2L), + new BookRecordDto.RecordEmotionDto(Emotion.COMPLICATED, 0L), + new BookRecordDto.RecordEmotionDto(Emotion.SAD, 0L), + new BookRecordDto.RecordEmotionDto(Emotion.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.length()").value(6)) .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.totalCount").type(JsonFieldType.NUMBER).description("해당 도서에 대해 사용자가 작성한 전체 독서 기록 수"), + fieldWithPath("result.emotionCounts").type(JsonFieldType.ARRAY).description("해당 도서의 기록을 감정 enum 전체 기준으로 집계한 목록. 기록이 없는 감정도 0으로 포함"), fieldWithPath("result.emotionCounts[].emotion").type(JsonFieldType.STRING).description("기록에 저장된 감정 값. FUN, EMPATHIZING, USEFUL, COMPLICATED, SAD, UNCOMFORTABLE 중 하나"), fieldWithPath("result.emotionCounts[].recordCount").type(JsonFieldType.NUMBER).description("해당 감정으로 작성된 독서 기록 수") )) @@ -378,7 +402,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 +439,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()) + )); + } } } diff --git a/src/test/java/app/nook/record/repository/RecordRepositoryTest.java b/src/test/java/app/nook/record/repository/RecordRepositoryTest.java index a66d9c2..bf29bfb 100644 --- a/src/test/java/app/nook/record/repository/RecordRepositoryTest.java +++ b/src/test/java/app/nook/record/repository/RecordRepositoryTest.java @@ -135,12 +135,15 @@ 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); diff --git a/src/test/java/app/nook/record/service/RecordQueryServiceTest.java b/src/test/java/app/nook/record/service/RecordQueryServiceTest.java index 3806fd4..1c26938 100644 --- a/src/test/java/app/nook/record/service/RecordQueryServiceTest.java +++ b/src/test/java/app/nook/record/service/RecordQueryServiceTest.java @@ -212,22 +212,68 @@ 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) ); - 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()).hasSize(Emotion.values().length - 1); assertThat(result.emotionCounts().get(0).emotion()).isEqualTo(Emotion.FUN); - assertThat(result.emotionCounts().get(1).emotion()).isEqualTo(Emotion.SAD); + assertThat(result.emotionCounts().get(0).recordCount()).isEqualTo(5L); + assertThat(result.emotionCounts().get(1).emotion()).isEqualTo(Emotion.EMPATHIZING); + assertThat(result.emotionCounts().get(1).recordCount()).isZero(); + assertThat(result.emotionCounts().get(4).emotion()).isEqualTo(Emotion.SAD); + assertThat(result.emotionCounts().get(4).recordCount()).isEqualTo(3L); + assertThat(result.emotionCounts().get(5).emotion()).isEqualTo(Emotion.UNCOMFORTABLE); + assertThat(result.emotionCounts().get(5).recordCount()).isZero(); + assertThat(result.emotionCounts()).noneMatch(item -> item.emotion() == Emotion.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 +521,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 534fa44..5466994 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 From ace92fa846c24e4cc34724a7c8e4d7cb35382a36 Mon Sep 17 00:00:00 2001 From: ljw42b <80021912+JiwonLee42@users.noreply.github.com> Date: Tue, 5 May 2026 01:03:04 +0900 Subject: [PATCH 2/9] =?UTF-8?q?[CHORE]=20=EC=84=9C=EC=9E=AC=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=20=EC=97=91=EC=84=B8=EC=8A=A4=EB=A0=88?= =?UTF-8?q?=EB=B2=A8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/app/nook/library/domain/Library.java | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/main/java/app/nook/library/domain/Library.java b/src/main/java/app/nook/library/domain/Library.java index f2482a2..235f103 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 = { From a10093ceac0dc632108946e4460688a3c8b848fc Mon Sep 17 00:00:00 2001 From: ljw42b <80021912+JiwonLee42@users.noreply.github.com> Date: Tue, 5 May 2026 01:14:02 +0900 Subject: [PATCH 3/9] =?UTF-8?q?feat:=20=ED=86=A0=ED=81=B0=20=EC=9E=AC?= =?UTF-8?q?=EB=B0=9C=EA=B8=89=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../nook/user/controller/AuthController.java | 10 ++ src/main/java/app/nook/user/dto/UserDTO.java | 17 +++ .../java/app/nook/user/filter/JwtFilter.java | 58 ++------ .../java/app/nook/user/jwt/JwtProvider.java | 8 -- .../app/nook/user/oauth/OAuthService.java | 1 + .../java/app/nook/user/redis/TokenRedis.java | 3 +- .../nook/user/redis/TokenRedisRepository.java | 3 +- .../app/nook/user/service/UserService.java | 29 ++++ .../controller/user/AuthControllerTest.java | 49 ++++++- .../app/nook/user/filter/JwtFilterTest.java | 131 ++++++++++++++++++ .../nook/user/service/UserServiceTest.java | 63 +++++++++ 11 files changed, 313 insertions(+), 59 deletions(-) create mode 100644 src/test/java/app/nook/user/filter/JwtFilterTest.java diff --git a/src/main/java/app/nook/user/controller/AuthController.java b/src/main/java/app/nook/user/controller/AuthController.java index a93926b..4ea0594 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 bd9678c..c81c7e0 100644 --- a/src/main/java/app/nook/user/dto/UserDTO.java +++ b/src/main/java/app/nook/user/dto/UserDTO.java @@ -1,6 +1,7 @@ package app.nook.user.dto; import com.fasterxml.jackson.annotation.JsonInclude; +import jakarta.validation.constraints.NotBlank; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -17,6 +18,7 @@ public static class LoginResponse { private String email; private String nickName; private String accessToken; + private String refreshToken; } @AllArgsConstructor @@ -34,4 +36,19 @@ public static class DevSignUpRequest { private String email; private String nickName; } + + @AllArgsConstructor + @Getter + @NoArgsConstructor + public static class TokenReissueRequest { + @NotBlank + private String refreshToken; + } + + @AllArgsConstructor + @Getter + @NoArgsConstructor + public static class TokenReissueResponse { + private String accessToken; + } } diff --git a/src/main/java/app/nook/user/filter/JwtFilter.java b/src/main/java/app/nook/user/filter/JwtFilter.java index e1eae47..9c92d61 100644 --- a/src/main/java/app/nook/user/filter/JwtFilter.java +++ b/src/main/java/app/nook/user/filter/JwtFilter.java @@ -4,14 +4,11 @@ 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 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 +27,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 +41,16 @@ 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"); } } 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 +59,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 +76,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 5b8d9b6..2b11592 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 ccba33c..df7a20e 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 27c8a86..ba559cf 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 0c285f6..fba9595 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 68aee23..b965422 100644 --- a/src/main/java/app/nook/user/service/UserService.java +++ b/src/main/java/app/nook/user/service/UserService.java @@ -83,6 +83,34 @@ 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(() -> new CustomException(AuthErrorCode.INVALID_TOKEN)); + + User user = userRepository.findById(tokenRedis.getId()) + .orElseThrow(() -> new CustomException(AuthErrorCode.USER_NOT_FOUND)); + + String newAccessToken = jwtProvider.createAccessToken(user); + + tokenRedisRepository.save( + TokenRedis.builder() + .id(user.getId()) + .refreshToken(refreshToken) + .accessToken(newAccessToken) + .build() + ); + + return new UserDTO.TokenReissueResponse(newAccessToken); + } + private void validateDevUserRole(User user) { // DEV 로그인은 USER 권한만 허용 — 다른 역할은 존재하지 않는 것과 동일하게 처리 if (user.getRole() != UserRole.USER) { @@ -107,6 +135,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/user/AuthControllerTest.java b/src/test/java/app/nook/controller/user/AuthControllerTest.java index 87b24d6..4170ad3 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,47 @@ 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"); + + 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") + ) + ) + )); + } + + @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/user/filter/JwtFilterTest.java b/src/test/java/app/nook/user/filter/JwtFilterTest.java new file mode 100644 index 0000000..46e2058 --- /dev/null +++ b/src/test/java/app/nook/user/filter/JwtFilterTest.java @@ -0,0 +1,131 @@ +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.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 유효하지않고_만료되지도_않은_토큰이면_인증정보를_저장하지_않는다() 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); + + jwtFilter.doFilter(request, response, filterChain); + + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + verify(filterChain).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 2bb0350..b5a4607 100644 --- a/src/test/java/app/nook/user/service/UserServiceTest.java +++ b/src/test/java/app/nook/user/service/UserServiceTest.java @@ -63,6 +63,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 +142,66 @@ 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); + + 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); + + UserDTO.TokenReissueResponse response = userService.reissueAccessToken(refreshToken); + + assertThat(response.getAccessToken()).isEqualTo(newAccessToken); + + ArgumentCaptor tokenCaptor = ArgumentCaptor.forClass(TokenRedis.class); + verify(tokenRedisRepository).save(tokenCaptor.capture()); + assertThat(tokenCaptor.getValue().getRefreshToken()).isEqualTo(refreshToken); + 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); + } } From 750ffccd1fd7f4e02945f55811cf0ec2b912f4e6 Mon Sep 17 00:00:00 2001 From: ljw42b <80021912+JiwonLee42@users.noreply.github.com> Date: Tue, 5 May 2026 01:29:56 +0900 Subject: [PATCH 4/9] =?UTF-8?q?[FEAT]=20=EB=8F=85=EC=84=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=EA=B0=90=EC=A0=95=20=EA=B0=9C=EC=88=98=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=EC=8B=9C=20ALL=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/nook/record/dto/BookRecordDto.java | 2 +- .../repository/RecordQueryRepositoryImpl.java | 2 +- .../record/service/RecordQueryService.java | 10 ++++--- .../record/RecordControllerTest.java | 27 ++++++++++--------- .../repository/RecordRepositoryTest.java | 4 +-- .../service/RecordQueryServiceTest.java | 26 +++++++++--------- 6 files changed, 40 insertions(+), 31 deletions(-) diff --git a/src/main/java/app/nook/record/dto/BookRecordDto.java b/src/main/java/app/nook/record/dto/BookRecordDto.java index 6aef9b3..3e15fd4 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/RecordQueryRepositoryImpl.java b/src/main/java/app/nook/record/repository/RecordQueryRepositoryImpl.java index c9b5324..e8c364c 100644 --- a/src/main/java/app/nook/record/repository/RecordQueryRepositoryImpl.java +++ b/src/main/java/app/nook/record/repository/RecordQueryRepositoryImpl.java @@ -298,7 +298,7 @@ public BookRecordDto.RecordEmotionCountResponse countRecordsByEmotion(Long userI List emotionCounts = queryFactory .select(Projections.constructor(BookRecordDto.RecordEmotionDto.class, - record.emotion, + record.emotion.stringValue(), record.count() )) .from(record) diff --git a/src/main/java/app/nook/record/service/RecordQueryService.java b/src/main/java/app/nook/record/service/RecordQueryService.java index 7598771..2798f31 100644 --- a/src/main/java/app/nook/record/service/RecordQueryService.java +++ b/src/main/java/app/nook/record/service/RecordQueryService.java @@ -93,17 +93,21 @@ public BookRecordDto.RecordEmotionCountResponse getRecordEmotionCounts(User user BookRecordDto.RecordEmotionCountResponse response = recordRepository.countRecordsByEmotion(user.getId(), bookId); Map emotionCountMap = new EnumMap<>(Emotion.class); - response.emotionCounts().forEach(item -> emotionCountMap.put(item.emotion(), item.recordCount())); + 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, + emotion.name(), emotionCountMap.getOrDefault(emotion, 0L) )) .toList(); - return new BookRecordDto.RecordEmotionCountResponse(response.totalCount(), normalizedEmotionCounts); + List emotionCountsWithAll = new java.util.ArrayList<>(); + emotionCountsWithAll.add(new BookRecordDto.RecordEmotionDto("ALL", response.totalCount())); + emotionCountsWithAll.addAll(normalizedEmotionCounts); + + return new BookRecordDto.RecordEmotionCountResponse(response.totalCount(), emotionCountsWithAll); } // 해당 책의 독서 기록 목록 조회 diff --git a/src/test/java/app/nook/controller/record/RecordControllerTest.java b/src/test/java/app/nook/controller/record/RecordControllerTest.java index 1722a11..587e95b 100644 --- a/src/test/java/app/nook/controller/record/RecordControllerTest.java +++ b/src/test/java/app/nook/controller/record/RecordControllerTest.java @@ -332,12 +332,13 @@ 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(Emotion.COMPLICATED, 0L), - new BookRecordDto.RecordEmotionDto(Emotion.SAD, 0L), - new BookRecordDto.RecordEmotionDto(Emotion.UNCOMFORTABLE, 0L) + 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) ) ); @@ -349,9 +350,11 @@ class GetRecordEmotionCounts { .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.length()").value(6)) + .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( @@ -359,9 +362,9 @@ class GetRecordEmotionCounts { ), responseFields(ApiResponseSnippet.withResult( fieldWithPath("result.totalCount").type(JsonFieldType.NUMBER).description("해당 도서에 대해 사용자가 작성한 전체 독서 기록 수"), - fieldWithPath("result.emotionCounts").type(JsonFieldType.ARRAY).description("해당 도서의 기록을 감정 enum 전체 기준으로 집계한 목록. 기록이 없는 감정도 0으로 포함"), - fieldWithPath("result.emotionCounts[].emotion").type(JsonFieldType.STRING).description("기록에 저장된 감정 값. FUN, EMPATHIZING, USEFUL, COMPLICATED, SAD, UNCOMFORTABLE 중 하나"), - fieldWithPath("result.emotionCounts[].recordCount").type(JsonFieldType.NUMBER).description("해당 감정으로 작성된 독서 기록 수") + fieldWithPath("result.emotionCounts").type(JsonFieldType.ARRAY).description("해당 도서의 기록을 집계한 목록. ALL은 전체 기록 수를 뜻하고, 나머지 감정은 기록이 없어도 0으로 포함"), + fieldWithPath("result.emotionCounts[].emotion").type(JsonFieldType.STRING).description("집계 기준 값. ALL, FUN, EMPATHIZING, USEFUL, COMPLICATED, SAD, UNCOMFORTABLE 중 하나"), + fieldWithPath("result.emotionCounts[].recordCount").type(JsonFieldType.NUMBER).description("해당 기준으로 집계된 기록 수") )) )); } diff --git a/src/test/java/app/nook/record/repository/RecordRepositoryTest.java b/src/test/java/app/nook/record/repository/RecordRepositoryTest.java index bf29bfb..d771bd7 100644 --- a/src/test/java/app/nook/record/repository/RecordRepositoryTest.java +++ b/src/test/java/app/nook/record/repository/RecordRepositoryTest.java @@ -150,11 +150,11 @@ class QueryDslQuery { 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 1c26938..3f0fb50 100644 --- a/src/test/java/app/nook/record/service/RecordQueryServiceTest.java +++ b/src/test/java/app/nook/record/service/RecordQueryServiceTest.java @@ -214,8 +214,8 @@ class GetRecordEmotionCounts { 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(bookRepository.existsById(bookId)).willReturn(true); @@ -228,16 +228,18 @@ class GetRecordEmotionCounts { // then assertThat(result.totalCount()).isEqualTo(8L); - assertThat(result.emotionCounts()).hasSize(Emotion.values().length - 1); - assertThat(result.emotionCounts().get(0).emotion()).isEqualTo(Emotion.FUN); - assertThat(result.emotionCounts().get(0).recordCount()).isEqualTo(5L); - assertThat(result.emotionCounts().get(1).emotion()).isEqualTo(Emotion.EMPATHIZING); - assertThat(result.emotionCounts().get(1).recordCount()).isZero(); - assertThat(result.emotionCounts().get(4).emotion()).isEqualTo(Emotion.SAD); - assertThat(result.emotionCounts().get(4).recordCount()).isEqualTo(3L); - assertThat(result.emotionCounts().get(5).emotion()).isEqualTo(Emotion.UNCOMFORTABLE); - assertThat(result.emotionCounts().get(5).recordCount()).isZero(); - assertThat(result.emotionCounts()).noneMatch(item -> item.emotion() == Emotion.EMPTY); + 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); } From a9ebd8ca65526845e8e791bdcbdc765c4de9efe5 Mon Sep 17 00:00:00 2001 From: ljw42b <80021912+JiwonLee42@users.noreply.github.com> Date: Tue, 5 May 2026 03:05:24 +0900 Subject: [PATCH 5/9] =?UTF-8?q?[FEAT]=20=EA=B8=B0=EB=A1=9D=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=EC=A1=B0=ED=9A=8C=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/docs/asciidoc/record-query.adoc | 10 +++ .../record/controller/RecordController.java | 12 +++ .../record/repository/RecordRepository.java | 3 + .../record/service/RecordQueryService.java | 11 +++ .../record/RecordControllerTest.java | 84 +++++++++++++++++++ .../app/nook/user/filter/JwtFilterTest.java | 8 +- 6 files changed, 125 insertions(+), 3 deletions(-) diff --git a/src/docs/asciidoc/record-query.adoc b/src/docs/asciidoc/record-query.adoc index 5eb5ab4..669d6ef 100644 --- a/src/docs/asciidoc/record-query.adoc +++ b/src/docs/asciidoc/record-query.adoc @@ -24,6 +24,16 @@ 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] + === 독서 기록 감정별 개수 조회 특정 책의 전체 기록 수와 감정별 기록 수를 함께 반환합니다. diff --git a/src/main/java/app/nook/record/controller/RecordController.java b/src/main/java/app/nook/record/controller/RecordController.java index 1e0d30a..518be67 100644 --- a/src/main/java/app/nook/record/controller/RecordController.java +++ b/src/main/java/app/nook/record/controller/RecordController.java @@ -112,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/repository/RecordRepository.java b/src/main/java/app/nook/record/repository/RecordRepository.java index e7d0eab..bec12ea 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/RecordQueryService.java b/src/main/java/app/nook/record/service/RecordQueryService.java index 2798f31..6598e7e 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; @@ -184,4 +185,14 @@ private Emotion parseEmotionFilter(String emotion) { } } + 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/test/java/app/nook/controller/record/RecordControllerTest.java b/src/test/java/app/nook/controller/record/RecordControllerTest.java index 587e95b..aec9dae 100644 --- a/src/test/java/app/nook/controller/record/RecordControllerTest.java +++ b/src/test/java/app/nook/controller/record/RecordControllerTest.java @@ -612,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("기록 감정 값. 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/user/filter/JwtFilterTest.java b/src/test/java/app/nook/user/filter/JwtFilterTest.java index 46e2058..4781fea 100644 --- a/src/test/java/app/nook/user/filter/JwtFilterTest.java +++ b/src/test/java/app/nook/user/filter/JwtFilterTest.java @@ -24,6 +24,7 @@ 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; @@ -82,7 +83,7 @@ class DoFilterInternal { } @Test - void 유효하지않고_만료되지도_않은_토큰이면_인증정보를_저장하지_않는다() throws Exception { + void 유효하지않고_만료되지도_않은_토큰이면_JwtException을_던진다() throws Exception { String invalidAccessToken = "invalid-access-token"; MockHttpServletRequest request = new MockHttpServletRequest(); @@ -92,10 +93,11 @@ class DoFilterInternal { given(jwtProvider.validateToken(invalidAccessToken)).willReturn(false); given(jwtProvider.isExpiredToken(invalidAccessToken)).willReturn(false); - jwtFilter.doFilter(request, response, filterChain); + assertThatThrownBy(() -> jwtFilter.doFilter(request, response, filterChain)) + .isInstanceOf(io.jsonwebtoken.JwtException.class); assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); - verify(filterChain).doFilter(request, response); + verify(filterChain, never()).doFilter(request, response); } @Test From 9eebda7f7e84a9c3c3203463ffd2bf6a0975c101 Mon Sep 17 00:00:00 2001 From: ljw42b <80021912+JiwonLee42@users.noreply.github.com> Date: Tue, 5 May 2026 04:50:54 +0900 Subject: [PATCH 6/9] =?UTF-8?q?[FEAT]=20=ED=86=A0=ED=81=B0=20=EC=9E=AC?= =?UTF-8?q?=EB=B0=9C=EA=B8=89=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/app/nook/user/dto/UserDTO.java | 24 +++++++++++++------ .../java/app/nook/user/filter/JwtFilter.java | 2 ++ .../app/nook/user/service/UserService.java | 5 ++-- .../library/LibraryControllerTest.java | 4 ++++ .../controller/user/AuthControllerTest.java | 5 ++-- .../nook/user/service/UserServiceTest.java | 22 +++++++++++++++-- 6 files changed, 49 insertions(+), 13 deletions(-) diff --git a/src/main/java/app/nook/user/dto/UserDTO.java b/src/main/java/app/nook/user/dto/UserDTO.java index c81c7e0..391811f 100644 --- a/src/main/java/app/nook/user/dto/UserDTO.java +++ b/src/main/java/app/nook/user/dto/UserDTO.java @@ -1,7 +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; @@ -25,7 +27,12 @@ public static class LoginResponse { @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; } @@ -33,7 +40,12 @@ 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; } @@ -41,14 +53,12 @@ public static class DevSignUpRequest { @Getter @NoArgsConstructor public static class TokenReissueRequest { - @NotBlank + @NotBlank(message = "리프레시토큰은 필수입니다.") private String refreshToken; } - @AllArgsConstructor - @Getter - @NoArgsConstructor - public static class TokenReissueResponse { - private String accessToken; - } + 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 9c92d61..aff0686 100644 --- a/src/main/java/app/nook/user/filter/JwtFilter.java +++ b/src/main/java/app/nook/user/filter/JwtFilter.java @@ -6,6 +6,7 @@ import app.nook.user.jwt.JwtProvider; 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; @@ -44,6 +45,7 @@ protected void doFilterInternal(HttpServletRequest request, log.warn("[TOKEN] 만료된 access token. /auth/reissue API 사용 필요"); } else { log.warn("[TOKEN] 유효하지 않은 access token"); + throw new JwtException("유효하지 않은 access token"); } } diff --git a/src/main/java/app/nook/user/service/UserService.java b/src/main/java/app/nook/user/service/UserService.java index b965422..9a13d81 100644 --- a/src/main/java/app/nook/user/service/UserService.java +++ b/src/main/java/app/nook/user/service/UserService.java @@ -99,16 +99,17 @@ public UserDTO.TokenReissueResponse reissueAccessToken(String refreshToken) { .orElseThrow(() -> new CustomException(AuthErrorCode.USER_NOT_FOUND)); String newAccessToken = jwtProvider.createAccessToken(user); + String newRefreshToken = jwtProvider.createRefreshToken(); tokenRedisRepository.save( TokenRedis.builder() .id(user.getId()) - .refreshToken(refreshToken) + .refreshToken(newRefreshToken) .accessToken(newAccessToken) .build() ); - return new UserDTO.TokenReissueResponse(newAccessToken); + return new UserDTO.TokenReissueResponse(newAccessToken, newRefreshToken); } private void validateDevUserRole(User user) { diff --git a/src/test/java/app/nook/controller/library/LibraryControllerTest.java b/src/test/java/app/nook/controller/library/LibraryControllerTest.java index 8039639..dcbfaa3 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/user/AuthControllerTest.java b/src/test/java/app/nook/controller/user/AuthControllerTest.java index 4170ad3..8f07eeb 100644 --- a/src/test/java/app/nook/controller/user/AuthControllerTest.java +++ b/src/test/java/app/nook/controller/user/AuthControllerTest.java @@ -231,7 +231,7 @@ class AuthControllerTest extends AbstractWebMvcRestDocsTests { @Test void 토큰_재발급_성공() throws Exception { UserDTO.TokenReissueRequest request = new UserDTO.TokenReissueRequest("valid-refresh-token"); - UserDTO.TokenReissueResponse response = new UserDTO.TokenReissueResponse("new-access-token"); + UserDTO.TokenReissueResponse response = new UserDTO.TokenReissueResponse("new-access-token", "new-refresh-token"); given(userService.reissueAccessToken(any())) .willReturn(response); @@ -248,7 +248,8 @@ class AuthControllerTest extends AbstractWebMvcRestDocsTests { ), responseFields( ApiResponseSnippet.withResult( - fieldWithPath("result.accessToken").description("재발급된 access token") + fieldWithPath("result.accessToken").description("재발급된 access token"), + fieldWithPath("result.refreshToken").description("재발급된 refresh token") ) ) )); diff --git a/src/test/java/app/nook/user/service/UserServiceTest.java b/src/test/java/app/nook/user/service/UserServiceTest.java index b5a4607..f4c5e97 100644 --- a/src/test/java/app/nook/user/service/UserServiceTest.java +++ b/src/test/java/app/nook/user/service/UserServiceTest.java @@ -157,6 +157,8 @@ class UserServiceTest { .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() @@ -166,14 +168,16 @@ class UserServiceTest { .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.getAccessToken()).isEqualTo(newAccessToken); + 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(refreshToken); + assertThat(tokenCaptor.getValue().getRefreshToken()).isEqualTo(newRefreshToken); assertThat(tokenCaptor.getValue().getAccessToken()).isEqualTo(newAccessToken); } @@ -204,4 +208,18 @@ class UserServiceTest { assertThat(ex.getErrorCode()).isEqualTo(AuthErrorCode.INVALID_TOKEN); } + + @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); + } } From 8d133dbd8144ddb68e04b920f5ce6f3374c550f8 Mon Sep 17 00:00:00 2001 From: ljw42b <80021912+JiwonLee42@users.noreply.github.com> Date: Tue, 5 May 2026 04:51:30 +0900 Subject: [PATCH 7/9] =?UTF-8?q?[FEAT]=20=ED=8F=AC=EC=BB=A4=EC=8A=A4=20?= =?UTF-8?q?=ED=97=A4=EB=8D=94=20=ED=86=A0=ED=81=B0=20=EC=A3=BC=EC=9E=85=20?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/focus/FocusControllerTest.java | 56 +++++++++---------- .../focus/FocusThemeControllerTest.java | 49 ++++++++-------- 2 files changed, 53 insertions(+), 52 deletions(-) diff --git a/src/test/java/app/nook/controller/focus/FocusControllerTest.java b/src/test/java/app/nook/controller/focus/FocusControllerTest.java index 0390edf..fd85f48 100644 --- a/src/test/java/app/nook/controller/focus/FocusControllerTest.java +++ b/src/test/java/app/nook/controller/focus/FocusControllerTest.java @@ -1,21 +1,25 @@ 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; @@ -26,35 +30,32 @@ 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 +77,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 +104,7 @@ void setUpUser() { } @Test + @WithCustomUser @DisplayName("포커스 종료 성공") void 포커스_종료_성공() throws Exception { // given @@ -130,8 +131,7 @@ void setUpUser() { // 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)) ) diff --git a/src/test/java/app/nook/controller/focus/FocusThemeControllerTest.java b/src/test/java/app/nook/controller/focus/FocusThemeControllerTest.java index c41576b..599a5d7 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( From 900e9b704fc7cc41afce545b73b09e99ad62f6ae Mon Sep 17 00:00:00 2001 From: ljw42b <80021912+JiwonLee42@users.noreply.github.com> Date: Tue, 5 May 2026 05:00:27 +0900 Subject: [PATCH 8/9] =?UTF-8?q?[FEAT]=20=ED=8F=AC=EC=BB=A4=EC=8A=A4=20?= =?UTF-8?q?=EC=A2=85=EB=A3=8C=20given=20=EB=8A=90=EC=8A=A8=ED=95=98?= =?UTF-8?q?=EA=B2=8C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/app/nook/controller/focus/FocusControllerTest.java | 5 +++-- .../app/nook/controller/record/RecordControllerTest.java | 6 +++--- src/test/java/app/nook/user/service/UserServiceTest.java | 2 ++ 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/test/java/app/nook/controller/focus/FocusControllerTest.java b/src/test/java/app/nook/controller/focus/FocusControllerTest.java index fd85f48..d45ba8b 100644 --- a/src/test/java/app/nook/controller/focus/FocusControllerTest.java +++ b/src/test/java/app/nook/controller/focus/FocusControllerTest.java @@ -24,6 +24,7 @@ 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; @@ -126,7 +127,7 @@ public class FocusControllerTest extends AbstractWebMvcRestDocsTests { "FINISHED" ); - given(focusService.endFocus(eq(1L), eq(request))).willReturn(response); + given(focusService.endFocus(anyLong(), eq(request))).willReturn(response); // when & then mockMvc.perform( @@ -158,4 +159,4 @@ public class FocusControllerTest extends AbstractWebMvcRestDocsTests { ) )); } -} \ No newline at end of file +} diff --git a/src/test/java/app/nook/controller/record/RecordControllerTest.java b/src/test/java/app/nook/controller/record/RecordControllerTest.java index aec9dae..84b2ba8 100644 --- a/src/test/java/app/nook/controller/record/RecordControllerTest.java +++ b/src/test/java/app/nook/controller/record/RecordControllerTest.java @@ -361,9 +361,9 @@ class GetRecordEmotionCounts { parameterWithName("bookId").description("감정별 기록 개수를 조회할 도서 ID") ), responseFields(ApiResponseSnippet.withResult( - fieldWithPath("result.totalCount").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 중 하나"), + 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("해당 기준으로 집계된 기록 수") )) )); @@ -653,7 +653,7 @@ class Success { 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("기록 감정 값. FUN, EMPATHIZING, USEFUL, COMPLICATED, SAD, UNCOMFORTABLE 중 하나"), + fieldWithPath("result.emotion").type(JsonFieldType.STRING).description("기록 감정 값. EMPTY, FUN, EMPATHIZING, USEFUL, COMPLICATED, SAD, UNCOMFORTABLE 중 하나"), fieldWithPath("result.createdDate").type(JsonFieldType.STRING).description("기록 작성 날짜 (yyyy-MM-dd)") )) )); diff --git a/src/test/java/app/nook/user/service/UserServiceTest.java b/src/test/java/app/nook/user/service/UserServiceTest.java index f4c5e97..3fa64e9 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) @@ -207,6 +208,7 @@ class UserServiceTest { ); assertThat(ex.getErrorCode()).isEqualTo(AuthErrorCode.INVALID_TOKEN); + verify(tokenRedisRepository, never()).save(any()); } @Test From f95c626d820fa8b1cebe9600f86c4d39810bbbbe Mon Sep 17 00:00:00 2001 From: ljw42b <80021912+JiwonLee42@users.noreply.github.com> Date: Tue, 5 May 2026 05:01:22 +0900 Subject: [PATCH 9/9] =?UTF-8?q?[FEAT]=20=ED=86=A0=ED=81=B0=20=EC=9E=AC?= =?UTF-8?q?=EB=B0=9C=EA=B8=89=20=EC=98=88=EC=99=B8=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/app/nook/user/service/UserService.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/app/nook/user/service/UserService.java b/src/main/java/app/nook/user/service/UserService.java index 9a13d81..c8a7e42 100644 --- a/src/main/java/app/nook/user/service/UserService.java +++ b/src/main/java/app/nook/user/service/UserService.java @@ -93,7 +93,10 @@ public UserDTO.TokenReissueResponse reissueAccessToken(String refreshToken) { } TokenRedis tokenRedis = tokenRedisRepository.findByRefreshToken(refreshToken) - .orElseThrow(() -> new CustomException(AuthErrorCode.INVALID_TOKEN)); + .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));