Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.project.InsightPrep.domain.question.event;

import com.project.InsightPrep.domain.question.entity.Answer;

public record AnswerSavedEvent(Answer answer) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package com.project.InsightPrep.domain.question.event;

import com.project.InsightPrep.domain.question.dto.response.FeedbackResponse;
import com.project.InsightPrep.domain.question.entity.Answer;
import com.project.InsightPrep.domain.question.entity.AnswerFeedback;
import com.project.InsightPrep.domain.question.exception.QuestionErrorCode;
import com.project.InsightPrep.domain.question.exception.QuestionException;
import com.project.InsightPrep.domain.question.mapper.FeedbackMapper;
import com.project.InsightPrep.global.gpt.prompt.PromptFactory;
import com.project.InsightPrep.global.gpt.service.GptResponseType;
import com.project.InsightPrep.global.gpt.service.GptService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

@Slf4j
@RequiredArgsConstructor
@Component
public class FeedbackEventListener {

private final GptService gptService;
private final FeedbackMapper feedbackMapper;

@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleAnswerSaved(AnswerSavedEvent event) {
Answer answer = event.answer();
if (answer == null) throw new QuestionException(QuestionErrorCode.ANSWER_NOT_FOUND);
if (answer.getQuestion() == null) throw new QuestionException(QuestionErrorCode.QUESTION_NOT_FOUND);

String question = answer.getQuestion().getContent();
String userAnswer = answer.getContent();
if (userAnswer == null) throw new QuestionException(QuestionErrorCode.ANSWER_NOT_FOUND);

FeedbackResponse gptResult = gptService.callOpenAI(PromptFactory.forFeedbackGeneration(question, userAnswer), 1000, 0.4, GptResponseType.FEEDBACK);
AnswerFeedback feedback = AnswerFeedback.builder()
.answer(answer)
.score(gptResult.getScore())
.modelAnswer(gptResult.getModelAnswer())
.improvement(gptResult.getImprovement())
.build();

feedbackMapper.insertFeedback(feedback);
log.info("Feedback saved for Answer id = {}", answer.getId());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import com.project.InsightPrep.domain.question.entity.Answer;
import com.project.InsightPrep.domain.question.entity.AnswerStatus;
import com.project.InsightPrep.domain.question.entity.Question;
import com.project.InsightPrep.domain.question.event.AnswerSavedEvent;
import com.project.InsightPrep.domain.question.exception.QuestionErrorCode;
import com.project.InsightPrep.domain.question.exception.QuestionException;
import com.project.InsightPrep.domain.question.mapper.AnswerMapper;
Expand All @@ -16,6 +17,7 @@
import com.project.InsightPrep.global.auth.util.SecurityUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

Expand All @@ -28,6 +30,7 @@ public class AnswerServiceImpl implements AnswerService {
private final QuestionMapper questionMapper;
private final AnswerMapper answerMapper;
private final FeedbackService feedbackService;
private final ApplicationEventPublisher eventPublisher;

@Override
@Transactional
Expand All @@ -43,7 +46,8 @@ public AnswerResponse.AnswerDto saveAnswer(AnswerDto dto, Long questionId) {

questionMapper.updateStatus(questionId, AnswerStatus.ANSWERED.name());
answerMapper.insertAnswer(answer);
feedbackService.saveFeedback(answer);
//feedbackService.saveFeedback(answer);
eventPublisher.publishEvent(new AnswerSavedEvent(answer));
return AnswerResponse.AnswerDto.builder()
.answerId(answer.getId()).build();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,6 @@ public void saveFeedback(Answer answer) {
String userAnswer = answer.getContent();

FeedbackResponse gptResult = gptService.callOpenAI(PromptFactory.forFeedbackGeneration(question, userAnswer), 1000, 0.4, GptResponseType.FEEDBACK);
System.out.println(gptResult.getScore());
System.out.println(gptResult.getImprovement());
System.out.println(gptResult.getModelAnswer());
AnswerFeedback feedback = AnswerFeedback.builder()
.answer(answer)
.score(gptResult.getScore())
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package com.project.InsightPrep.domain.question.event;

import com.project.InsightPrep.domain.question.dto.response.FeedbackResponse;
import com.project.InsightPrep.domain.question.entity.Answer;
import com.project.InsightPrep.domain.question.entity.AnswerFeedback;
import com.project.InsightPrep.domain.question.entity.Question;
import com.project.InsightPrep.domain.question.exception.QuestionErrorCode;
import com.project.InsightPrep.domain.question.exception.QuestionException;
import com.project.InsightPrep.domain.question.mapper.FeedbackMapper;
import com.project.InsightPrep.global.gpt.service.GptResponseType;
import com.project.InsightPrep.global.gpt.service.GptService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;

class FeedbackEventListenerTest {

@Mock
private GptService gptService;

@Mock
private FeedbackMapper feedbackMapper;

@InjectMocks
private FeedbackEventListener feedbackEventListener;

private Answer answer;

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

Question question = Question.builder()
.id(1L)
.content("OS에서 스레드와 프로세스의 차이점은?")
.build();

answer = Answer.builder()
.id(10L)
.question(question)
.content("스레드는 프로세스 내 실행 단위입니다.")
.build();
}

@Test
@DisplayName("정상적인 AnswerSavedEvent가 주어지면 Feedback이 저장된다")
void handleAnswerSaved_success() {
// given
FeedbackResponse mockResponse = FeedbackResponse.builder()
.score(5)
.modelAnswer("모델 답변")
.improvement("개선 필요")
.build();
when(gptService.callOpenAI(anyList(), anyInt(), anyDouble(), eq(GptResponseType.FEEDBACK)))
.thenReturn(mockResponse);

AnswerSavedEvent event = new AnswerSavedEvent(answer);

// when
feedbackEventListener.handleAnswerSaved(event);

// then
ArgumentCaptor<AnswerFeedback> captor = ArgumentCaptor.forClass(AnswerFeedback.class);
verify(feedbackMapper, times(1)).insertFeedback(captor.capture());

AnswerFeedback saved = captor.getValue();
assertThat(saved.getAnswer()).isEqualTo(answer);
assertThat(saved.getScore()).isEqualTo(5);
assertThat(saved.getModelAnswer()).isEqualTo("모델 답변");
assertThat(saved.getImprovement()).isEqualTo("개선 필요");
}

@Test
@DisplayName("Answer가 null이면 예외가 발생한다")
void handleAnswerSaved_nullAnswer() {
// given
AnswerSavedEvent event = new AnswerSavedEvent(null);

// when & then
assertThrows(QuestionException.class, () -> feedbackEventListener.handleAnswerSaved(event));
verify(feedbackMapper, never()).insertFeedback(any());
}

@Test
@DisplayName("Answer에 Question 또는 content가 null이면 예외가 발생한다")
void handleAnswerSaved_invalidAnswer() {
Answer invalidAnswer = Answer.builder()
.id(20L)
.question(null) // 질문 없음
.content(null) // 답변 없음
.build();

AnswerSavedEvent event = new AnswerSavedEvent(invalidAnswer);




assertThrows(QuestionException.class, () -> feedbackEventListener.handleAnswerSaved(event));
verify(feedbackMapper, never()).insertFeedback(any());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;
Expand All @@ -19,19 +20,23 @@
import com.project.InsightPrep.domain.question.entity.Answer;
import com.project.InsightPrep.domain.question.entity.AnswerStatus;
import com.project.InsightPrep.domain.question.entity.Question;
import com.project.InsightPrep.domain.question.event.AnswerSavedEvent;
import com.project.InsightPrep.domain.question.exception.QuestionErrorCode;
import com.project.InsightPrep.domain.question.exception.QuestionException;
import com.project.InsightPrep.domain.question.mapper.AnswerMapper;
import com.project.InsightPrep.domain.question.mapper.QuestionMapper;
import com.project.InsightPrep.global.auth.util.SecurityUtil;
import java.lang.reflect.Field;
import java.util.concurrent.TimeUnit;
import org.awaitility.Awaitility;
import org.junit.jupiter.api.DisplayName;
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.context.ApplicationEventPublisher;

@ExtendWith(MockitoExtension.class)
class AnswerServiceImplTest {
Expand All @@ -51,6 +56,11 @@ class AnswerServiceImplTest {
@Mock
private FeedbackServiceImpl feedbackService;

@Mock
private ApplicationEventPublisher eventPublisher;



@DisplayName("답변 저장 - 정상 동작")
@Test
void saveAnswer_success() {
Expand Down Expand Up @@ -83,9 +93,6 @@ void saveAnswer_success() {
return null;
}).when(answerMapper).insertAnswer(any(Answer.class));

// feedback 호출 자체만 확인; 내용은 captor로 검증
doNothing().when(feedbackService).saveFeedback(any(Answer.class));

// when
AnswerResponse.AnswerDto res = answerService.saveAnswer(dto, questionId);

Expand All @@ -95,10 +102,13 @@ void saveAnswer_success() {
verify(questionMapper).updateStatus(eq(questionId), eq(AnswerStatus.ANSWERED.name()));
verify(answerMapper).insertAnswer(any(Answer.class));

// feedbackService로 전달된 Answer에 id가 채워졌는지 검증
ArgumentCaptor<Answer> answerCaptor = ArgumentCaptor.forClass(Answer.class);
verify(feedbackService).saveFeedback(answerCaptor.capture());
Answer savedForFeedback = answerCaptor.getValue();
// 이벤트 객체 캡처
ArgumentCaptor<AnswerSavedEvent> eventCaptor = ArgumentCaptor.forClass(AnswerSavedEvent.class);
verify(eventPublisher).publishEvent(eventCaptor.capture());

AnswerSavedEvent publishedEvent = eventCaptor.getValue();
Answer savedForFeedback = publishedEvent.answer(); // 이벤트 안에서 Answer 꺼내기

assertThat(savedForFeedback.getId()).isEqualTo(100L);
assertThat(savedForFeedback.getMember().getId()).isEqualTo(1L);
assertThat(savedForFeedback.getQuestion().getId()).isEqualTo(questionId);
Expand Down Expand Up @@ -155,4 +165,27 @@ void deleteAnswer_alreadyDeleted() {
verify(answerMapper, never()).resetQuestionStatusIfNoAnswers(anyLong(), anyString());
verifyNoMoreInteractions(answerMapper, securityUtil);
}

@Test
void saveAnswer_shouldPublishEvent_andTriggerFeedbackListener() {
// given
Long questionId = 1L;
AnswerDto dto = new AnswerDto("테스트 답변");

doAnswer(invocation -> {
Answer arg = invocation.getArgument(0);
Field idField = Answer.class.getDeclaredField("id");
idField.setAccessible(true);
idField.set(arg, 123L); // PK 강제 설정
return null;
}).when(answerMapper).insertAnswer(any(Answer.class));

// when
answerService.saveAnswer(dto, questionId);

// then (비동기라 약간 대기 필요)
Awaitility.await().atMost(3, TimeUnit.SECONDS).untilAsserted(() ->
verify(eventPublisher, times(1)).publishEvent(any(AnswerSavedEvent.class))
);
}
}