Skip to content

Commit 14b2470

Browse files
Copilotnanotaboada
andcommitted
Add /books/search endpoint to search books by description
Co-authored-by: nanotaboada <87288+nanotaboada@users.noreply.github.com>
1 parent d9a9a8f commit 14b2470

6 files changed

Lines changed: 201 additions & 0 deletions

File tree

src/main/java/ar/com/nanotaboada/java/samples/spring/boot/controllers/BooksController.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import java.util.List;
77

88
import jakarta.validation.Valid;
9+
import jakarta.validation.constraints.NotBlank;
910

1011
import org.hibernate.validator.constraints.ISBN;
1112
import org.springframework.http.HttpStatus;
@@ -16,6 +17,7 @@
1617
import org.springframework.web.bind.annotation.PostMapping;
1718
import org.springframework.web.bind.annotation.PutMapping;
1819
import org.springframework.web.bind.annotation.RequestBody;
20+
import org.springframework.web.bind.annotation.RequestParam;
1921
import org.springframework.web.bind.annotation.RestController;
2022
import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder;
2123

@@ -100,6 +102,17 @@ public ResponseEntity<List<BookDTO>> getAll() {
100102
return ResponseEntity.status(HttpStatus.OK).body(books);
101103
}
102104

105+
@GetMapping("/books/search")
106+
@Operation(summary = "Searches books by description")
107+
@ApiResponses(value = {
108+
@ApiResponse(responseCode = "200", description = "OK", content = @Content(mediaType = "application/json", schema = @Schema(implementation = BookDTO[].class))),
109+
@ApiResponse(responseCode = "400", description = "Bad Request", content = @Content)
110+
})
111+
public ResponseEntity<List<BookDTO>> searchByDescription(@RequestParam @NotBlank String description) {
112+
List<BookDTO> books = booksService.searchByDescription(description);
113+
return ResponseEntity.status(HttpStatus.OK).body(books);
114+
}
115+
103116
/*
104117
* -------------------------------------------------------------------------
105118
* HTTP PUT

src/main/java/ar/com/nanotaboada/java/samples/spring/boot/repositories/BooksRepository.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
package ar.com.nanotaboada.java.samples.spring.boot.repositories;
22

3+
import org.springframework.data.jpa.repository.Query;
34
import org.springframework.data.repository.CrudRepository;
5+
import org.springframework.data.repository.query.Param;
46
import org.springframework.stereotype.Repository;
57

68
import ar.com.nanotaboada.java.samples.spring.boot.models.Book;
79

10+
import java.util.List;
811
import java.util.Optional;
912

1013
@Repository
@@ -16,4 +19,14 @@ public interface BooksRepository extends CrudRepository<Book, String> {
1619
// Non-default methods in interfaces are not shown in coverage reports
1720
// https://www.jacoco.org/jacoco/trunk/doc/faq.html
1821
Optional<Book> findByIsbn(String isbn);
22+
23+
/**
24+
* Finds books whose description contains the given keyword (case-insensitive).
25+
* Uses JPQL with CAST to handle CLOB description field.
26+
*
27+
* @param keyword the keyword to search for in the description
28+
* @return a list of books matching the search criteria
29+
*/
30+
@Query("SELECT b FROM Book b WHERE LOWER(CAST(b.description AS string)) LIKE LOWER(CONCAT('%', :keyword, '%'))")
31+
List<Book> findByDescriptionContainingIgnoreCase(@Param("keyword") String keyword);
1932
}

src/main/java/ar/com/nanotaboada/java/samples/spring/boot/services/BooksService.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,19 @@ public List<BookDTO> retrieveAll() {
6060
.toList();
6161
}
6262

63+
/*
64+
* -------------------------------------------------------------------------
65+
* Search
66+
* -------------------------------------------------------------------------
67+
*/
68+
69+
public List<BookDTO> searchByDescription(String keyword) {
70+
return booksRepository.findByDescriptionContainingIgnoreCase(keyword)
71+
.stream()
72+
.map(this::mapFrom)
73+
.toList();
74+
}
75+
6376
/*
6477
* -------------------------------------------------------------------------
6578
* Update

src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/controllers/BooksControllerTests.java

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,4 +340,96 @@ void givenDelete_whenPathVariableIsInvalidISBN_thenResponseStatusIsBadRequest()
340340
verify(booksServiceMock, never()).delete(anyString());
341341
assertThat(response.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value());
342342
}
343+
344+
/*
345+
* -------------------------------------------------------------------------
346+
* HTTP GET /books/search
347+
* -------------------------------------------------------------------------
348+
*/
349+
350+
@Test
351+
void givenSearchByDescription_whenRequestParamIsValidAndMatchingBooksExist_thenResponseStatusIsOKAndResultIsBooks()
352+
throws Exception {
353+
// Arrange
354+
List<BookDTO> bookDTOs = BookDTOFakes.createManyValid();
355+
String keyword = "Java";
356+
Mockito
357+
.when(booksServiceMock.searchByDescription(anyString()))
358+
.thenReturn(bookDTOs);
359+
MockHttpServletRequestBuilder request = MockMvcRequestBuilders
360+
.get(PATH + "/search")
361+
.param("description", keyword);
362+
// Act
363+
MockHttpServletResponse response = application
364+
.perform(request)
365+
.andReturn()
366+
.getResponse();
367+
response.setContentType("application/json;charset=UTF-8");
368+
String content = response.getContentAsString();
369+
List<BookDTO> result = new ObjectMapper().readValue(content, new TypeReference<List<BookDTO>>() {
370+
});
371+
// Assert
372+
verify(booksServiceMock, times(1)).searchByDescription(anyString());
373+
assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value());
374+
assertThat(result).usingRecursiveComparison().isEqualTo(bookDTOs);
375+
}
376+
377+
@Test
378+
void givenSearchByDescription_whenRequestParamIsValidAndNoMatchingBooks_thenResponseStatusIsOKAndResultIsEmptyList()
379+
throws Exception {
380+
// Arrange
381+
String keyword = "nonexistentkeyword";
382+
Mockito
383+
.when(booksServiceMock.searchByDescription(anyString()))
384+
.thenReturn(List.of());
385+
MockHttpServletRequestBuilder request = MockMvcRequestBuilders
386+
.get(PATH + "/search")
387+
.param("description", keyword);
388+
// Act
389+
MockHttpServletResponse response = application
390+
.perform(request)
391+
.andReturn()
392+
.getResponse();
393+
response.setContentType("application/json;charset=UTF-8");
394+
String content = response.getContentAsString();
395+
List<BookDTO> result = new ObjectMapper().readValue(content, new TypeReference<List<BookDTO>>() {
396+
});
397+
// Assert
398+
verify(booksServiceMock, times(1)).searchByDescription(anyString());
399+
assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value());
400+
assertThat(result).isEmpty();
401+
}
402+
403+
@Test
404+
void givenSearchByDescription_whenRequestParamIsBlank_thenResponseStatusIsBadRequest()
405+
throws Exception {
406+
// Arrange
407+
MockHttpServletRequestBuilder request = MockMvcRequestBuilders
408+
.get(PATH + "/search")
409+
.param("description", "");
410+
// Act
411+
MockHttpServletResponse response = application
412+
.perform(request)
413+
.andReturn()
414+
.getResponse();
415+
// Assert
416+
verify(booksServiceMock, never()).searchByDescription(anyString());
417+
assertThat(response.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value());
418+
}
419+
420+
@Test
421+
void givenSearchByDescription_whenRequestParamIsMissing_thenResponseStatusIsBadRequest()
422+
throws Exception {
423+
// Arrange
424+
MockHttpServletRequestBuilder request = MockMvcRequestBuilders
425+
.get(PATH + "/search");
426+
// Act
427+
MockHttpServletResponse response = application
428+
.perform(request)
429+
.andReturn()
430+
.getResponse();
431+
// Assert
432+
verify(booksServiceMock, never()).searchByDescription(anyString());
433+
assertThat(response.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value());
434+
}
343435
}

src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/repositories/BooksRepositoryTests.java

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import static org.assertj.core.api.Assertions.assertThat;
44
import static org.junit.jupiter.api.Assertions.assertTrue;
55

6+
import java.util.List;
67
import java.util.Optional;
78

89
import org.junit.jupiter.api.DisplayName;
@@ -42,4 +43,29 @@ void givenFindByIsbn_whenISBNDoesNotExist_thenShouldReturnEmptyOptional() {
4243
// Assert
4344
assertThat(actual).isEmpty();
4445
}
46+
47+
@Test
48+
void givenFindByDescriptionContainingIgnoreCase_whenKeywordMatchesDescription_thenShouldReturnMatchingBooks() {
49+
// Arrange
50+
List<Book> books = BookFakes.createManyValid();
51+
for (Book book : books) {
52+
repository.save(book);
53+
}
54+
// Act
55+
List<Book> actual = repository.findByDescriptionContainingIgnoreCase("Java");
56+
// Assert
57+
assertThat(actual).isNotEmpty();
58+
assertThat(actual).allMatch(book -> book.getDescription().toLowerCase().contains("java"));
59+
}
60+
61+
@Test
62+
void givenFindByDescriptionContainingIgnoreCase_whenKeywordDoesNotMatch_thenShouldReturnEmptyList() {
63+
// Arrange
64+
Book book = BookFakes.createOneValid();
65+
repository.save(book);
66+
// Act
67+
List<Book> actual = repository.findByDescriptionContainingIgnoreCase("nonexistentkeyword");
68+
// Assert
69+
assertThat(actual).isEmpty();
70+
}
4571
}

src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/services/BooksServiceTests.java

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,4 +218,48 @@ void givenDelete_whenRepositoryExistsByIdReturnsTrue_thenRepositoryDeleteBookAnd
218218
verify(modelMapperMock, never()).map(bookDTO, Book.class);
219219
assertThat(result).isTrue();
220220
}
221+
222+
/*
223+
* -------------------------------------------------------------------------
224+
* Search
225+
* -------------------------------------------------------------------------
226+
*/
227+
228+
@Test
229+
void givenSearchByDescription_whenRepositoryReturnsMatchingBooks_thenResultIsEqualToBooks() {
230+
// Arrange
231+
List<Book> books = BookFakes.createManyValid();
232+
List<BookDTO> bookDTOs = BookDTOFakes.createManyValid();
233+
String keyword = "Java";
234+
Mockito
235+
.when(booksRepositoryMock.findByDescriptionContainingIgnoreCase(keyword))
236+
.thenReturn(books);
237+
for (int index = 0; index < books.size(); index++) {
238+
Mockito
239+
.when(modelMapperMock.map(books.get(index), BookDTO.class))
240+
.thenReturn(bookDTOs.get(index));
241+
}
242+
// Act
243+
List<BookDTO> result = booksService.searchByDescription(keyword);
244+
// Assert
245+
verify(booksRepositoryMock, times(1)).findByDescriptionContainingIgnoreCase(keyword);
246+
for (Book book : books) {
247+
verify(modelMapperMock, times(1)).map(book, BookDTO.class);
248+
}
249+
assertThat(result).usingRecursiveComparison().isEqualTo(bookDTOs);
250+
}
251+
252+
@Test
253+
void givenSearchByDescription_whenRepositoryReturnsEmptyList_thenResultIsEmptyList() {
254+
// Arrange
255+
String keyword = "nonexistentkeyword";
256+
Mockito
257+
.when(booksRepositoryMock.findByDescriptionContainingIgnoreCase(keyword))
258+
.thenReturn(List.of());
259+
// Act
260+
List<BookDTO> result = booksService.searchByDescription(keyword);
261+
// Assert
262+
verify(booksRepositoryMock, times(1)).findByDescriptionContainingIgnoreCase(keyword);
263+
assertThat(result).isEmpty();
264+
}
221265
}

0 commit comments

Comments
 (0)