Skip to content

Commit

Permalink
Merge pull request #46 from MARU-EGG/refactor/question-list-response
Browse files Browse the repository at this point in the history
[feat] answer 답변 내용 수정 api
  • Loading branch information
Hoya324 authored Jul 21, 2024
2 parents 2b16006 + 68a302e commit c7d99e0
Show file tree
Hide file tree
Showing 16 changed files with 318 additions and 20 deletions.
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ dependencies {
testImplementation 'org.testcontainers:mysql'
testImplementation 'org.testcontainers:jdbc:1.19.7'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
testImplementation "org.springframework.security:spring-security-test"

//QueryDsl
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package mju.iphak.maru_egg.answer.api;

import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import mju.iphak.maru_egg.answer.application.AnswerService;
import mju.iphak.maru_egg.answer.dto.request.UpdateAnswerContentRequest;
import mju.iphak.maru_egg.common.meta.CustomApiResponse;
import mju.iphak.maru_egg.common.meta.CustomApiResponses;

@Tag(name = "Answer API", description = "답변 관련 API 입니다.")
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/admin/answers")
public class AdminAnswerController {

private final AnswerService answerService;

@Operation(summary = "답변 수정", description = "답변을 수정하는 API", responses = {
@ApiResponse(responseCode = "200", description = "답변을 수정 성공")
})
@CustomApiResponses({
@CustomApiResponse(error = "HttpMessageNotReadableException", status = 400, message = "Invalid input format: JSON parse error: Cannot deserialize value of type `java.lang.Long` from String \"ㅇㅇ\": not a valid `java.lang.Long` value", description = "잘못된 요청 값을 보낸 경우"),
@CustomApiResponse(error = "EntityNotFoundException", status = 404, message = "답변 id가 123131인 답변을 찾을 수 없습니다.", description = "답변을 찾지 못한 경우"),
@CustomApiResponse(error = "InternalServerError", status = 500, message = "내부 서버 오류가 발생했습니다.", description = "내부 서버 오류")
})
@PostMapping()
public void updateAnswerContent(@Valid @RequestBody UpdateAnswerContentRequest request) {
answerService.updateAnswerContent(request.id(), request.content());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public class AnswerService {
public Answer getAnswerByQuestionId(Long questionId) {
return answerRepository.findByQuestionId(questionId)
.orElseThrow(() -> new EntityNotFoundException(
String.format(NOT_FOUND_ANSWER.getMessage(), questionId)));
String.format(NOT_FOUND_ANSWER_BY_QUESTION_ID.getMessage(), questionId)));
}

public Mono<LLMAnswerResponse> askQuestion(LLMAskQuestionRequest request) {
Expand All @@ -50,4 +50,11 @@ public Mono<LLMAnswerResponse> askQuestion(LLMAskQuestionRequest request) {
public Answer saveAnswer(Answer answer) {
return answerRepository.save(answer);
}

public void updateAnswerContent(final Long id, final String content) {
Answer answer = answerRepository.findById(id)
.orElseThrow(() -> new EntityNotFoundException(
String.format(NOT_FOUND_ANSWER.getMessage(), id)));
answer.updateContent(content);
}
}
4 changes: 4 additions & 0 deletions src/main/java/mju/iphak/maru_egg/answer/domain/Answer.java
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ public String getDateInformation() {
return "생성일자: %s, 마지막 DB 갱신일자: %s".formatted(this.getCreatedAt(), this.getUpdatedAt());
}

public void updateContent(String content) {
this.content = content != null ? content : this.content;
}

public static Answer of(Question question, String content) {
return Answer.builder()
.content(content)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package mju.iphak.maru_egg.answer.dto.request;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;

@Schema(description = "답변 수정 요청 DTO", example = """
{
"id": 601440912047263653,
"content": "변경된 답변입니다."
}
""")
public record UpdateAnswerContentRequest(

@Schema(description = "답변 ID")
Long id,

@Schema(description = "변경할 답변 내용")
@NotBlank(message = "변경할 답변은 비어있을 수 없습니다.")
String content
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
Expand All @@ -32,6 +33,7 @@

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {

Expand Down Expand Up @@ -69,6 +71,8 @@ public SecurityFilterChain filterChain(HttpSecurity http, HandlerMappingIntrospe
.permitAll()
.requestMatchers(new MvcRequestMatcher(introspector, API_PREFIX + "/questions/**"))
.permitAll()
.requestMatchers(new MvcRequestMatcher(introspector, API_PREFIX + "/admin/answers/**"))
.hasRole("ADMIN")
.requestMatchers(new MvcRequestMatcher(introspector, "/maru-egg/api-docs/**"))
.permitAll()
.requestMatchers(new MvcRequestMatcher(introspector, "/maru-egg/swagger-ui/index.html"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,16 @@ public enum ErrorCode {

// 400 error

// 401 error
UNAUTHORIZED_REQUEST(UNAUTHORIZED, "로그인 후 다시 시도해주세요."),

// 404 error
NOT_FOUND_QUESTION(NOT_FOUND, "type: %s, category: %s, content: %s인 질문을 찾을 수 없습니다."),
NOT_FOUND_QUESTION_BY_ID(NOT_FOUND, "id: %s인 질문을 찾을 수 없습니다."),
NOT_FOUND_QUESTION_WITHOUT_CATEGORY(NOT_FOUND, "type: %s, content: %s인 질문을 찾을 수 없습니다."),
NOT_FOUND_QUESTION_BY_TYPE_CATEGORY(NOT_FOUND, "type: %s, category: %s인 질문을 찾을 수 없습니다."),
NOT_FOUND_ANSWER(NOT_FOUND, "질문 id가 %s인 답변을 찾을 수 없습니다."),
NOT_FOUND_ANSWER(NOT_FOUND, "답변 id가 %s인 답변을 찾을 수 없습니다."),
NOT_FOUND_ANSWER_BY_QUESTION_ID(NOT_FOUND, "질문 id가 %s인 답변을 찾을 수 없습니다."),
NOT_FOUND_USER(NOT_FOUND, "유저 이메일이 %s인 유저를 찾을 수 없습니다."),

// 500 error
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import mju.iphak.maru_egg.question.dto.request.FindQuestionsRequest;
import mju.iphak.maru_egg.question.dto.request.QuestionRequest;
import mju.iphak.maru_egg.question.dto.request.SearchQuestionsRequest;
import mju.iphak.maru_egg.question.dto.response.QuestionListItemResponse;
import mju.iphak.maru_egg.question.dto.response.QuestionResponse;
import mju.iphak.maru_egg.question.dto.response.SearchedQuestionsResponse;

Expand Down Expand Up @@ -55,7 +56,7 @@ public QuestionResponse question(@Valid @RequestBody QuestionRequest request) {
@CustomApiResponse(error = "InternalServerError", status = 500, message = "내부 서버 오류가 발생했습니다.", description = "내부 서버 오류")
})
@GetMapping()
public List<QuestionResponse> getQuestions(@Valid @ModelAttribute FindQuestionsRequest request) {
public List<QuestionListItemResponse> getQuestions(@Valid @ModelAttribute FindQuestionsRequest request) {
return questionService.getQuestions(request.type(), request.category());
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import mju.iphak.maru_egg.question.domain.Question;
import mju.iphak.maru_egg.question.domain.QuestionCategory;
import mju.iphak.maru_egg.question.domain.QuestionType;
import mju.iphak.maru_egg.question.dto.response.QuestionResponse;
import mju.iphak.maru_egg.question.dto.response.QuestionListItemResponse;
import mju.iphak.maru_egg.question.dto.response.SearchedQuestionsResponse;
import mju.iphak.maru_egg.question.repository.QuestionRepository;

Expand All @@ -30,7 +30,7 @@ public class QuestionService {
private final QuestionRepository questionRepository;
private final AnswerService answerService;

public List<QuestionResponse> getQuestions(final QuestionType type, final QuestionCategory category) {
public List<QuestionListItemResponse> getQuestions(final QuestionType type, final QuestionCategory category) {
List<Question> questions = findQuestions(type, category);
return questions.stream()
.map(question -> createQuestionResponse(question, answerService.getAnswerByQuestionId(question.getId())))
Expand All @@ -47,13 +47,13 @@ public SliceQuestionResponse<SearchedQuestionsResponse> searchQuestionsOfCursorP

private List<Question> findQuestions(final QuestionType type, final QuestionCategory category) {
if (category == null) {
return questionRepository.findAllByQuestionType(type);
return questionRepository.findAllByQuestionTypeOrderByViewCountDesc(type);
}
return questionRepository.findAllByQuestionTypeAndQuestionCategory(type, category);
return questionRepository.findAllByQuestionTypeAndQuestionCategoryOrderByViewCountDesc(type, category);
}

private QuestionResponse createQuestionResponse(final Question question, final Answer answer) {
private QuestionListItemResponse createQuestionResponse(final Question question, final Answer answer) {
AnswerResponse answerResponse = AnswerResponse.from(answer);
return QuestionResponse.of(question, answerResponse);
return QuestionListItemResponse.of(question, answerResponse);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package mju.iphak.maru_egg.question.dto.response;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import mju.iphak.maru_egg.answer.dto.response.AnswerResponse;
import mju.iphak.maru_egg.question.domain.Question;

@Builder
@Schema(description = "질문 응답 DTO", example = """
{
"id": 600697396846981500,
"content": "수시 일정 알려주세요."
"dateInformation": "생성일자: 2024-07-15T23:36:59.834804, 마지막 DB 갱신일자: 2024-07-15T23:36:59.834804",
"answer": {
"id": 600697396935061600,
"content": "2024년 수시 일정은 다음과 같습니다:\\n\\n- 전체 전형 2024.12.19.(목)~12.26.(목) 18:00: 최초합격자 발표 및 시충원 관련 내용 공지 예정\\n- 문서등록 및 등록금 납부 기간: 2025. 2. 10.(월) 10:00 ~ 2. 12.(수) 15:00\\n- 등록금 납부 기간: 2024.12.16.(월) 10:00 ~ 12. 18.(수) 15:00\\n\\n추가로, 복수지원 관련하여 수시모집의 모든 전형에 중복 지원이 가능하며, 최대 6개 이내의 전형에만 지원할 수 있습니다. 반드시 지정 기간 내에 문서등록과 최종 등록(등록금 납부)을 해야 합니다. 또한, 합격자는 합격한 대학에 등록하지 않을 경우 합격 포기로 간주되니 유의하시기 바랍니다.",
"renewalYear": 2024,
"dateInformation": "생성일자: 2024-07-15T23:36:59.847690, 마지막 DB 갱신일자: 2024-07-15T23:36:59.847690"
}
}
""")
public record QuestionListItemResponse(
@Schema(description = "질문 id")
Long id,

@Schema(description = "질문")
String content,

@Schema(description = "질문 조회수")
int viewCount,

@Schema(description = "답변 DB 생성 및 업데이트 날짜")
String dateInformation,

@Schema(description = "답변 DTO")
AnswerResponse answer
) {

public static QuestionListItemResponse of(Question question, AnswerResponse answer) {
return QuestionListItemResponse.builder()
.id(question.getId())
.content(question.getContent())
.viewCount(question.getViewCount())
.dateInformation(question.getDateInformation())
.answer(answer)
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
import mju.iphak.maru_egg.question.domain.QuestionType;

public interface QuestionRepository extends JpaRepository<Question, Long>, QuestionRepositoryCustom {
List<Question> findAllByQuestionTypeAndQuestionCategory(QuestionType type, QuestionCategory category);
List<Question> findAllByQuestionTypeAndQuestionCategoryOrderByViewCountDesc(QuestionType type,
QuestionCategory category);

List<Question> findAllByQuestionType(QuestionType type);
List<Question> findAllByQuestionTypeOrderByViewCountDesc(QuestionType type);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package mju.iphak.maru_egg.answer.api;

import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.MockitoAnnotations;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.ResultActions;

import jakarta.persistence.EntityNotFoundException;
import mju.iphak.maru_egg.answer.application.AnswerService;
import mju.iphak.maru_egg.answer.dto.request.UpdateAnswerContentRequest;
import mju.iphak.maru_egg.common.IntegrationTest;

class AdminAnswerControllerTest extends IntegrationTest {

@MockBean
private AnswerService answerService;

@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
}

@Test
@WithMockUser(roles = "ADMIN")
void 답변_수정_API_정상적인_요청() throws Exception {
// given
UpdateAnswerContentRequest request = new UpdateAnswerContentRequest(1L, "새로운 답변 내용");

// when
ResultActions resultActions = mvc.perform(post("/api/admin/answers")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)));

// then
resultActions
.andExpect(status().isOk())
.andDo(print());
}

@Test
@WithMockUser(roles = "ADMIN")
void 답변_수정_API_잘못된_JSON_형식() throws Exception {
// given
String invalidJson = "{\"id\": \"ㅇㅇ\", \"content\": \"새로운 답변 내용\"}";

// when
ResultActions resultActions = mvc.perform(post("/api/admin/answers")
.contentType(MediaType.APPLICATION_JSON)
.content(invalidJson));

// then
resultActions
.andExpect(status().isBadRequest())
.andDo(print());
}

@Test
@WithMockUser(roles = "ADMIN")
void 답변_수정_API_존재하지_않는_답변_ID() throws Exception {
// given
UpdateAnswerContentRequest request = new UpdateAnswerContentRequest(999L, "새로운 답변 내용");
doThrow(new EntityNotFoundException("답변을 찾을 수 없습니다.")).when(answerService)
.updateAnswerContent(anyLong(), anyString());

// when
ResultActions resultActions = mvc.perform(post("/api/admin/answers")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)));

// then
resultActions
.andExpect(status().isNotFound())
.andDo(print());
}

@Test
@WithMockUser(roles = "ADMIN")
void 답변_수정_API_서버_내부_오류() throws Exception {
// given
UpdateAnswerContentRequest request = new UpdateAnswerContentRequest(1L, "새로운 답변 내용");
doThrow(new RuntimeException("내부 서버 오류")).when(answerService).updateAnswerContent(anyLong(), anyString());

// when
ResultActions resultActions = mvc.perform(post("/api/admin/answers")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)));

// then
resultActions
.andExpect(status().isInternalServerError())
.andDo(print());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -112,4 +112,19 @@ public void setUp() {
assertThat(result.answer()).isEqualTo(answer.getContent());
assertThat(expectedResponse).isEqualTo(result);
}

@DisplayName("답변 내용을 수정에 성공한 경우")
@Test
public void 답변_내용_수정_성공() throws Exception {
// given
when(answerRepository.findById(1L)).thenReturn(Optional.of(answer));
Long id = 1L;

// when
String updateContent = "변경된 답변";
answerService.updateAnswerContent(id, updateContent);

// then
assertThat(answer.getContent()).isEqualTo(updateContent);
}
}
Loading

0 comments on commit c7d99e0

Please sign in to comment.