Skip to content

Commit d2e6403

Browse files
committed
refactor(book): extract S3 upload logic into BookMediaService
BookService was mixing domain logic with storage infrastructure. Upload responsibility now lives in a dedicated service. - Create BookMediaService with uploadCover(Long, MultipartFile) - Remove S3Service, prefix and S3_FOLDER_NAME from BookService - Update BookController to inject and delegate to BookMediaService - Rename uploadFile to uploadCover for semantic clarity - Make find() package-private to support same-package access
1 parent 485b458 commit d2e6403

File tree

4 files changed

+79
-65
lines changed

4 files changed

+79
-65
lines changed

src/main/java/com/example/library/book/BookController.java

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,19 +27,18 @@
2727
import com.example.library.common.dto.PageResponseDTO;
2828

2929
import jakarta.validation.Valid;
30+
import lombok.RequiredArgsConstructor;
3031

3132
@Tag(name = "Books", description = "Endpoints para gerenciamento de livros")
33+
@RequiredArgsConstructor
3234
@RestController
3335
@RequestMapping("/api/v1/books")
3436
public class BookController {
3537

3638
private static final Logger log = LoggerFactory.getLogger(BookController.class);
3739

3840
private final BookService bookService;
39-
40-
public BookController(BookService bookService) {
41-
this.bookService = bookService;
42-
}
41+
private final BookMediaService bookMediaService;
4342

4443
@Operation(
4544
summary = "Criar novo livro",
@@ -89,10 +88,10 @@ public ResponseEntity<Void> deleteById(@PathVariable Long id) {
8988
return ResponseEntity.noContent().build();
9089
}
9190

92-
@PostMapping("/{bookId}/picture")
93-
public ResponseEntity<Void> uploadPicture(@PathVariable Long bookId, @RequestPart("file") MultipartFile file) {
94-
URI uri = bookService.uploadFile(bookId, file);
95-
return ResponseEntity.created(uri).build();
96-
}
91+
@PostMapping("/{id}/cover")
92+
public ResponseEntity<URI> uploadCover(@PathVariable Long id, @RequestPart ("file") MultipartFile file) {
93+
URI uri = bookMediaService.uploadCover(id, file);
94+
return ResponseEntity.ok(uri);
95+
}
9796

9897
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package com.example.library.book;
2+
3+
import java.net.URI;
4+
5+
import org.slf4j.Logger;
6+
import org.slf4j.LoggerFactory;
7+
import org.springframework.beans.factory.annotation.Value;
8+
import org.springframework.stereotype.Service;
9+
import org.springframework.transaction.annotation.Transactional;
10+
import org.springframework.web.multipart.MultipartFile;
11+
12+
import com.example.library.aws.S3Service;
13+
import com.example.library.book.exception.BookNotFoundException;
14+
15+
import lombok.RequiredArgsConstructor;
16+
17+
@Service
18+
@RequiredArgsConstructor
19+
public class BookMediaService {
20+
21+
private static final Logger log = LoggerFactory.getLogger(BookMediaService.class);
22+
private static final String S3_FOLDER_NAME = "books/";
23+
24+
private final BookRepository repository;
25+
private final S3Service s3Service;
26+
27+
@Value("${img.prefix.book}")
28+
private String prefix;
29+
30+
@Transactional
31+
public URI uploadCover(Long bookId, MultipartFile file) {
32+
Book book = repository.findById(bookId).orElseThrow(() -> {
33+
log.warn("Book not found for cover upload: {}", bookId);
34+
return new BookNotFoundException(bookId);
35+
});
36+
37+
String fileName = prefix + book.getId();
38+
URI uri = s3Service.uploadFile(file, S3_FOLDER_NAME, fileName);
39+
book.setCoverImageUrl(uri.toString());
40+
repository.save(book);
41+
42+
log.info("Cover uploaded for bookId={} uri={}", bookId, uri);
43+
return uri;
44+
}
45+
}

src/main/java/com/example/library/book/BookService.java

Lines changed: 1 addition & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,23 @@
11
package com.example.library.book;
22

3-
import java.net.URI;
43
import java.util.HashSet;
54
import java.util.List;
65
import java.util.Set;
76

87
import org.slf4j.Logger;
98
import org.slf4j.LoggerFactory;
10-
import org.springframework.beans.factory.annotation.Value;
119
import org.springframework.cache.annotation.CacheEvict;
1210
import org.springframework.cache.annotation.Cacheable;
1311
import org.springframework.cache.annotation.Caching;
1412
import org.springframework.data.domain.Page;
1513
import org.springframework.data.domain.Pageable;
1614
import org.springframework.stereotype.Service;
1715
import org.springframework.transaction.annotation.Transactional;
18-
import org.springframework.web.multipart.MultipartFile;
1916

2017
import io.micrometer.core.instrument.Counter;
2118

2219
import com.example.library.author.Author;
2320
import com.example.library.author.AuthorLookupService;
24-
import com.example.library.aws.S3Service;
2521
import com.example.library.book.dto.BookCreateDTO;
2622
import com.example.library.book.dto.BookResponseDTO;
2723
import com.example.library.book.exception.BookAlreadyExistsException;
@@ -41,20 +37,15 @@
4137
public class BookService {
4238

4339
private static final Logger log = LoggerFactory.getLogger(BookService.class);
44-
private static final String S3_FOLDER_NAME = "books/";
4540

4641
private final BookRepository repository;
4742
private final AuthorLookupService authorPort;
4843
private final CategoryLookupService categoryPort;
4944
private final BookMapper mapper;
50-
private final S3Service s3Service;
5145

5246
private final ArtificialDelayService delayService;
5347
private final Counter bookCreatedCounter;
5448

55-
@Value("${img.prefix.book}")
56-
private String prefix;
57-
5849
@CacheEvict(value = "books", allEntries = true)
5950
@Transactional
6051
public BookResponseDTO create(BookCreateDTO dto) {
@@ -130,18 +121,7 @@ public void deleteById(Long id) {
130121
repository.deleteById(id);
131122
}
132123

133-
// @CacheEvict(value = "bookById", key = "#id")
134-
@Transactional
135-
public URI uploadFile(Long bookId, MultipartFile file) {
136-
Book book = find(bookId);
137-
String fileName = prefix + book.getId();
138-
URI uri = s3Service.uploadFile(file, S3_FOLDER_NAME, fileName);
139-
book.setCoverImageUrl(uri.toString());
140-
repository.save(book);
141-
return uri;
142-
}
143-
144-
private Book find(Long id) {
124+
Book find(Long id) {
145125
return repository.findById(id).orElseThrow(() -> {
146126
log.warn("Book not found: {}", id);
147127
return new BookNotFoundException(id);

src/test/java/com/example/library/book/BookServiceS3UploadTest.java

Lines changed: 25 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import org.junit.jupiter.api.Test;
1010
import org.junit.jupiter.api.extension.ExtendWith;
1111
import org.mockito.ArgumentCaptor;
12+
import org.mockito.InjectMocks;
1213
import org.mockito.Mock;
1314
import org.mockito.junit.jupiter.MockitoExtension;
1415
import org.springframework.mock.web.MockMultipartFile;
@@ -31,7 +32,6 @@
3132
/**
3233
* Testes para a funcionalidade de upload de imagem de capa do livro com S3
3334
*
34-
* VERSÃO ATUALIZADA: Inclui mock do ImageProcessingService
3535
*/
3636
@ExtendWith(MockitoExtension.class)
3737
@DisplayName("BookService - Upload S3 Tests (Updated)")
@@ -43,7 +43,8 @@ class BookServiceS3UploadTest {
4343
@Mock
4444
private S3Service s3Service;
4545

46-
private BookService bookService;
46+
@InjectMocks
47+
private BookMediaService mediaService;
4748

4849
private Book book;
4950
private Category category;
@@ -52,19 +53,8 @@ class BookServiceS3UploadTest {
5253

5354
@BeforeEach
5455
void setUp() {
55-
// Inicializar BookService
56-
bookService = new BookService(
57-
bookRepository,
58-
null, // authorRepository
59-
null, // categoryRepository
60-
null, // bookMapper
61-
s3Service,
62-
null, // delayService
63-
null // bookCreatedCounter
64-
);
65-
6656
// Inject prefix via ReflectionTestUtils (config mudou para estrutura aninhada)
67-
ReflectionTestUtils.setField(bookService, "prefix", "book-");
57+
ReflectionTestUtils.setField(mediaService, "prefix", "book-");
6858

6959
// Setup category
7060
category = new Category();
@@ -107,7 +97,7 @@ void shouldUploadPngImageSuccessfully() {
10797
.thenReturn(expectedS3Uri);
10898

10999
// Act
110-
URI result = bookService.uploadFile(1L, validImage);
100+
URI result = mediaService.uploadCover(1L, validImage);
111101

112102
// Assert
113103
assertThat(result).isEqualTo(expectedS3Uri);
@@ -142,7 +132,7 @@ void shouldUploadJpegImageSuccessfully() {
142132
.thenReturn(jpegUri);
143133

144134
// Act
145-
URI result = bookService.uploadFile(1L, jpegImage);
135+
URI result = mediaService.uploadCover(1L, jpegImage);
146136

147137
// Assert
148138
assertThat(result).isEqualTo(jpegUri);
@@ -160,7 +150,7 @@ void shouldReplaceExistingUrlOnNewUpload() {
160150
.thenReturn(expectedS3Uri);
161151

162152
// Act
163-
bookService.uploadFile(1L, validImage);
153+
mediaService.uploadCover(1L, validImage);
164154

165155
// Assert
166156
ArgumentCaptor<Book> bookCaptor = ArgumentCaptor.forClass(Book.class);
@@ -180,7 +170,7 @@ void shouldUseCorrectFilenamePrefix() {
180170
.thenReturn(expectedS3Uri);
181171

182172
// Act
183-
bookService.uploadFile(1L, validImage);
173+
mediaService.uploadCover(1L, validImage);
184174

185175
// Assert
186176
// Verifica que o prefixo "book-" + ID está correto
@@ -196,7 +186,7 @@ void shouldUseCorrectS3Folder() {
196186
.thenReturn(expectedS3Uri);
197187

198188
// Act
199-
bookService.uploadFile(1L, validImage);
189+
mediaService.uploadCover(1L, validImage);
200190

201191
// Assert
202192
verify(s3Service).uploadFile(validImage, "books/", "book-1");
@@ -211,7 +201,7 @@ void shouldSaveBookWithUrlAfterSuccessfulUpload() {
211201
.thenReturn(expectedS3Uri);
212202

213203
// Act
214-
bookService.uploadFile(1L, validImage);
204+
mediaService.uploadCover(1L, validImage);
215205

216206
// Assert
217207
verify(bookRepository).save(book);
@@ -232,7 +222,7 @@ void shouldUseExistingBookIdInFilename() {
232222
.thenReturn(expectedS3Uri);
233223

234224
// Act
235-
bookService.uploadFile(12345L, validImage);
225+
mediaService.uploadCover(12345L, validImage);
236226

237227
// Assert
238228
verify(s3Service).uploadFile(validImage, "books/", "book-12345");
@@ -254,7 +244,7 @@ void shouldThrowBookNotFoundExceptionWhenBookDoesNotExist() {
254244
when(bookRepository.findById(999L)).thenReturn(Optional.empty());
255245

256246
// Act & Assert
257-
assertThatThrownBy(() -> bookService.uploadFile(999L, validImage))
247+
assertThatThrownBy(() -> mediaService.uploadCover(999L, validImage))
258248
.isInstanceOf(BookNotFoundException.class);
259249

260250
// Verifica que S3Service não foi chamado
@@ -271,7 +261,7 @@ void shouldPropagateAmazonClientExceptionOnS3Failure() {
271261
.thenThrow(new AmazonClientException());
272262

273263
// Act & Assert
274-
assertThatThrownBy(() -> bookService.uploadFile(1L, validImage))
264+
assertThatThrownBy(() -> mediaService.uploadCover(1L, validImage))
275265
.isInstanceOf(AmazonClientException.class);
276266

277267
// Verifica que o livro NÃO foi salvo
@@ -287,7 +277,7 @@ void shouldNotSaveBookWhenS3ServiceThrowsException() {
287277
.thenThrow(new RuntimeException("S3 connection failed"));
288278

289279
// Act & Assert
290-
assertThatThrownBy(() -> bookService.uploadFile(1L, validImage))
280+
assertThatThrownBy(() -> mediaService.uploadCover(1L, validImage))
291281
.isInstanceOf(RuntimeException.class)
292282
.hasMessageContaining("S3 connection failed");
293283

@@ -311,7 +301,7 @@ void shouldThrowIllegalArgumentExceptionWhenInvalidContentType() {
311301
.thenThrow(new IllegalArgumentException("Invalid content type: application/pdf"));
312302

313303
// Act & Assert
314-
assertThatThrownBy(() -> bookService.uploadFile(1L, invalidFile))
304+
assertThatThrownBy(() -> mediaService.uploadCover(1L, invalidFile))
315305
.isInstanceOf(IllegalArgumentException.class)
316306
.hasMessageContaining("Invalid content type");
317307

@@ -335,7 +325,7 @@ void shouldThrowIllegalArgumentExceptionWhenFileTooLarge() {
335325
.thenThrow(new IllegalArgumentException("File too large"));
336326

337327
// Act & Assert
338-
assertThatThrownBy(() -> bookService.uploadFile(1L, largeFile))
328+
assertThatThrownBy(() -> mediaService.uploadCover(1L, largeFile))
339329
.isInstanceOf(IllegalArgumentException.class)
340330
.hasMessageContaining("File too large");
341331

@@ -358,7 +348,7 @@ void shouldThrowIllegalArgumentExceptionWhenFileTooSmall() {
358348
.thenThrow(new IllegalArgumentException("File too small"));
359349

360350
// Act & Assert
361-
assertThatThrownBy(() -> bookService.uploadFile(1L, tinyFile))
351+
assertThatThrownBy(() -> mediaService.uploadCover(1L, tinyFile))
362352
.isInstanceOf(IllegalArgumentException.class)
363353
.hasMessageContaining("File too small");
364354

@@ -388,7 +378,7 @@ void shouldHandleVeryLargeBookId() {
388378
.thenReturn(expectedS3Uri);
389379

390380
// Act
391-
bookService.uploadFile(largeId, validImage);
381+
mediaService.uploadCover(largeId, validImage);
392382

393383
// Assert
394384
verify(s3Service).uploadFile(validImage, "books/", "book-" + largeId);
@@ -410,7 +400,7 @@ void shouldHandleDifferentAllowedImageTypes() {
410400
.thenReturn(expectedS3Uri);
411401

412402
// Act
413-
bookService.uploadFile(1L, webpImage);
403+
mediaService.uploadCover(1L, webpImage);
414404

415405
// Assert
416406
verify(s3Service).uploadFile(webpImage, "books/", "book-1");
@@ -430,14 +420,14 @@ void shouldHandleMultipleUploadsForSameBook() {
430420
.thenReturn(secondUri);
431421

432422
// Act - Primeiro upload
433-
URI result1 = bookService.uploadFile(1L, validImage);
423+
URI result1 = mediaService.uploadCover(1L, validImage);
434424

435425
// Assert - Primeiro upload
436426
assertThat(result1).isEqualTo(firstUri);
437427
assertThat(book.getCoverImageUrl()).isEqualTo(firstUri.toString());
438428

439429
// Act - Segundo upload
440-
URI result2 = bookService.uploadFile(1L, validImage);
430+
URI result2 = mediaService.uploadCover(1L, validImage);
441431

442432
// Assert - Segundo upload substitui
443433
assertThat(result2).isEqualTo(secondUri);
@@ -465,7 +455,7 @@ void shouldFetchBookBeforeUpload() {
465455
.thenReturn(expectedS3Uri);
466456

467457
// Act
468-
bookService.uploadFile(1L, validImage);
458+
mediaService.uploadCover(1L, validImage);
469459

470460
// Assert
471461
// Verifica ordem de chamadas
@@ -486,7 +476,7 @@ void shouldReturnUriFromS3Service() {
486476
.thenReturn(customUri);
487477

488478
// Act
489-
URI result = bookService.uploadFile(1L, validImage);
479+
URI result = mediaService.uploadCover(1L, validImage);
490480

491481
// Assert
492482
assertThat(result).isEqualTo(customUri);
@@ -501,7 +491,7 @@ void shouldSaveBookWithSameUriReturned() {
501491
.thenReturn(expectedS3Uri);
502492

503493
// Act
504-
URI returnedUri = bookService.uploadFile(1L, validImage);
494+
URI returnedUri = mediaService.uploadCover(1L, validImage);
505495

506496
// Assert
507497
ArgumentCaptor<Book> bookCaptor = ArgumentCaptor.forClass(Book.class);
@@ -524,7 +514,7 @@ void shouldKeepOtherBookFieldsUnchanged() {
524514
.thenReturn(expectedS3Uri);
525515

526516
// Act
527-
bookService.uploadFile(1L, validImage);
517+
mediaService.uploadCover(1L, validImage);
528518

529519
// Assert
530520
ArgumentCaptor<Book> bookCaptor = ArgumentCaptor.forClass(Book.class);

0 commit comments

Comments
 (0)