diff --git a/src/main/java/com/devpick/domain/community/controller/AnswerController.java b/src/main/java/com/devpick/domain/community/controller/AnswerController.java index 54048ff3..2336033b 100644 --- a/src/main/java/com/devpick/domain/community/controller/AnswerController.java +++ b/src/main/java/com/devpick/domain/community/controller/AnswerController.java @@ -43,8 +43,9 @@ public class AnswerController { }) @GetMapping public ApiResponse getAnswers( + @AuthenticationPrincipal UUID userId, @Parameter(description = "게시글 ID (UUID)", required = true) @PathVariable UUID postId) { - return ApiResponse.ok(answerService.getAnswers(postId)); + return ApiResponse.ok(answerService.getAnswers(postId, userId)); } @Operation(summary = "답변 작성", description = "게시글에 답변을 작성합니다.") diff --git a/src/main/java/com/devpick/domain/community/dto/AnswerWithCommentsResponse.java b/src/main/java/com/devpick/domain/community/dto/AnswerWithCommentsResponse.java index 52dbce41..9f03b884 100644 --- a/src/main/java/com/devpick/domain/community/dto/AnswerWithCommentsResponse.java +++ b/src/main/java/com/devpick/domain/community/dto/AnswerWithCommentsResponse.java @@ -20,11 +20,17 @@ public record AnswerWithCommentsResponse( String authorProfileImage, Boolean isAdopted, Boolean isEdited, + Boolean canAdopt, Instant createdAt, Instant updatedAt, List comments ) { - public static AnswerWithCommentsResponse of(Answer answer, List comments) { + public static AnswerWithCommentsResponse of(Answer answer, List comments, + UUID currentUserId, UUID postAuthorId, boolean anyAdopted) { + boolean canAdopt = postAuthorId.equals(currentUserId) + && !anyAdopted + && !Boolean.TRUE.equals(answer.getIsAdopted()) + && !answer.getUser().getId().equals(currentUserId); return new AnswerWithCommentsResponse( answer.getId(), answer.getContent(), @@ -35,6 +41,7 @@ public static AnswerWithCommentsResponse of(Answer answer, List comment answer.getUser().getProfileImage(), answer.getIsAdopted(), answer.getIsEdited(), + canAdopt, answer.getCreatedAt() != null ? answer.getCreatedAt().toInstant(ZoneOffset.UTC) : null, answer.getUpdatedAt() != null ? answer.getUpdatedAt().toInstant(ZoneOffset.UTC) : null, comments.stream().map(CommentResponse::of).toList() diff --git a/src/main/java/com/devpick/domain/community/service/AnswerService.java b/src/main/java/com/devpick/domain/community/service/AnswerService.java index 55931e95..0f3d5459 100644 --- a/src/main/java/com/devpick/domain/community/service/AnswerService.java +++ b/src/main/java/com/devpick/domain/community/service/AnswerService.java @@ -40,15 +40,18 @@ public class AnswerService { private final AnswerLikeRepository answerLikeRepository; @Transactional(readOnly = true) - public AnswerListResponse getAnswers(UUID postId) { - postRepository.findById(postId) + public AnswerListResponse getAnswers(UUID postId, UUID currentUserId) { + Post post = postRepository.findById(postId) .orElseThrow(() -> new DevpickException(ErrorCode.COMMUNITY_POST_NOT_FOUND)); List answers = answerRepository.findByPost_IdOrderByCreatedAtAsc(postId); + boolean anyAdopted = answers.stream().anyMatch(a -> Boolean.TRUE.equals(a.getIsAdopted())); + UUID postAuthorId = post.getUser().getId(); + List result = answers.stream() .map(answer -> { List comments = commentRepository.findByAnswer_IdOrderByCreatedAtAsc(answer.getId()); - return AnswerWithCommentsResponse.of(answer, comments); + return AnswerWithCommentsResponse.of(answer, comments, currentUserId, postAuthorId, anyAdopted); }) .toList(); return new AnswerListResponse(result); @@ -137,6 +140,10 @@ public AnswerResponse adoptAnswer(UUID userId, UUID postId, UUID answerId) { throw new DevpickException(ErrorCode.COMMUNITY_ANSWER_NOT_FOUND); } + if (answer.getUser().getId().equals(userId)) { + throw new DevpickException(ErrorCode.COMMUNITY_CANNOT_ADOPT_OWN_ANSWER); + } + answer.adopt(); pointService.earn(answer.getUser(), PointAction.ANSWER_ADOPTED); return AnswerResponse.of(answer); diff --git a/src/main/java/com/devpick/global/common/exception/ErrorCode.java b/src/main/java/com/devpick/global/common/exception/ErrorCode.java index 04bc3b10..46b3bcf2 100644 --- a/src/main/java/com/devpick/global/common/exception/ErrorCode.java +++ b/src/main/java/com/devpick/global/common/exception/ErrorCode.java @@ -86,6 +86,7 @@ public enum ErrorCode { COMMUNITY_ANSWER_NOT_LIKED(HttpStatus.NOT_FOUND, "COMMUNITY_012", "좋아요하지 않은 답변입니다."), COMMUNITY_DUPLICATE_POST(HttpStatus.CONFLICT, "COMMUNITY_013", "잠시 후 다시 시도해 주세요."), COMMUNITY_AI_NOT_SUPPORTED(HttpStatus.BAD_REQUEST, "COMMUNITY_014", "커리어 게시글은 AI 기능을 지원하지 않습니다."), + COMMUNITY_CANNOT_ADOPT_OWN_ANSWER(HttpStatus.FORBIDDEN, "COMMUNITY_015", "본인이 작성한 답변은 채택할 수 없습니다."), // Report REPORT_NOT_FOUND(HttpStatus.NOT_FOUND, "REPORT_001", "주간 리포트를 찾을 수 없습니다."), diff --git a/src/test/java/com/devpick/domain/community/controller/AnswerControllerTest.java b/src/test/java/com/devpick/domain/community/controller/AnswerControllerTest.java index 9c0826fb..3c830e73 100644 --- a/src/test/java/com/devpick/domain/community/controller/AnswerControllerTest.java +++ b/src/test/java/com/devpick/domain/community/controller/AnswerControllerTest.java @@ -190,23 +190,24 @@ void adoptAnswer_alreadyAdopted_returns409() throws Exception { void getAnswers_success_returns200() throws Exception { AnswerWithCommentsResponse answerWithComments = new AnswerWithCommentsResponse( answerId, "Test Answer", userId, "tester", null, null, null, - false, false, Instant.now(), Instant.now(), List.of() + false, false, false, Instant.now(), Instant.now(), List.of() ); AnswerListResponse listResponse = new AnswerListResponse(List.of(answerWithComments)); - given(answerService.getAnswers(postId)).willReturn(listResponse); + given(answerService.getAnswers(postId, userId)).willReturn(listResponse); mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders .get("/posts/" + postId + "/answers")) .andExpect(status().isOk()) .andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.data.answers[0].content").value("Test Answer")) - .andExpect(jsonPath("$.data.answers[0].comments").isArray()); + .andExpect(jsonPath("$.data.answers[0].comments").isArray()) + .andExpect(jsonPath("$.data.answers[0].canAdopt").value(false)); } @Test @DisplayName("GET /posts/{postId}/answers - 게시글 없으면 404 반환") void getAnswers_postNotFound_returns404() throws Exception { - given(answerService.getAnswers(postId)) + given(answerService.getAnswers(postId, userId)) .willThrow(new DevpickException(ErrorCode.COMMUNITY_POST_NOT_FOUND)); mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders @@ -214,4 +215,15 @@ void getAnswers_postNotFound_returns404() throws Exception { .andExpect(status().isNotFound()) .andExpect(jsonPath("$.success").value(false)); } + + @Test + @DisplayName("POST /posts/{postId}/answers/{answerId}/adopt - 본인 답변 채택 시 403 반환") + void adoptAnswer_ownAnswer_returns403() throws Exception { + given(answerService.adoptAnswer(userId, postId, answerId)) + .willThrow(new DevpickException(ErrorCode.COMMUNITY_CANNOT_ADOPT_OWN_ANSWER)); + + mockMvc.perform(post("/posts/" + postId + "/answers/" + answerId + "/adopt")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.success").value(false)); + } } diff --git a/src/test/java/com/devpick/domain/community/service/AnswerServiceTest.java b/src/test/java/com/devpick/domain/community/service/AnswerServiceTest.java index eeb75764..9465537f 100644 --- a/src/test/java/com/devpick/domain/community/service/AnswerServiceTest.java +++ b/src/test/java/com/devpick/domain/community/service/AnswerServiceTest.java @@ -108,11 +108,12 @@ void getAnswers_success_returnsAnswerListWithComments() { given(answerRepository.findByPost_IdOrderByCreatedAtAsc(postId)).willReturn(List.of(answer)); given(commentRepository.findByAnswer_IdOrderByCreatedAtAsc(answerId)).willReturn(List.of()); - AnswerListResponse response = answerService.getAnswers(postId); + AnswerListResponse response = answerService.getAnswers(postId, userId); assertThat(response.answers()).hasSize(1); assertThat(response.answers().get(0).id()).isEqualTo(answerId); assertThat(response.answers().get(0).comments()).isEmpty(); + assertThat(response.answers().get(0).canAdopt()).isFalse(); } @Test @@ -120,12 +121,76 @@ void getAnswers_success_returnsAnswerListWithComments() { void getAnswers_postNotFound_throwsException() { given(postRepository.findById(postId)).willReturn(Optional.empty()); - assertThatThrownBy(() -> answerService.getAnswers(postId)) + assertThatThrownBy(() -> answerService.getAnswers(postId, userId)) .isInstanceOf(DevpickException.class) .satisfies(e -> assertThat(((DevpickException) e).getErrorCode()) .isEqualTo(ErrorCode.COMMUNITY_POST_NOT_FOUND)); } + @Test + @DisplayName("getAnswers — 게시글 작성자가 타인의 미채택 답변 조회 시 canAdopt=true") + void getAnswers_postAuthor_othersAnswer_canAdoptTrue() { + UUID otherUserId = UUID.randomUUID(); + User otherUser = User.builder().email("other@devpick.kr").nickname("other").job(Job.FRONTEND).level(Level.JUNIOR).build(); + ReflectionTestUtils.setField(otherUser, "id", otherUserId); + + Answer otherAnswer = Answer.builder().post(post).user(otherUser).content("Other Answer").build(); + ReflectionTestUtils.setField(otherAnswer, "id", UUID.randomUUID()); + + given(postRepository.findById(postId)).willReturn(Optional.of(post)); + given(answerRepository.findByPost_IdOrderByCreatedAtAsc(postId)).willReturn(List.of(otherAnswer)); + given(commentRepository.findByAnswer_IdOrderByCreatedAtAsc(any())).willReturn(List.of()); + + AnswerListResponse response = answerService.getAnswers(postId, userId); + + assertThat(response.answers().get(0).canAdopt()).isTrue(); + } + + @Test + @DisplayName("getAnswers — 게시글 작성자가 본인 답변 조회 시 canAdopt=false (본인 답변 채택 불가)") + void getAnswers_postAuthor_ownAnswer_canAdoptFalse() { + given(postRepository.findById(postId)).willReturn(Optional.of(post)); + given(answerRepository.findByPost_IdOrderByCreatedAtAsc(postId)).willReturn(List.of(answer)); + given(commentRepository.findByAnswer_IdOrderByCreatedAtAsc(answerId)).willReturn(List.of()); + + AnswerListResponse response = answerService.getAnswers(postId, userId); + + assertThat(response.answers().get(0).canAdopt()).isFalse(); + } + + @Test + @DisplayName("getAnswers — 게시글 작성자가 아닌 유저는 canAdopt=false") + void getAnswers_notPostAuthor_canAdoptFalse() { + UUID otherUserId = UUID.randomUUID(); + given(postRepository.findById(postId)).willReturn(Optional.of(post)); + given(answerRepository.findByPost_IdOrderByCreatedAtAsc(postId)).willReturn(List.of(answer)); + given(commentRepository.findByAnswer_IdOrderByCreatedAtAsc(answerId)).willReturn(List.of()); + + AnswerListResponse response = answerService.getAnswers(postId, otherUserId); + + assertThat(response.answers().get(0).canAdopt()).isFalse(); + } + + @Test + @DisplayName("getAnswers — 이미 채택된 답변이 있으면 모든 답변에 canAdopt=false") + void getAnswers_anyAnswerAlreadyAdopted_canAdoptFalse() { + UUID otherUserId = UUID.randomUUID(); + User otherUser = User.builder().email("other@devpick.kr").nickname("other").job(Job.FRONTEND).level(Level.JUNIOR).build(); + ReflectionTestUtils.setField(otherUser, "id", otherUserId); + + Answer adoptedAnswer = Answer.builder().post(post).user(otherUser).content("Adopted Answer").build(); + ReflectionTestUtils.setField(adoptedAnswer, "id", UUID.randomUUID()); + adoptedAnswer.adopt(); + + given(postRepository.findById(postId)).willReturn(Optional.of(post)); + given(answerRepository.findByPost_IdOrderByCreatedAtAsc(postId)).willReturn(List.of(adoptedAnswer)); + given(commentRepository.findByAnswer_IdOrderByCreatedAtAsc(any())).willReturn(List.of()); + + AnswerListResponse response = answerService.getAnswers(postId, userId); + + assertThat(response.answers().get(0).canAdopt()).isFalse(); + } + @Test @DisplayName("createAnswer — 성공 시 답변 저장 후 반환") void createAnswer_success_savesAndReturns() { AnswerCreateRequest request = new AnswerCreateRequest("Test Answer"); @@ -231,9 +296,15 @@ void deleteAnswer_unauthorized_throwsException() { @Test @DisplayName("adoptAnswer — 성공 시 isAdopted=true, isEdited는 변경되지 않는다") void adoptAnswer_success_adoptsAnswer() { + UUID otherUserId = UUID.randomUUID(); + User otherUser = User.builder().email("other@devpick.kr").nickname("other").job(Job.BACKEND).level(Level.JUNIOR).build(); + ReflectionTestUtils.setField(otherUser, "id", otherUserId); + Answer otherAnswer = Answer.builder().post(post).user(otherUser).content("Other Answer").build(); + ReflectionTestUtils.setField(otherAnswer, "id", answerId); + given(postRepository.findById(postId)).willReturn(Optional.of(post)); given(answerRepository.findAdoptedByPostIdForUpdate(postId)).willReturn(List.of()); - given(answerRepository.findById(answerId)).willReturn(Optional.of(answer)); + given(answerRepository.findById(answerId)).willReturn(Optional.of(otherAnswer)); AnswerResponse response = answerService.adoptAnswer(userId, postId, answerId); @@ -279,4 +350,17 @@ void adoptAnswer_answerNotFound_throwsException() { .satisfies(e -> assertThat(((DevpickException) e).getErrorCode()) .isEqualTo(ErrorCode.COMMUNITY_ANSWER_NOT_FOUND)); } + + @Test + @DisplayName("adoptAnswer — 본인 답변 채택 시 COMMUNITY_CANNOT_ADOPT_OWN_ANSWER 예외") + void adoptAnswer_ownAnswer_throwsException() { + given(postRepository.findById(postId)).willReturn(Optional.of(post)); + given(answerRepository.findAdoptedByPostIdForUpdate(postId)).willReturn(List.of()); + given(answerRepository.findById(answerId)).willReturn(Optional.of(answer)); // answer.user == post.user == userId + + assertThatThrownBy(() -> answerService.adoptAnswer(userId, postId, answerId)) + .isInstanceOf(DevpickException.class) + .satisfies(e -> assertThat(((DevpickException) e).getErrorCode()) + .isEqualTo(ErrorCode.COMMUNITY_CANNOT_ADOPT_OWN_ANSWER)); + } }