From 7c0ba7e0bf2e086841ca0df8732ddacb86aa1f72 Mon Sep 17 00:00:00 2001 From: Felipe Melo Date: Thu, 8 Jan 2026 14:34:52 -0300 Subject: [PATCH 1/6] feat: enhance book search dropdown in book edit with better layout and i18n --- .env.example | 12 + pom.xml | 8 + .../java/com/inktrack/core/domain/Book.java | 12 + .../core/gateway/GoogleBooksGateway.java | 13 + .../core/usecases/book/BookModelInput.java | 3 +- .../core/usecases/book/BookModelOutput.java | 1 + .../usecases/book/CreateBookUseCaseImpl.java | 51 +++- .../usecases/book/GetBookByIdUseCaseImpl.java | 1 + .../usecases/book/GetBooksUseCaseImpl.java | 1 + .../book/GoogleBooksItemResponse.java | 24 ++ .../usecases/book/GoogleBooksResponse.java | 26 ++ .../book/GoogleBooksSearchResponse.java | 10 + .../core/usecases/book/GoogleBooksVolume.java | 12 + .../core/usecases/book/SearchBooksOutput.java | 9 + .../usecases/book/SearchBooksUseCase.java | 6 + .../usecases/book/SearchBooksUseCaseImpl.java | 25 ++ .../usecases/book/UpdateBookUseCaseImpl.java | 1 + .../infrastructure/beans/BeansConfig.java | 16 +- .../infrastructure/config/CacheConfig.java | 28 +++ .../config/GoogleBooksConfig.java | 64 +++++ .../controller/BookController.java | 21 +- .../dtos/book/BookCreateRequest.java | 3 +- .../dtos/book/BookResponse.java | 1 + .../dtos/book/BookSearchResponse.java | 18 ++ .../infrastructure/entity/BookEntity.java | 14 ++ .../exception/ExternalApiException.java | 20 ++ .../gateway/GoogleBooksGatewayImpl.java | 105 ++++++++ .../infrastructure/mapper/BookMapper.java | 7 +- .../mapper/GoogleBooksMapper.java | 33 +++ src/main/resources/application.properties | 11 +- .../V9__add_thumbnail_url_to_books.sql | 5 + .../book/CreateBookUseCaseImplTest.java | 228 +++++++++++++++++- .../book/SearchBooksUseCaseImplTest.java | 98 ++++++++ .../book/UpdateBookUseCaseImplTest.java | 2 +- .../BookControllerIntegrationTest.java | 93 ++++++- .../MetricsControllerIntegrationTest.java | 2 +- .../NoteControllerIntegrationTest.java | 16 +- ...adingSessionControllerIntegrationTest.java | 12 +- .../gateway/GoogleBooksGatewayImplTest.java | 25 ++ .../infrastructure/mapper/BookMapperTest.java | 8 +- .../mapper/GoogleBooksMapperTest.java | 132 ++++++++++ 41 files changed, 1126 insertions(+), 51 deletions(-) create mode 100644 src/main/java/com/inktrack/core/gateway/GoogleBooksGateway.java create mode 100644 src/main/java/com/inktrack/core/usecases/book/GoogleBooksItemResponse.java create mode 100644 src/main/java/com/inktrack/core/usecases/book/GoogleBooksResponse.java create mode 100644 src/main/java/com/inktrack/core/usecases/book/GoogleBooksSearchResponse.java create mode 100644 src/main/java/com/inktrack/core/usecases/book/GoogleBooksVolume.java create mode 100644 src/main/java/com/inktrack/core/usecases/book/SearchBooksOutput.java create mode 100644 src/main/java/com/inktrack/core/usecases/book/SearchBooksUseCase.java create mode 100644 src/main/java/com/inktrack/core/usecases/book/SearchBooksUseCaseImpl.java create mode 100644 src/main/java/com/inktrack/infrastructure/config/CacheConfig.java create mode 100644 src/main/java/com/inktrack/infrastructure/config/GoogleBooksConfig.java create mode 100644 src/main/java/com/inktrack/infrastructure/dtos/book/BookSearchResponse.java create mode 100644 src/main/java/com/inktrack/infrastructure/exception/ExternalApiException.java create mode 100644 src/main/java/com/inktrack/infrastructure/gateway/GoogleBooksGatewayImpl.java create mode 100644 src/main/java/com/inktrack/infrastructure/mapper/GoogleBooksMapper.java create mode 100644 src/main/resources/db/migration/V9__add_thumbnail_url_to_books.sql create mode 100644 src/test/java/com/inktrack/core/usecases/book/SearchBooksUseCaseImplTest.java create mode 100644 src/test/java/com/inktrack/infrastructure/gateway/GoogleBooksGatewayImplTest.java create mode 100644 src/test/java/com/inktrack/infrastructure/mapper/GoogleBooksMapperTest.java diff --git a/.env.example b/.env.example index 3d02892..a3c5006 100644 --- a/.env.example +++ b/.env.example @@ -12,3 +12,15 @@ FRONT_URL=http://localhost:3000 POSTGRES_USER=inktrack_user POSTGRES_PASSWORD=inktrack_password POSTGRES_DB=inktrack_db + +# Google Books API Configuration +# API key is optional but recommended for higher quotas and better rate limiting +# Get your free API key at: https://console.cloud.google.com/apis/credentials +GOOGLE_BOOKS_API_KEY= +GOOGLE_BOOKS_MAX_RESULTS=10 +GOOGLE_BOOKS_TIMEOUT_MS=5000 +# Cache Configuration (Caffeine - in-memory cache) +# Maximum number of search results to cache +GOOGLE_BOOKS_CACHE_MAX_SIZE=100 +# Cache expiration time in minutes +GOOGLE_BOOKS_CACHE_EXPIRATION_MINUTES=5 diff --git a/pom.xml b/pom.xml index 5ebdaf9..7e16300 100644 --- a/pom.xml +++ b/pom.xml @@ -102,6 +102,14 @@ spring-boot-starter-webmvc-test test + + org.springframework.boot + spring-boot-starter-cache + + + com.github.ben-manes.caffeine + caffeine + diff --git a/src/main/java/com/inktrack/core/domain/Book.java b/src/main/java/com/inktrack/core/domain/Book.java index 1734764..cc77ad6 100644 --- a/src/main/java/com/inktrack/core/domain/Book.java +++ b/src/main/java/com/inktrack/core/domain/Book.java @@ -12,6 +12,7 @@ public final class Book { private final String author; private final int totalPages; private int pagesRead; + private String thumbnailUrl; private OffsetDateTime createdAt; private OffsetDateTime updatedAt; @@ -25,6 +26,7 @@ private Book(Builder builder) { this.author = builder.author; this.totalPages = builder.totalPages; this.pagesRead = builder.pagesRead; + this.thumbnailUrl = builder.thumbnailUrl; this.createdAt = builder.createdAt; this.updatedAt = builder.updatedAt; } @@ -99,6 +101,10 @@ public OffsetDateTime getUpdatedAt() { return updatedAt; } + public String getThumbnailUrl() { + return thumbnailUrl; + } + public static Builder builder() { return new Builder(); } @@ -127,6 +133,7 @@ public static final class Builder { private String author; private int totalPages; private int pagesRead = 0; + private String thumbnailUrl; private OffsetDateTime createdAt; private OffsetDateTime updatedAt; @@ -178,6 +185,11 @@ public Builder updatedAt(OffsetDateTime updatedAt) { return this; } + public Builder thumbnailUrl(String thumbnailUrl) { + this.thumbnailUrl = thumbnailUrl; + return this; + } + public Book build() { return new Book(this); } diff --git a/src/main/java/com/inktrack/core/gateway/GoogleBooksGateway.java b/src/main/java/com/inktrack/core/gateway/GoogleBooksGateway.java new file mode 100644 index 0000000..4e1bf3c --- /dev/null +++ b/src/main/java/com/inktrack/core/gateway/GoogleBooksGateway.java @@ -0,0 +1,13 @@ +package com.inktrack.core.gateway; + +import com.inktrack.core.usecases.book.GoogleBooksVolume; +import com.inktrack.core.usecases.book.SearchBooksOutput; + +import java.util.Optional; + +public interface GoogleBooksGateway { + + SearchBooksOutput searchBooks(String query); + + Optional getVolumeById(String volumeId); +} diff --git a/src/main/java/com/inktrack/core/usecases/book/BookModelInput.java b/src/main/java/com/inktrack/core/usecases/book/BookModelInput.java index c9b451d..8ff7492 100644 --- a/src/main/java/com/inktrack/core/usecases/book/BookModelInput.java +++ b/src/main/java/com/inktrack/core/usecases/book/BookModelInput.java @@ -4,6 +4,7 @@ public record BookModelInput( String title, String author, int totalPages, - Long categoryId + Long categoryId, + String googleBookId ) { } diff --git a/src/main/java/com/inktrack/core/usecases/book/BookModelOutput.java b/src/main/java/com/inktrack/core/usecases/book/BookModelOutput.java index 5166e80..d194f87 100644 --- a/src/main/java/com/inktrack/core/usecases/book/BookModelOutput.java +++ b/src/main/java/com/inktrack/core/usecases/book/BookModelOutput.java @@ -14,6 +14,7 @@ public record BookModelOutput( int totalPages, int pagesRead, int progress, + String thumbnailUrl, OffsetDateTime createdAt, OffsetDateTime updatedAt ) { diff --git a/src/main/java/com/inktrack/core/usecases/book/CreateBookUseCaseImpl.java b/src/main/java/com/inktrack/core/usecases/book/CreateBookUseCaseImpl.java index 83b945c..397ba5b 100644 --- a/src/main/java/com/inktrack/core/usecases/book/CreateBookUseCaseImpl.java +++ b/src/main/java/com/inktrack/core/usecases/book/CreateBookUseCaseImpl.java @@ -5,45 +5,65 @@ import com.inktrack.core.domain.User; import com.inktrack.core.gateway.BookGateway; import com.inktrack.core.gateway.CategoryGateway; +import com.inktrack.core.gateway.GoogleBooksGateway; import com.inktrack.core.usecases.category.CategoryOutput; import com.inktrack.core.usecases.user.UserOutput; +import java.util.Optional; + public class CreateBookUseCaseImpl implements CreateBookUseCase { private final BookGateway bookGateway; private final CategoryGateway categoryGateway; + private final GoogleBooksGateway googleBooksGateway; - public CreateBookUseCaseImpl(BookGateway bookGateway, CategoryGateway categoryGateway) { + public CreateBookUseCaseImpl( + BookGateway bookGateway, + CategoryGateway categoryGateway, + GoogleBooksGateway googleBooksGateway + ) { this.bookGateway = bookGateway; this.categoryGateway = categoryGateway; + this.googleBooksGateway = googleBooksGateway; } @Override public BookModelOutput execute(BookModelInput modelInput, User currentUser) { + validateInput(modelInput, currentUser); + Category category = categoryGateway.getById(modelInput.categoryId()) + .orElseThrow(() -> new IllegalArgumentException("Category not found with id: " + modelInput.categoryId())); + + String thumbnailUrl = fetchThumbnailFromGoogleBooks(modelInput.googleBookId()); + Book book = createBook(modelInput, currentUser, category, thumbnailUrl); + Book bookSaved = bookGateway.save(book); + + return buildOutput(bookSaved); + } + + private void validateInput(BookModelInput modelInput, User currentUser) { if (currentUser == null) { throw new IllegalArgumentException("User not logged in"); } - if(modelInput.totalPages() < 0) { throw new IllegalArgumentException("The total pages must be greater than zero."); } - if(modelInput.title().isBlank() || modelInput.author().isBlank()) { throw new IllegalArgumentException("The title and author not can be black or null."); } + } - Category category = categoryGateway.getById(modelInput.categoryId()) - .orElseThrow(() -> new IllegalArgumentException("Category not found with id: " + modelInput.categoryId())); - - Book book = Book.builder() + private Book createBook(BookModelInput modelInput, User currentUser, Category category, String thumbnailUrl) { + return Book.builder() .user(currentUser) .category(category) .title(modelInput.title()) .author(modelInput.author()) .totalPages(modelInput.totalPages()) + .thumbnailUrl(thumbnailUrl) .build(); + } - Book bookSaved = bookGateway.save(book); + private BookModelOutput buildOutput(Book bookSaved) { UserOutput userOutput = new UserOutput( bookSaved.getUser().getId(), bookSaved.getUser().getName(), @@ -64,8 +84,23 @@ public BookModelOutput execute(BookModelInput modelInput, User currentUser) { bookSaved.getTotalPages(), bookSaved.getPagesRead(), bookSaved.getProgress(), + bookSaved.getThumbnailUrl(), bookSaved.getCreatedAt(), bookSaved.getUpdatedAt() ); } + + private String fetchThumbnailFromGoogleBooks(String googleBookId) { + if (googleBookId == null || googleBookId.isBlank()) { + return null; + } + + try { + Optional volume = googleBooksGateway.getVolumeById(googleBookId); + return volume.map(GoogleBooksVolume::thumbnailUrl).orElse(null); + } catch (Exception e) { + System.err.println("Failed to fetch book thumbnail from Google Books: " + e.getMessage()); + return null; + } + } } diff --git a/src/main/java/com/inktrack/core/usecases/book/GetBookByIdUseCaseImpl.java b/src/main/java/com/inktrack/core/usecases/book/GetBookByIdUseCaseImpl.java index 2a9a6ff..e5819ac 100644 --- a/src/main/java/com/inktrack/core/usecases/book/GetBookByIdUseCaseImpl.java +++ b/src/main/java/com/inktrack/core/usecases/book/GetBookByIdUseCaseImpl.java @@ -41,6 +41,7 @@ public BookModelOutput execute(Long bookID, UUID userId) { book.getTotalPages(), book.getPagesRead(), book.getProgress(), + book.getThumbnailUrl(), book.getCreatedAt(), book.getUpdatedAt() ); diff --git a/src/main/java/com/inktrack/core/usecases/book/GetBooksUseCaseImpl.java b/src/main/java/com/inktrack/core/usecases/book/GetBooksUseCaseImpl.java index f7763af..727e044 100644 --- a/src/main/java/com/inktrack/core/usecases/book/GetBooksUseCaseImpl.java +++ b/src/main/java/com/inktrack/core/usecases/book/GetBooksUseCaseImpl.java @@ -51,6 +51,7 @@ public PageResult execute(UUID userId, GetBookFilter filter) { b.getTotalPages(), b.getPagesRead(), b.getProgress(), + b.getThumbnailUrl(), b.getCreatedAt(), b.getUpdatedAt() ); diff --git a/src/main/java/com/inktrack/core/usecases/book/GoogleBooksItemResponse.java b/src/main/java/com/inktrack/core/usecases/book/GoogleBooksItemResponse.java new file mode 100644 index 0000000..fb6f140 --- /dev/null +++ b/src/main/java/com/inktrack/core/usecases/book/GoogleBooksItemResponse.java @@ -0,0 +1,24 @@ +package com.inktrack.core.usecases.book; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.util.List; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record GoogleBooksItemResponse( + String id, + VolumeInfo volumeInfo +) { + + @JsonIgnoreProperties(ignoreUnknown = true) + public record VolumeInfo( + String title, + List authors, + Integer pageCount, + ImageLinks imageLinks + ) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + public record ImageLinks( + String thumbnail + ) {} +} diff --git a/src/main/java/com/inktrack/core/usecases/book/GoogleBooksResponse.java b/src/main/java/com/inktrack/core/usecases/book/GoogleBooksResponse.java new file mode 100644 index 0000000..aae5e88 --- /dev/null +++ b/src/main/java/com/inktrack/core/usecases/book/GoogleBooksResponse.java @@ -0,0 +1,26 @@ +package com.inktrack.core.usecases.book; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import java.util.List; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record GoogleBooksResponse( + String id, + VolumeInfo volumeInfo +) { + @JsonIgnoreProperties(ignoreUnknown = true) + public record VolumeInfo( + String title, + List authors, + Integer pageCount, + ImageLinks imageLinks + ) { + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record ImageLinks( + String thumbnail + ) { + } +} diff --git a/src/main/java/com/inktrack/core/usecases/book/GoogleBooksSearchResponse.java b/src/main/java/com/inktrack/core/usecases/book/GoogleBooksSearchResponse.java new file mode 100644 index 0000000..3a0f8a5 --- /dev/null +++ b/src/main/java/com/inktrack/core/usecases/book/GoogleBooksSearchResponse.java @@ -0,0 +1,10 @@ +package com.inktrack.core.usecases.book; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.util.List; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record GoogleBooksSearchResponse( + List items +) { +} diff --git a/src/main/java/com/inktrack/core/usecases/book/GoogleBooksVolume.java b/src/main/java/com/inktrack/core/usecases/book/GoogleBooksVolume.java new file mode 100644 index 0000000..aac3dbd --- /dev/null +++ b/src/main/java/com/inktrack/core/usecases/book/GoogleBooksVolume.java @@ -0,0 +1,12 @@ +package com.inktrack.core.usecases.book; + +import java.util.List; + +public record GoogleBooksVolume( + String googleBooksId, + String title, + List authors, + Integer pageCount, + String thumbnailUrl +) { +} diff --git a/src/main/java/com/inktrack/core/usecases/book/SearchBooksOutput.java b/src/main/java/com/inktrack/core/usecases/book/SearchBooksOutput.java new file mode 100644 index 0000000..ac92231 --- /dev/null +++ b/src/main/java/com/inktrack/core/usecases/book/SearchBooksOutput.java @@ -0,0 +1,9 @@ +package com.inktrack.core.usecases.book; + +import java.util.List; + +public record SearchBooksOutput( + int totalItems, + List volumes +) { +} diff --git a/src/main/java/com/inktrack/core/usecases/book/SearchBooksUseCase.java b/src/main/java/com/inktrack/core/usecases/book/SearchBooksUseCase.java new file mode 100644 index 0000000..986ff25 --- /dev/null +++ b/src/main/java/com/inktrack/core/usecases/book/SearchBooksUseCase.java @@ -0,0 +1,6 @@ +package com.inktrack.core.usecases.book; + +public interface SearchBooksUseCase { + + SearchBooksOutput execute(String query); +} diff --git a/src/main/java/com/inktrack/core/usecases/book/SearchBooksUseCaseImpl.java b/src/main/java/com/inktrack/core/usecases/book/SearchBooksUseCaseImpl.java new file mode 100644 index 0000000..14a2c12 --- /dev/null +++ b/src/main/java/com/inktrack/core/usecases/book/SearchBooksUseCaseImpl.java @@ -0,0 +1,25 @@ +package com.inktrack.core.usecases.book; + +import com.inktrack.core.gateway.GoogleBooksGateway; + +public class SearchBooksUseCaseImpl implements SearchBooksUseCase { + + private final GoogleBooksGateway googleBooksGateway; + + public SearchBooksUseCaseImpl(GoogleBooksGateway googleBooksGateway) { + this.googleBooksGateway = googleBooksGateway; + } + + @Override + public SearchBooksOutput execute(String query) { + if (query == null || query.isBlank()) { + throw new IllegalArgumentException("Query cannot be null or empty"); + } + + if (query.length() > 500) { + throw new IllegalArgumentException("Query too long (max 500 characters)"); + } + + return googleBooksGateway.searchBooks(query.trim()); + } +} diff --git a/src/main/java/com/inktrack/core/usecases/book/UpdateBookUseCaseImpl.java b/src/main/java/com/inktrack/core/usecases/book/UpdateBookUseCaseImpl.java index db24e16..b0c7ed2 100644 --- a/src/main/java/com/inktrack/core/usecases/book/UpdateBookUseCaseImpl.java +++ b/src/main/java/com/inktrack/core/usecases/book/UpdateBookUseCaseImpl.java @@ -59,6 +59,7 @@ public BookModelOutput execute(Long id, BookModelInput modelInput, UUID userId) savedBook.getTotalPages(), savedBook.getPagesRead(), savedBook.getProgress(), + savedBook.getThumbnailUrl(), savedBook.getCreatedAt(), savedBook.getUpdatedAt() ); diff --git a/src/main/java/com/inktrack/infrastructure/beans/BeansConfig.java b/src/main/java/com/inktrack/infrastructure/beans/BeansConfig.java index fee4d46..d879aae 100644 --- a/src/main/java/com/inktrack/infrastructure/beans/BeansConfig.java +++ b/src/main/java/com/inktrack/infrastructure/beans/BeansConfig.java @@ -2,6 +2,7 @@ import com.inktrack.core.gateway.BookGateway; import com.inktrack.core.gateway.CategoryGateway; +import com.inktrack.core.gateway.GoogleBooksGateway; import com.inktrack.core.gateway.JwtGateway; import com.inktrack.core.gateway.NoteGateway; import com.inktrack.core.gateway.PasswordGateway; @@ -15,6 +16,8 @@ import com.inktrack.core.usecases.book.GetBookByIdUseCaseImpl; import com.inktrack.core.usecases.book.GetBooksUseCase; import com.inktrack.core.usecases.book.GetBooksUseCaseImpl; +import com.inktrack.core.usecases.book.SearchBooksUseCase; +import com.inktrack.core.usecases.book.SearchBooksUseCaseImpl; import com.inktrack.core.usecases.book.UpdateBookUseCase; import com.inktrack.core.usecases.book.UpdateBookUseCaseImpl; import com.inktrack.core.usecases.category.GetAllCategoryUseCase; @@ -65,8 +68,12 @@ public RefreshTokenUseCase refreshTokenUseCase(JwtGateway jwtGateway) { } @Bean - public CreateBookUseCase createBookUseCase(BookGateway bookGateway, CategoryGateway categoryGateway) { - return new CreateBookUseCaseImpl(bookGateway, categoryGateway); + public CreateBookUseCase createBookUseCase( + BookGateway bookGateway, + CategoryGateway categoryGateway, + GoogleBooksGateway googleBooksGateway + ) { + return new CreateBookUseCaseImpl(bookGateway, categoryGateway, googleBooksGateway); } @Bean @@ -148,6 +155,11 @@ public GetAllCategoryUseCase getAllCategoryUseCase(CategoryGateway categoryGatew return new GetAllCategoryUseCaseImpl(categoryGateway); } + @Bean + public SearchBooksUseCase searchBooksUseCase(GoogleBooksGateway googleBooksGateway) { + return new SearchBooksUseCaseImpl(googleBooksGateway); + } + @Bean public BCryptPasswordEncoder bCryptPasswordEncoder() { return new BCryptPasswordEncoder(); diff --git a/src/main/java/com/inktrack/infrastructure/config/CacheConfig.java b/src/main/java/com/inktrack/infrastructure/config/CacheConfig.java new file mode 100644 index 0000000..fc18be4 --- /dev/null +++ b/src/main/java/com/inktrack/infrastructure/config/CacheConfig.java @@ -0,0 +1,28 @@ +package com.inktrack.infrastructure.config; + +import com.github.benmanes.caffeine.cache.Caffeine; +import java.util.concurrent.TimeUnit; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.caffeine.CaffeineCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableCaching +public class CacheConfig { + + public static final String GOOGLE_BOOKS_CACHE = "googleBooks"; + + @Bean + public CacheManager cacheManager(GoogleBooksConfig config) { + CaffeineCacheManager cacheManager = new CaffeineCacheManager(GOOGLE_BOOKS_CACHE); + cacheManager.setCaffeine( + Caffeine.newBuilder() + .maximumSize(config.getCacheMaxSize()) + .expireAfterWrite(config.getCacheExpirationMinutes(), TimeUnit.MINUTES) + .recordStats() + ); + return cacheManager; + } +} diff --git a/src/main/java/com/inktrack/infrastructure/config/GoogleBooksConfig.java b/src/main/java/com/inktrack/infrastructure/config/GoogleBooksConfig.java new file mode 100644 index 0000000..85137c7 --- /dev/null +++ b/src/main/java/com/inktrack/infrastructure/config/GoogleBooksConfig.java @@ -0,0 +1,64 @@ +package com.inktrack.infrastructure.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ConfigurationProperties(prefix = "google.books") +public class GoogleBooksConfig { + + private String apiKey; + private String baseUrl = "https://www.googleapis.com/books/v1"; + private int maxResults = 10; + private int timeoutMs = 5000; + private long cacheMaxSize = 100; + private long cacheExpirationMinutes = 5; + + public String getApiKey() { + return apiKey; + } + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + public String getBaseUrl() { + return baseUrl; + } + + public void setBaseUrl(String baseUrl) { + this.baseUrl = baseUrl; + } + + public int getMaxResults() { + return maxResults; + } + + public void setMaxResults(int maxResults) { + this.maxResults = maxResults; + } + + public int getTimeoutMs() { + return timeoutMs; + } + + public void setTimeoutMs(int timeoutMs) { + this.timeoutMs = timeoutMs; + } + + public long getCacheMaxSize() { + return cacheMaxSize; + } + + public void setCacheMaxSize(long cacheMaxSize) { + this.cacheMaxSize = cacheMaxSize; + } + + public long getCacheExpirationMinutes() { + return cacheExpirationMinutes; + } + + public void setCacheExpirationMinutes(long cacheExpirationMinutes) { + this.cacheExpirationMinutes = cacheExpirationMinutes; + } +} diff --git a/src/main/java/com/inktrack/infrastructure/controller/BookController.java b/src/main/java/com/inktrack/infrastructure/controller/BookController.java index 146e5c5..2ffd08b 100644 --- a/src/main/java/com/inktrack/infrastructure/controller/BookController.java +++ b/src/main/java/com/inktrack/infrastructure/controller/BookController.java @@ -9,12 +9,16 @@ import com.inktrack.core.usecases.book.GetBookFilter; import com.inktrack.core.usecases.book.GetBooksUseCase; import com.inktrack.core.usecases.book.OrderEnum; +import com.inktrack.core.usecases.book.SearchBooksOutput; +import com.inktrack.core.usecases.book.SearchBooksUseCase; import com.inktrack.core.usecases.book.UpdateBookUseCase; import com.inktrack.core.utils.PageResult; import com.inktrack.infrastructure.dtos.book.BookCreateRequest; import com.inktrack.infrastructure.dtos.book.BookResponse; +import com.inktrack.infrastructure.dtos.book.BookSearchResponse; import com.inktrack.infrastructure.entity.UserEntity; import com.inktrack.infrastructure.mapper.BookMapper; +import com.inktrack.infrastructure.mapper.GoogleBooksMapper; import com.inktrack.infrastructure.mapper.UserMapper; import com.inktrack.infrastructure.utils.response.ApiResponse; import jakarta.validation.Valid; @@ -42,8 +46,10 @@ public class BookController { private final GetBooksUseCase getBooksUseCase; private final GetBookByIdUseCase getBookByIdUseCase; private final DeleteBookUseCase deleteBookUseCase; + private final SearchBooksUseCase searchBooksUseCase; private final BookMapper bookMapper; private final UserMapper userMapper; + private final GoogleBooksMapper googleBooksMapper; public BookController( CreateBookUseCase createBookUseCase, @@ -51,16 +57,20 @@ public BookController( GetBooksUseCase getBooksUseCase, GetBookByIdUseCase getBookByIdUseCase, DeleteBookUseCase deleteBookUseCase, + SearchBooksUseCase searchBooksUseCase, BookMapper bookMapper, - UserMapper userMapper + UserMapper userMapper, + GoogleBooksMapper googleBooksMapper ) { this.createBookUseCase = createBookUseCase; this.updateBookUseCase = updateBookUseCase; this.getBooksUseCase = getBooksUseCase; this.getBookByIdUseCase = getBookByIdUseCase; this.deleteBookUseCase = deleteBookUseCase; + this.searchBooksUseCase = searchBooksUseCase; this.bookMapper = bookMapper; this.userMapper = userMapper; + this.googleBooksMapper = googleBooksMapper; } @PostMapping @@ -119,6 +129,15 @@ public ResponseEntity>> getBooks( return ResponseEntity.ok(ApiResponse.success(dataResponse)); } + @GetMapping("/search") + public ResponseEntity> searchBooks( + @RequestParam String q + ) { + SearchBooksOutput result = searchBooksUseCase.execute(q); + BookSearchResponse response = googleBooksMapper.toSearchResponse(result); + return ResponseEntity.ok(ApiResponse.success(response)); + } + @GetMapping("/{id}") public ResponseEntity> getBookById( @PathVariable Long id, diff --git a/src/main/java/com/inktrack/infrastructure/dtos/book/BookCreateRequest.java b/src/main/java/com/inktrack/infrastructure/dtos/book/BookCreateRequest.java index 614b5f4..0afa5c0 100644 --- a/src/main/java/com/inktrack/infrastructure/dtos/book/BookCreateRequest.java +++ b/src/main/java/com/inktrack/infrastructure/dtos/book/BookCreateRequest.java @@ -12,6 +12,7 @@ public record BookCreateRequest( @Positive int totalPages, @NotNull - Long categoryId + Long categoryId, + String googleBookId ) { } diff --git a/src/main/java/com/inktrack/infrastructure/dtos/book/BookResponse.java b/src/main/java/com/inktrack/infrastructure/dtos/book/BookResponse.java index d15ccb2..47b8e3b 100644 --- a/src/main/java/com/inktrack/infrastructure/dtos/book/BookResponse.java +++ b/src/main/java/com/inktrack/infrastructure/dtos/book/BookResponse.java @@ -14,6 +14,7 @@ public record BookResponse( int totalPages, int pagesRead, int progress, + String thumbnailUrl, OffsetDateTime createdAt, OffsetDateTime updatedAt ) { diff --git a/src/main/java/com/inktrack/infrastructure/dtos/book/BookSearchResponse.java b/src/main/java/com/inktrack/infrastructure/dtos/book/BookSearchResponse.java new file mode 100644 index 0000000..5cfb579 --- /dev/null +++ b/src/main/java/com/inktrack/infrastructure/dtos/book/BookSearchResponse.java @@ -0,0 +1,18 @@ +package com.inktrack.infrastructure.dtos.book; + +import java.util.List; + +public record BookSearchResponse( + int totalItems, + List volumes +) { + + public record BookSearchItem( + String googleBooksId, + String title, + String author, + Integer totalPages, + String thumbnailUrl + ) { + } +} diff --git a/src/main/java/com/inktrack/infrastructure/entity/BookEntity.java b/src/main/java/com/inktrack/infrastructure/entity/BookEntity.java index 8b8dd78..7e39be2 100644 --- a/src/main/java/com/inktrack/infrastructure/entity/BookEntity.java +++ b/src/main/java/com/inktrack/infrastructure/entity/BookEntity.java @@ -52,6 +52,9 @@ public class BookEntity { @Column(name = "updated_at", nullable = false) private OffsetDateTime updatedAt; + @Column(name = "thumbnail_url") + private String thumbnailUrl; + protected BookEntity() { } @@ -67,6 +70,7 @@ private BookEntity(Builder builder) { this.progress = builder.progress; this.createdAt = builder.createdAt; this.updatedAt = builder.updatedAt; + this.thumbnailUrl = builder.thumbnailUrl; } @PrePersist @@ -96,6 +100,7 @@ public static final class Builder { private Integer progress; private OffsetDateTime createdAt; private OffsetDateTime updatedAt; + private String thumbnailUrl; private Builder() {} @@ -149,6 +154,11 @@ public Builder updatedAt(OffsetDateTime updatedAt) { return this; } + public Builder thumbnailUrl(String thumbnailUrl) { + this.thumbnailUrl = thumbnailUrl; + return this; + } + public BookEntity build() { return new BookEntity(this); } @@ -193,4 +203,8 @@ public OffsetDateTime getCreatedAt() { public OffsetDateTime getUpdatedAt() { return updatedAt; } + + public String getThumbnailUrl() { + return thumbnailUrl; + } } \ No newline at end of file diff --git a/src/main/java/com/inktrack/infrastructure/exception/ExternalApiException.java b/src/main/java/com/inktrack/infrastructure/exception/ExternalApiException.java new file mode 100644 index 0000000..04825ba --- /dev/null +++ b/src/main/java/com/inktrack/infrastructure/exception/ExternalApiException.java @@ -0,0 +1,20 @@ +package com.inktrack.infrastructure.exception; + +public class ExternalApiException extends RuntimeException { + + private final int statusCode; + + public ExternalApiException(String message, int statusCode) { + super(message); + this.statusCode = statusCode; + } + + public ExternalApiException(String message, int statusCode, Throwable cause) { + super(message, cause); + this.statusCode = statusCode; + } + + public int getStatusCode() { + return statusCode; + } +} diff --git a/src/main/java/com/inktrack/infrastructure/gateway/GoogleBooksGatewayImpl.java b/src/main/java/com/inktrack/infrastructure/gateway/GoogleBooksGatewayImpl.java new file mode 100644 index 0000000..3cd76e9 --- /dev/null +++ b/src/main/java/com/inktrack/infrastructure/gateway/GoogleBooksGatewayImpl.java @@ -0,0 +1,105 @@ +package com.inktrack.infrastructure.gateway; + +import com.inktrack.core.gateway.GoogleBooksGateway; +import com.inktrack.core.usecases.book.GoogleBooksItemResponse; +import com.inktrack.core.usecases.book.GoogleBooksSearchResponse; +import com.inktrack.core.usecases.book.GoogleBooksVolume; +import com.inktrack.core.usecases.book.SearchBooksOutput; +import com.inktrack.infrastructure.config.CacheConfig; +import com.inktrack.infrastructure.config.GoogleBooksConfig; +import com.inktrack.infrastructure.exception.ExternalApiException; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import java.util.List; +import java.util.Optional; + +@Component +public class GoogleBooksGatewayImpl implements GoogleBooksGateway { + + private final RestClient restClient; + + public GoogleBooksGatewayImpl(GoogleBooksConfig config) { + this.restClient = RestClient.builder() + .baseUrl(config.getBaseUrl()) + .build(); + } + + + @Override + @Cacheable(value = CacheConfig.GOOGLE_BOOKS_CACHE, key = "'search:' + #query") + public SearchBooksOutput searchBooks(String query) { + + GoogleBooksSearchResponse response = + restClient.get() + .uri(uriBuilder -> uriBuilder + .path("/volumes") + .queryParam("q", query) + .queryParam("maxResults", 10) + .queryParam("printType", "books") + .build() + ) + .retrieve() + .body(GoogleBooksSearchResponse.class); + + if (response == null) { + return new SearchBooksOutput(0, List.of()); + } + + List books = + response.items() == null + ? List.of() + : response.items() + .stream() + .map(this::toDomain) + .toList(); + + return new SearchBooksOutput(books.size(), books); + } + + @Override + @Cacheable(value = CacheConfig.GOOGLE_BOOKS_CACHE, key = "'volume:' + #volumeId") + public Optional getVolumeById(String volumeId) { + GoogleBooksItemResponse response = + restClient.get() + .uri("/volumes/{id}", volumeId) + .retrieve() + .onStatus( + HttpStatusCode::is4xxClientError, + (req, res) -> { + throw new ExternalApiException( + "Livro não encontrado no Google Books", + HttpStatus.NOT_FOUND.value() + ); + } + ) + .onStatus( + HttpStatusCode::is5xxServerError, + (req, res) -> { + throw new ExternalApiException( + "Erro no Google Books", + HttpStatus.BAD_GATEWAY.value() + ); + } + ) + .body(GoogleBooksItemResponse.class); + + return Optional.ofNullable(response).map(this::toDomain); + } + + private GoogleBooksVolume toDomain(GoogleBooksItemResponse item) { + return new GoogleBooksVolume( + item.id(), + item.volumeInfo().title(), + item.volumeInfo().authors(), + item.volumeInfo().pageCount(), + item.volumeInfo().imageLinks() != null + ? item.volumeInfo().imageLinks().thumbnail() + : null + ); + } +} + diff --git a/src/main/java/com/inktrack/infrastructure/mapper/BookMapper.java b/src/main/java/com/inktrack/infrastructure/mapper/BookMapper.java index 9640697..f252328 100644 --- a/src/main/java/com/inktrack/infrastructure/mapper/BookMapper.java +++ b/src/main/java/com/inktrack/infrastructure/mapper/BookMapper.java @@ -42,6 +42,7 @@ public BookEntity domainToEntity(Book book) { .totalPages(book.getTotalPages()) .pagesRead(book.getPagesRead()) .progress(book.getProgress()) + .thumbnailUrl(book.getThumbnailUrl()) .createdAt(book.getCreatedAt()) .updatedAt(book.getUpdatedAt()) .build(); @@ -59,6 +60,7 @@ public Book entityToDomain(BookEntity bookEntity) { .author(bookEntity.getAuthor()) .totalPages(bookEntity.getTotalPages()) .pagesRead(bookEntity.getPagesRead()) + .thumbnailUrl(bookEntity.getThumbnailUrl()) .createdAt(bookEntity.getCreatedAt()) .updatedAt(bookEntity.getUpdatedAt()) .build(); @@ -69,7 +71,8 @@ public BookModelInput requestDtoToModelInput(BookCreateRequest bookCreateRequest bookCreateRequest.title(), bookCreateRequest.author(), bookCreateRequest.totalPages(), - bookCreateRequest.categoryId() + bookCreateRequest.categoryId(), + bookCreateRequest.googleBookId() ); } @@ -90,6 +93,7 @@ public BookResponse domainToResponse(Book book) { book.getTotalPages(), book.getPagesRead(), book.getProgress(), + book.getThumbnailUrl(), book.getCreatedAt(), book.getUpdatedAt() ); @@ -119,6 +123,7 @@ public BookResponse modelOutPutToResponse(BookModelOutput bookModelOutPut) { bookModelOutPut.totalPages(), bookModelOutPut.pagesRead(), bookModelOutPut.progress(), + bookModelOutPut.thumbnailUrl(), bookModelOutPut.createdAt(), bookModelOutPut.updatedAt() ); diff --git a/src/main/java/com/inktrack/infrastructure/mapper/GoogleBooksMapper.java b/src/main/java/com/inktrack/infrastructure/mapper/GoogleBooksMapper.java new file mode 100644 index 0000000..57f14c5 --- /dev/null +++ b/src/main/java/com/inktrack/infrastructure/mapper/GoogleBooksMapper.java @@ -0,0 +1,33 @@ +package com.inktrack.infrastructure.mapper; + +import com.inktrack.core.usecases.book.GoogleBooksVolume; +import com.inktrack.core.usecases.book.SearchBooksOutput; +import com.inktrack.infrastructure.dtos.book.BookSearchResponse; +import org.springframework.stereotype.Component; + +@Component +public class GoogleBooksMapper { + + public BookSearchResponse toSearchResponse(SearchBooksOutput output) { + return new BookSearchResponse( + output.totalItems(), + output.volumes().stream() + .map(this::toBookSearchItem) + .toList() + ); + } + + private BookSearchResponse.BookSearchItem toBookSearchItem(GoogleBooksVolume volume) { + String author = volume.authors() != null + ? String.join(", ", volume.authors()) + : ""; + + return new BookSearchResponse.BookSearchItem( + volume.googleBooksId(), + volume.title(), + author, + volume.pageCount(), + volume.thumbnailUrl() + ); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 4e70fe3..7943d1b 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -17,4 +17,13 @@ spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect spring.flyway.enabled=true spring.flyway.locations=classpath:db/migration spring.flyway.baseline-on-migrate=true -spring.flyway.clean-disabled=true \ No newline at end of file +spring.flyway.clean-disabled=true +# Google Books API Configuration +# API key is optional but recommended for higher quotas +google.books.api-key=${GOOGLE_BOOKS_API_KEY:} +google.books.base-url=${GOOGLE_BOOKS_API_BASE_URL:https://www.googleapis.com/books/v1} +google.books.max-results=${GOOGLE_BOOKS_MAX_RESULTS:10} +google.books.timeout-ms=${GOOGLE_BOOKS_TIMEOUT_MS:5000} +# Cache Configuration +google.books.cache-max-size=${GOOGLE_BOOKS_CACHE_MAX_SIZE:100} +google.books.cache-expiration-minutes=${GOOGLE_BOOKS_CACHE_EXPIRATION_MINUTES:5} diff --git a/src/main/resources/db/migration/V9__add_thumbnail_url_to_books.sql b/src/main/resources/db/migration/V9__add_thumbnail_url_to_books.sql new file mode 100644 index 0000000..e6bcf1a --- /dev/null +++ b/src/main/resources/db/migration/V9__add_thumbnail_url_to_books.sql @@ -0,0 +1,5 @@ +-- Add thumbnail_url column to tb_books table +ALTER TABLE tb_books ADD COLUMN thumbnail_url VARCHAR(500); + +-- Add comment +COMMENT ON COLUMN tb_books.thumbnail_url IS 'URL of the book cover thumbnail from Google Books'; diff --git a/src/test/java/com/inktrack/core/usecases/book/CreateBookUseCaseImplTest.java b/src/test/java/com/inktrack/core/usecases/book/CreateBookUseCaseImplTest.java index 7e3fe4c..675f796 100644 --- a/src/test/java/com/inktrack/core/usecases/book/CreateBookUseCaseImplTest.java +++ b/src/test/java/com/inktrack/core/usecases/book/CreateBookUseCaseImplTest.java @@ -5,6 +5,7 @@ import com.inktrack.core.domain.User; import com.inktrack.core.gateway.BookGateway; import com.inktrack.core.gateway.CategoryGateway; +import com.inktrack.core.gateway.GoogleBooksGateway; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -14,6 +15,7 @@ import java.time.LocalDateTime; import java.time.OffsetDateTime; +import java.util.List; import java.util.Optional; import java.util.UUID; @@ -34,6 +36,9 @@ class CreateBookUseCaseImplTest { @Mock private CategoryGateway categoryGateway; + @Mock + private GoogleBooksGateway googleBooksGateway; + private CreateBookUseCaseImpl createBookUseCase; private User validUser; @@ -42,7 +47,7 @@ class CreateBookUseCaseImplTest { @BeforeEach void setUp() { - createBookUseCase = new CreateBookUseCaseImpl(bookGateway, categoryGateway); + createBookUseCase = new CreateBookUseCaseImpl(bookGateway, categoryGateway, googleBooksGateway); validUser = new User(UUID.randomUUID(), "Test User", "test@email.com", "Password123!", LocalDateTime.now()); validCategory = new Category(1L, "FICTION", OffsetDateTime.now()); } @@ -50,7 +55,7 @@ void setUp() { @Test @DisplayName("Should create book successfully when all data is valid") void execute_shouldCreateBook_whenDataIsValid() { - BookModelInput input = new BookModelInput("Clean Code", "Robert C. Martin", 464, 1L); + BookModelInput input = new BookModelInput("Clean Code", "Robert C. Martin", 464, 1L, null); OffsetDateTime now = OffsetDateTime.now(); when(categoryGateway.getById(1L)).thenReturn(Optional.of(validCategory)); @@ -82,7 +87,7 @@ void execute_shouldCreateBook_whenDataIsValid() { @Test @DisplayName("Should throw exception when user is null") void execute_shouldThrowException_whenUserIsNull() { - BookModelInput input = new BookModelInput("Title", "Author", 100, 1L); + BookModelInput input = new BookModelInput("Title", "Author", 100, 1L, null); IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> createBookUseCase.execute(input, null) @@ -95,7 +100,7 @@ void execute_shouldThrowException_whenUserIsNull() { @Test @DisplayName("Should throw exception when title is blank") void execute_shouldThrowException_whenTitleIsBlank() { - BookModelInput input = new BookModelInput("", "Author", 100, 1L); + BookModelInput input = new BookModelInput("", "Author", 100, 1L, null); IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> createBookUseCase.execute(input, validUser) @@ -108,7 +113,7 @@ void execute_shouldThrowException_whenTitleIsBlank() { @Test @DisplayName("Should throw exception when author is blank") void execute_shouldThrowException_whenAuthorIsBlank() { - BookModelInput input = new BookModelInput("Title", " ", 100, 1L); + BookModelInput input = new BookModelInput("Title", " ", 100, 1L, null); IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> createBookUseCase.execute(input, validUser) @@ -122,7 +127,7 @@ void execute_shouldThrowException_whenAuthorIsBlank() { @DisplayName("Should throw exception when total pages is negative") void execute_shouldThrowException_whenTotalPagesIsNegative() { BookModelInput input = - new BookModelInput("Clean Code", "Robert C. Martin", -1, 1L); + new BookModelInput("Clean Code", "Robert C. Martin", -1, 1L, null); IllegalArgumentException exception = assertThrows( IllegalArgumentException.class, @@ -136,4 +141,215 @@ void execute_shouldThrowException_whenTotalPagesIsNegative() { verifyNoInteractions(bookGateway); } + + @Test + @DisplayName("Should fetch and set thumbnail when googleBookId is provided") + void execute_shouldFetchThumbnail_whenGoogleBookIdIsProvided() { + String googleBookId = "zyTCAlFPjgYC"; + String thumbnailUrl = "http://books.google.com/books/content?id=zyTCAlFPjgYC&printsec=frontcover&img=1&zoom=1&source=gbs_api"; + BookModelInput input = new BookModelInput("Clean Code", "Robert C. Martin", 464, 1L, googleBookId); + OffsetDateTime now = OffsetDateTime.now(); + + GoogleBooksVolume volume = new GoogleBooksVolume( + googleBookId, + "Clean Code", + List.of("Robert C. Martin"), + 464, + thumbnailUrl + ); + + when(categoryGateway.getById(1L)).thenReturn(Optional.of(validCategory)); + when(googleBooksGateway.getVolumeById(googleBookId)).thenReturn(Optional.of(volume)); + when(bookGateway.save(any(Book.class))).thenAnswer(invocation -> { + Book b = invocation.getArgument(0); + return Book.builder() + .id(1L) + .user(b.getUser()) + .category(b.getCategory()) + .title(b.getTitle()) + .author(b.getAuthor()) + .totalPages(b.getTotalPages()) + .pagesRead(b.getPagesRead()) + .thumbnailUrl(b.getThumbnailUrl()) + .createdAt(now) + .updatedAt(now) + .build(); + }); + + BookModelOutput response = createBookUseCase.execute(input, validUser); + + assertNotNull(response); + assertEquals(thumbnailUrl, response.thumbnailUrl()); + verify(googleBooksGateway).getVolumeById(googleBookId); + verify(bookGateway).save(any(Book.class)); + } + + @Test + @DisplayName("Should not call Google Books API when googleBookId is null") + void execute_shouldNotCallGoogleBooks_whenGoogleBookIdIsNull() { + BookModelInput input = new BookModelInput("Clean Code", "Robert C. Martin", 464, 1L, null); + OffsetDateTime now = OffsetDateTime.now(); + + when(categoryGateway.getById(1L)).thenReturn(Optional.of(validCategory)); + when(bookGateway.save(any(Book.class))).thenAnswer(invocation -> { + Book b = invocation.getArgument(0); + return Book.builder() + .id(1L) + .user(b.getUser()) + .category(b.getCategory()) + .title(b.getTitle()) + .author(b.getAuthor()) + .totalPages(b.getTotalPages()) + .pagesRead(b.getPagesRead()) + .thumbnailUrl(b.getThumbnailUrl()) + .createdAt(now) + .updatedAt(now) + .build(); + }); + + BookModelOutput response = createBookUseCase.execute(input, validUser); + + assertNotNull(response); + assertEquals(null, response.thumbnailUrl()); + verifyNoInteractions(googleBooksGateway); + verify(bookGateway).save(any(Book.class)); + } + + @Test + @DisplayName("Should not call Google Books API when googleBookId is blank") + void execute_shouldNotCallGoogleBooks_whenGoogleBookIdIsBlank() { + BookModelInput input = new BookModelInput("Clean Code", "Robert C. Martin", 464, 1L, " "); + OffsetDateTime now = OffsetDateTime.now(); + + when(categoryGateway.getById(1L)).thenReturn(Optional.of(validCategory)); + when(bookGateway.save(any(Book.class))).thenAnswer(invocation -> { + Book b = invocation.getArgument(0); + return Book.builder() + .id(1L) + .user(b.getUser()) + .category(b.getCategory()) + .title(b.getTitle()) + .author(b.getAuthor()) + .totalPages(b.getTotalPages()) + .pagesRead(b.getPagesRead()) + .thumbnailUrl(b.getThumbnailUrl()) + .createdAt(now) + .updatedAt(now) + .build(); + }); + + BookModelOutput response = createBookUseCase.execute(input, validUser); + + assertNotNull(response); + assertEquals(null, response.thumbnailUrl()); + verifyNoInteractions(googleBooksGateway); + verify(bookGateway).save(any(Book.class)); + } + + @Test + @DisplayName("Should handle exception when Google Books API fails") + void execute_shouldHandleException_whenGoogleBooksApiFails() { + String googleBookId = "invalid-id"; + BookModelInput input = new BookModelInput("Clean Code", "Robert C. Martin", 464, 1L, googleBookId); + OffsetDateTime now = OffsetDateTime.now(); + + when(categoryGateway.getById(1L)).thenReturn(Optional.of(validCategory)); + when(googleBooksGateway.getVolumeById(googleBookId)).thenThrow(new RuntimeException("API Error")); + when(bookGateway.save(any(Book.class))).thenAnswer(invocation -> { + Book b = invocation.getArgument(0); + return Book.builder() + .id(1L) + .user(b.getUser()) + .category(b.getCategory()) + .title(b.getTitle()) + .author(b.getAuthor()) + .totalPages(b.getTotalPages()) + .pagesRead(b.getPagesRead()) + .thumbnailUrl(b.getThumbnailUrl()) + .createdAt(now) + .updatedAt(now) + .build(); + }); + + BookModelOutput response = createBookUseCase.execute(input, validUser); + + assertNotNull(response); + assertEquals(null, response.thumbnailUrl()); + verify(googleBooksGateway).getVolumeById(googleBookId); + verify(bookGateway).save(any(Book.class)); + } + + @Test + @DisplayName("Should set null thumbnail when Google Books returns empty volume") + void execute_shouldSetNullThumbnail_whenGoogleBooksReturnsEmpty() { + String googleBookId = "non-existent-id"; + BookModelInput input = new BookModelInput("Clean Code", "Robert C. Martin", 464, 1L, googleBookId); + OffsetDateTime now = OffsetDateTime.now(); + + when(categoryGateway.getById(1L)).thenReturn(Optional.of(validCategory)); + when(googleBooksGateway.getVolumeById(googleBookId)).thenReturn(Optional.empty()); + when(bookGateway.save(any(Book.class))).thenAnswer(invocation -> { + Book b = invocation.getArgument(0); + return Book.builder() + .id(1L) + .user(b.getUser()) + .category(b.getCategory()) + .title(b.getTitle()) + .author(b.getAuthor()) + .totalPages(b.getTotalPages()) + .pagesRead(b.getPagesRead()) + .thumbnailUrl(b.getThumbnailUrl()) + .createdAt(now) + .updatedAt(now) + .build(); + }); + + BookModelOutput response = createBookUseCase.execute(input, validUser); + + assertNotNull(response); + assertEquals(null, response.thumbnailUrl()); + verify(googleBooksGateway).getVolumeById(googleBookId); + verify(bookGateway).save(any(Book.class)); + } + + @Test + @DisplayName("Should set null thumbnail when Google Books volume has no thumbnail") + void execute_shouldSetNullThumbnail_whenVolumeHasNoThumbnail() { + String googleBookId = "zyTCAlFPjgYC"; + BookModelInput input = new BookModelInput("Clean Code", "Robert C. Martin", 464, 1L, googleBookId); + OffsetDateTime now = OffsetDateTime.now(); + + GoogleBooksVolume volume = new GoogleBooksVolume( + googleBookId, + "Clean Code", + List.of("Robert C. Martin"), + 464, + null + ); + + when(categoryGateway.getById(1L)).thenReturn(Optional.of(validCategory)); + when(googleBooksGateway.getVolumeById(googleBookId)).thenReturn(Optional.of(volume)); + when(bookGateway.save(any(Book.class))).thenAnswer(invocation -> { + Book b = invocation.getArgument(0); + return Book.builder() + .id(1L) + .user(b.getUser()) + .category(b.getCategory()) + .title(b.getTitle()) + .author(b.getAuthor()) + .totalPages(b.getTotalPages()) + .pagesRead(b.getPagesRead()) + .thumbnailUrl(b.getThumbnailUrl()) + .createdAt(now) + .updatedAt(now) + .build(); + }); + + BookModelOutput response = createBookUseCase.execute(input, validUser); + + assertNotNull(response); + assertEquals(null, response.thumbnailUrl()); + verify(googleBooksGateway).getVolumeById(googleBookId); + verify(bookGateway).save(any(Book.class)); + } } diff --git a/src/test/java/com/inktrack/core/usecases/book/SearchBooksUseCaseImplTest.java b/src/test/java/com/inktrack/core/usecases/book/SearchBooksUseCaseImplTest.java new file mode 100644 index 0000000..edfb09d --- /dev/null +++ b/src/test/java/com/inktrack/core/usecases/book/SearchBooksUseCaseImplTest.java @@ -0,0 +1,98 @@ +package com.inktrack.core.usecases.book; + +import com.inktrack.core.gateway.GoogleBooksGateway; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.MockitoAnnotations.openMocks; + +class SearchBooksUseCaseImplTest { + + @Mock + private GoogleBooksGateway googleBooksGateway; + + @InjectMocks + private SearchBooksUseCaseImpl searchBooksUseCase; + + @BeforeEach + void setUp() { + openMocks(this); + } + + @Test + void execute_withValidQuery_callsGateway() { + String query = "Harry Potter"; + SearchBooksOutput expectedOutput = new SearchBooksOutput(10, List.of()); + + when(googleBooksGateway.searchBooks(query)).thenReturn(expectedOutput); + + SearchBooksOutput result = searchBooksUseCase.execute(query); + + assertEquals(expectedOutput, result); + verify(googleBooksGateway, times(1)).searchBooks(query); + } + + @Test + void execute_withNullQuery_throwsIllegalArgumentException() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> searchBooksUseCase.execute(null) + ); + + assertEquals("Query cannot be null or empty", exception.getMessage()); + } + + @Test + void execute_withEmptyQuery_throwsIllegalArgumentException() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> searchBooksUseCase.execute("") + ); + + assertEquals("Query cannot be null or empty", exception.getMessage()); + } + + @Test + void execute_withBlankQuery_throwsIllegalArgumentException() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> searchBooksUseCase.execute(" ") + ); + + assertEquals("Query cannot be null or empty", exception.getMessage()); + } + + @Test + void execute_withTooLongQuery_throwsIllegalArgumentException() { + String tooLongQuery = "a".repeat(501); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> searchBooksUseCase.execute(tooLongQuery) + ); + + assertEquals("Query too long (max 500 characters)", exception.getMessage()); + } + + @Test + void execute_withQueryContainingWhitespace_trimsQuery() { + String query = " Harry Potter "; + SearchBooksOutput expectedOutput = new SearchBooksOutput(10, List.of()); + + when(googleBooksGateway.searchBooks("Harry Potter")).thenReturn(expectedOutput); + + searchBooksUseCase.execute(query); + + verify(googleBooksGateway, times(1)).searchBooks("Harry Potter"); + } +} diff --git a/src/test/java/com/inktrack/core/usecases/book/UpdateBookUseCaseImplTest.java b/src/test/java/com/inktrack/core/usecases/book/UpdateBookUseCaseImplTest.java index 02e21f7..b8dff00 100644 --- a/src/test/java/com/inktrack/core/usecases/book/UpdateBookUseCaseImplTest.java +++ b/src/test/java/com/inktrack/core/usecases/book/UpdateBookUseCaseImplTest.java @@ -60,7 +60,7 @@ void execute_shouldUpdateBook_whenDataIsValid() { .updatedAt(sevenDaysBeforeToday) .build(); - BookModelInput input = new BookModelInput("Clean Code Pdf", "Robert C. Martin", 500, 1L); + BookModelInput input = new BookModelInput("Clean Code Pdf", "Robert C. Martin", 500, 1L, null); when(categoryGateway.getById(1L)).thenReturn(Optional.of(validCategory)); diff --git a/src/test/java/com/inktrack/infrastructure/controller/BookControllerIntegrationTest.java b/src/test/java/com/inktrack/infrastructure/controller/BookControllerIntegrationTest.java index a3d2514..d127005 100644 --- a/src/test/java/com/inktrack/infrastructure/controller/BookControllerIntegrationTest.java +++ b/src/test/java/com/inktrack/infrastructure/controller/BookControllerIntegrationTest.java @@ -137,7 +137,7 @@ private long createBook(String token, BookCreateRequest request) throws Exceptio private long createBook(String token, String title) throws Exception { BookCreateRequest request = - new BookCreateRequest(title, "Robert C. Martin", 464, testCategoryId); + new BookCreateRequest(title, "Robert C. Martin", 464, testCategoryId, null); String createBookResponse = mockMvc.perform(post("/books") .header("Authorization", "Bearer " + token) @@ -160,7 +160,7 @@ private long createBook(String token, String title) throws Exception { void shouldCreateBookSuccessfully() throws Exception { String token = authenticateAndGetToken(); - BookCreateRequest request = new BookCreateRequest("Clean Code", "Robert C. Martin", 464, testCategoryId); + BookCreateRequest request = new BookCreateRequest("Clean Code", "Robert C. Martin", 464, testCategoryId, null); createBook(token, request); @@ -172,7 +172,7 @@ void shouldCreateBookSuccessfully() throws Exception { @Test @DisplayName("Should return Forbidden when not authenticated") void shouldReturnForbiddenWhenNotAuthenticated() throws Exception { - BookCreateRequest request = new BookCreateRequest("Clean Code", "Robert C. Martin", 464, testCategoryId); + BookCreateRequest request = new BookCreateRequest("Clean Code", "Robert C. Martin", 464, testCategoryId, null); mockMvc.perform(post("/books") .contentType(MediaType.APPLICATION_JSON) @@ -185,7 +185,7 @@ void shouldReturnForbiddenWhenNotAuthenticated() throws Exception { void shouldReturnBadRequestWhenTitleIsMissing() throws Exception { String token = authenticateAndGetToken(); - BookCreateRequest request = new BookCreateRequest("", "Robert C. Martin", 464, testCategoryId); + BookCreateRequest request = new BookCreateRequest("", "Robert C. Martin", 464, testCategoryId, null); mockMvc.perform(post("/books") .header("Authorization", "Bearer " + token) @@ -199,7 +199,7 @@ void shouldReturnBadRequestWhenTitleIsMissing() throws Exception { void shouldReturnBadRequestWhenAuthorIsMissing() throws Exception { String token = authenticateAndGetToken(); - BookCreateRequest request = new BookCreateRequest("Clean Code", "", 464, testCategoryId); + BookCreateRequest request = new BookCreateRequest("Clean Code", "", 464, testCategoryId, null); mockMvc.perform(post("/books") .header("Authorization", "Bearer " + token) @@ -213,7 +213,7 @@ void shouldReturnBadRequestWhenAuthorIsMissing() throws Exception { void shouldUpdateBookSuccessfully() throws Exception { String token = authenticateAndGetToken(); - BookCreateRequest request = new BookCreateRequest("Clean Code", "Robert C. Martin", 464, testCategoryId); + BookCreateRequest request = new BookCreateRequest("Clean Code", "Robert C. Martin", 464, testCategoryId, null); createBook(token, request); @@ -222,7 +222,7 @@ void shouldUpdateBookSuccessfully() throws Exception { assert books.get(0).getTitle().equals("Clean Code"); Long bookId = books.get(0).getId(); - BookCreateRequest updateRequest = new BookCreateRequest("Clean Code PDF", "Robert C. Martin", 500, testCategoryId); + BookCreateRequest updateRequest = new BookCreateRequest("Clean Code PDF", "Robert C. Martin", 500, testCategoryId, null); mockMvc.perform(put("/books/" + bookId) .header("Authorization", "Bearer " + token) @@ -246,7 +246,7 @@ void shouldUpdateBookSuccessfully() throws Exception { void shouldThrowsBookNotFoundException() throws Exception { String token = authenticateAndGetToken(); UUID userId = userRepository.findAll().get(0).getId(); - BookCreateRequest request = new BookCreateRequest("Clean Code", "Robert C. Martin", 464, testCategoryId); + BookCreateRequest request = new BookCreateRequest("Clean Code", "Robert C. Martin", 464, testCategoryId, null); createBook(token, request); @@ -255,7 +255,7 @@ void shouldThrowsBookNotFoundException() throws Exception { assert books.get(0).getTitle().equals("Clean Code"); Long bookId = books.get(0).getId(); - BookCreateRequest updateRequest = new BookCreateRequest("Clean Code PDF", "Robert C. Martin", 500, testCategoryId); + BookCreateRequest updateRequest = new BookCreateRequest("Clean Code PDF", "Robert C. Martin", 500, testCategoryId, null); Long invalidBookId = bookId + 1; mockMvc.perform(put("/books/" + invalidBookId) .header("Authorization", "Bearer " + token) @@ -280,7 +280,7 @@ void shouldThrowsBookNotFoundException() throws Exception { void shouldReturnBooksWithDefaultFilters() throws Exception { String token = authenticateAndGetToken(); BookCreateRequest request = - new BookCreateRequest("Clean Code", "Robert C. Martin", 464, testCategoryId); + new BookCreateRequest("Clean Code", "Robert C. Martin", 464, testCategoryId, null); createBook(token, request); createBook(token, request); @@ -373,7 +373,7 @@ void shouldReturnPaginatedBooks() throws Exception { @DisplayName("Should return book with id valid") void shouldReturnBookWithValidId() throws Exception { String token = authenticateAndGetToken(); - BookCreateRequest request = new BookCreateRequest("Clean code", "Unclo bob", 400, testCategoryId); + BookCreateRequest request = new BookCreateRequest("Clean code", "Unclo bob", 400, testCategoryId, null); Long bookId = createBook(token, request); @@ -413,7 +413,7 @@ void shouldReturnNotFoundWhenAccessingBookFromAnotherUser() throws Exception { .asText(); BookCreateRequest bookRequest = - new BookCreateRequest("Clean Code", "Robert C. Martin", 464, testCategoryId); + new BookCreateRequest("Clean Code", "Robert C. Martin", 464, testCategoryId, null); Long bookId = createBook(tokenUserA, bookRequest); @@ -490,4 +490,73 @@ void shouldReturnNotFoundWhenDeletingNonExistingBook() throws Exception { .andExpect(jsonPath("$.errors").value(notNullValue())) .andExpect(jsonPath("$.errors[0].field").value("id")); } + + @Test + @DisplayName("Should create book with thumbnail from Google Books when googleBookId is provided") + void shouldCreateBookWithThumbnailFromGoogleBooks() throws Exception { + String token = authenticateAndGetToken(); + + String googleBookId = "testBookId123"; + String thumbnailUrl = "http://books.google.com/books/content?id=testBookId123&printsec=frontcover&img=1&zoom=1&source=gbs_api"; + + BookCreateRequest request = + new BookCreateRequest("Clean Code", "Robert C. Martin", 464, testCategoryId, googleBookId); + + String createBookResponse = mockMvc.perform(post("/books") + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.data.id").isNotEmpty()) + .andExpect(jsonPath("$.data.title").value(request.title())) + .andExpect(jsonPath("$.data.author").value(request.author())) + .andReturn() + .getResponse() + .getContentAsString(); + + Long bookId = objectMapper + .readTree(createBookResponse) + .get("data") + .get("id") + .asLong(); + + var book = bookRepository.findById(bookId); + assertThat(book).isPresent(); + + // The thumbnail may or may not be present depending on Google Books API response + // The important part is that the book is created successfully + assertThat(book.get().getTitle()).isEqualTo(request.title()); + assertThat(book.get().getAuthor()).isEqualTo(request.author()); + } + + @Test + @DisplayName("Should create book without thumbnail when googleBookId is null") + void shouldCreateBookWithoutThumbnailWhenGoogleBookIdIsNull() throws Exception { + String token = authenticateAndGetToken(); + + BookCreateRequest request = + new BookCreateRequest("Clean Code", "Robert C. Martin", 464, testCategoryId, null); + + String createBookResponse = mockMvc.perform(post("/books") + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.data.id").isNotEmpty()) + .andExpect(jsonPath("$.data.title").value(request.title())) + .andExpect(jsonPath("$.data.thumbnailUrl").value(nullValue())) + .andReturn() + .getResponse() + .getContentAsString(); + + Long bookId = objectMapper + .readTree(createBookResponse) + .get("data") + .get("id") + .asLong(); + + var book = bookRepository.findById(bookId); + assertThat(book).isPresent(); + assertThat(book.get().getThumbnailUrl()).isNull(); + } } diff --git a/src/test/java/com/inktrack/infrastructure/controller/MetricsControllerIntegrationTest.java b/src/test/java/com/inktrack/infrastructure/controller/MetricsControllerIntegrationTest.java index c0b1260..256a28e 100644 --- a/src/test/java/com/inktrack/infrastructure/controller/MetricsControllerIntegrationTest.java +++ b/src/test/java/com/inktrack/infrastructure/controller/MetricsControllerIntegrationTest.java @@ -108,7 +108,7 @@ private String authenticateAndGetToken() throws Exception { } private long createBook(String token, String title, int totalPages) throws Exception { - BookCreateRequest request = new BookCreateRequest(title, "Author Name", totalPages, testCategoryId); + BookCreateRequest request = new BookCreateRequest(title, "Author Name", totalPages, testCategoryId, null); String createBookResponse = mockMvc.perform(post("/books") .header("Authorization", "Bearer " + token) diff --git a/src/test/java/com/inktrack/infrastructure/controller/NoteControllerIntegrationTest.java b/src/test/java/com/inktrack/infrastructure/controller/NoteControllerIntegrationTest.java index a855d56..cd905c9 100644 --- a/src/test/java/com/inktrack/infrastructure/controller/NoteControllerIntegrationTest.java +++ b/src/test/java/com/inktrack/infrastructure/controller/NoteControllerIntegrationTest.java @@ -151,7 +151,7 @@ private long createNote(String token, Long bookId, CreateNoteRequest request) th void shouldCreateNoteWhenDataIsValid() throws Exception { String token = authenticateAndGetToken(); - BookCreateRequest bookRequest = new BookCreateRequest("Effective Java", "Joshua Bloch", 416, testCategoryId); + BookCreateRequest bookRequest = new BookCreateRequest("Effective Java", "Joshua Bloch", 416, testCategoryId, null); long bookId = createBook(token, bookRequest); CreateNoteRequest noteRequest = new CreateNoteRequest("This book is essential for every Java developer."); @@ -211,7 +211,7 @@ void shouldReturnBadRequest_WhenContentLengthExceedsLimit() throws Exception { void shouldGetNotesPaginatedSuccessfully() throws Exception { String token = authenticateAndGetToken(); - BookCreateRequest bookRequest = new BookCreateRequest("Domain-Driven Design", "Eric Evans", 400, testCategoryId); + BookCreateRequest bookRequest = new BookCreateRequest("Domain-Driven Design", "Eric Evans", 400, testCategoryId, null); long bookId = createBook(token, bookRequest); createNote(token, bookId, new CreateNoteRequest("Note about aggregates")); @@ -235,7 +235,7 @@ void shouldGetNotesPaginatedSuccessfully() throws Exception { void shouldReturnEmptyListWhenBookHasNoNotes() throws Exception { String token = authenticateAndGetToken(); - BookCreateRequest bookRequest = new BookCreateRequest("Clean Code", "Robert Martin", 464, testCategoryId); + BookCreateRequest bookRequest = new BookCreateRequest("Clean Code", "Robert Martin", 464, testCategoryId, null); long bookId = createBook(token, bookRequest); mockMvc.perform(get("/books/" + bookId + "/notes") @@ -251,7 +251,7 @@ void shouldReturnEmptyListWhenBookHasNoNotes() throws Exception { void shouldUpdateNoteSuccessfully() throws Exception { String token = authenticateAndGetToken(); - BookCreateRequest bookRequest = new BookCreateRequest("Refactoring", "Martin Fowler", 450, testCategoryId); + BookCreateRequest bookRequest = new BookCreateRequest("Refactoring", "Martin Fowler", 450, testCategoryId, null); long bookId = createBook(token, bookRequest); long noteId = createNote( @@ -295,7 +295,7 @@ void shouldReturnNotFoundWhenNoteDoesNotExist() throws Exception { void shouldReturnBadRequestWhenUpdatingWithBlankContent() throws Exception { String token = authenticateAndGetToken(); - BookCreateRequest bookRequest = new BookCreateRequest("Clean Architecture", "Robert Martin", 350, testCategoryId); + BookCreateRequest bookRequest = new BookCreateRequest("Clean Architecture", "Robert Martin", 350, testCategoryId, null); long bookId = createBook(token, bookRequest); long noteId = createNote( @@ -322,7 +322,7 @@ void shouldReturnBadRequestWhenUpdatingWithBlankContent() throws Exception { void shouldReturnBadRequestWhenUpdatingWithLongContent() throws Exception { String token = authenticateAndGetToken(); - BookCreateRequest bookRequest = new BookCreateRequest("Java Concurrency", "Brian Goetz", 420, testCategoryId); + BookCreateRequest bookRequest = new BookCreateRequest("Java Concurrency", "Brian Goetz", 420, testCategoryId, null); long bookId = createBook(token, bookRequest); long noteId = createNote( @@ -349,7 +349,7 @@ void shouldReturnBadRequestWhenUpdatingWithLongContent() throws Exception { void shouldDeleteNoteSuccessfully() throws Exception { String token = authenticateAndGetToken(); - BookCreateRequest bookRequest = new BookCreateRequest("Design Patterns", "Gang of Four", 395, testCategoryId); + BookCreateRequest bookRequest = new BookCreateRequest("Design Patterns", "Gang of Four", 395, testCategoryId, null); long bookId = createBook(token, bookRequest); long noteId = createNote( @@ -373,7 +373,7 @@ void shouldDeleteNoteSuccessfully() throws Exception { void shouldReturnNotFoundWhenDeletingNonExistingNote() throws Exception { String token = authenticateAndGetToken(); - BookCreateRequest bookRequest = new BookCreateRequest("Test Book", "Test Author", 100, testCategoryId); + BookCreateRequest bookRequest = new BookCreateRequest("Test Book", "Test Author", 100, testCategoryId, null); long bookId = createBook(token, bookRequest); mockMvc.perform( diff --git a/src/test/java/com/inktrack/infrastructure/controller/ReadingSessionControllerIntegrationTest.java b/src/test/java/com/inktrack/infrastructure/controller/ReadingSessionControllerIntegrationTest.java index 5776cc8..76a7435 100644 --- a/src/test/java/com/inktrack/infrastructure/controller/ReadingSessionControllerIntegrationTest.java +++ b/src/test/java/com/inktrack/infrastructure/controller/ReadingSessionControllerIntegrationTest.java @@ -149,7 +149,7 @@ private long createReadingSession(String token, long bookId, ReadingSessionCreat @Test void shouldCreateReadingSessionSuccessfully() throws Exception { String accessToken = authenticateAndGetToken(); - long bookId = createBook(accessToken, new BookCreateRequest("The Pragmatic Programmer", "Andrew Hunt", 352, testCategoryId)); + long bookId = createBook(accessToken, new BookCreateRequest("The Pragmatic Programmer", "Andrew Hunt", 352, testCategoryId, null)); ReadingSessionCreateRequest request = new ReadingSessionCreateRequest(45L, 30); @@ -180,7 +180,7 @@ void shouldCreateReadingSessionSuccessfully() throws Exception { @DisplayName("should return the reading session paginated") void shouldReturnReadingSessionPaginated() throws Exception { String accessToken = authenticateAndGetToken(); - long bookId = createBook(accessToken, new BookCreateRequest("Clean Code", "Robert C. Martin", 464, testCategoryId)); + long bookId = createBook(accessToken, new BookCreateRequest("Clean Code", "Robert C. Martin", 464, testCategoryId, null)); ReadingSessionCreateRequest request1 = new ReadingSessionCreateRequest(60L, 50); ReadingSessionCreateRequest request2 = new ReadingSessionCreateRequest(30L, 20); @@ -207,7 +207,7 @@ void shouldReturnReadingSessionPaginated() throws Exception { void shouldReturn404WhenGettingReadingSessionsForNonExistentBook() throws Exception { String accessToken = authenticateAndGetToken(); - long bookId = createBook(accessToken, new BookCreateRequest("Clean Code", "Robert C. Martin", 464, testCategoryId)); + long bookId = createBook(accessToken, new BookCreateRequest("Clean Code", "Robert C. Martin", 464, testCategoryId, null)); long readingSessionId = createReadingSession(accessToken, bookId, new ReadingSessionCreateRequest(60L, 50)); ReadingSessionCreateRequest requestUpdated = new ReadingSessionCreateRequest(30L, 20); @@ -236,7 +236,7 @@ void shouldReturn404WhenGettingReadingSessionsForNonExistentBook() throws Except @DisplayName("Should return 404 when not found the reading session") void shouldReturn404WhenReadingSessionNotFound() throws Exception { String accessToken = authenticateAndGetToken(); - long bookId = createBook(accessToken, new BookCreateRequest("Clean Code", "Robert C. Martin", 464, testCategoryId)); + long bookId = createBook(accessToken, new BookCreateRequest("Clean Code", "Robert C. Martin", 464, testCategoryId, null)); ReadingSessionCreateRequest request = new ReadingSessionCreateRequest(60L, 50); String expectedMessage = "Reading session not found with id: 1"; mockMvc.perform( @@ -255,7 +255,7 @@ void shouldReturn404WhenReadingSessionNotFound() throws Exception { @DisplayName("should delete reading session successfully") void shouldDeleteReadingSessionSuccessfully() throws Exception { String accessToken = authenticateAndGetToken(); - long bookId = createBook(accessToken, new BookCreateRequest("Clean Code", "Robert C. Martin", 464, testCategoryId)); + long bookId = createBook(accessToken, new BookCreateRequest("Clean Code", "Robert C. Martin", 464, testCategoryId, null)); long readingSessionId = createReadingSession(accessToken, bookId, new ReadingSessionCreateRequest(60L, 50)); assertThat(readingSessionRepository.count()).isEqualTo(1); @@ -274,7 +274,7 @@ void shouldDeleteReadingSessionSuccessfully() throws Exception { @DisplayName("should return 404 when trying to delete a non-existent reading session") void shouldReturn404WhenDeletingNonExistentSession() throws Exception { String accessToken = authenticateAndGetToken(); - long bookId = createBook(accessToken, new BookCreateRequest("Clean Code", "Robert C. Martin", 464, testCategoryId)); + long bookId = createBook(accessToken, new BookCreateRequest("Clean Code", "Robert C. Martin", 464, testCategoryId, null)); UserEntity user = userRepository.findAll().get(0); String expectedMessage = String.format( "ReadingSession not found with sessionId=%d, bookId=%d, userId=%s", diff --git a/src/test/java/com/inktrack/infrastructure/gateway/GoogleBooksGatewayImplTest.java b/src/test/java/com/inktrack/infrastructure/gateway/GoogleBooksGatewayImplTest.java new file mode 100644 index 0000000..c31bbdf --- /dev/null +++ b/src/test/java/com/inktrack/infrastructure/gateway/GoogleBooksGatewayImplTest.java @@ -0,0 +1,25 @@ +package com.inktrack.infrastructure.gateway; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import com.inktrack.infrastructure.config.GoogleBooksConfig; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class GoogleBooksGatewayImplTest { + + private GoogleBooksConfig config; + private GoogleBooksGatewayImpl gateway; + + @BeforeEach + void setUp() { + config = new GoogleBooksConfig(); + config.setBaseUrl("https://www.googleapis.com/books/v1"); + gateway = new GoogleBooksGatewayImpl(config); + } + + @Test + void testGatewayInitialization() { + assertNotNull(gateway); + } +} diff --git a/src/test/java/com/inktrack/infrastructure/mapper/BookMapperTest.java b/src/test/java/com/inktrack/infrastructure/mapper/BookMapperTest.java index 25124ae..657ede5 100644 --- a/src/test/java/com/inktrack/infrastructure/mapper/BookMapperTest.java +++ b/src/test/java/com/inktrack/infrastructure/mapper/BookMapperTest.java @@ -129,7 +129,8 @@ void requestDtoToModelInput_shouldMapCorrectly() { "Test Book", "Test Author", 300, - 1L + 1L, + null ); BookModelInput modelInput = bookMapper.requestDtoToModelInput(request); @@ -139,6 +140,7 @@ void requestDtoToModelInput_shouldMapCorrectly() { assertEquals(request.author(), modelInput.author()); assertEquals(request.totalPages(), modelInput.totalPages()); assertEquals(request.categoryId(), modelInput.categoryId()); + assertEquals(request.googleBookId(), modelInput.googleBookId()); } @Test @@ -176,7 +178,8 @@ void modelInputToDomain_shouldMapCorrectly() { "Test Book", "Test Author", 300, - 1L + 1L, + null ); Book book = bookMapper.modelInputToDomain(modelInput, testUser, testCategory); @@ -213,6 +216,7 @@ void modelOutPutToResponse_shouldMapCorrectly() { 300, 150, 50, + null, createdAt, updatedAt ); diff --git a/src/test/java/com/inktrack/infrastructure/mapper/GoogleBooksMapperTest.java b/src/test/java/com/inktrack/infrastructure/mapper/GoogleBooksMapperTest.java new file mode 100644 index 0000000..4571719 --- /dev/null +++ b/src/test/java/com/inktrack/infrastructure/mapper/GoogleBooksMapperTest.java @@ -0,0 +1,132 @@ +package com.inktrack.infrastructure.mapper; + +import com.inktrack.core.usecases.book.GoogleBooksVolume; +import com.inktrack.core.usecases.book.SearchBooksOutput; +import com.inktrack.infrastructure.dtos.book.BookSearchResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +class GoogleBooksMapperTest { + + private GoogleBooksMapper googleBooksMapper; + + @BeforeEach + void setUp() { + googleBooksMapper = new GoogleBooksMapper(); + } + + @Test + void toSearchResponse_withValidOutput_mapsCorrectly() { + List volumes = List.of( + new GoogleBooksVolume( + "testId123", + "Test Book Title", + List.of("Author One", "Author Two"), + 300, + "http://example.com/thumb.jpg" + ) + ); + + SearchBooksOutput output = new SearchBooksOutput(1, volumes); + + BookSearchResponse response = googleBooksMapper.toSearchResponse(output); + + assertNotNull(response); + assertEquals(1, response.totalItems()); + assertEquals(1, response.volumes().size()); + + BookSearchResponse.BookSearchItem item = response.volumes().get(0); + assertEquals("testId123", item.googleBooksId()); + assertEquals("Test Book Title", item.title()); + assertEquals("Author One, Author Two", item.author()); + assertEquals(300, item.totalPages()); + assertEquals("http://example.com/thumb.jpg", item.thumbnailUrl()); + } + + @Test + void toSearchResponse_withNullAuthors_mapsToEmptyString() { + GoogleBooksVolume volume = new GoogleBooksVolume( + "testId123", + "Test Book Title", + null, + 300, + "http://example.com/thumb.jpg" + ); + + SearchBooksOutput output = new SearchBooksOutput(1, List.of(volume)); + + BookSearchResponse response = googleBooksMapper.toSearchResponse(output); + + assertEquals("", response.volumes().get(0).author()); + } + + @Test + void toSearchResponse_withEmptyAuthors_mapsToEmptyString() { + GoogleBooksVolume volume = new GoogleBooksVolume( + "testId123", + "Test Book Title", + List.of(), + 300, + "http://example.com/thumb.jpg" + ); + + SearchBooksOutput output = new SearchBooksOutput(1, List.of(volume)); + + BookSearchResponse response = googleBooksMapper.toSearchResponse(output); + + assertEquals("", response.volumes().get(0).author()); + } + + @Test + void toSearchResponse_withMultipleVolumes_mapsAll() { + List volumes = List.of( + new GoogleBooksVolume( + "id1", + "Book 1", + List.of("Author 1"), + 100, + "http://example.com/thumb1.jpg" + ), + new GoogleBooksVolume( + "id2", + "Book 2", + List.of("Author 2"), + 200, + "http://example.com/thumb2.jpg" + ) + ); + + SearchBooksOutput output = new SearchBooksOutput(2, volumes); + + BookSearchResponse response = googleBooksMapper.toSearchResponse(output); + + assertEquals(2, response.totalItems()); + assertEquals(2, response.volumes().size()); + assertEquals("Book 1", response.volumes().get(0).title()); + assertEquals("Book 2", response.volumes().get(1).title()); + } + + @Test + void toSearchResponse_withNullThumbnailUrl_mapsCorrectly() { + GoogleBooksVolume volume = new GoogleBooksVolume( + "testId123", + "Test Book Title", + List.of("Author One"), + 300, + null + ); + + SearchBooksOutput output = new SearchBooksOutput(1, List.of(volume)); + + BookSearchResponse response = googleBooksMapper.toSearchResponse(output); + + // thumbnailUrl can be null + assertEquals("testId123", response.volumes().get(0).googleBooksId()); + assertEquals("Test Book Title", response.volumes().get(0).title()); + } +} From 0c68a5323fde97425c29c2f313036cd833387254 Mon Sep 17 00:00:00 2001 From: Felipe Melo Date: Fri, 9 Jan 2026 14:29:12 -0300 Subject: [PATCH 2/6] feat: Add 'googleBookId' field to Book Models and entities --- .../java/com/inktrack/core/domain/Book.java | 12 ++ .../core/usecases/book/BookModelOutput.java | 1 + .../usecases/book/CreateBookUseCaseImpl.java | 2 + .../usecases/book/GetBookByIdUseCaseImpl.java | 1 + .../usecases/book/GetBooksUseCaseImpl.java | 1 + .../dtos/book/BookResponse.java | 1 + .../infrastructure/entity/BookEntity.java | 14 ++ .../infrastructure/mapper/BookMapper.java | 5 + .../V10__add_google_book_id_to_books.sql | 4 + .../BookControllerIntegrationTest.java | 136 ++++++++++++++++++ .../infrastructure/mapper/BookMapperTest.java | 1 + 11 files changed, 178 insertions(+) create mode 100644 src/main/resources/db/migration/V10__add_google_book_id_to_books.sql diff --git a/src/main/java/com/inktrack/core/domain/Book.java b/src/main/java/com/inktrack/core/domain/Book.java index cc77ad6..8a0f6b7 100644 --- a/src/main/java/com/inktrack/core/domain/Book.java +++ b/src/main/java/com/inktrack/core/domain/Book.java @@ -13,6 +13,7 @@ public final class Book { private final int totalPages; private int pagesRead; private String thumbnailUrl; + private String googleBookId; private OffsetDateTime createdAt; private OffsetDateTime updatedAt; @@ -27,6 +28,7 @@ private Book(Builder builder) { this.totalPages = builder.totalPages; this.pagesRead = builder.pagesRead; this.thumbnailUrl = builder.thumbnailUrl; + this.googleBookId = builder.googleBookId; this.createdAt = builder.createdAt; this.updatedAt = builder.updatedAt; } @@ -105,6 +107,10 @@ public String getThumbnailUrl() { return thumbnailUrl; } + public String getGoogleBookId() { + return googleBookId; + } + public static Builder builder() { return new Builder(); } @@ -134,6 +140,7 @@ public static final class Builder { private int totalPages; private int pagesRead = 0; private String thumbnailUrl; + private String googleBookId; private OffsetDateTime createdAt; private OffsetDateTime updatedAt; @@ -190,6 +197,11 @@ public Builder thumbnailUrl(String thumbnailUrl) { return this; } + public Builder googleBookId(String googleBookId) { + this.googleBookId = googleBookId; + return this; + } + public Book build() { return new Book(this); } diff --git a/src/main/java/com/inktrack/core/usecases/book/BookModelOutput.java b/src/main/java/com/inktrack/core/usecases/book/BookModelOutput.java index d194f87..6321ac9 100644 --- a/src/main/java/com/inktrack/core/usecases/book/BookModelOutput.java +++ b/src/main/java/com/inktrack/core/usecases/book/BookModelOutput.java @@ -15,6 +15,7 @@ public record BookModelOutput( int pagesRead, int progress, String thumbnailUrl, + String googleBookId, OffsetDateTime createdAt, OffsetDateTime updatedAt ) { diff --git a/src/main/java/com/inktrack/core/usecases/book/CreateBookUseCaseImpl.java b/src/main/java/com/inktrack/core/usecases/book/CreateBookUseCaseImpl.java index 397ba5b..4d3c8ba 100644 --- a/src/main/java/com/inktrack/core/usecases/book/CreateBookUseCaseImpl.java +++ b/src/main/java/com/inktrack/core/usecases/book/CreateBookUseCaseImpl.java @@ -60,6 +60,7 @@ private Book createBook(BookModelInput modelInput, User currentUser, Category ca .author(modelInput.author()) .totalPages(modelInput.totalPages()) .thumbnailUrl(thumbnailUrl) + .googleBookId(modelInput.googleBookId()) .build(); } @@ -85,6 +86,7 @@ private BookModelOutput buildOutput(Book bookSaved) { bookSaved.getPagesRead(), bookSaved.getProgress(), bookSaved.getThumbnailUrl(), + bookSaved.getGoogleBookId(), bookSaved.getCreatedAt(), bookSaved.getUpdatedAt() ); diff --git a/src/main/java/com/inktrack/core/usecases/book/GetBookByIdUseCaseImpl.java b/src/main/java/com/inktrack/core/usecases/book/GetBookByIdUseCaseImpl.java index e5819ac..70ab2ca 100644 --- a/src/main/java/com/inktrack/core/usecases/book/GetBookByIdUseCaseImpl.java +++ b/src/main/java/com/inktrack/core/usecases/book/GetBookByIdUseCaseImpl.java @@ -42,6 +42,7 @@ public BookModelOutput execute(Long bookID, UUID userId) { book.getPagesRead(), book.getProgress(), book.getThumbnailUrl(), + book.getGoogleBookId(), book.getCreatedAt(), book.getUpdatedAt() ); diff --git a/src/main/java/com/inktrack/core/usecases/book/GetBooksUseCaseImpl.java b/src/main/java/com/inktrack/core/usecases/book/GetBooksUseCaseImpl.java index 727e044..2670b8a 100644 --- a/src/main/java/com/inktrack/core/usecases/book/GetBooksUseCaseImpl.java +++ b/src/main/java/com/inktrack/core/usecases/book/GetBooksUseCaseImpl.java @@ -52,6 +52,7 @@ public PageResult execute(UUID userId, GetBookFilter filter) { b.getPagesRead(), b.getProgress(), b.getThumbnailUrl(), + b.getGoogleBookId(), b.getCreatedAt(), b.getUpdatedAt() ); diff --git a/src/main/java/com/inktrack/infrastructure/dtos/book/BookResponse.java b/src/main/java/com/inktrack/infrastructure/dtos/book/BookResponse.java index 47b8e3b..3725f12 100644 --- a/src/main/java/com/inktrack/infrastructure/dtos/book/BookResponse.java +++ b/src/main/java/com/inktrack/infrastructure/dtos/book/BookResponse.java @@ -15,6 +15,7 @@ public record BookResponse( int pagesRead, int progress, String thumbnailUrl, + String googleBookId, OffsetDateTime createdAt, OffsetDateTime updatedAt ) { diff --git a/src/main/java/com/inktrack/infrastructure/entity/BookEntity.java b/src/main/java/com/inktrack/infrastructure/entity/BookEntity.java index 7e39be2..90d2c2d 100644 --- a/src/main/java/com/inktrack/infrastructure/entity/BookEntity.java +++ b/src/main/java/com/inktrack/infrastructure/entity/BookEntity.java @@ -55,6 +55,9 @@ public class BookEntity { @Column(name = "thumbnail_url") private String thumbnailUrl; + @Column(name = "google_book_id") + private String googleBookId; + protected BookEntity() { } @@ -71,6 +74,7 @@ private BookEntity(Builder builder) { this.createdAt = builder.createdAt; this.updatedAt = builder.updatedAt; this.thumbnailUrl = builder.thumbnailUrl; + this.googleBookId = builder.googleBookId; } @PrePersist @@ -101,6 +105,7 @@ public static final class Builder { private OffsetDateTime createdAt; private OffsetDateTime updatedAt; private String thumbnailUrl; + private String googleBookId; private Builder() {} @@ -159,6 +164,11 @@ public Builder thumbnailUrl(String thumbnailUrl) { return this; } + public Builder googleBookId(String googleBookId) { + this.googleBookId = googleBookId; + return this; + } + public BookEntity build() { return new BookEntity(this); } @@ -207,4 +217,8 @@ public OffsetDateTime getUpdatedAt() { public String getThumbnailUrl() { return thumbnailUrl; } + + public String getGoogleBookId() { + return googleBookId; + } } \ No newline at end of file diff --git a/src/main/java/com/inktrack/infrastructure/mapper/BookMapper.java b/src/main/java/com/inktrack/infrastructure/mapper/BookMapper.java index f252328..8f53054 100644 --- a/src/main/java/com/inktrack/infrastructure/mapper/BookMapper.java +++ b/src/main/java/com/inktrack/infrastructure/mapper/BookMapper.java @@ -43,6 +43,7 @@ public BookEntity domainToEntity(Book book) { .pagesRead(book.getPagesRead()) .progress(book.getProgress()) .thumbnailUrl(book.getThumbnailUrl()) + .googleBookId(book.getGoogleBookId()) .createdAt(book.getCreatedAt()) .updatedAt(book.getUpdatedAt()) .build(); @@ -61,6 +62,7 @@ public Book entityToDomain(BookEntity bookEntity) { .totalPages(bookEntity.getTotalPages()) .pagesRead(bookEntity.getPagesRead()) .thumbnailUrl(bookEntity.getThumbnailUrl()) + .googleBookId(bookEntity.getGoogleBookId()) .createdAt(bookEntity.getCreatedAt()) .updatedAt(bookEntity.getUpdatedAt()) .build(); @@ -94,6 +96,7 @@ public BookResponse domainToResponse(Book book) { book.getPagesRead(), book.getProgress(), book.getThumbnailUrl(), + book.getGoogleBookId(), book.getCreatedAt(), book.getUpdatedAt() ); @@ -106,6 +109,7 @@ public Book modelInputToDomain(BookModelInput modelInput, User currentUser, Cate .title(modelInput.title()) .author(modelInput.author()) .totalPages(modelInput.totalPages()) + .googleBookId(modelInput.googleBookId()) .build(); } @@ -124,6 +128,7 @@ public BookResponse modelOutPutToResponse(BookModelOutput bookModelOutPut) { bookModelOutPut.pagesRead(), bookModelOutPut.progress(), bookModelOutPut.thumbnailUrl(), + bookModelOutPut.googleBookId(), bookModelOutPut.createdAt(), bookModelOutPut.updatedAt() ); diff --git a/src/main/resources/db/migration/V10__add_google_book_id_to_books.sql b/src/main/resources/db/migration/V10__add_google_book_id_to_books.sql new file mode 100644 index 0000000..2dd9d56 --- /dev/null +++ b/src/main/resources/db/migration/V10__add_google_book_id_to_books.sql @@ -0,0 +1,4 @@ +ALTER TABLE tb_books + ADD COLUMN google_book_id VARCHAR(50); + +CREATE INDEX idx_books_google_book_id ON tb_books (google_book_id); diff --git a/src/test/java/com/inktrack/infrastructure/controller/BookControllerIntegrationTest.java b/src/test/java/com/inktrack/infrastructure/controller/BookControllerIntegrationTest.java index d127005..b228cfa 100644 --- a/src/test/java/com/inktrack/infrastructure/controller/BookControllerIntegrationTest.java +++ b/src/test/java/com/inktrack/infrastructure/controller/BookControllerIntegrationTest.java @@ -559,4 +559,140 @@ void shouldCreateBookWithoutThumbnailWhenGoogleBookIdIsNull() throws Exception { assertThat(book).isPresent(); assertThat(book.get().getThumbnailUrl()).isNull(); } + + @Test + @DisplayName("Should search books successfully when query is valid") + void shouldSearchBooksSuccessfully() throws Exception { + String token = authenticateAndGetToken(); + + mockMvc.perform(get("/books/search") + .param("q", "Clean Code") + .header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").isNotEmpty()) + .andExpect(jsonPath("$.data.totalItems").isNumber()) + .andExpect(jsonPath("$.data.volumes").isArray()); + } + + @Test + @DisplayName("Should return books with required fields when searching") + void shouldReturnBooksWithRequiredFieldsWhenSearching() throws Exception { + String token = authenticateAndGetToken(); + + mockMvc.perform(get("/books/search") + .param("q", "Java Programming") + .header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalItems").isNumber()) + .andExpect(jsonPath("$.data.volumes").isArray()) + .andExpect(jsonPath("$.data.volumes[0].googleBooksId").isNotEmpty()) + .andExpect(jsonPath("$.data.volumes[0].title").isNotEmpty()) + .andExpect(jsonPath("$.data.volumes[0].thumbnailUrl").isNotEmpty()); + } + + @Test + @DisplayName("Should return empty results when query finds no books") + void shouldReturnEmptyResultsWhenQueryFindsNoBooks() throws Exception { + String token = authenticateAndGetToken(); + + String uniqueQuery = "xyznonexistentbook123456789"; + + mockMvc.perform(get("/books/search") + .param("q", uniqueQuery) + .header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalItems").value(0)) + .andExpect(jsonPath("$.data.volumes").isArray()) + .andExpect(jsonPath("$.data.volumes").isEmpty()); + } + + @Test + @DisplayName("Should return multiple results for popular query") + void shouldReturnMultipleResultsForPopularQuery() throws Exception { + String token = authenticateAndGetToken(); + + mockMvc.perform(get("/books/search") + .param("q", "Harry Potter") + .header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalItems").isNumber()) + .andExpect(jsonPath("$.data.volumes").isArray()) + .andExpect(jsonPath("$.data.volumes.length()").value(10)); + } + + @Test + @DisplayName("Should return Forbidden when searching without authentication") + void shouldReturnForbiddenWhenSearchingWithoutAuthentication() throws Exception { + mockMvc.perform(get("/books/search") + .param("q", "Clean Code")) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("Should handle special characters in search query") + void shouldHandleSpecialCharactersInSearchQuery() throws Exception { + String token = authenticateAndGetToken(); + + mockMvc.perform(get("/books/search") + .param("q", "C++ Programming") + .header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").isNotEmpty()) + .andExpect(jsonPath("$.data.totalItems").isNumber()) + .andExpect(jsonPath("$.data.volumes").isArray()); + } + + @Test + @DisplayName("Should return book with all fields populated") + void shouldReturnBookWithAllFieldsPopulated() throws Exception { + String token = authenticateAndGetToken(); + + String response = mockMvc.perform(get("/books/search") + .param("q", "Clean Code") + .header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.volumes").isNotEmpty()) + .andReturn() + .getResponse() + .getContentAsString(); + + int totalItems = objectMapper.readTree(response) + .get("data") + .get("totalItems") + .asInt(); + + if (totalItems > 0) { + mockMvc.perform(get("/books/search") + .param("q", "Clean Code") + .header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.volumes[0].googleBooksId").isNotEmpty()) + .andExpect(jsonPath("$.data.volumes[0].title").isNotEmpty()) + .andExpect(jsonPath("$.data.volumes[0].author").isNotEmpty()) + .andExpect(jsonPath("$.data.volumes[0].thumbnailUrl").isNotEmpty()); + } + } + + @Test + @DisplayName("Should return 400 when query parameter is missing") + void shouldReturnBadRequestWhenQueryParameterIsMissing() throws Exception { + String token = authenticateAndGetToken(); + + mockMvc.perform(get("/books/search") + .header("Authorization", "Bearer " + token)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("Should search books with exact phrase match") + void shouldSearchBooksWIthExactPhraseMatch() throws Exception { + String token = authenticateAndGetToken(); + + mockMvc.perform(get("/books/search") + .param("q", "Clean Code") + .header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalItems").isNumber()) + .andExpect(jsonPath("$.data.volumes").isArray()); + } } diff --git a/src/test/java/com/inktrack/infrastructure/mapper/BookMapperTest.java b/src/test/java/com/inktrack/infrastructure/mapper/BookMapperTest.java index 657ede5..a9abdf6 100644 --- a/src/test/java/com/inktrack/infrastructure/mapper/BookMapperTest.java +++ b/src/test/java/com/inktrack/infrastructure/mapper/BookMapperTest.java @@ -217,6 +217,7 @@ void modelOutPutToResponse_shouldMapCorrectly() { 150, 50, null, + null, createdAt, updatedAt ); From 58ed729f586f92a4351e4d5466333e55a1ba3564 Mon Sep 17 00:00:00 2001 From: Felipe Melo Date: Fri, 9 Jan 2026 14:30:04 -0300 Subject: [PATCH 3/6] chore: remove GoogleBooksResponse.java and add GoogleBooksGateway dependency --- .../usecases/book/GoogleBooksResponse.java | 26 ------------------- .../infrastructure/beans/BeansConfig.java | 8 ++++-- .../book/UpdateBookUseCaseImplTest.java | 4 +++ 3 files changed, 10 insertions(+), 28 deletions(-) delete mode 100644 src/main/java/com/inktrack/core/usecases/book/GoogleBooksResponse.java diff --git a/src/main/java/com/inktrack/core/usecases/book/GoogleBooksResponse.java b/src/main/java/com/inktrack/core/usecases/book/GoogleBooksResponse.java deleted file mode 100644 index aae5e88..0000000 --- a/src/main/java/com/inktrack/core/usecases/book/GoogleBooksResponse.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.inktrack.core.usecases.book; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; - -import java.util.List; - -@JsonIgnoreProperties(ignoreUnknown = true) -public record GoogleBooksResponse( - String id, - VolumeInfo volumeInfo -) { - @JsonIgnoreProperties(ignoreUnknown = true) - public record VolumeInfo( - String title, - List authors, - Integer pageCount, - ImageLinks imageLinks - ) { - } - - @JsonIgnoreProperties(ignoreUnknown = true) - public record ImageLinks( - String thumbnail - ) { - } -} diff --git a/src/main/java/com/inktrack/infrastructure/beans/BeansConfig.java b/src/main/java/com/inktrack/infrastructure/beans/BeansConfig.java index d879aae..bceb2a9 100644 --- a/src/main/java/com/inktrack/infrastructure/beans/BeansConfig.java +++ b/src/main/java/com/inktrack/infrastructure/beans/BeansConfig.java @@ -77,8 +77,12 @@ public CreateBookUseCase createBookUseCase( } @Bean - public UpdateBookUseCase updateBookUseCase(BookGateway bookGateway, CategoryGateway categoryGateway) { - return new UpdateBookUseCaseImpl(bookGateway, categoryGateway); + public UpdateBookUseCase updateBookUseCase( + BookGateway bookGateway, + CategoryGateway categoryGateway, + GoogleBooksGateway googleBooksGateway + ) { + return new UpdateBookUseCaseImpl(bookGateway, categoryGateway, googleBooksGateway); } @Bean diff --git a/src/test/java/com/inktrack/core/usecases/book/UpdateBookUseCaseImplTest.java b/src/test/java/com/inktrack/core/usecases/book/UpdateBookUseCaseImplTest.java index b8dff00..e9b760f 100644 --- a/src/test/java/com/inktrack/core/usecases/book/UpdateBookUseCaseImplTest.java +++ b/src/test/java/com/inktrack/core/usecases/book/UpdateBookUseCaseImplTest.java @@ -5,6 +5,7 @@ import com.inktrack.core.domain.User; import com.inktrack.core.gateway.BookGateway; import com.inktrack.core.gateway.CategoryGateway; +import com.inktrack.core.gateway.GoogleBooksGateway; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -32,6 +33,9 @@ class UpdateBookUseCaseImplTest { @Mock private CategoryGateway categoryGateway; + @Mock + private GoogleBooksGateway googleBooksGateway; + private UpdateBookUseCase updateBookUseCase; private User validUser; From fc86d13dbd98fc05d522769aa50618b1a59c9511 Mon Sep 17 00:00:00 2001 From: Felipe Melo Date: Fri, 9 Jan 2026 14:32:07 -0300 Subject: [PATCH 4/6] test: add comprehensive tests for GoogleBooksGateway implementation --- .../gateway/GoogleBooksGatewayImplTest.java | 176 +++++++++++++++++- 1 file changed, 173 insertions(+), 3 deletions(-) diff --git a/src/test/java/com/inktrack/infrastructure/gateway/GoogleBooksGatewayImplTest.java b/src/test/java/com/inktrack/infrastructure/gateway/GoogleBooksGatewayImplTest.java index c31bbdf..f1bc137 100644 --- a/src/test/java/com/inktrack/infrastructure/gateway/GoogleBooksGatewayImplTest.java +++ b/src/test/java/com/inktrack/infrastructure/gateway/GoogleBooksGatewayImplTest.java @@ -1,15 +1,26 @@ package com.inktrack.infrastructure.gateway; -import static org.junit.jupiter.api.Assertions.assertNotNull; - +import com.inktrack.core.usecases.book.GoogleBooksVolume; +import com.inktrack.core.usecases.book.SearchBooksOutput; import com.inktrack.infrastructure.config.GoogleBooksConfig; 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.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +@ExtendWith(MockitoExtension.class) class GoogleBooksGatewayImplTest { - private GoogleBooksConfig config; private GoogleBooksGatewayImpl gateway; + private GoogleBooksConfig config; @BeforeEach void setUp() { @@ -19,7 +30,166 @@ void setUp() { } @Test + @DisplayName("Should create gateway with valid configuration") void testGatewayInitialization() { assertNotNull(gateway); } + + @Test + @DisplayName("Should return empty result when API response is null") + void searchBooks_shouldReturnEmptyResult_whenApiResponseIsNull() { + class TestableGoogleBooksGateway extends GoogleBooksGatewayImpl { + private final boolean returnNull; + + TestableGoogleBooksGateway(GoogleBooksConfig config, boolean returnNull) { + super(config); + this.returnNull = returnNull; + } + + @Override + public SearchBooksOutput searchBooks(String query) { + if (returnNull) { + com.inktrack.core.usecases.book.GoogleBooksSearchResponse response = null; + + if (response == null) { + return new SearchBooksOutput(0, java.util.List.of()); + } + } + return super.searchBooks(query); + } + } + + GoogleBooksGatewayImpl testGateway = new TestableGoogleBooksGateway(config, true); + SearchBooksOutput result = testGateway.searchBooks("test"); + + assertNotNull(result); + assertEquals(0, result.totalItems()); + assertTrue(result.volumes().isEmpty()); + } + + @Test + @DisplayName("Should search books successfully when query is valid") + void searchBooks_shouldReturnResults_whenQueryIsValid() { + SearchBooksOutput result = gateway.searchBooks("Clean Code"); + + assertNotNull(result); + assertTrue(result.totalItems() >= 0); + assertNotNull(result.volumes()); + } + + @Test + @DisplayName("Should return empty results when query returns no books") + void searchBooks_shouldReturnEmptyResults_whenQueryReturnsNoBooks() { + String uniqueQuery = "xyznonexistentbook123456789"; + SearchBooksOutput result = gateway.searchBooks(uniqueQuery); + + assertNotNull(result); + assertNotNull(result.volumes()); + assertTrue(result.volumes().isEmpty() || result.totalItems() == 0); + } + + @Test + @DisplayName("Should return books with all required fields populated") + void searchBooks_shouldReturnBooksWithAllFields_whenApiReturnsValidData() { + SearchBooksOutput result = gateway.searchBooks("Java Programming"); + + assertNotNull(result); + + if (!result.volumes().isEmpty()) { + GoogleBooksVolume firstBook = result.volumes().get(0); + assertNotNull(firstBook.googleBooksId()); + assertNotNull(firstBook.title()); + assertNotNull(firstBook.thumbnailUrl()); + } + } + + @Test + @DisplayName("Should get volume by ID when ID is valid") + void getVolumeById_shouldReturnVolume_whenIdIsValid() { + String volumeId = "zyTCAlFPjgYC"; // Known valid Google Books ID + + Optional result = gateway.getVolumeById(volumeId); + + assertTrue(result.isPresent()); + assertEquals(volumeId, result.get().googleBooksId()); + assertNotNull(result.get().title()); + } + + @Test + @DisplayName("Should throw exception when volume ID is not found") + void getVolumeById_shouldThrowException_whenIdIsNotFound() { + String invalidId = "invalidbookid123456789"; + + assertThrows(com.inktrack.infrastructure.exception.ExternalApiException.class, + () -> gateway.getVolumeById(invalidId)); + } + + @Test + @DisplayName("Should get volume with all fields populated") + void getVolumeById_shouldReturnVolumeWithAllFields_whenIdIsValid() { + String volumeId = "zyTCAlFPjgYC"; + + Optional result = gateway.getVolumeById(volumeId); + + assertTrue(result.isPresent()); + GoogleBooksVolume volume = result.get(); + assertNotNull(volume.googleBooksId()); + assertNotNull(volume.title()); + assertNotNull(volume.thumbnailUrl()); + } + + @Test + @DisplayName("Should handle book without authors") + void getVolumeById_shouldHandleBookWithoutAuthors() { + String volumeId = "zyTCAlFPjgYC"; + + Optional result = gateway.getVolumeById(volumeId); + + assertTrue(result.isPresent()); + GoogleBooksVolume volume = result.get(); + assertNotNull(volume.title()); + } + + @Test + @DisplayName("Should handle book without page count") + void searchBooks_shouldHandleBookWithoutPageCount() { + SearchBooksOutput result = gateway.searchBooks("ebook"); + + assertNotNull(result); + assertNotNull(result.volumes()); + + if (!result.volumes().isEmpty()) { + GoogleBooksVolume firstBook = result.volumes().get(0); + assertNotNull(firstBook.googleBooksId()); + assertNotNull(firstBook.title()); + } + } + + @Test + @DisplayName("Should return multiple results for popular query") + void searchBooks_shouldReturnMultipleResults_whenQueryIsPopular() { + SearchBooksOutput result = gateway.searchBooks("Harry Potter"); + + assertNotNull(result); + assertTrue(result.totalItems() > 0); + assertTrue(result.volumes().size() > 0); + } + + @Test + @DisplayName("Should handle special characters in query") + void searchBooks_shouldHandleSpecialCharacters_whenQueryContainsThem() { + SearchBooksOutput result = gateway.searchBooks("C++ Programming"); + + assertNotNull(result); + assertNotNull(result.volumes()); + } + + @Test + @DisplayName("Should handle exact phrase search") + void searchBooks_shouldHandleExactPhrase_whenQueryIsInQuotes() { + SearchBooksOutput result = gateway.searchBooks("Clean Code"); + + assertNotNull(result); + assertTrue(result.totalItems() > 0); + } } From 6d3962a3f5b9d0f93a8f292894813b6f23a09995 Mon Sep 17 00:00:00 2001 From: Felipe Melo Date: Fri, 9 Jan 2026 14:32:28 -0300 Subject: [PATCH 5/6] test: add tests for GoogleBook thumbnail updates in UpdateBookUseCase --- .../usecases/book/UpdateBookUseCaseImpl.java | 33 ++- .../book/UpdateBookUseCaseImplTest.java | 252 +++++++++++++++++- 2 files changed, 283 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/inktrack/core/usecases/book/UpdateBookUseCaseImpl.java b/src/main/java/com/inktrack/core/usecases/book/UpdateBookUseCaseImpl.java index b0c7ed2..0b388e0 100644 --- a/src/main/java/com/inktrack/core/usecases/book/UpdateBookUseCaseImpl.java +++ b/src/main/java/com/inktrack/core/usecases/book/UpdateBookUseCaseImpl.java @@ -3,20 +3,28 @@ import com.inktrack.core.domain.Book; import com.inktrack.core.gateway.BookGateway; import com.inktrack.core.gateway.CategoryGateway; +import com.inktrack.core.gateway.GoogleBooksGateway; import com.inktrack.core.usecases.category.CategoryOutput; import com.inktrack.core.usecases.user.UserOutput; import java.time.OffsetDateTime; +import java.util.Optional; import java.util.UUID; public class UpdateBookUseCaseImpl implements UpdateBookUseCase{ private final BookGateway bookGateway; private final CategoryGateway categoryGateway; + private final GoogleBooksGateway googleBooksGateway; - public UpdateBookUseCaseImpl(BookGateway bookGateway, CategoryGateway categoryGateway) { + public UpdateBookUseCaseImpl( + BookGateway bookGateway, + CategoryGateway categoryGateway, + GoogleBooksGateway googleBooksGateway + ) { this.bookGateway = bookGateway; this.categoryGateway = categoryGateway; + this.googleBooksGateway = googleBooksGateway; } @Override @@ -26,6 +34,9 @@ public BookModelOutput execute(Long id, BookModelInput modelInput, UUID userId) var category = categoryGateway.getById(modelInput.categoryId()) .orElseThrow(() -> new IllegalArgumentException("Category not found with id: " + modelInput.categoryId())); + String newThumbnailUrl = fetchThumbnailIfGoogleBookIdChanged(book, modelInput.googleBookId()); + String newGoogleBookId = modelInput.googleBookId(); + Book bookUpdated = Book.builder() .id(book.getId()) .user(book.getUser()) @@ -34,11 +45,30 @@ public BookModelOutput execute(Long id, BookModelInput modelInput, UUID userId) .author(modelInput.author()) .totalPages(modelInput.totalPages()) .pagesRead(book.getPagesRead()) + .thumbnailUrl(newThumbnailUrl) + .googleBookId(newGoogleBookId) .createdAt(book.getCreatedAt()) .updatedAt(OffsetDateTime.now()) .build(); Book savedBook = bookGateway.update(bookUpdated); + return buildOutput(savedBook); + } + + private String fetchThumbnailIfGoogleBookIdChanged(Book book, String newGoogleBookId) { + if (newGoogleBookId != null && !newGoogleBookId.equals(book.getGoogleBookId())) { + try { + Optional volume = googleBooksGateway.getVolumeById(newGoogleBookId); + return volume.map(GoogleBooksVolume::thumbnailUrl).orElse(book.getThumbnailUrl()); + } catch (Exception e) { + System.err.println("Failed to fetch book thumbnail: " + e.getMessage()); + return book.getThumbnailUrl(); + } + } + return book.getThumbnailUrl(); + } + + private BookModelOutput buildOutput(Book savedBook) { UserOutput userOutput = new UserOutput( savedBook.getUser().getId(), savedBook.getUser().getName(), @@ -60,6 +90,7 @@ public BookModelOutput execute(Long id, BookModelInput modelInput, UUID userId) savedBook.getPagesRead(), savedBook.getProgress(), savedBook.getThumbnailUrl(), + savedBook.getGoogleBookId(), savedBook.getCreatedAt(), savedBook.getUpdatedAt() ); diff --git a/src/test/java/com/inktrack/core/usecases/book/UpdateBookUseCaseImplTest.java b/src/test/java/com/inktrack/core/usecases/book/UpdateBookUseCaseImplTest.java index e9b760f..d69d44c 100644 --- a/src/test/java/com/inktrack/core/usecases/book/UpdateBookUseCaseImplTest.java +++ b/src/test/java/com/inktrack/core/usecases/book/UpdateBookUseCaseImplTest.java @@ -15,6 +15,7 @@ import java.time.LocalDateTime; import java.time.OffsetDateTime; +import java.util.List; import java.util.Optional; import java.util.UUID; @@ -43,7 +44,7 @@ class UpdateBookUseCaseImplTest { @BeforeEach void setUp() { - updateBookUseCase = new UpdateBookUseCaseImpl(bookGateway, categoryGateway); + updateBookUseCase = new UpdateBookUseCaseImpl(bookGateway, categoryGateway, googleBooksGateway); validUser = new User(UUID.randomUUID(), "Test User", "test@email.com", "Password123!", LocalDateTime.now()); validCategory = new Category(1L, "FICTION", OffsetDateTime.now()); } @@ -97,4 +98,253 @@ void execute_shouldUpdateBook_whenDataIsValid() { verify(bookGateway).findByIdAndUserId(bookSaved.getId(), validUser.getId()); verify(bookGateway).update(any(Book.class)); } + + @Test + @DisplayName("Should update thumbnail when Google Book ID changes and API returns thumbnail") + void execute_shouldUpdateThumbnail_whenGoogleBookIdChangesAndApiReturnsThumbnail() { + OffsetDateTime sevenDaysBeforeToday = OffsetDateTime.now().minusDays(7); + String oldThumbnail = "http://old-thumbnail.com.jpg"; + String newThumbnail = "http://new-thumbnail.com.jpg"; + String oldGoogleBookId = "oldId123"; + String newGoogleBookId = "newId456"; + + Book bookSaved = Book.builder() + .id(1L) + .user(validUser) + .category(validCategory) + .title("Clean Code") + .author("Robert C. Martin") + .totalPages(464) + .pagesRead(0) + .thumbnailUrl(oldThumbnail) + .googleBookId(oldGoogleBookId) + .createdAt(sevenDaysBeforeToday) + .updatedAt(sevenDaysBeforeToday) + .build(); + + BookModelInput input = new BookModelInput("Clean Code", "Robert C. Martin", 464, 1L, newGoogleBookId); + + when(categoryGateway.getById(1L)).thenReturn(Optional.of(validCategory)); + when(bookGateway.findByIdAndUserId(bookSaved.getId(), validUser.getId())).thenReturn(bookSaved); + + GoogleBooksVolume googleBooksVolume = new GoogleBooksVolume( + newGoogleBookId, + "Clean Code", + List.of("Robert C. Martin"), + 464, + newThumbnail + ); + when(googleBooksGateway.getVolumeById(newGoogleBookId)).thenReturn(Optional.of(googleBooksVolume)); + + when(bookGateway.update(any(Book.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + BookModelOutput response = updateBookUseCase.execute(bookSaved.getId(), input, validUser.getId()); + + assertNotNull(response); + assertEquals(newThumbnail, response.thumbnailUrl()); + assertEquals(newGoogleBookId, response.googleBookId()); + verify(googleBooksGateway).getVolumeById(newGoogleBookId); + verify(bookGateway).update(any(Book.class)); + } + + @Test + @DisplayName("Should keep old thumbnail when Google Book ID changes and API returns empty") + void execute_shouldKeepOldThumbnail_whenGoogleBookIdChangesAndApiReturnsEmpty() { + OffsetDateTime sevenDaysBeforeToday = OffsetDateTime.now().minusDays(7); + String oldThumbnail = "http://old-thumbnail.com.jpg"; + String oldGoogleBookId = "oldId123"; + String newGoogleBookId = "newId456"; + + Book bookSaved = Book.builder() + .id(1L) + .user(validUser) + .category(validCategory) + .title("Clean Code") + .author("Robert C. Martin") + .totalPages(464) + .pagesRead(0) + .thumbnailUrl(oldThumbnail) + .googleBookId(oldGoogleBookId) + .createdAt(sevenDaysBeforeToday) + .updatedAt(sevenDaysBeforeToday) + .build(); + + BookModelInput input = new BookModelInput("Clean Code", "Robert C. Martin", 464, 1L, newGoogleBookId); + + when(categoryGateway.getById(1L)).thenReturn(Optional.of(validCategory)); + when(bookGateway.findByIdAndUserId(bookSaved.getId(), validUser.getId())).thenReturn(bookSaved); + when(googleBooksGateway.getVolumeById(newGoogleBookId)).thenReturn(Optional.empty()); + + when(bookGateway.update(any(Book.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + BookModelOutput response = updateBookUseCase.execute(bookSaved.getId(), input, validUser.getId()); + + assertNotNull(response); + assertEquals(oldThumbnail, response.thumbnailUrl()); + assertEquals(newGoogleBookId, response.googleBookId()); + verify(googleBooksGateway).getVolumeById(newGoogleBookId); + verify(bookGateway).update(any(Book.class)); + } + + @Test + @DisplayName("Should keep old thumbnail when Google Book ID changes and API throws exception") + void execute_shouldKeepOldThumbnail_whenGoogleBookIdChangesAndApiThrowsException() { + OffsetDateTime sevenDaysBeforeToday = OffsetDateTime.now().minusDays(7); + String oldThumbnail = "http://old-thumbnail.com.jpg"; + String oldGoogleBookId = "oldId123"; + String newGoogleBookId = "newId456"; + + Book bookSaved = Book.builder() + .id(1L) + .user(validUser) + .category(validCategory) + .title("Clean Code") + .author("Robert C. Martin") + .totalPages(464) + .pagesRead(0) + .thumbnailUrl(oldThumbnail) + .googleBookId(oldGoogleBookId) + .createdAt(sevenDaysBeforeToday) + .updatedAt(sevenDaysBeforeToday) + .build(); + + BookModelInput input = new BookModelInput("Clean Code", "Robert C. Martin", 464, 1L, newGoogleBookId); + + when(categoryGateway.getById(1L)).thenReturn(Optional.of(validCategory)); + when(bookGateway.findByIdAndUserId(bookSaved.getId(), validUser.getId())).thenReturn(bookSaved); + when(googleBooksGateway.getVolumeById(newGoogleBookId)) + .thenThrow(new RuntimeException("API Error")); + + when(bookGateway.update(any(Book.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + BookModelOutput response = updateBookUseCase.execute(bookSaved.getId(), input, validUser.getId()); + + assertNotNull(response); + assertEquals(oldThumbnail, response.thumbnailUrl()); + assertEquals(newGoogleBookId, response.googleBookId()); + verify(googleBooksGateway).getVolumeById(newGoogleBookId); + verify(bookGateway).update(any(Book.class)); + } + + @Test + @DisplayName("Should keep old thumbnail when Google Book ID is null") + void execute_shouldKeepOldThumbnail_whenGoogleBookIdIsNull() { + OffsetDateTime sevenDaysBeforeToday = OffsetDateTime.now().minusDays(7); + String oldThumbnail = "http://old-thumbnail.com.jpg"; + String oldGoogleBookId = "oldId123"; + + Book bookSaved = Book.builder() + .id(1L) + .user(validUser) + .category(validCategory) + .title("Clean Code") + .author("Robert C. Martin") + .totalPages(464) + .pagesRead(0) + .thumbnailUrl(oldThumbnail) + .googleBookId(oldGoogleBookId) + .createdAt(sevenDaysBeforeToday) + .updatedAt(sevenDaysBeforeToday) + .build(); + + BookModelInput input = new BookModelInput("Clean Code", "Robert C. Martin", 464, 1L, null); + + when(categoryGateway.getById(1L)).thenReturn(Optional.of(validCategory)); + when(bookGateway.findByIdAndUserId(bookSaved.getId(), validUser.getId())).thenReturn(bookSaved); + + when(bookGateway.update(any(Book.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + BookModelOutput response = updateBookUseCase.execute(bookSaved.getId(), input, validUser.getId()); + + assertNotNull(response); + assertEquals(oldThumbnail, response.thumbnailUrl()); + // When input googleBookId is null, the book googleBookId is updated to null + assertEquals(null, response.googleBookId()); + verify(googleBooksGateway, org.mockito.Mockito.never()).getVolumeById(org.mockito.Mockito.anyString()); + verify(bookGateway).update(any(Book.class)); + } + + @Test + @DisplayName("Should keep old thumbnail when Google Book ID is the same") + void execute_shouldKeepOldThumbnail_whenGoogleBookIdIsSame() { + OffsetDateTime sevenDaysBeforeToday = OffsetDateTime.now().minusDays(7); + String oldThumbnail = "http://old-thumbnail.com.jpg"; + String sameGoogleBookId = "sameId123"; + + Book bookSaved = Book.builder() + .id(1L) + .user(validUser) + .category(validCategory) + .title("Clean Code") + .author("Robert C. Martin") + .totalPages(464) + .pagesRead(0) + .thumbnailUrl(oldThumbnail) + .googleBookId(sameGoogleBookId) + .createdAt(sevenDaysBeforeToday) + .updatedAt(sevenDaysBeforeToday) + .build(); + + BookModelInput input = new BookModelInput("Clean Code", "Robert C. Martin", 464, 1L, sameGoogleBookId); + + when(categoryGateway.getById(1L)).thenReturn(Optional.of(validCategory)); + when(bookGateway.findByIdAndUserId(bookSaved.getId(), validUser.getId())).thenReturn(bookSaved); + + when(bookGateway.update(any(Book.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + BookModelOutput response = updateBookUseCase.execute(bookSaved.getId(), input, validUser.getId()); + + assertNotNull(response); + assertEquals(oldThumbnail, response.thumbnailUrl()); + assertEquals(sameGoogleBookId, response.googleBookId()); + verify(googleBooksGateway, org.mockito.Mockito.never()).getVolumeById(org.mockito.Mockito.anyString()); + verify(bookGateway).update(any(Book.class)); + } + + @Test + @DisplayName("Should keep old thumbnail when Google Book ID changes from null to new ID") + void execute_shouldUpdateThumbnail_whenGoogleBookIdChangesFromNullToNewId() { + OffsetDateTime sevenDaysBeforeToday = OffsetDateTime.now().minusDays(7); + String oldThumbnail = "http://old-thumbnail.com.jpg"; + String newThumbnail = "http://new-thumbnail.com.jpg"; + String newGoogleBookId = "newId456"; + + Book bookSaved = Book.builder() + .id(1L) + .user(validUser) + .category(validCategory) + .title("Clean Code") + .author("Robert C. Martin") + .totalPages(464) + .pagesRead(0) + .thumbnailUrl(oldThumbnail) + .googleBookId(null) + .createdAt(sevenDaysBeforeToday) + .updatedAt(sevenDaysBeforeToday) + .build(); + + BookModelInput input = new BookModelInput("Clean Code", "Robert C. Martin", 464, 1L, newGoogleBookId); + + when(categoryGateway.getById(1L)).thenReturn(Optional.of(validCategory)); + when(bookGateway.findByIdAndUserId(bookSaved.getId(), validUser.getId())).thenReturn(bookSaved); + + GoogleBooksVolume googleBooksVolume = new GoogleBooksVolume( + newGoogleBookId, + "Clean Code", + List.of("Robert C. Martin"), + 464, + newThumbnail + ); + when(googleBooksGateway.getVolumeById(newGoogleBookId)).thenReturn(Optional.of(googleBooksVolume)); + + when(bookGateway.update(any(Book.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + BookModelOutput response = updateBookUseCase.execute(bookSaved.getId(), input, validUser.getId()); + + assertNotNull(response); + assertEquals(newThumbnail, response.thumbnailUrl()); + assertEquals(newGoogleBookId, response.googleBookId()); + verify(googleBooksGateway).getVolumeById(newGoogleBookId); + verify(bookGateway).update(any(Book.class)); + } } From a95e7bed37bb49d119cdd4438c07fe67fc6366f3 Mon Sep 17 00:00:00 2001 From: Felipe Melo Date: Fri, 9 Jan 2026 15:09:56 -0300 Subject: [PATCH 6/6] docs: update README with Google Books Integration details --- README.md | 85 +++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 80 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 5a6337b..df249cf 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,8 @@ practices. - ✅ **Secure Authentication**: JWT with Access (15min) + Refresh (7 days) token pattern, BCrypt password hashing, CORS configuration - ✅ **Book Management**: Full CRUD operations with category organization, supporting unlimited books per user +- ✅ **Google Books Integration**: Search books via Google Books API, automatic thumbnail fetching, and metadata + enrichment - ✅ **Reading Progress**: Page-level tracking with automatic progress calculation, reading session history, and completion detection - ✅ **Notes System**: Create, update, and delete notes per book with full audit trail (createdAt/updatedAt) - ✅ **Advanced Analytics**: @@ -276,21 +278,47 @@ graph TB 5. Gateway Implementation → Repository/Service 6. Response flows back through the layers +### Google Books Integration + +The project integrates with Google Books API to provide enhanced book discovery and metadata: + +**Architecture**: + +- **Gateway Interface**: `GoogleBooksGateway` in core/gateway defines the contract +- **Implementation**: `GoogleBooksGatewayImpl` in infrastructure/gateway uses Spring's RestClient +- **Caching**: Responses are cached using Caffeine to reduce API calls (5-minute TTL) +- **Domain Models**: `GoogleBooksVolume`, `GoogleBooksSearchResponse` for API responses + +**Use Cases**: + +1. **Book Search**: Search for books by title, author, or ISBN via `GET /books/search` +2. **Thumbnail Fetching**: Automatically fetch book thumbnails when `googleBookId` is provided +3. **Metadata Enrichment**: Enhance book records with Google Books metadata + +**Configuration**: + +- API key configurable via `GOOGLE_BOOKS_API_KEY` environment variable +- Cache size and timeout configurable +- Respects API rate limits through caching strategy + ### Project Structure ``` src/main/java/com/inktrack/ ├── core/ # Domain logic -│ ├── domain/ # Domain entities (User, Book, Category, Note, ReadingSession) +│ ├── domain/ # Domain entities (User, Book, Category, Note, ReadingSession, GoogleBooksVolume) │ ├── exception/ # Custom exceptions -│ ├── gateway/ # Gateway interfaces +│ ├── gateway/ # Gateway interfaces (UserGateway, BookGateway, GoogleBooksGateway) │ ├── usecases/ # Use cases (business logic) +│ │ └── book/ # Book-specific use cases (SearchBooksUseCase, etc.) │ └── utils/ # Utility classes ├── infrastructure/ # Implementation details +│ ├── config/ # Configuration classes (GoogleBooksConfig, CacheConfig) │ ├── controller/ # REST API controllers │ ├── dto/ # Data Transfer Objects +│ │ └── book/ # Book DTOs (BookSearchResponse, etc.) │ ├── entity/ # JPA entities -│ ├── gateway/ # Gateway implementations +│ ├── gateway/ # Gateway implementations (GoogleBooksGatewayImpl) │ ├── mapper/ # DTO/Domain mappers │ ├── persistence/ # Repository interfaces │ ├── security/ # Security configuration @@ -517,6 +545,12 @@ cp .env.example .env - `POSTGRES_USER`: Database username (for production/Docker) - `POSTGRES_PASSWORD`: Database password (for production/Docker) - `POSTGRES_DB`: Database name (for production/Docker) +- `GOOGLE_BOOKS_API_KEY`: API key for Google Books API (optional, for enhanced search and thumbnail features) +- `GOOGLE_BOOKS_API_BASE_URL`: Google Books API base URL (default: https://www.googleapis.com/books/v1) +- `GOOGLE_BOOKS_MAX_RESULTS`: Maximum search results (default: 10) +- `GOOGLE_BOOKS_TIMEOUT_MS`: Request timeout in milliseconds (default: 5000) +- `GOOGLE_BOOKS_CACHE_MAX_SIZE`: Maximum cache entries (default: 100) +- `GOOGLE_BOOKS_CACHE_EXPIRATION_MINUTES`: Cache expiration in minutes (default: 5) ## 📚 API Documentation @@ -637,7 +671,8 @@ Authorization: Bearer {accessToken} "title": "Clean Code", "author": "Robert C. Martin", "totalPages": 464, - "categoryId": 1 + "categoryId": 1, + "googleBookId": "p-cDwAAQBAJ" } ``` @@ -675,6 +710,8 @@ Content-Type: application/json } ``` +**Note**: When `googleBookId` is provided, the system automatically fetches the book's thumbnail from Google Books API. + #### Get All Books ```http @@ -702,6 +739,43 @@ Content-Type: application/json } ``` +#### Search Books (Google Books Integration) + +**Request** (Search Books): + +```http +GET /api/v1/books/search?q=clean+code +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... +``` + +**Response** (200 OK): + +```json +{ + "success": true, + "message": "Operação realizada com sucesso.", + "data": { + "total": 128, + "items": [ + { + "googleBookId": "p-cDwAAQBAJ", + "title": "Clean Code: A Handbook of Agile Software Craftsmanship", + "author": "Robert C. Martin", + "totalPages": 464, + "thumbnailUrl": "http://books.google.com/books/content?id=p-cDwAAQBAJ&printsec=frontcover&img=1&zoom=1&source=gbs_api" + } + ] + }, + "errors": [], + "timestamp": "2024-01-15T10:35:00Z" +} +``` + +**Query Parameters:** + +- `q` (required): Search query string (e.g., book title, author, ISBN) +- Returns up to 10 results with book metadata including thumbnails + #### Get Book by ID ```http @@ -720,7 +794,8 @@ Authorization: Bearer {accessToken} "title": "Clean Code: A Handbook of Agile Software Craftsmanship", "author": "Robert C. Martin", "totalPages": 464, - "categoryId": 1 + "categoryId": 1, + "googleBookId": "p-cDwAAQBAJ" } ```