From 00aa19d0c435cc8c57e10801d548f14c9eff8089 Mon Sep 17 00:00:00 2001 From: codesungrape Date: Tue, 18 Nov 2025 17:45:08 +0000 Subject: [PATCH 1/8] Add TDD for success and custom ResourceNotFound exception --- .../hmcts/bookapi/BookServiceTest.java | 67 +++++++++++++++++-- 1 file changed, 63 insertions(+), 4 deletions(-) diff --git a/src/test/java/com/codesungrape/hmcts/bookapi/BookServiceTest.java b/src/test/java/com/codesungrape/hmcts/bookapi/BookServiceTest.java index 227e950..d8b1074 100644 --- a/src/test/java/com/codesungrape/hmcts/bookapi/BookServiceTest.java +++ b/src/test/java/com/codesungrape/hmcts/bookapi/BookServiceTest.java @@ -2,6 +2,7 @@ import com.codesungrape.hmcts.bookapi.dto.BookRequest; import com.codesungrape.hmcts.bookapi.entity.Book; +import com.codesungrape.hmcts.bookapi.exception.ResourceNotFoundException; import com.codesungrape.hmcts.bookapi.repository.BookRepository; import com.codesungrape.hmcts.bookapi.service.BookService; import org.junit.jupiter.api.BeforeEach; @@ -14,12 +15,14 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import java.util.Optional; import java.util.UUID; import java.util.stream.Stream; 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; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.never; import static org.mockito.Mockito.when; @@ -53,6 +56,10 @@ class BookServiceTest { private Book persistedBook; private UUID testId; + // -------------------------------------- + // Parameter Sources + // -------------------------------------- + // Provide test data, static method: can be called without creating an object. private static Stream provideLongFieldTestCases() { UUID testId = UUID.randomUUID(); @@ -84,8 +91,9 @@ private static Stream provideLongFieldTestCases() { ); } - // --------- TESTS ------------ - + // -------------------------------------- + // Tests + // -------------------------------------- @BeforeEach void setUp() { testId = UUID.randomUUID(); @@ -109,6 +117,9 @@ void setUp() { .build(); } + // -------------------------------------- + // Tests: createBook + // -------------------------------------- @Test void testCreateBook_Success() { @@ -192,7 +203,6 @@ void testCreateBook_BlankTitle_ThrowsException() { ); } - // --------- Repository failures @Test void testCreateBook_RepositoryFailure_ThrowsException() { // Arrange @@ -213,7 +223,10 @@ void testCreateBook_RepositoryFailure_ThrowsException() { @ParameterizedTest(name = "{0}") // Display the test name @MethodSource("provideLongFieldTestCases") void testCreateBook_VeryLongFields_Success( - String testName, BookRequest request, Book expectedBook) { + String testName, + BookRequest request, + Book expectedBook + ) { // Arrange when(testBookRepository.save(any(Book.class))).thenReturn(expectedBook); @@ -260,4 +273,50 @@ void testCreateBook_SpecialCharactersInTitle_Success() { // Did the service perform the correct action on its dependency? verify(testBookRepository, times(1)).save(any(Book.class)); } + + // -------------------------------------------------------------------------------------------- + // Tests: deleteBookById(UUID) + // ------------------------------------------------------------------------------------------- + + @Test + void testDelete_Book_ShouldThrowException_WhenIdNotFound() { + + // Arrange: As goal is to test what happens when the resource doesn't exist, + // we intentionally simulate DB returning NO result + when(testBookRepository.findByIdAndDeletedFalse(testId)).thenReturn(Optional.empty()); + + // ACT and ASSERT: throw ResourceNotFoundException when calling the delete method. + assertThrows( + // custom exception to reflect business rules vs technical problem + ResourceNotFoundException.class, + () -> testBookService.deleteBookById(testId) + ); + + // Assert: ensure the save method was NEVER called. + // proves delete business logic halts immediately when the resource isn't found. + verify(testBookRepository, never()).save(any(Book.class)); + } + + @Test + void testDeleteBookById_Success() { + + // Arrange: + persistedBook.setDeleted(false); // ensure starting state + + when(testBookRepository.findByIdAndDeletedFalse(testId)) + .thenReturn(Optional.of(persistedBook)); + + when(testBookRepository.save(any(Book.class))) + .thenReturn(persistedBook); + + // Act: call the service method we are testing + testBookService.deleteBookById(testId); + + // Assert: the entity was marked deleted + assertTrue(persistedBook.isDeleted()); + + // Assert: repository methods were called correctly + verify(testBookRepository, times(1)).findByIdAndDeletedFalse(testId); + verify(testBookRepository, times(1)).save(persistedBook); + } } From c4e07252c5dc7abd4bcb6d851616d7122f5f5048 Mon Sep 17 00:00:00 2001 From: codesungrape Date: Tue, 18 Nov 2025 17:46:19 +0000 Subject: [PATCH 2/8] Add custom ResourceNotFoundException class --- .../exception/ResourceNotFoundException.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 src/main/java/com/codesungrape/hmcts/bookapi/exception/ResourceNotFoundException.java diff --git a/src/main/java/com/codesungrape/hmcts/bookapi/exception/ResourceNotFoundException.java b/src/main/java/com/codesungrape/hmcts/bookapi/exception/ResourceNotFoundException.java new file mode 100644 index 0000000..d160459 --- /dev/null +++ b/src/main/java/com/codesungrape/hmcts/bookapi/exception/ResourceNotFoundException.java @@ -0,0 +1,13 @@ +package com.codesungrape.hmcts.bookapi.exception; + +/** + * Custom exception to signal that a requested resource (Book, Reservation, etc.) + * could not be found, typically mapping to HTTP 404. + */ + +public class ResourceNotFoundException extends RuntimeException { + + public ResourceNotFoundException(String message) { + super(message); + } +} From a69d9ede789ca530cc6e3e6e260f54830e20dff9 Mon Sep 17 00:00:00 2001 From: codesungrape Date: Wed, 19 Nov 2025 10:51:05 +0000 Subject: [PATCH 3/8] Add deleteBookById() with idempotency; Update tests to use findById() --- .../hmcts/bookapi/service/BookService.java | 19 ++++++++++++++++++- .../hmcts/bookapi/BookServiceTest.java | 6 +++--- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/codesungrape/hmcts/bookapi/service/BookService.java b/src/main/java/com/codesungrape/hmcts/bookapi/service/BookService.java index 7858129..cf9b97f 100644 --- a/src/main/java/com/codesungrape/hmcts/bookapi/service/BookService.java +++ b/src/main/java/com/codesungrape/hmcts/bookapi/service/BookService.java @@ -2,10 +2,13 @@ import com.codesungrape.hmcts.bookapi.dto.BookRequest; import com.codesungrape.hmcts.bookapi.entity.Book; +import com.codesungrape.hmcts.bookapi.exception.ResourceNotFoundException; import com.codesungrape.hmcts.bookapi.repository.BookRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import java.util.UUID; + /** * Service layer responsible for all business logic related to the Book resource. */ @@ -13,7 +16,7 @@ @RequiredArgsConstructor // Lombok creates constructor for dependency injection public class BookService { - // Create a field to store the repo + // Create a field to store the repo in this scope to be accessed and used/reused by methods below private final BookRepository bookRepository; /** @@ -54,4 +57,18 @@ public Book createBook(BookRequest request) { return savedBook; } + + // Soft Delete + public void deleteBookById(UUID bookId) { + + // find the book only if it's not already soft-deleted + Book book = bookRepository.findById(bookId) + .orElseThrow(() -> new ResourceNotFoundException("Book not found")); + + // Idempotent way to mark soft-delete and save + if (!book.isDeleted()) { + book.setDeleted(true); + bookRepository.save(book); + } + } } diff --git a/src/test/java/com/codesungrape/hmcts/bookapi/BookServiceTest.java b/src/test/java/com/codesungrape/hmcts/bookapi/BookServiceTest.java index d8b1074..5f12b4f 100644 --- a/src/test/java/com/codesungrape/hmcts/bookapi/BookServiceTest.java +++ b/src/test/java/com/codesungrape/hmcts/bookapi/BookServiceTest.java @@ -283,7 +283,7 @@ void testDelete_Book_ShouldThrowException_WhenIdNotFound() { // Arrange: As goal is to test what happens when the resource doesn't exist, // we intentionally simulate DB returning NO result - when(testBookRepository.findByIdAndDeletedFalse(testId)).thenReturn(Optional.empty()); + when(testBookRepository.findById(testId)).thenReturn(Optional.empty()); // ACT and ASSERT: throw ResourceNotFoundException when calling the delete method. assertThrows( @@ -303,7 +303,7 @@ void testDeleteBookById_Success() { // Arrange: persistedBook.setDeleted(false); // ensure starting state - when(testBookRepository.findByIdAndDeletedFalse(testId)) + when(testBookRepository.findById(testId)) .thenReturn(Optional.of(persistedBook)); when(testBookRepository.save(any(Book.class))) @@ -316,7 +316,7 @@ void testDeleteBookById_Success() { assertTrue(persistedBook.isDeleted()); // Assert: repository methods were called correctly - verify(testBookRepository, times(1)).findByIdAndDeletedFalse(testId); + verify(testBookRepository, times(1)).findById(testId); verify(testBookRepository, times(1)).save(persistedBook); } } From 00d27447a45176b5358671349ca471ad01104907 Mon Sep 17 00:00:00 2001 From: codesungrape Date: Wed, 19 Nov 2025 10:57:30 +0000 Subject: [PATCH 4/8] Fix wrong path for jacoco coverage report --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index efa9508..5780434 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ JaCoCo generates unit test and integration test coverage reports in XML and HTML ### Coverage report location -`build/reports/jacoco/test/jacocoTestReport.html` +`build/reports/jacoco/test/html/index.html` ---------- From d7531185c3abaca9cd3d5097635fd413c5699979 Mon Sep 17 00:00:00 2001 From: codesungrape Date: Wed, 19 Nov 2025 11:46:30 +0000 Subject: [PATCH 5/8] build(jacoco): print clickable HTML report link to console --- build.gradle | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/build.gradle b/build.gradle index 244bdbb..1f61b40 100644 --- a/build.gradle +++ b/build.gradle @@ -72,6 +72,16 @@ jacocoTestReport { html.required = true // For human-readable reports csv.required = false } + + doLast { + // We extract the file path into a variable + def reportFile = reports.html.entryPoint + + println "\n=========================================================" + // Wrap the path in quotes to handle the space in "BookAPI 2" better + println "JaCoCo Report: file://${reportFile}" + println "=========================================================\n" + } } // ENFORCE 100% coverage with branch coverage From 85fb4ff2b85089e126bd0eacc09279c0578b0586 Mon Sep 17 00:00:00 2001 From: codesungrape Date: Wed, 19 Nov 2025 12:28:39 +0000 Subject: [PATCH 6/8] Add test delete ShouldDoNothing_WhenAlreadyDeleted; 100% coverage --- .../hmcts/bookapi/BookServiceTest.java | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/test/java/com/codesungrape/hmcts/bookapi/BookServiceTest.java b/src/test/java/com/codesungrape/hmcts/bookapi/BookServiceTest.java index 5f12b4f..4ba79bb 100644 --- a/src/test/java/com/codesungrape/hmcts/bookapi/BookServiceTest.java +++ b/src/test/java/com/codesungrape/hmcts/bookapi/BookServiceTest.java @@ -319,4 +319,20 @@ void testDeleteBookById_Success() { verify(testBookRepository, times(1)).findById(testId); verify(testBookRepository, times(1)).save(persistedBook); } + + @Test + void testDeleteBookById_ShouldDoNothing_WhenAlreadyDeleted() { + + // Arrange: + persistedBook.setDeleted(true); // ensure starting state + when(testBookRepository.findById(testId)) + .thenReturn(Optional.of(persistedBook)); + + // Act: call the service method we are testing + testBookService.deleteBookById(testId); + + // Assert + // Verify save was NEVER called (it entered the 'false' branch of if) + verify(testBookRepository, never()).save(any(Book.class)); + } } From 3c8a250781ed70f481f3554930530bb694a939c0 Mon Sep 17 00:00:00 2001 From: codesungrape Date: Wed, 19 Nov 2025 13:08:59 +0000 Subject: [PATCH 7/8] Remove manual validation once Controller validation (@Valid) is implemented. --- .../codesungrape/hmcts/bookapi/service/BookService.java | 5 ++++- .../com/codesungrape/hmcts/bookapi/BookServiceTest.java | 7 ------- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/codesungrape/hmcts/bookapi/service/BookService.java b/src/main/java/com/codesungrape/hmcts/bookapi/service/BookService.java index cf9b97f..4453918 100644 --- a/src/main/java/com/codesungrape/hmcts/bookapi/service/BookService.java +++ b/src/main/java/com/codesungrape/hmcts/bookapi/service/BookService.java @@ -4,6 +4,7 @@ import com.codesungrape.hmcts.bookapi.entity.Book; import com.codesungrape.hmcts.bookapi.exception.ResourceNotFoundException; import com.codesungrape.hmcts.bookapi.repository.BookRepository; +import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -27,13 +28,14 @@ public class BookService { * @throws NullPointerException if request is null * @throws IllegalArgumentException if title is null or blank */ + @Transactional // Required: This method modifies data public Book createBook(BookRequest request) { // Validation check for business rules (e.g., uniqueness, if required) if (request == null) { throw new NullPointerException("BookRequest cannot be null"); } - // TODO: Leaving this here for now as i haven't implemented the Controller Layer yet + // TODO: Remove manual validation once Controller validation (@Valid) is implemented. // The service layer is duplicating validation that already exists in the // BookRequest DTO with @notblank annotations. Since the DTO has validation // constraints, this manual check is redundant when Spring's validation @@ -59,6 +61,7 @@ public Book createBook(BookRequest request) { } // Soft Delete + @Transactional // Required: This method modifies data public void deleteBookById(UUID bookId) { // find the book only if it's not already soft-deleted diff --git a/src/test/java/com/codesungrape/hmcts/bookapi/BookServiceTest.java b/src/test/java/com/codesungrape/hmcts/bookapi/BookServiceTest.java index 4ba79bb..74eaab1 100644 --- a/src/test/java/com/codesungrape/hmcts/bookapi/BookServiceTest.java +++ b/src/test/java/com/codesungrape/hmcts/bookapi/BookServiceTest.java @@ -140,13 +140,6 @@ void testCreateBook_Success() { verify(testBookRepository, times(1)).save(any(Book.class)); } - // CoPilot feedback: - // This test will fail because BookRequest uses @value from Lombok with @notblank validation. - // The @notblank constraint on the title field means that creating a BookRequest with a null - // title should trigger validation failure at the DTO level, not allow the object to be - // created. Either the test expectations are incorrect, or the DTO validation is not being - // applied. The same issue affects tests on lines 105-116, 119-127, and 130-138. - @Test void testCreateBook_NullRequest_ThrowsException() { // Act & Assert From 13f3a4b7661520927ad7ace0c96a4c0db5dd27aa Mon Sep 17 00:00:00 2001 From: codesungrape Date: Wed, 19 Nov 2025 13:52:24 +0000 Subject: [PATCH 8/8] Improve not-found error; tidy comments; update Javadoc --- .../hmcts/bookapi/service/BookService.java | 13 ++++++++++--- .../codesungrape/hmcts/bookapi/BookServiceTest.java | 2 +- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/codesungrape/hmcts/bookapi/service/BookService.java b/src/main/java/com/codesungrape/hmcts/bookapi/service/BookService.java index 4453918..6071aab 100644 --- a/src/main/java/com/codesungrape/hmcts/bookapi/service/BookService.java +++ b/src/main/java/com/codesungrape/hmcts/bookapi/service/BookService.java @@ -60,13 +60,20 @@ public Book createBook(BookRequest request) { return savedBook; } - // Soft Delete + /** + * Performs a soft delete on a Book entity by marking it as deleted. + * This operation is idempotent - repeated calls will not trigger additional database writes. + * + * @param bookId The UUID of the book to soft delete + * @throws ResourceNotFoundException if no book exists with the given ID + */ @Transactional // Required: This method modifies data public void deleteBookById(UUID bookId) { - // find the book only if it's not already soft-deleted Book book = bookRepository.findById(bookId) - .orElseThrow(() -> new ResourceNotFoundException("Book not found")); + .orElseThrow(() -> new ResourceNotFoundException(String.format( + "Book not found with id: %s", bookId + ))); // Idempotent way to mark soft-delete and save if (!book.isDeleted()) { diff --git a/src/test/java/com/codesungrape/hmcts/bookapi/BookServiceTest.java b/src/test/java/com/codesungrape/hmcts/bookapi/BookServiceTest.java index 74eaab1..3d4fa45 100644 --- a/src/test/java/com/codesungrape/hmcts/bookapi/BookServiceTest.java +++ b/src/test/java/com/codesungrape/hmcts/bookapi/BookServiceTest.java @@ -325,7 +325,7 @@ void testDeleteBookById_ShouldDoNothing_WhenAlreadyDeleted() { testBookService.deleteBookById(testId); // Assert - // Verify save was NEVER called (it entered the 'false' branch of if) + // Verify save was NEVER called (the if condition was false, so the if block was skipped) verify(testBookRepository, never()).save(any(Book.class)); } }