From 1b038c1f82a9af5273d51db59103a6c595b65bf2 Mon Sep 17 00:00:00 2001 From: SeongHun Jeon Date: Tue, 14 Jul 2026 16:43:56 +0900 Subject: [PATCH] fix: shorten upload db transaction scope --- .../com/kw/readwith/service/BookService.java | 88 +++++---- .../service/BookUploadTransactionService.java | 70 +++++++ .../kw/readwith/service/BookServiceTest.java | 184 ++++++++++-------- .../BookUploadTransactionServiceTest.java | 112 +++++++++++ 4 files changed, 326 insertions(+), 128 deletions(-) create mode 100644 src/main/java/com/kw/readwith/service/BookUploadTransactionService.java create mode 100644 src/test/java/com/kw/readwith/service/BookUploadTransactionServiceTest.java diff --git a/src/main/java/com/kw/readwith/service/BookService.java b/src/main/java/com/kw/readwith/service/BookService.java index 452d463..eb9e0ab 100644 --- a/src/main/java/com/kw/readwith/service/BookService.java +++ b/src/main/java/com/kw/readwith/service/BookService.java @@ -3,26 +3,22 @@ import com.kw.readwith.apiPayload.code.status.ErrorStatus; import com.kw.readwith.apiPayload.exception.GeneralException; import com.kw.readwith.domain.Book; -import com.kw.readwith.domain.User; import com.kw.readwith.domain.enums.NormalizationStatus; -import com.kw.readwith.domain.processing.ProcessingJob; import com.kw.readwith.dto.book.BookDetailDTO; import com.kw.readwith.dto.book.BookSummaryDTO; import com.kw.readwith.repository.BookRepository; import com.kw.readwith.repository.FavoriteRepository; -import com.kw.readwith.repository.UserRepository; +import com.kw.readwith.service.BookUploadTransactionService.BookUploadCompletion; import com.kw.readwith.service.normalization.EpubMetadataExtractorService; import com.kw.readwith.service.normalization.ExtractedEpubMetadata; import com.kw.readwith.service.normalization.NormalizationJobDispatcher; -import com.kw.readwith.service.normalization.NormalizationJobService; import com.kw.readwith.service.normalization.NormalizedArtifactStorageService; import com.kw.readwith.service.normalization.NormalizationVersionService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; -import org.springframework.transaction.support.TransactionSynchronization; -import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.web.multipart.MultipartFile; import java.util.List; @@ -37,12 +33,11 @@ public class BookService { private final BookRepository bookRepository; private final FavoriteRepository favoriteRepository; - private final UserRepository userRepository; private final EpubMetadataExtractorService epubMetadataExtractorService; private final NormalizedArtifactStorageService normalizedArtifactStorageService; - private final NormalizationJobService normalizationJobService; private final NormalizationJobDispatcher normalizationJobDispatcher; private final NormalizationVersionService normalizationVersionService; + private final BookUploadTransactionService bookUploadTransactionService; public List getBooks(String keyword, String language, @@ -116,7 +111,7 @@ public BookDetailDTO getBook(Long bookId, Long userId) { return convertToDetailDTO(book, isFavorite); } - @Transactional + @Transactional(propagation = Propagation.NOT_SUPPORTED) public BookDetailDTO uploadBook(Long userId, MultipartFile epubFile, String title, @@ -126,41 +121,42 @@ public BookDetailDTO uploadBook(Long userId, throw new GeneralException(ErrorStatus._BAD_REQUEST); } - User uploader = userRepository.findById(userId) - .orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND)); - ExtractedEpubMetadata extractedMetadata = epubMetadataExtractorService.extract(epubFile); String resolvedTitle = resolveRequiredMetadata("title", extractedMetadata.title(), null); String resolvedAuthor = resolveRequiredMetadata("author", extractedMetadata.author(), null); String resolvedLanguage = resolveRequiredMetadata("language", extractedMetadata.language(), language); - Book book = Book.builder() - .title(resolvedTitle) - .author(resolvedAuthor) - .language(resolvedLanguage) - .isDefault(false) - .coverImgUrl(null) - .uploadedBy(uploader) - .summary(false) - .build(); - - Book savedBook = bookRepository.save(book); + Book savedBook = bookUploadTransactionService.createUploadBook( + userId, + resolvedTitle, + resolvedAuthor, + resolvedLanguage + ); String sourceVersion = normalizedArtifactStorageService.newSourceVersion(); - String sourcePath = normalizedArtifactStorageService.storeSourceEpub(savedBook.getId(), sourceVersion, epubFile); - savedBook.assignUploadedSource(sourcePath); - storeCoverImageIfPresent(savedBook, sourceVersion, extractedMetadata); - savedBook.markNormalizationQueued(); - savedBook.resetAnalysisStatus(); - - ProcessingJob job = normalizationJobService.createQueuedJob(savedBook, sourceVersion, "UPLOAD"); - TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { - @Override - public void afterCommit() { - normalizationJobDispatcher.dispatch(job.getId()); - } - }); + String sourcePath; + try { + sourcePath = normalizedArtifactStorageService.storeSourceEpub(savedBook.getId(), sourceVersion, epubFile); + } catch (RuntimeException e) { + markUploadFailedQuietly(savedBook.getId()); + throw e; + } + String coverUrl = storeCoverImageIfPresent(savedBook.getId(), sourceVersion, extractedMetadata); + + BookUploadCompletion completion; + try { + completion = bookUploadTransactionService.completeUpload( + savedBook.getId(), + sourceVersion, + sourcePath, + coverUrl + ); + } catch (RuntimeException e) { + markUploadFailedQuietly(savedBook.getId()); + throw e; + } + normalizationJobDispatcher.dispatch(completion.jobId()); - return convertToDetailDTO(savedBook, false); + return convertToDetailDTO(completion.book(), false); } private BookDetailDTO convertToDetailDTO(Book book, boolean isFavorite) { @@ -189,15 +185,23 @@ private String enumName(Enum value) { return value == null ? null : value.name(); } - private void storeCoverImageIfPresent(Book book, String sourceVersion, ExtractedEpubMetadata extractedMetadata) { + private String storeCoverImageIfPresent(Long bookId, String sourceVersion, ExtractedEpubMetadata extractedMetadata) { if (extractedMetadata.cover() == null || extractedMetadata.cover().isEmpty()) { - return; + return null; } try { - String coverUrl = normalizedArtifactStorageService.storeBookCover(book.getId(), sourceVersion, extractedMetadata.cover()); - book.updateCoverImage(coverUrl); + return normalizedArtifactStorageService.storeBookCover(bookId, sourceVersion, extractedMetadata.cover()); } catch (RuntimeException e) { - log.warn("Failed to store EPUB cover image. bookId={}, sourceVersion={}", book.getId(), sourceVersion, e); + log.warn("Failed to store EPUB cover image. bookId={}, sourceVersion={}", bookId, sourceVersion, e); + return null; + } + } + + private void markUploadFailedQuietly(Long bookId) { + try { + bookUploadTransactionService.markUploadFailed(bookId); + } catch (RuntimeException nested) { + log.warn("Failed to mark upload as failed. bookId={}", bookId, nested); } } diff --git a/src/main/java/com/kw/readwith/service/BookUploadTransactionService.java b/src/main/java/com/kw/readwith/service/BookUploadTransactionService.java new file mode 100644 index 0000000..4394f83 --- /dev/null +++ b/src/main/java/com/kw/readwith/service/BookUploadTransactionService.java @@ -0,0 +1,70 @@ +package com.kw.readwith.service; + +import com.kw.readwith.apiPayload.code.status.ErrorStatus; +import com.kw.readwith.apiPayload.exception.GeneralException; +import com.kw.readwith.domain.Book; +import com.kw.readwith.domain.User; +import com.kw.readwith.domain.processing.ProcessingJob; +import com.kw.readwith.repository.BookRepository; +import com.kw.readwith.repository.UserRepository; +import com.kw.readwith.service.normalization.NormalizationJobService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class BookUploadTransactionService { + + private final UserRepository userRepository; + private final BookRepository bookRepository; + private final NormalizationJobService normalizationJobService; + + @Transactional + public Book createUploadBook(Long userId, String title, String author, String language) { + User uploader = userRepository.findById(userId) + .orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND)); + + Book book = Book.builder() + .title(title) + .author(author) + .language(language) + .isDefault(false) + .coverImgUrl(null) + .uploadedBy(uploader) + .summary(false) + .build(); + + return bookRepository.save(book); + } + + @Transactional + public BookUploadCompletion completeUpload( + Long bookId, + String sourceVersion, + String sourcePath, + String coverUrl + ) { + Book book = bookRepository.findById(bookId) + .orElseThrow(() -> new GeneralException(ErrorStatus.BOOK_NOT_FOUND)); + + book.assignUploadedSource(sourcePath); + if (coverUrl != null && !coverUrl.isBlank()) { + book.updateCoverImage(coverUrl); + } + book.markNormalizationQueued(); + book.resetAnalysisStatus(); + + ProcessingJob job = normalizationJobService.createQueuedJob(book, sourceVersion, "UPLOAD"); + return new BookUploadCompletion(book, job.getId()); + } + + @Transactional + public void markUploadFailed(Long bookId) { + bookRepository.findById(bookId) + .ifPresent(Book::markNormalizationFailed); + } + + public record BookUploadCompletion(Book book, Long jobId) { + } +} diff --git a/src/test/java/com/kw/readwith/service/BookServiceTest.java b/src/test/java/com/kw/readwith/service/BookServiceTest.java index d4a2341..6859a8e 100644 --- a/src/test/java/com/kw/readwith/service/BookServiceTest.java +++ b/src/test/java/com/kw/readwith/service/BookServiceTest.java @@ -2,48 +2,41 @@ import com.kw.readwith.apiPayload.exception.GeneralException; import com.kw.readwith.domain.Book; -import com.kw.readwith.domain.User; import com.kw.readwith.domain.enums.AnalysisStatus; import com.kw.readwith.domain.enums.NormalizationStatus; import com.kw.readwith.domain.enums.NormalizationVersionStatus; -import com.kw.readwith.domain.enums.ProcessingJobStatus; -import com.kw.readwith.domain.enums.ProcessingPipelineType; -import com.kw.readwith.domain.enums.Provider; -import com.kw.readwith.domain.processing.ProcessingJob; import com.kw.readwith.dto.book.BookDetailDTO; import com.kw.readwith.repository.BookRepository; import com.kw.readwith.repository.FavoriteRepository; -import com.kw.readwith.repository.UserRepository; +import com.kw.readwith.service.BookUploadTransactionService.BookUploadCompletion; import com.kw.readwith.service.normalization.EpubMetadataExtractorService; import com.kw.readwith.service.normalization.ExtractedEpubCover; import com.kw.readwith.service.normalization.ExtractedEpubMetadata; import com.kw.readwith.service.normalization.NormalizationJobDispatcher; -import com.kw.readwith.service.normalization.NormalizationJobService; import com.kw.readwith.service.normalization.NormalizationVersionService; import com.kw.readwith.service.normalization.NormalizedArtifactStorageService; -import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; 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.InOrder; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.mock.web.MockMultipartFile; -import org.springframework.test.util.ReflectionTestUtils; -import org.springframework.transaction.support.TransactionSynchronizationManager; import java.nio.charset.StandardCharsets; -import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -55,48 +48,37 @@ class BookServiceTest { @Mock private FavoriteRepository favoriteRepository; - @Mock - private UserRepository userRepository; - @Mock private EpubMetadataExtractorService epubMetadataExtractorService; @Mock private NormalizedArtifactStorageService normalizedArtifactStorageService; - @Mock - private NormalizationJobService normalizationJobService; - @Mock private NormalizationJobDispatcher normalizationJobDispatcher; @Mock private NormalizationVersionService normalizationVersionService; + @Mock + private BookUploadTransactionService bookUploadTransactionService; + @InjectMocks private BookService bookService; @BeforeEach void setUp() { - TransactionSynchronizationManager.initSynchronization(); lenient().when(normalizationVersionService.resolveStatus(any(Book.class))).thenReturn(NormalizationVersionStatus.NOT_READY); lenient().when(normalizationVersionService.needsRenormalization(any(Book.class))).thenReturn(false); } - @AfterEach - void tearDown() { - if (TransactionSynchronizationManager.isSynchronizationActive()) { - TransactionSynchronizationManager.clearSynchronization(); - } - } - @Test - @DisplayName("uploadBook stores extracted EPUB metadata and cover image") - void uploadBookUsesExtractedMetadataAndCoverImage() { + @DisplayName("uploadBook stores external artifacts before completing DB upload state") + void uploadBookStoresExternalArtifactsBeforeCompletingDbState() { MockMultipartFile file = sampleEpubFile(); - User uploader = sampleUser(); + String sourcePath = "books/101/source/source-v1/book.epub"; + String coverUrl = "https://cdn.readwith.store/public/books/101/covers/source-v1/cover.jpg"; - when(userRepository.findById(1L)).thenReturn(Optional.of(uploader)); when(epubMetadataExtractorService.extract(file)) .thenReturn(new ExtractedEpubMetadata( "Dracula", @@ -104,64 +86,50 @@ void uploadBookUsesExtractedMetadataAndCoverImage() { "en", new ExtractedEpubCover("cover.jpg", "image/jpeg", "cover".getBytes(StandardCharsets.UTF_8)) )); - when(bookRepository.save(any(Book.class))).thenAnswer(invocation -> { - Book saved = invocation.getArgument(0); - ReflectionTestUtils.setField(saved, "id", 101L); - return saved; - }); + when(bookUploadTransactionService.createUploadBook(1L, "Dracula", "Bram Stoker", "en")) + .thenReturn(uploadBookShell()); when(normalizedArtifactStorageService.newSourceVersion()).thenReturn("source-v1"); - when(normalizedArtifactStorageService.storeSourceEpub(101L, "source-v1", file)) - .thenReturn("books/101/source/source-v1/book.epub"); + when(normalizedArtifactStorageService.storeSourceEpub(101L, "source-v1", file)).thenReturn(sourcePath); when(normalizedArtifactStorageService.storeBookCover(eq(101L), eq("source-v1"), any(ExtractedEpubCover.class))) - .thenReturn("https://cdn.readwith.store/public/books/101/covers/source-v1/cover.jpg"); - when(normalizationJobService.createQueuedJob(any(Book.class), eq("source-v1"), eq("UPLOAD"))) - .thenAnswer(invocation -> ProcessingJob.builder() - .id(55L) - .book(invocation.getArgument(0)) - .pipelineType(ProcessingPipelineType.NORMALIZATION) - .runId("run-1") - .sourceVersion("source-v1") - .status(ProcessingJobStatus.QUEUED) - .build()); + .thenReturn(coverUrl); + when(bookUploadTransactionService.completeUpload(101L, "source-v1", sourcePath, coverUrl)) + .thenReturn(new BookUploadCompletion(completedBook(sourcePath, coverUrl), 55L)); BookDetailDTO response = bookService.uploadBook(1L, file, null, " ", null); - ArgumentCaptor bookCaptor = ArgumentCaptor.forClass(Book.class); - verify(bookRepository).save(bookCaptor.capture()); - Book savedBook = bookCaptor.getValue(); - - assertThat(savedBook.getTitle()).isEqualTo("Dracula"); - assertThat(savedBook.getAuthor()).isEqualTo("Bram Stoker"); - assertThat(savedBook.getLanguage()).isEqualTo("en"); - assertThat(savedBook.getCoverImgUrl()).isEqualTo("https://cdn.readwith.store/public/books/101/covers/source-v1/cover.jpg"); - assertThat(savedBook.getNormalizationStatus()).isEqualTo(NormalizationStatus.QUEUED); - assertThat(savedBook.getAnalysisStatus()).isEqualTo(AnalysisStatus.NONE); + InOrder inOrder = inOrder(bookUploadTransactionService, normalizedArtifactStorageService, normalizationJobDispatcher); + inOrder.verify(bookUploadTransactionService).createUploadBook(1L, "Dracula", "Bram Stoker", "en"); + inOrder.verify(normalizedArtifactStorageService).newSourceVersion(); + inOrder.verify(normalizedArtifactStorageService).storeSourceEpub(101L, "source-v1", file); + inOrder.verify(normalizedArtifactStorageService).storeBookCover(eq(101L), eq("source-v1"), any(ExtractedEpubCover.class)); + inOrder.verify(bookUploadTransactionService).completeUpload(101L, "source-v1", sourcePath, coverUrl); + inOrder.verify(normalizationJobDispatcher).dispatch(55L); assertThat(response.getTitle()).isEqualTo("Dracula"); assertThat(response.getAuthor()).isEqualTo("Bram Stoker"); assertThat(response.getLanguage()).isEqualTo("en"); - assertThat(response.getCoverImgUrl()).isEqualTo("https://cdn.readwith.store/public/books/101/covers/source-v1/cover.jpg"); + assertThat(response.getCoverImgUrl()).isEqualTo(coverUrl); + assertThat(response.getNormalizationStatus()).isEqualTo(NormalizationStatus.QUEUED.name()); + assertThat(response.getAnalysisStatus()).isEqualTo(AnalysisStatus.NONE.name()); } @Test @DisplayName("uploadBook requires title and author metadata from the EPUB package") void uploadBookRequiresTitleAndAuthorMetadataFromEpub() { MockMultipartFile file = sampleEpubFile(); - User uploader = sampleUser(); - when(userRepository.findById(1L)).thenReturn(Optional.of(uploader)); when(epubMetadataExtractorService.extract(file)).thenReturn(ExtractedEpubMetadata.empty()); assertThrows(GeneralException.class, () -> bookService.uploadBook(1L, file, "Manual Title", "Manual Author", "ko")); + verifyNoInteractions(bookUploadTransactionService); } @Test @DisplayName("uploadBook continues when cover upload fails") void uploadBookContinuesWhenCoverUploadFails() { MockMultipartFile file = sampleEpubFile(); - User uploader = sampleUser(); + String sourcePath = "books/101/source/source-v1/book.epub"; - when(userRepository.findById(1L)).thenReturn(Optional.of(uploader)); when(epubMetadataExtractorService.extract(file)) .thenReturn(new ExtractedEpubMetadata( "Dracula", @@ -169,31 +137,64 @@ void uploadBookContinuesWhenCoverUploadFails() { "en", new ExtractedEpubCover("cover.jpg", "image/jpeg", "cover".getBytes(StandardCharsets.UTF_8)) )); - when(bookRepository.save(any(Book.class))).thenAnswer(invocation -> { - Book saved = invocation.getArgument(0); - ReflectionTestUtils.setField(saved, "id", 101L); - return saved; - }); + when(bookUploadTransactionService.createUploadBook(1L, "Dracula", "Bram Stoker", "en")) + .thenReturn(uploadBookShell()); when(normalizedArtifactStorageService.newSourceVersion()).thenReturn("source-v1"); - when(normalizedArtifactStorageService.storeSourceEpub(101L, "source-v1", file)) - .thenReturn("books/101/source/source-v1/book.epub"); + when(normalizedArtifactStorageService.storeSourceEpub(101L, "source-v1", file)).thenReturn(sourcePath); doThrow(new IllegalStateException("cover upload failed")) .when(normalizedArtifactStorageService) .storeBookCover(eq(101L), eq("source-v1"), any(ExtractedEpubCover.class)); - when(normalizationJobService.createQueuedJob(any(Book.class), eq("source-v1"), eq("UPLOAD"))) - .thenAnswer(invocation -> ProcessingJob.builder() - .id(55L) - .book(invocation.getArgument(0)) - .pipelineType(ProcessingPipelineType.NORMALIZATION) - .runId("run-1") - .sourceVersion("source-v1") - .status(ProcessingJobStatus.QUEUED) - .build()); + when(bookUploadTransactionService.completeUpload(101L, "source-v1", sourcePath, null)) + .thenReturn(new BookUploadCompletion(completedBook(sourcePath, null), 55L)); BookDetailDTO response = bookService.uploadBook(1L, file, null, null, null); assertThat(response.getTitle()).isEqualTo("Dracula"); assertThat(response.getCoverImgUrl()).isNull(); + verify(normalizationJobDispatcher).dispatch(55L); + } + + @Test + @DisplayName("uploadBook marks upload failed when source EPUB staging fails") + void uploadBookMarksFailedWhenSourceUploadFails() { + MockMultipartFile file = sampleEpubFile(); + + when(epubMetadataExtractorService.extract(file)) + .thenReturn(new ExtractedEpubMetadata("Dracula", "Bram Stoker", "en", null)); + when(bookUploadTransactionService.createUploadBook(1L, "Dracula", "Bram Stoker", "en")) + .thenReturn(uploadBookShell()); + when(normalizedArtifactStorageService.newSourceVersion()).thenReturn("source-v1"); + doThrow(new IllegalStateException("source upload failed")) + .when(normalizedArtifactStorageService) + .storeSourceEpub(101L, "source-v1", file); + + assertThrows(IllegalStateException.class, () -> bookService.uploadBook(1L, file, null, null, null)); + + verify(bookUploadTransactionService).markUploadFailed(101L); + verify(bookUploadTransactionService, never()).completeUpload(any(), any(), any(), any()); + verifyNoInteractions(normalizationJobDispatcher); + } + + @Test + @DisplayName("uploadBook marks upload failed when DB completion fails after source staging") + void uploadBookMarksFailedWhenCompletionFails() { + MockMultipartFile file = sampleEpubFile(); + String sourcePath = "books/101/source/source-v1/book.epub"; + + when(epubMetadataExtractorService.extract(file)) + .thenReturn(new ExtractedEpubMetadata("Dracula", "Bram Stoker", "en", null)); + when(bookUploadTransactionService.createUploadBook(1L, "Dracula", "Bram Stoker", "en")) + .thenReturn(uploadBookShell()); + when(normalizedArtifactStorageService.newSourceVersion()).thenReturn("source-v1"); + when(normalizedArtifactStorageService.storeSourceEpub(101L, "source-v1", file)).thenReturn(sourcePath); + doThrow(new IllegalStateException("db completion failed")) + .when(bookUploadTransactionService) + .completeUpload(101L, "source-v1", sourcePath, null); + + assertThrows(IllegalStateException.class, () -> bookService.uploadBook(1L, file, null, null, null)); + + verify(bookUploadTransactionService).markUploadFailed(101L); + verifyNoInteractions(normalizationJobDispatcher); } private MockMultipartFile sampleEpubFile() { @@ -205,14 +206,25 @@ private MockMultipartFile sampleEpubFile() { ); } - private User sampleUser() { - return User.builder() - .id(1L) - .email("user@example.com") - .nickname("user") - .provider(Provider.GOOGLE) - .providerUid("provider-uid") - .isAdmin(false) + private Book uploadBookShell() { + return Book.builder() + .id(101L) + .title("Dracula") + .author("Bram Stoker") + .language("en") + .isDefault(false) + .summary(false) .build(); } + + private Book completedBook(String sourcePath, String coverUrl) { + Book book = uploadBookShell(); + book.assignUploadedSource(sourcePath); + if (coverUrl != null) { + book.updateCoverImage(coverUrl); + } + book.markNormalizationQueued(); + book.resetAnalysisStatus(); + return book; + } } diff --git a/src/test/java/com/kw/readwith/service/BookUploadTransactionServiceTest.java b/src/test/java/com/kw/readwith/service/BookUploadTransactionServiceTest.java new file mode 100644 index 0000000..8b64d00 --- /dev/null +++ b/src/test/java/com/kw/readwith/service/BookUploadTransactionServiceTest.java @@ -0,0 +1,112 @@ +package com.kw.readwith.service; + +import com.kw.readwith.domain.Book; +import com.kw.readwith.domain.User; +import com.kw.readwith.domain.enums.AnalysisStatus; +import com.kw.readwith.domain.enums.NormalizationStatus; +import com.kw.readwith.domain.enums.ProcessingJobStatus; +import com.kw.readwith.domain.enums.ProcessingPipelineType; +import com.kw.readwith.domain.enums.Provider; +import com.kw.readwith.domain.processing.ProcessingJob; +import com.kw.readwith.repository.BookRepository; +import com.kw.readwith.repository.UserRepository; +import com.kw.readwith.service.BookUploadTransactionService.BookUploadCompletion; +import com.kw.readwith.service.normalization.NormalizationJobService; +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 java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class BookUploadTransactionServiceTest { + + @Mock + private UserRepository userRepository; + + @Mock + private BookRepository bookRepository; + + @Mock + private NormalizationJobService normalizationJobService; + + @InjectMocks + private BookUploadTransactionService bookUploadTransactionService; + + @Test + @DisplayName("createUploadBook stores only the initial book row") + void createUploadBookStoresInitialBookRow() { + User uploader = sampleUser(); + when(userRepository.findById(1L)).thenReturn(Optional.of(uploader)); + when(bookRepository.save(org.mockito.ArgumentMatchers.any(Book.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + Book book = bookUploadTransactionService.createUploadBook(1L, "Dracula", "Bram Stoker", "en"); + + ArgumentCaptor bookCaptor = ArgumentCaptor.forClass(Book.class); + verify(bookRepository).save(bookCaptor.capture()); + assertThat(bookCaptor.getValue().getTitle()).isEqualTo("Dracula"); + assertThat(bookCaptor.getValue().getAuthor()).isEqualTo("Bram Stoker"); + assertThat(bookCaptor.getValue().getLanguage()).isEqualTo("en"); + assertThat(bookCaptor.getValue().getUploadedBy()).isEqualTo(uploader); + assertThat(book).isSameAs(bookCaptor.getValue()); + } + + @Test + @DisplayName("completeUpload attaches artifacts and creates normalization job in one short DB transaction") + void completeUploadAttachesArtifactsAndCreatesJob() { + Book book = Book.builder() + .id(101L) + .title("Dracula") + .author("Bram Stoker") + .language("en") + .isDefault(false) + .summary(false) + .build(); + + when(bookRepository.findById(101L)).thenReturn(Optional.of(book)); + when(normalizationJobService.createQueuedJob(book, "source-v1", "UPLOAD")) + .thenReturn(ProcessingJob.builder() + .id(55L) + .book(book) + .pipelineType(ProcessingPipelineType.NORMALIZATION) + .runId("run-1") + .sourceVersion("source-v1") + .status(ProcessingJobStatus.QUEUED) + .build()); + + BookUploadCompletion completion = bookUploadTransactionService.completeUpload( + 101L, + "source-v1", + "books/101/source/source-v1/book.epub", + "https://cdn.readwith.store/public/books/101/covers/source-v1/cover.jpg" + ); + + assertThat(completion.book()).isSameAs(book); + assertThat(completion.jobId()).isEqualTo(55L); + assertThat(book.getEpubPath()).isEqualTo("books/101/source/source-v1/book.epub"); + assertThat(book.getCoverImgUrl()).isEqualTo("https://cdn.readwith.store/public/books/101/covers/source-v1/cover.jpg"); + assertThat(book.getNormalizationStatus()).isEqualTo(NormalizationStatus.QUEUED); + assertThat(book.getAnalysisStatus()).isEqualTo(AnalysisStatus.NONE); + verify(normalizationJobService).createQueuedJob(eq(book), eq("source-v1"), eq("UPLOAD")); + } + + private User sampleUser() { + return User.builder() + .id(1L) + .email("user@example.com") + .nickname("user") + .provider(Provider.GOOGLE) + .providerUid("provider-uid") + .isAdmin(false) + .build(); + } +}