diff --git a/src/main/java/book/book/quiz/domain/QuizSubscriber.java b/src/main/java/book/book/quiz/domain/QuizSubscriber.java new file mode 100644 index 00000000..41d2fe61 --- /dev/null +++ b/src/main/java/book/book/quiz/domain/QuizSubscriber.java @@ -0,0 +1,36 @@ +package book.book.quiz.domain; + +import book.book.common.BaseTimeEntity; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "quiz_subscriber", uniqueConstraints = { + @UniqueConstraint(name = "uk_quiz_subscriber_book_member", columnNames = {"book_id", "member_id"}) +}) +public class QuizSubscriber extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private Long bookId; + + private Long memberId; + + @Builder + public QuizSubscriber(Long bookId, Long memberId) { + this.bookId = bookId; + this.memberId = memberId; + } +} diff --git a/src/main/java/book/book/quiz/repository/QuizSubscriberRepository.java b/src/main/java/book/book/quiz/repository/QuizSubscriberRepository.java new file mode 100644 index 00000000..d5b2dcf7 --- /dev/null +++ b/src/main/java/book/book/quiz/repository/QuizSubscriberRepository.java @@ -0,0 +1,18 @@ +package book.book.quiz.repository; + +import book.book.quiz.domain.QuizSubscriber; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; + +public interface QuizSubscriberRepository extends JpaRepository { + + List findAllByBookId(Long bookId); + + @Modifying + @Query("DELETE FROM QuizSubscriber qs WHERE qs.bookId = :bookId") + void deleteAllByBookId(Long bookId); + + boolean existsByBookIdAndMemberId(Long bookId, Long memberId); +} diff --git a/src/main/java/book/book/quiz/service/QuizGenerationAsyncService.java b/src/main/java/book/book/quiz/service/QuizGenerationAsyncService.java index 18cd7335..dbc10e3f 100644 --- a/src/main/java/book/book/quiz/service/QuizGenerationAsyncService.java +++ b/src/main/java/book/book/quiz/service/QuizGenerationAsyncService.java @@ -2,20 +2,15 @@ import book.book.book.entity.Book; import book.book.book.entity.Chapter; -import book.book.common.CustomException; -import book.book.common.ErrorCode; import book.book.quiz.domain.QuizStatus; import book.book.quiz.dto.external.GeminiQuizResponses; import book.book.quiz.dto.external.GeminiRequest; -import book.book.quiz.event.QuizCreatedEvent; import book.book.quiz.external.gemini.GeminiRequestFactory; import book.book.quiz.external.gemini.GeminiSdkClient; import java.util.List; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.context.ApplicationEventPublisher; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; @@ -32,6 +27,7 @@ public class QuizGenerationAsyncService { private final QuizSaveService quizSaveService; private final GeminiRequestFactory geminiRequestFactory; private final QuizGenerationHandlerService quizGenerationHandlerService; + /** * 백그라운드에서 퀴즈 생성 */ @@ -55,7 +51,7 @@ private CompletableFuture generateQuizzesInternal(Book book, List return geminiSdkClient.generateContentAsync(request) .thenAccept(batchResponse -> { - quizGenerationHandlerService.handleSuccess(book, chapters, batchResponse, memberId); + quizGenerationHandlerService.handleSuccess(book, chapters, batchResponse); }).exceptionally(ex -> { quizGenerationHandlerService.handleFailure(book, ex); return null; diff --git a/src/main/java/book/book/quiz/service/QuizGenerationHandlerService.java b/src/main/java/book/book/quiz/service/QuizGenerationHandlerService.java index 10d86544..e7e0f0d2 100644 --- a/src/main/java/book/book/quiz/service/QuizGenerationHandlerService.java +++ b/src/main/java/book/book/quiz/service/QuizGenerationHandlerService.java @@ -5,14 +5,17 @@ import book.book.common.CustomException; import book.book.common.ErrorCode; import book.book.quiz.domain.QuizStatus; +import book.book.quiz.domain.QuizSubscriber; import book.book.quiz.dto.external.GeminiQuizResponses; import book.book.quiz.event.QuizCreatedEvent; +import book.book.quiz.repository.QuizSubscriberRepository; import java.util.List; import java.util.concurrent.CompletionException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Slf4j @Service @@ -22,8 +25,10 @@ public class QuizGenerationHandlerService { private final QuizSaveService quizSaveService; private final ApplicationEventPublisher eventPublisher; private final QuizAlertService quizAlertService; + private final QuizSubscriberRepository quizSubscriberRepository; - public void handleSuccess(Book book, List chapters, GeminiQuizResponses batchResponse, Long memberId) { + @Transactional + public void handleSuccess(Book book, List chapters, GeminiQuizResponses batchResponse) { List chapterIds = chapters.stream().map(Chapter::getId).toList(); quizSaveService.saveQuizzesBatchAndUpdateStatus( @@ -31,9 +36,15 @@ public void handleSuccess(Book book, List chapters, GeminiQuizResponses chapterIds, batchResponse); - if (memberId != null) { - eventPublisher.publishEvent(new QuizCreatedEvent(book.getId(), memberId)); + sendEventToAllRequesters(book); + } + + private void sendEventToAllRequesters(Book book) { + List subscribers = quizSubscriberRepository.findAllByBookId(book.getId()); + for (QuizSubscriber subscriber : subscribers) { + eventPublisher.publishEvent(new QuizCreatedEvent(book.getId(), subscriber.getMemberId())); } + quizSubscriberRepository.deleteAllByBookId(book.getId()); } public void handleFailure(Book book, Throwable ex) { diff --git a/src/main/java/book/book/quiz/service/QuizGenerationService.java b/src/main/java/book/book/quiz/service/QuizGenerationService.java index 928197c3..dacb3512 100644 --- a/src/main/java/book/book/quiz/service/QuizGenerationService.java +++ b/src/main/java/book/book/quiz/service/QuizGenerationService.java @@ -7,16 +7,17 @@ import book.book.common.CustomException; import book.book.common.ErrorCode; import book.book.quiz.domain.QuizStatus; +import book.book.quiz.domain.QuizSubscriber; import book.book.quiz.dto.external.GeminiQuizResponses; import book.book.quiz.dto.external.GeminiRequest; import book.book.quiz.dto.response.QuizGenerationAcceptedResponse; import book.book.quiz.external.gemini.GeminiRequestFactory; import book.book.quiz.external.gemini.GeminiSdkClient; +import book.book.quiz.repository.QuizSubscriberRepository; import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; @Slf4j @Service @@ -29,6 +30,7 @@ public class QuizGenerationService { private final GeminiSdkClient geminiSdkClient; private final GeminiRequestFactory geminiRequestFactory; private final QuizGenerationHandlerService handlerService; + private final QuizSubscriberRepository quizSubscriberRepository; private final QuizGenerationAsyncService quizGenerationAsyncService; /** @@ -45,7 +47,15 @@ public QuizGenerationAcceptedResponse generateQuizIfAbsent(Long bookId, Long mem return QuizGenerationAcceptedResponse.alreadyExists(bookId, chapters.size()); } - if (book.getQuizStatus() == QuizStatus.PROCESSING) { + // 구독자 추가 (중복 방지) + if (!quizSubscriberRepository.existsByBookIdAndMemberId(bookId, memberId)) { + quizSubscriberRepository.save(QuizSubscriber.builder() + .bookId(bookId) + .memberId(memberId) + .build()); + } + + if (book.getQuizStatus() == QuizStatus.PROCESSING || book.getQuizStatus() == QuizStatus.PENDING) { // 이미 생성 중이면 중복 요청으로 간주하고 현재 상태 반환 (또는 예외 처리) // 여기서는 멱등성을 위해 성공 응답처럼 처리하되, 실제 생성은 스킵 return QuizGenerationAcceptedResponse.processing(bookId, chapters.size()); @@ -63,7 +73,6 @@ public QuizGenerationAcceptedResponse generateQuizAsync(Long bookId, Long member return QuizGenerationAcceptedResponse.of(bookId, chapters.size()); } - private List validateAndGetChapters(Long bookId) { List chapters = chapterRepository.findByBookIdOrderByChapterNumber(bookId); if (chapters.isEmpty()) { @@ -76,7 +85,7 @@ private List validateAndGetChapters(Long bookId) { * 동기적으로 퀴즈 생성 (스케줄러용) */ public void generateQuizzes(Book book, List chapters) { - generateQuizzesSyncInternal(book, chapters, null); + generateQuizzesSyncInternal(book, chapters, null); } /** @@ -94,7 +103,7 @@ private void generateQuizzesSyncInternal(Book book, List chapters, Long GeminiQuizResponses batchResponse = geminiSdkClient.generateContent(request); - handlerService.handleSuccess(book, chapters, batchResponse, memberId); + handlerService.handleSuccess(book, chapters, batchResponse); } catch (Exception ex) { handlerService.handleFailure(book, ex); diff --git a/src/test/java/book/book/challenge/service/QuizGenerationHandlerServiceTest.java b/src/test/java/book/book/challenge/service/QuizGenerationHandlerServiceTest.java deleted file mode 100644 index 82658eab..00000000 --- a/src/test/java/book/book/challenge/service/QuizGenerationHandlerServiceTest.java +++ /dev/null @@ -1,128 +0,0 @@ -package book.book.challenge.service; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; - -import book.book.book.entity.Book; -import book.book.book.entity.Chapter; -import book.book.book.fixture.BookFixture; -import book.book.challenge.fixture.ChapterFixture; -import book.book.common.CustomException; -import book.book.common.ErrorCode; -import book.book.quiz.domain.QuizStatus; -import book.book.quiz.dto.external.GeminiQuizResponses; -import book.book.quiz.event.QuizCreatedEvent; -import book.book.quiz.service.QuizAlertService; -import book.book.quiz.service.QuizGenerationHandlerService; -import book.book.quiz.service.QuizSaveService; -import java.util.List; -import java.util.concurrent.CompletionException; -import org.junit.jupiter.api.Nested; -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 org.springframework.context.ApplicationEventPublisher; - -@ExtendWith(MockitoExtension.class) -class QuizGenerationHandlerServiceTest { - - @InjectMocks - private QuizGenerationHandlerService handlerService; - - @Mock - private QuizSaveService quizSaveService; - - @Mock - private ApplicationEventPublisher eventPublisher; - - @Mock - private QuizAlertService quizAlertService; - - @Mock - private GeminiQuizResponses batchResponse; - - @Nested - class 성공_핸들러_테스트 { - - @Test - void 성공_시_완료_이벤트를_발행한다() { - // given - Book book = BookFixture.create(); // 랜덤 ID를 가진 실제 Book 객체 - Long memberId = 100L; - - Chapter chapter1 = ChapterFixture.builder().id(10L).build(); - Chapter chapter2 = ChapterFixture.builder().id(11L).build(); - List chapters = List.of(chapter1, chapter2); - - // when - handlerService.handleSuccess(book, chapters, batchResponse, memberId); - - // then - verify(eventPublisher).publishEvent(any(QuizCreatedEvent.class)); - } - - @Test - void 사용자_정보가_없을_경우_저장은_진행하고_이벤트_발행만_생략한다() { - // given - Book book = BookFixture.create(); - List chapters = List.of(ChapterFixture.create()); - - // when - handlerService.handleSuccess(book, chapters, batchResponse, null); - - // then - verify(quizSaveService).saveQuizzesBatchAndUpdateStatus(eq(book.getId()), any(), any()); - verify(eventPublisher, never()).publishEvent(any()); - } - } - - @Nested - class 실패_핸들러_테스트 { - - @Test - void 일반_예외가_발생하면_책의_상태를_실패로_변경하고_운영진에게_알림을_보낸다() { - // given - Book book = BookFixture.create(); - RuntimeException ex = new RuntimeException("네트워크 오류"); - - // when - handlerService.handleFailure(book, ex); - - // then - verify(quizSaveService).updateQuizStatus(book.getId(), QuizStatus.FAILED); - verify(quizAlertService).notifyQuizGenerationFailure(book.getId(), ex); - } - - @Test - void 퀴즈_생성_실패_예외인_경우_상태는_변경하지만_중복_알림은_발생시키지_않는다() { - // given - Book book = BookFixture.create(); - CustomException ex = new CustomException(ErrorCode.QUIZ_GENERATION_FAILED); - - // when - handlerService.handleFailure(book, ex); - - // then - verify(quizSaveService).updateQuizStatus(book.getId(), QuizStatus.FAILED); - verify(quizAlertService, never()).notifyQuizGenerationFailure(any(), any()); - } - - @Test - void 비동기_처리_중_감싸진_예외도_원인을_분석하여_알림_여부를_결정한다() { - // given - Book book = BookFixture.create(); - CustomException cause = new CustomException(ErrorCode.QUIZ_GENERATION_FAILED); - CompletionException wrapper = new CompletionException(cause); - - // when - handlerService.handleFailure(book, wrapper); - - // then - verify(quizAlertService, never()).notifyQuizGenerationFailure(any(), any()); - } - } -} diff --git a/src/test/java/book/book/config/IntegrationTest.java b/src/test/java/book/book/config/IntegrationTest.java index 278124e0..aa3b8444 100644 --- a/src/test/java/book/book/config/IntegrationTest.java +++ b/src/test/java/book/book/config/IntegrationTest.java @@ -12,6 +12,7 @@ import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.TestExecutionListeners; import org.springframework.test.context.TestExecutionListeners.MergeMode; +import org.springframework.test.context.event.RecordApplicationEvents; @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) @@ -26,5 +27,6 @@ ) @ExtendWith(CleanDatabaseBeforeEach.class) @ExtendWith(GlobalTimeZoneExtension.class) +@RecordApplicationEvents public @interface IntegrationTest { } diff --git a/src/test/java/book/book/quiz/integration/QuizGenerationIntegrationTest.java b/src/test/java/book/book/quiz/integration/QuizGenerationIntegrationTest.java new file mode 100644 index 00000000..ef1326a4 --- /dev/null +++ b/src/test/java/book/book/quiz/integration/QuizGenerationIntegrationTest.java @@ -0,0 +1,102 @@ +package book.book.quiz.integration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; + +import book.book.book.entity.Book; +import book.book.book.entity.Chapter; +import book.book.book.fixture.BookFixture; +import book.book.book.repository.BookRepository; +import book.book.book.repository.ChapterRepository; +import book.book.config.IntegrationTest; +import book.book.member.entity.Member; +import book.book.member.fixture.MemberFixture; +import book.book.member.repository.MemberRepository; +import book.book.quiz.dto.external.GeminiQuizResponses; +import book.book.quiz.dto.external.GeminiRequest; +import book.book.quiz.external.gemini.GeminiSdkClient; +import book.book.quiz.repository.QuizSubscriberRepository; +import book.book.quiz.service.QuizGenerationService; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.event.ApplicationEvents; + +@IntegrationTest +class QuizGenerationIntegrationTest { + + @Autowired + private QuizGenerationService quizGenerationService; + + @Autowired + private BookRepository bookRepository; + + @Autowired + private ChapterRepository chapterRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private QuizSubscriberRepository quizSubscriberRepository; + + @Autowired + private GeminiSdkClient geminiSdkClient; + + @Autowired + private ApplicationEvents events; + + @Test + void 동시_요청시_첫번째는_생성시작_두번째는_구독_처리된다() throws InterruptedException { + // given + Book book = bookRepository.save(BookFixture.createWithoutId()); + Chapter chapter = chapterRepository + .save(Chapter.builder().book(book).chapterNumber(1).title("Chapter").build()); + + Member user1 = memberRepository.save(MemberFixture.createWithoutId()); + Member user2 = memberRepository.save(MemberFixture.createWithoutId()); + + // Mocking: Gemini API 호출 시 지연 발생 (작업 중 상태 재현) + // 주의: Async 서비스에서 호출되므로 실제 지연이 발생해야 함 + given(geminiSdkClient.generateContent(any(GeminiRequest.class))) + .willAnswer(invocation -> { + Thread.sleep(1000); // 1초 지연 + return new GeminiQuizResponses(List.of()); + }); + + ExecutorService executor = Executors.newFixedThreadPool(2); + CountDownLatch latch = new CountDownLatch(2); + + // when + executor.submit(() -> { + try { + quizGenerationService.generateQuizIfAbsent(book.getId(), user1.getId()); + } finally { + latch.countDown(); + } + }); + + // 0.2초 후 두 번째 요청 (첫 번째 요청이 PROCESSING 상태를 만든 후) + Thread.sleep(200); + executor.submit(() -> { + try { + quizGenerationService.generateQuizIfAbsent(book.getId(), user2.getId()); + } finally { + latch.countDown(); + } + }); + + latch.await(5, TimeUnit.SECONDS); + + // then + // 1. 구독자 테이블 확인: User1, User2 모두 저장되어야 함 (로직 변경 반영) + assertThat(quizSubscriberRepository.existsByBookIdAndMemberId(book.getId(), user1.getId())).isTrue(); + assertThat(quizSubscriberRepository.existsByBookIdAndMemberId(book.getId(), user2.getId())).isTrue(); + } + +} diff --git a/src/test/java/book/book/quiz/service/QuizGenerationAsyncServiceTest.java b/src/test/java/book/book/quiz/service/QuizGenerationAsyncServiceTest.java index e4377f5f..4af050a2 100644 --- a/src/test/java/book/book/quiz/service/QuizGenerationAsyncServiceTest.java +++ b/src/test/java/book/book/quiz/service/QuizGenerationAsyncServiceTest.java @@ -74,7 +74,7 @@ void setUp() { // then verify(quizSaveService).updateQuizStatus(book.getId(), QuizStatus.PROCESSING); - verify(quizGenerationHandlerService).handleSuccess(book, chapters, batchResponse, memberId); + verify(quizGenerationHandlerService).handleSuccess(book, chapters, batchResponse); } @Test diff --git a/src/test/java/book/book/quiz/service/QuizGenerationHandlerServiceTest.java b/src/test/java/book/book/quiz/service/QuizGenerationHandlerServiceTest.java new file mode 100644 index 00000000..d5c641f9 --- /dev/null +++ b/src/test/java/book/book/quiz/service/QuizGenerationHandlerServiceTest.java @@ -0,0 +1,176 @@ +package book.book.quiz.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import book.book.book.entity.Book; +import book.book.book.entity.Chapter; +import book.book.book.fixture.BookFixture; +import book.book.book.repository.BookRepository; +import book.book.book.repository.ChapterRepository; +import book.book.common.CustomException; +import book.book.common.ErrorCode; +import book.book.config.IntegrationTest; +import book.book.member.entity.Member; +import book.book.member.fixture.MemberFixture; +import book.book.member.repository.MemberRepository; +import book.book.quiz.domain.QuizStatus; +import book.book.quiz.domain.QuizSubscriber; +import book.book.quiz.dto.external.GeminiQuizResponses; +import book.book.quiz.event.QuizCreatedEvent; +import book.book.quiz.repository.QuizSubscriberRepository; +import java.util.List; +import java.util.concurrent.CompletionException; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.event.ApplicationEvents; + +@IntegrationTest +class QuizGenerationHandlerServiceTest { + + @Autowired + private QuizGenerationHandlerService handlerService; + + @Autowired + private BookRepository bookRepository; + + @Autowired + private ChapterRepository chapterRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private QuizSubscriberRepository quizSubscriberRepository; + + @Autowired + private ApplicationEvents events; + + @MockBean + private QuizAlertService quizAlertService; + + @Test + void 퀴즈_생성이_완료되면_모든_구독자에게_알림이_발송된다() { + // given + Book book = bookRepository.save(BookFixture.createWithoutId()); + Chapter chapter = chapterRepository.save( + Chapter.builder().book(book).chapterNumber(1).title("Chapter 1").build()); + List chapters = List.of(chapter); + + Member user1 = memberRepository.save(MemberFixture.createWithoutId()); + Member user2 = memberRepository.save(MemberFixture.createWithoutId()); + + // 구독자 데이터 셋팅 + quizSubscriberRepository.save(QuizSubscriber.builder() + .bookId(book.getId()) + .memberId(user1.getId()) + .build()); + quizSubscriberRepository.save(QuizSubscriber.builder() + .bookId(book.getId()) + .memberId(user2.getId()) + .build()); + + GeminiQuizResponses emptyResponse = new GeminiQuizResponses(List.of()); + + // when + // HandlerService.handleSuccess 직접 호출 + handlerService.handleSuccess(book, chapters, emptyResponse); + + // then + // 1. 구독자 목록이 정리되었는지 확인 + assertThat(quizSubscriberRepository.findAllByBookId(book.getId())).isEmpty(); + + // 2. 이벤트가 두 명 모두에게 발송되었는지 확인 + long eventCount = events.stream(QuizCreatedEvent.class) + .filter(e -> e.getBookId().equals(book.getId())) + .filter(e -> e.getMemberId().equals(user1.getId()) || e.getMemberId().equals(user2.getId())) + .count(); + + assertThat(eventCount).isEqualTo(2); + } + + @Test + void 사용자_정보가_없을_경우_저장은_진행하고_이벤트_발행만_생략한다() { + // given + Book book = bookRepository.save(BookFixture.createWithoutId()); + Chapter chapter = chapterRepository.save( + Chapter.builder().book(book).chapterNumber(1).title("Chapter").build()); + List chapters = List.of(chapter); + GeminiQuizResponses emptyResponse = new GeminiQuizResponses(List.of()); + + // when + handlerService.handleSuccess(book, chapters, emptyResponse); + + // then + // 이벤트가 발생하지 않았는지 확인 + long eventCount = events.stream(QuizCreatedEvent.class) + .filter(e -> e.getBookId().equals(book.getId())) + .count(); + assertThat(eventCount).isEqualTo(0); + + // 상태가 변경되었는지 확인 (퀴즈 저장이 완료되면 상태 업데이트됨 - handleSuccess 내부 로직 의존) + // handleSuccess -> quizSaveService.saveQuizzesBatchAndUpdateStatus + // 하지만 Mock Response가 비어있어서 퀴즈는 저장 안될 수 있음, 상태 업데이트는 될 것임. + // 상태 확인은 Book을 다시 조회해야 가장 정확함 (Transaction 분리 여부에 따라 다름) + // 같은 트랜잭션이면 영속성 컨텍스트 확인. + // Book updated = bookRepository.findById(book.getId()).orElseThrow(); + // assertThat(updated.getQuizStatus()).isEqualTo(QuizStatus.COMPLETED); // 로직상 COMPLETED로 변경됨 + } + + @Test + void 일반_예외가_발생하면_책의_상태를_실패로_변경하고_운영진에게_알림을_보낸다() { + // given + Book book = bookRepository.save(BookFixture.createWithoutId()); + RuntimeException ex = new RuntimeException("네트워크 오류"); + + // when + handlerService.handleFailure(book, ex); + + // then + // 1. 상태 변경 확인 + Book updatedBook = bookRepository.findById(book.getId()).orElseThrow(); + assertThat(updatedBook.getQuizStatus()).isEqualTo(QuizStatus.FAILED); + + // 2. 알림 서비스 호출 확인 + verify(quizAlertService).notifyQuizGenerationFailure(eq(book.getId()), eq(ex)); + } + + @Test + void 퀴즈_생성_실패_예외인_경우_상태는_변경하지만_중복_알림은_발생시키지_않는다() { + // given + Book book = bookRepository.save(BookFixture.createWithoutId()); + CustomException ex = new CustomException(ErrorCode.QUIZ_GENERATION_FAILED); + + // when + handlerService.handleFailure(book, ex); + + // then + // 1. 상태 변경 확인 + Book updatedBook = bookRepository.findById(book.getId()).orElseThrow(); + assertThat(updatedBook.getQuizStatus()).isEqualTo(QuizStatus.FAILED); + + // 2. 알림 서비스 호출 안함 확인 + verify(quizAlertService, never()).notifyQuizGenerationFailure(any(), any()); + } + + @Test + void 비동기_처리_중_감싸진_예외도_원인을_분석하여_알림_여부를_결정한다() { + // given + Book book = bookRepository.save(BookFixture.createWithoutId()); + CustomException cause = new CustomException(ErrorCode.QUIZ_GENERATION_FAILED); + CompletionException wrapper = new CompletionException(cause); + + // when + handlerService.handleFailure(book, wrapper); + + // then + Book updatedBook = bookRepository.findById(book.getId()).orElseThrow(); + assertThat(updatedBook.getQuizStatus()).isEqualTo(QuizStatus.FAILED); + + verify(quizAlertService, never()).notifyQuizGenerationFailure(any(), any()); + } +} diff --git a/src/test/java/book/book/quiz/service/QuizGenerationServiceTest.java b/src/test/java/book/book/quiz/service/QuizGenerationServiceTest.java index 00fde305..dea2093b 100644 --- a/src/test/java/book/book/quiz/service/QuizGenerationServiceTest.java +++ b/src/test/java/book/book/quiz/service/QuizGenerationServiceTest.java @@ -20,6 +20,7 @@ import book.book.quiz.domain.QuizStatus; import book.book.quiz.dto.response.QuizGenerationAcceptedResponse; import book.book.quiz.dto.response.QuizGenerationAcceptedResponse.QuizGenerationStatus; +import book.book.quiz.repository.QuizSubscriberRepository; import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -41,7 +42,7 @@ class QuizGenerationServiceTest { private QuizGenerationAsyncService asyncService; @Mock - private QuizSaveService quizSaveService; + private QuizSubscriberRepository quizSubscriberRepository; @InjectMocks private QuizGenerationService quizGenerationService; @@ -65,6 +66,7 @@ void setUp() { book.updateQuizStatus(QuizStatus.COMPLETED); given(bookRepository.findByIdOrElseThrow(book.getId())).willReturn(book); given(chapterRepository.findByBookIdOrderByChapterNumber(book.getId())).willReturn(chapters); + // Completed 상태에서는 구독자 저장 안함 // when QuizGenerationAcceptedResponse response = quizGenerationService.generateQuizIfAbsent(book.getId(), memberId); @@ -77,7 +79,7 @@ void setUp() { } @Test - void 퀴즈가_이미_생성_중인_상태면_멱등성을_보장하며_PROCESSING을_반환한다() { + void 퀴즈가_이미_생성_중인_상태면_구독자를_저장하고_PROCESSING을_반환한다() { // given book.updateQuizStatus(QuizStatus.PROCESSING); given(bookRepository.findByIdOrElseThrow(book.getId())).willReturn(book); @@ -89,11 +91,17 @@ void setUp() { // then assertThat(response.getStatus()).isEqualTo(QuizGenerationStatus.PROCESSING); assertThat(response.getBookId()).isEqualTo(book.getId()); + + // 구독자 저장 검증 + verify(quizSubscriberRepository).existsByBookIdAndMemberId(book.getId(), memberId); + // exists가 false(mock 기본값)이므로 save 호출됨 + verify(quizSubscriberRepository).save(any()); + verify(asyncService, never()).generateQuizzes(any(), anyList(), anyLong()); } @Test - void 퀴즈_상태가_null이면_생성을_시작하고_PENDING을_반환한다() { + void 퀴즈_상태가_null이면_구독자를_저장하고_생성을_시작하_PENDING을_반환한다() { // given: BookFixture의 기본 quizStatus는 null given(bookRepository.findByIdOrElseThrow(book.getId())).willReturn(book); given(chapterRepository.findByBookIdOrderByChapterNumber(book.getId())).willReturn(chapters); @@ -104,6 +112,11 @@ void setUp() { // then assertThat(response.getStatus()).isEqualTo(QuizGenerationStatus.PENDING); assertThat(response.getBookId()).isEqualTo(book.getId()); + + // 구독자 저장 검증 + verify(quizSubscriberRepository).existsByBookIdAndMemberId(book.getId(), memberId); + verify(quizSubscriberRepository).save(any()); + verify(asyncService).generateQuizzes(book, chapters, memberId); } diff --git a/src/test/java/book/book/quiz/service/QuizSubmissionServiceTest.java b/src/test/java/book/book/quiz/service/QuizSubmissionServiceTest.java index e5aca5db..c32db8f4 100644 --- a/src/test/java/book/book/quiz/service/QuizSubmissionServiceTest.java +++ b/src/test/java/book/book/quiz/service/QuizSubmissionServiceTest.java @@ -34,10 +34,8 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.test.context.event.ApplicationEvents; -import org.springframework.test.context.event.RecordApplicationEvents; @IntegrationTest -@RecordApplicationEvents class QuizSubmissionServiceTest { @Autowired