diff --git a/src/main/java/me/thinkcat/opic/practice/controller/AnswerController.java b/src/main/java/me/thinkcat/opic/practice/controller/AnswerController.java index 9f64a92..13c1d42 100644 --- a/src/main/java/me/thinkcat/opic/practice/controller/AnswerController.java +++ b/src/main/java/me/thinkcat/opic/practice/controller/AnswerController.java @@ -4,6 +4,7 @@ import lombok.RequiredArgsConstructor; import me.thinkcat.opic.practice.dto.request.CompleteAnswerUploadRequest; import me.thinkcat.opic.practice.dto.request.PrepareAnswerUploadRequest; +import me.thinkcat.opic.practice.dto.request.S3UploadDetectedRequest; import me.thinkcat.opic.practice.dto.request.UpdateFeedbackRequest; import me.thinkcat.opic.practice.dto.request.UpdateFeedbackStatusRequest; import me.thinkcat.opic.practice.dto.request.UpdateTranscriptionRequest; @@ -74,6 +75,20 @@ public ResponseEntity> completeAnswerUpload( return ResponseEntity.ok(response); } + @PostMapping("/internal/s3-upload-detected") + public ResponseEntity> handleS3UploadDetected( + @Valid @RequestBody S3UploadDetectedRequest request) { + + answerService.handleS3UploadDetected(request.getFileKey()); + + CommonResponse response = CommonResponse.builder() + .success(true) + .message("S3 upload detected processed successfully") + .build(); + + return ResponseEntity.ok(response); + } + @PatchMapping("/internal/transcription") public ResponseEntity> updateTranscription( @Valid @RequestBody UpdateTranscriptionRequest request) { diff --git a/src/main/java/me/thinkcat/opic/practice/controller/DrillAnswerController.java b/src/main/java/me/thinkcat/opic/practice/controller/DrillAnswerController.java index d0d1f73..7805a14 100644 --- a/src/main/java/me/thinkcat/opic/practice/controller/DrillAnswerController.java +++ b/src/main/java/me/thinkcat/opic/practice/controller/DrillAnswerController.java @@ -6,6 +6,7 @@ import me.thinkcat.opic.practice.config.security.annotation.AuthUser; import me.thinkcat.opic.practice.dto.CommonResponse; import me.thinkcat.opic.practice.dto.request.PrepareDrillAnswerUploadRequest; +import me.thinkcat.opic.practice.dto.request.S3UploadDetectedRequest; import me.thinkcat.opic.practice.dto.request.SubmitDrillAnswerRequest; import me.thinkcat.opic.practice.dto.request.UpdateDrillFeedbackRequest; import me.thinkcat.opic.practice.dto.request.UpdateDrillTranscriptionRequest; @@ -129,6 +130,20 @@ public ResponseEntity> getQuesti return ResponseEntity.ok(response); } + @PostMapping("/internal/s3-upload-detected") + public ResponseEntity> handleS3UploadDetected( + @Valid @RequestBody S3UploadDetectedRequest request) { + + drillAnswerService.handleS3UploadDetected(request.getFileKey()); + + CommonResponse response = CommonResponse.builder() + .success(true) + .message("Drill S3 upload detected processed successfully") + .build(); + + return ResponseEntity.ok(response); + } + @PatchMapping("/internal/feedback-status") public ResponseEntity> updateDrillFeedbackStatus( @Valid @RequestBody UpdateFeedbackStatusRequest request) { diff --git a/src/main/java/me/thinkcat/opic/practice/dto/request/S3UploadDetectedRequest.java b/src/main/java/me/thinkcat/opic/practice/dto/request/S3UploadDetectedRequest.java new file mode 100644 index 0000000..ab5a3a0 --- /dev/null +++ b/src/main/java/me/thinkcat/opic/practice/dto/request/S3UploadDetectedRequest.java @@ -0,0 +1,19 @@ +package me.thinkcat.opic.practice.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class S3UploadDetectedRequest { + + @NotBlank(message = "File key is required") + @Pattern(regexp = "^uploads/.+", message = "File key must start with 'uploads/'") + private String fileKey; +} diff --git a/src/main/java/me/thinkcat/opic/practice/repository/AnswerRepository.java b/src/main/java/me/thinkcat/opic/practice/repository/AnswerRepository.java index 7941881..faeba23 100644 --- a/src/main/java/me/thinkcat/opic/practice/repository/AnswerRepository.java +++ b/src/main/java/me/thinkcat/opic/practice/repository/AnswerRepository.java @@ -1,7 +1,9 @@ package me.thinkcat.opic.practice.repository; +import jakarta.persistence.LockModeType; import me.thinkcat.opic.practice.entity.Answer; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @@ -20,6 +22,14 @@ public interface AnswerRepository extends JpaRepository { Optional findByAudioUrl(String audioUrl); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT a FROM Answer a WHERE a.id = :id") + Optional findByIdForUpdate(@Param("id") Long id); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT a FROM Answer a WHERE a.audioUrl = :audioUrl") + Optional findByAudioUrlForUpdate(@Param("audioUrl") String audioUrl); + List findByUploadStatusCodeAndUpdatedAtBefore(String uploadStatusCode, LocalDateTime threshold); List findByFeedbackStatusCodeAndUpdatedAtBefore(String feedbackStatusCode, LocalDateTime threshold); diff --git a/src/main/java/me/thinkcat/opic/practice/repository/DrillAnswerRepository.java b/src/main/java/me/thinkcat/opic/practice/repository/DrillAnswerRepository.java index 0f873b1..d50e811 100644 --- a/src/main/java/me/thinkcat/opic/practice/repository/DrillAnswerRepository.java +++ b/src/main/java/me/thinkcat/opic/practice/repository/DrillAnswerRepository.java @@ -1,7 +1,9 @@ package me.thinkcat.opic.practice.repository; +import jakarta.persistence.LockModeType; import me.thinkcat.opic.practice.entity.DrillAnswer; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @@ -15,6 +17,14 @@ public interface DrillAnswerRepository extends JpaRepository Optional findByAudioUrl(String audioUrl); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT d FROM DrillAnswer d WHERE d.id = :id") + Optional findByIdForUpdate(@Param("id") Long id); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT d FROM DrillAnswer d WHERE d.audioUrl = :audioUrl") + Optional findByAudioUrlForUpdate(@Param("audioUrl") String audioUrl); + List findByUserIdAndQuestionIdAndUploadStatusCodeOrderByCreatedAtDesc( Long userId, Long questionId, String uploadStatusCode); diff --git a/src/main/java/me/thinkcat/opic/practice/service/AnswerService.java b/src/main/java/me/thinkcat/opic/practice/service/AnswerService.java index 694ba48..5dedb4e 100644 --- a/src/main/java/me/thinkcat/opic/practice/service/AnswerService.java +++ b/src/main/java/me/thinkcat/opic/practice/service/AnswerService.java @@ -17,9 +17,11 @@ import me.thinkcat.opic.practice.exception.ResourceNotFoundException; import me.thinkcat.opic.practice.exception.ValidationException; import me.thinkcat.opic.practice.entity.Question; +import me.thinkcat.opic.practice.entity.User; import me.thinkcat.opic.practice.repository.AnswerRepository; import me.thinkcat.opic.practice.repository.QuestionRepository; import me.thinkcat.opic.practice.repository.SessionRepository; +import me.thinkcat.opic.practice.repository.UserRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -39,6 +41,7 @@ public class AnswerService { private final AnswerRepository answerRepository; private final SessionRepository sessionRepository; private final QuestionRepository questionRepository; + private final UserRepository userRepository; private final PresignedUrlService presignedUrlService; private final FeedbackLambdaService feedbackLambdaService; @@ -88,24 +91,26 @@ public AnswerResponse completeAnswerUpload( Long answerId, Integer durationMs) { - Answer answer = answerRepository.findById(answerId) + Answer answer = answerRepository.findByIdForUpdate(answerId) .orElseThrow(() -> new ResourceNotFoundException("Answer not found with id: " + answerId)); sessionRepository.findByIdAndUserId(answer.getSessionId(), userId) .orElseThrow(() -> new ValidationException("Unauthorized access to answer")); - if (answer.isUploadSuccess()) { - log.warn("event=answer_upload_already_done | who={} | answerId={}", userId, answerId); - return resolveAnswerResponse(answer); + if (answer.isUploadPending()) { + answer.markUploadSuccess(); + log.info("event=answer_upload_complete | who={} | answerId={} | audioUrl={}", + userId, answerId, answer.getAudioUrl()); } - answer.markUploadSuccess(); - if (durationMs != null) { + if (durationMs != null && answer.getDurationMs() == 0) { answer.setDurationMs(durationMs); } - log.info("event=answer_upload_complete | who={} | answerId={} | audioUrl={}", - userId, answerId, answer.getAudioUrl()); + if (!answer.isFeedbackNone()) { + log.warn("event=answer_feedback_already_requested | who={} | answerId={}", userId, answerId); + return resolveAnswerResponse(answerRepository.save(answer)); + } if (userRole == UserRole.PAID || userRole == UserRole.ADMIN || featureFlagService.isEnabled("ai-for-free")) { answer.requestFeedback(); @@ -123,6 +128,51 @@ public AnswerResponse completeAnswerUpload( return resolveAnswerResponse(updatedAnswer); } + @Transactional + public void handleS3UploadDetected(String fileKey) { + Answer answer = answerRepository.findByAudioUrlForUpdate(fileKey).orElse(null); + if (answer == null) { + log.warn("event=s3_upload_detected_not_found | fileKey={}", fileKey); + return; + } + + if (answer.isUploadPending()) { + answer.markUploadSuccess(); + log.info("event=s3_upload_detected_marked_success | answerId={} | audioUrl={}", + answer.getId(), fileKey); + } + + if (!answer.isFeedbackNone()) { + log.warn("event=s3_upload_detected_feedback_already_requested | answerId={} | audioUrl={}", + answer.getId(), fileKey); + answerRepository.save(answer); + return; + } + + Long userId = sessionRepository.findById(answer.getSessionId()) + .orElseThrow(() -> new ResourceNotFoundException("Session not found for answer: " + answer.getId())) + .getUserId(); + + UserRole userRole = userRepository.findById(userId) + .map(User::getUserRole) + .orElse(UserRole.FREE); + + if (userRole == UserRole.PAID || userRole == UserRole.ADMIN || featureFlagService.isEnabled("ai-for-free")) { + answer.requestFeedback(); + answerRepository.save(answer); + log.info("event=s3_upload_detected_feedback_requested | who={} | answerId={} | audioUrl={}", + userId, answer.getId(), fileKey); + String questionText = questionRepository.findById(answer.getQuestionId()) + .map(Question::getQuestion) + .orElse(null); + feedbackLambdaService.invokeSessionFeedbackAsync(fileKey, userId, questionText); + return; + } + + answerRepository.save(answer); + log.info("event=s3_upload_detected_no_feedback | who={} | answerId={}", userId, answer.getId()); + } + @Transactional(readOnly = true) public List getSessionAnswers(Long sessionId, Long userId) { sessionRepository.findByIdAndUserId(sessionId, userId) diff --git a/src/main/java/me/thinkcat/opic/practice/service/DrillAnswerService.java b/src/main/java/me/thinkcat/opic/practice/service/DrillAnswerService.java index 235d29d..1b8e551 100644 --- a/src/main/java/me/thinkcat/opic/practice/service/DrillAnswerService.java +++ b/src/main/java/me/thinkcat/opic/practice/service/DrillAnswerService.java @@ -17,10 +17,12 @@ import me.thinkcat.opic.practice.entity.UserRole; import me.thinkcat.opic.practice.exception.ResourceNotFoundException; import me.thinkcat.opic.practice.exception.ValidationException; +import me.thinkcat.opic.practice.entity.User; import me.thinkcat.opic.practice.repository.CategoryRepository; import me.thinkcat.opic.practice.repository.DrillAnswerRepository; import me.thinkcat.opic.practice.repository.QuestionRepository; import me.thinkcat.opic.practice.repository.RecentDrillQuestionProjection; +import me.thinkcat.opic.practice.repository.UserRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -40,6 +42,7 @@ public class DrillAnswerService { private final DrillAnswerRepository drillAnswerRepository; private final QuestionRepository questionRepository; private final CategoryRepository categoryRepository; + private final UserRepository userRepository; private final PresignedUrlService presignedUrlService; private final FeedbackLambdaService feedbackLambdaService; @@ -83,25 +86,27 @@ public PrepareDrillAnswerUploadResponse prepareDrillAnswerUpload( @Transactional public DrillAnswerResponse submitDrillAnswer(Long userId, UserRole userRole, Long drillAnswerId, Integer durationMs) { - DrillAnswer answer = drillAnswerRepository.findById(drillAnswerId) + DrillAnswer answer = drillAnswerRepository.findByIdForUpdate(drillAnswerId) .orElseThrow(() -> new ResourceNotFoundException("Drill answer not found with id: " + drillAnswerId)); if (!answer.getUserId().equals(userId)) { throw new ValidationException("Unauthorized access to drill answer"); } - if (answer.isUploadSuccess()) { - log.warn("event=drill_submit_already_done | who={} | drillAnswerId={}", userId, drillAnswerId); - return resolveDrillAnswerResponse(answer); + if (answer.isUploadPending()) { + answer.markUploadSuccess(); + log.info("event=drill_submit | who={} | drillAnswerId={} | audioUrl={}", + userId, drillAnswerId, answer.getAudioUrl()); } - answer.markUploadSuccess(); - if (durationMs != null) { + if (durationMs != null && answer.getDurationMs() == 0) { answer.setDurationMs(durationMs); } - log.info("event=drill_submit | who={} | drillAnswerId={} | audioUrl={}", - userId, drillAnswerId, answer.getAudioUrl()); + if (!answer.isFeedbackNone()) { + log.warn("event=drill_feedback_already_requested | who={} | drillAnswerId={}", userId, drillAnswerId); + return resolveDrillAnswerResponse(drillAnswerRepository.save(answer)); + } if (userRole == UserRole.PAID || userRole == UserRole.ADMIN || featureFlagService.isEnabled("ai-for-free")) { answer.requestFeedback(); @@ -119,6 +124,48 @@ public DrillAnswerResponse submitDrillAnswer(Long userId, UserRole userRole, Lon return resolveDrillAnswerResponse(updatedAnswer); } + @Transactional + public void handleS3UploadDetected(String fileKey) { + DrillAnswer answer = drillAnswerRepository.findByAudioUrlForUpdate(fileKey).orElse(null); + if (answer == null) { + log.warn("event=drill_s3_upload_detected_not_found | fileKey={}", fileKey); + return; + } + + if (answer.isUploadPending()) { + answer.markUploadSuccess(); + log.info("event=drill_s3_upload_detected_marked_success | drillAnswerId={} | audioUrl={}", + answer.getId(), fileKey); + } + + if (!answer.isFeedbackNone()) { + log.warn("event=drill_s3_upload_detected_feedback_already_requested | drillAnswerId={} | audioUrl={}", + answer.getId(), fileKey); + drillAnswerRepository.save(answer); + return; + } + + UserRole userRole = userRepository.findById(answer.getUserId()) + .map(User::getUserRole) + .orElse(UserRole.FREE); + + if (userRole == UserRole.PAID || userRole == UserRole.ADMIN || featureFlagService.isEnabled("ai-for-free")) { + answer.requestFeedback(); + drillAnswerRepository.save(answer); + log.info("event=drill_s3_upload_detected_feedback_requested | who={} | drillAnswerId={} | audioUrl={}", + answer.getUserId(), answer.getId(), fileKey); + String questionText = questionRepository.findById(answer.getQuestionId()) + .map(Question::getQuestion) + .orElse(null); + feedbackLambdaService.invokeDrillAnswerFeedbackAsync(fileKey, answer.getUserId(), questionText); + return; + } + + drillAnswerRepository.save(answer); + log.info("event=drill_s3_upload_detected_no_feedback | who={} | drillAnswerId={}", + answer.getUserId(), answer.getId()); + } + @Transactional(readOnly = true) public List getRecentlyPracticedQuestions(Long userId) { List projections = drillAnswerRepository diff --git a/src/test/java/me/thinkcat/opic/practice/service/AnswerIdempotencyTest.java b/src/test/java/me/thinkcat/opic/practice/service/AnswerIdempotencyTest.java new file mode 100644 index 0000000..44f21e6 --- /dev/null +++ b/src/test/java/me/thinkcat/opic/practice/service/AnswerIdempotencyTest.java @@ -0,0 +1,129 @@ +package me.thinkcat.opic.practice.service; + +import me.thinkcat.opic.practice.dto.response.PresignedUrlResponse; +import me.thinkcat.opic.practice.entity.Answer; +import me.thinkcat.opic.practice.entity.FeedbackStatus; +import me.thinkcat.opic.practice.entity.Session; +import me.thinkcat.opic.practice.entity.SessionStatus; +import me.thinkcat.opic.practice.entity.StorageType; +import me.thinkcat.opic.practice.entity.User; +import me.thinkcat.opic.practice.entity.UserRole; +import me.thinkcat.opic.practice.repository.AnswerRepository; +import me.thinkcat.opic.practice.repository.QuestionRepository; +import me.thinkcat.opic.practice.repository.SessionRepository; +import me.thinkcat.opic.practice.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +/** + * completeAnswerUpload와 handleS3UploadDetected가 동시에 실행됐을 때 + * Lambda invoke가 정확히 1회만 발생하는지 검증. + * + * 핵심 메커니즘: + * - 두 메서드 모두 같은 Answer 엔티티 객체를 반환하도록 Mock 설정 + * - 먼저 실행된 메서드가 feedbackStatus를 REQUESTED로 변경 + * - 나중에 실행된 메서드는 isFeedbackNone() == false → Lambda 호출 없이 return + */ +@ExtendWith(MockitoExtension.class) +class AnswerIdempotencyTest { + + @Mock private AnswerRepository answerRepository; + @Mock private SessionRepository sessionRepository; + @Mock private QuestionRepository questionRepository; + @Mock private UserRepository userRepository; + @Mock private FeatureFlagService featureFlagService; + @Mock private FeedbackLambdaService feedbackLambdaService; + @Mock private PresignedUrlService presignedUrlService; + @InjectMocks private AnswerService answerService; + + private Answer pendingAnswer; + private final Long answerId = 1L; + private final Long userId = 10L; + private final String fileKey = "uploads/sessions/1/questions/1/uuid.m4a"; + + @BeforeEach + void setUp() { + pendingAnswer = Answer.builder() + .questionId(1L) + .sessionId(1L) + .audioUrl(fileKey) + .storageType(StorageType.S3) + .mimeType("audio/m4a") + .durationMs(0) + .build(); + + Session session = Session.builder() + .id(1L).userId(userId).title("test").mode("EXAM") + .statusCode(SessionStatus.IN_PROGRESS.getCode()) + .build(); + + User paidUser = User.builder() + .userRoleCode(UserRole.PAID.getCode()) + .build(); + + // completeAnswerUpload 경로 + given(answerRepository.findByIdForUpdate(answerId)).willReturn(Optional.of(pendingAnswer)); + given(sessionRepository.findByIdAndUserId(1L, userId)).willReturn(Optional.of(session)); + + // handleS3UploadDetected 경로 + given(answerRepository.findByAudioUrlForUpdate(fileKey)).willReturn(Optional.of(pendingAnswer)); + // completeUpload가 먼저 실행된 시나리오에서는 early return으로 도달하지 않으므로 lenient + lenient().when(sessionRepository.findById(1L)).thenReturn(Optional.of(session)); + lenient().when(userRepository.findById(userId)).thenReturn(Optional.of(paidUser)); + + lenient().when(questionRepository.findById(any())).thenReturn(Optional.empty()); + given(answerRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); + given(presignedUrlService.generateDownloadUrl(anyString())) + .willReturn(mock(PresignedUrlResponse.class)); + } + + @Test + void completeUpload_선행_s3Detected_후행_Lambda_1회만_호출() { + answerService.completeAnswerUpload(userId, UserRole.PAID, answerId, 5000); + answerService.handleS3UploadDetected(fileKey); + + assertThat(pendingAnswer.getFeedbackStatus()).isEqualTo(FeedbackStatus.REQUESTED); + verify(feedbackLambdaService, times(1)) + .invokeSessionFeedbackAsync(eq(fileKey), eq(userId), any()); + } + + @Test + void s3Detected_선행_completeUpload_후행_Lambda_1회만_호출() { + answerService.handleS3UploadDetected(fileKey); + answerService.completeAnswerUpload(userId, UserRole.PAID, answerId, 5000); + + assertThat(pendingAnswer.getFeedbackStatus()).isEqualTo(FeedbackStatus.REQUESTED); + verify(feedbackLambdaService, times(1)) + .invokeSessionFeedbackAsync(eq(fileKey), eq(userId), any()); + } + + @Test + void FREE유저_두_경로_모두_Lambda_미호출() { + given(userRepository.findById(userId)).willReturn(Optional.of(User.builder().build())); // FREE (default) + given(featureFlagService.isEnabled("ai-for-free")).willReturn(false); + + answerService.completeAnswerUpload(userId, UserRole.FREE, answerId, 5000); + answerService.handleS3UploadDetected(fileKey); + + assertThat(pendingAnswer.getFeedbackStatus()).isEqualTo(FeedbackStatus.NONE); + verify(feedbackLambdaService, never()) + .invokeSessionFeedbackAsync(any(), any(), any()); + } +} diff --git a/src/test/java/me/thinkcat/opic/practice/service/AnswerServiceTest.java b/src/test/java/me/thinkcat/opic/practice/service/AnswerServiceTest.java index ed5f42e..f6789b8 100644 --- a/src/test/java/me/thinkcat/opic/practice/service/AnswerServiceTest.java +++ b/src/test/java/me/thinkcat/opic/practice/service/AnswerServiceTest.java @@ -61,7 +61,7 @@ void setUp() { .statusCode(SessionStatus.IN_PROGRESS.getCode()) .build(); - given(answerRepository.findById(answerId)).willReturn(Optional.of(pendingAnswer)); + given(answerRepository.findByIdForUpdate(answerId)).willReturn(Optional.of(pendingAnswer)); given(sessionRepository.findByIdAndUserId(1L, userId)).willReturn(Optional.of(session)); lenient().when(questionRepository.findById(any())).thenReturn(Optional.empty()); given(answerRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); diff --git a/src/test/java/me/thinkcat/opic/practice/service/DrillAnswerIdempotencyTest.java b/src/test/java/me/thinkcat/opic/practice/service/DrillAnswerIdempotencyTest.java new file mode 100644 index 0000000..9d47f7a --- /dev/null +++ b/src/test/java/me/thinkcat/opic/practice/service/DrillAnswerIdempotencyTest.java @@ -0,0 +1,115 @@ +package me.thinkcat.opic.practice.service; + +import me.thinkcat.opic.practice.dto.response.PresignedUrlResponse; +import me.thinkcat.opic.practice.entity.DrillAnswer; +import me.thinkcat.opic.practice.entity.FeedbackStatus; +import me.thinkcat.opic.practice.entity.StorageType; +import me.thinkcat.opic.practice.entity.User; +import me.thinkcat.opic.practice.entity.UserRole; +import me.thinkcat.opic.practice.repository.CategoryRepository; +import me.thinkcat.opic.practice.repository.DrillAnswerRepository; +import me.thinkcat.opic.practice.repository.QuestionRepository; +import me.thinkcat.opic.practice.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +/** + * submitDrillAnswer와 handleS3UploadDetected가 동시에 실행됐을 때 + * Lambda invoke가 정확히 1회만 발생하는지 검증. + */ +@ExtendWith(MockitoExtension.class) +class DrillAnswerIdempotencyTest { + + @Mock private DrillAnswerRepository drillAnswerRepository; + @Mock private QuestionRepository questionRepository; + @Mock private CategoryRepository categoryRepository; + @Mock private UserRepository userRepository; + @Mock private FeatureFlagService featureFlagService; + @Mock private FeedbackLambdaService feedbackLambdaService; + @Mock private PresignedUrlService presignedUrlService; + @InjectMocks private DrillAnswerService drillAnswerService; + + private DrillAnswer pendingAnswer; + private final Long drillAnswerId = 1L; + private final Long userId = 10L; + private final String fileKey = "uploads/drills/10/questions/1/uuid.m4a"; + + @BeforeEach + void setUp() { + pendingAnswer = DrillAnswer.builder() + .userId(userId) + .questionId(1L) + .audioUrl(fileKey) + .storageType(StorageType.S3) + .mimeType("audio/m4a") + .durationMs(0) + .build(); + + User paidUser = User.builder() + .userRoleCode(UserRole.PAID.getCode()) + .build(); + + // submitDrillAnswer 경로 + given(drillAnswerRepository.findByIdForUpdate(drillAnswerId)).willReturn(Optional.of(pendingAnswer)); + + // handleS3UploadDetected 경로 + given(drillAnswerRepository.findByAudioUrlForUpdate(fileKey)).willReturn(Optional.of(pendingAnswer)); + // submit이 먼저 실행된 시나리오에서는 early return으로 도달하지 않으므로 lenient + lenient().when(userRepository.findById(userId)).thenReturn(Optional.of(paidUser)); + + lenient().when(questionRepository.findById(any())).thenReturn(Optional.empty()); + given(drillAnswerRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); + given(presignedUrlService.generateDownloadUrl(anyString())) + .willReturn(mock(PresignedUrlResponse.class)); + } + + @Test + void submit_선행_s3Detected_후행_Lambda_1회만_호출() { + drillAnswerService.submitDrillAnswer(userId, UserRole.PAID, drillAnswerId, 5000); + drillAnswerService.handleS3UploadDetected(fileKey); + + assertThat(pendingAnswer.getFeedbackStatus()).isEqualTo(FeedbackStatus.REQUESTED); + verify(feedbackLambdaService, times(1)) + .invokeDrillAnswerFeedbackAsync(eq(fileKey), eq(userId), any()); + } + + @Test + void s3Detected_선행_submit_후행_Lambda_1회만_호출() { + drillAnswerService.handleS3UploadDetected(fileKey); + drillAnswerService.submitDrillAnswer(userId, UserRole.PAID, drillAnswerId, 5000); + + assertThat(pendingAnswer.getFeedbackStatus()).isEqualTo(FeedbackStatus.REQUESTED); + verify(feedbackLambdaService, times(1)) + .invokeDrillAnswerFeedbackAsync(eq(fileKey), eq(userId), any()); + } + + @Test + void FREE유저_두_경로_모두_Lambda_미호출() { + given(userRepository.findById(userId)).willReturn(Optional.of(User.builder().build())); // FREE (default) + given(featureFlagService.isEnabled("ai-for-free")).willReturn(false); + + drillAnswerService.submitDrillAnswer(userId, UserRole.FREE, drillAnswerId, 5000); + drillAnswerService.handleS3UploadDetected(fileKey); + + assertThat(pendingAnswer.getFeedbackStatus()).isEqualTo(FeedbackStatus.NONE); + verify(feedbackLambdaService, never()) + .invokeDrillAnswerFeedbackAsync(any(), any(), any()); + } +}