Skip to content

Commit 3ccb4f8

Browse files
authored
feat: implement idempotent soft delete for Books (#4)
* Add TDD for success and custom ResourceNotFound exception * Add custom ResourceNotFoundException class * Add deleteBookById() with idempotency; Update tests to use findById() * Fix wrong path for jacoco coverage report * build(jacoco): print clickable HTML report link to console * Improve not-found error; tidy comments; update Javadoc
1 parent 0bcb2a0 commit 3ccb4f8

File tree

5 files changed

+132
-14
lines changed

5 files changed

+132
-14
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ JaCoCo generates unit test and integration test coverage reports in XML and HTML
7474

7575
### Coverage report location
7676

77-
`build/reports/jacoco/test/jacocoTestReport.html`
77+
`build/reports/jacoco/test/html/index.html`
7878

7979
----------
8080

build.gradle

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,16 @@ jacocoTestReport {
7272
html.required = true // For human-readable reports
7373
csv.required = false
7474
}
75+
76+
doLast {
77+
// We extract the file path into a variable
78+
def reportFile = reports.html.entryPoint
79+
80+
println "\n========================================================="
81+
// Wrap the path in quotes to handle the space in "BookAPI 2" better
82+
println "JaCoCo Report: file://${reportFile}"
83+
println "=========================================================\n"
84+
}
7585
}
7686

7787
// ENFORCE 100% coverage with branch coverage
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.codesungrape.hmcts.bookapi.exception;
2+
3+
/**
4+
* Custom exception to signal that a requested resource (Book, Reservation, etc.)
5+
* could not be found, typically mapping to HTTP 404.
6+
*/
7+
8+
public class ResourceNotFoundException extends RuntimeException {
9+
10+
public ResourceNotFoundException(String message) {
11+
super(message);
12+
}
13+
}

src/main/java/com/codesungrape/hmcts/bookapi/service/BookService.java

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,22 @@
22

33
import com.codesungrape.hmcts.bookapi.dto.BookRequest;
44
import com.codesungrape.hmcts.bookapi.entity.Book;
5+
import com.codesungrape.hmcts.bookapi.exception.ResourceNotFoundException;
56
import com.codesungrape.hmcts.bookapi.repository.BookRepository;
7+
import jakarta.transaction.Transactional;
68
import lombok.RequiredArgsConstructor;
79
import org.springframework.stereotype.Service;
810

11+
import java.util.UUID;
12+
913
/**
1014
* Service layer responsible for all business logic related to the Book resource.
1115
*/
1216
@Service
1317
@RequiredArgsConstructor // Lombok creates constructor for dependency injection
1418
public class BookService {
1519

16-
// Create a field to store the repo
20+
// Create a field to store the repo in this scope to be accessed and used/reused by methods below
1721
private final BookRepository bookRepository;
1822

1923
/**
@@ -24,13 +28,14 @@ public class BookService {
2428
* @throws NullPointerException if request is null
2529
* @throws IllegalArgumentException if title is null or blank
2630
*/
31+
@Transactional // Required: This method modifies data
2732
public Book createBook(BookRequest request) {
2833
// Validation check for business rules (e.g., uniqueness, if required)
2934
if (request == null) {
3035
throw new NullPointerException("BookRequest cannot be null");
3136
}
3237

33-
// TODO: Leaving this here for now as i haven't implemented the Controller Layer yet
38+
// TODO: Remove manual validation once Controller validation (@Valid) is implemented.
3439
// The service layer is duplicating validation that already exists in the
3540
// BookRequest DTO with @notblank annotations. Since the DTO has validation
3641
// constraints, this manual check is redundant when Spring's validation
@@ -54,4 +59,26 @@ public Book createBook(BookRequest request) {
5459

5560
return savedBook;
5661
}
62+
63+
/**
64+
* Performs a soft delete on a Book entity by marking it as deleted.
65+
* This operation is idempotent - repeated calls will not trigger additional database writes.
66+
*
67+
* @param bookId The UUID of the book to soft delete
68+
* @throws ResourceNotFoundException if no book exists with the given ID
69+
*/
70+
@Transactional // Required: This method modifies data
71+
public void deleteBookById(UUID bookId) {
72+
73+
Book book = bookRepository.findById(bookId)
74+
.orElseThrow(() -> new ResourceNotFoundException(String.format(
75+
"Book not found with id: %s", bookId
76+
)));
77+
78+
// Idempotent way to mark soft-delete and save
79+
if (!book.isDeleted()) {
80+
book.setDeleted(true);
81+
bookRepository.save(book);
82+
}
83+
}
5784
}

src/test/java/com/codesungrape/hmcts/bookapi/BookServiceTest.java

Lines changed: 79 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import com.codesungrape.hmcts.bookapi.dto.BookRequest;
44
import com.codesungrape.hmcts.bookapi.entity.Book;
5+
import com.codesungrape.hmcts.bookapi.exception.ResourceNotFoundException;
56
import com.codesungrape.hmcts.bookapi.repository.BookRepository;
67
import com.codesungrape.hmcts.bookapi.service.BookService;
78
import org.junit.jupiter.api.BeforeEach;
@@ -14,12 +15,14 @@
1415
import org.mockito.Mock;
1516
import org.mockito.junit.jupiter.MockitoExtension;
1617

18+
import java.util.Optional;
1719
import java.util.UUID;
1820
import java.util.stream.Stream;
1921

2022
import static org.junit.jupiter.api.Assertions.assertEquals;
2123
import static org.junit.jupiter.api.Assertions.assertNotNull;
2224
import static org.junit.jupiter.api.Assertions.assertThrows;
25+
import static org.junit.jupiter.api.Assertions.assertTrue;
2326
import static org.mockito.ArgumentMatchers.any;
2427
import static org.mockito.Mockito.never;
2528
import static org.mockito.Mockito.when;
@@ -53,6 +56,10 @@ class BookServiceTest {
5356
private Book persistedBook;
5457
private UUID testId;
5558

59+
// --------------------------------------
60+
// Parameter Sources
61+
// --------------------------------------
62+
5663
// Provide test data, static method: can be called without creating an object.
5764
private static Stream<Arguments> provideLongFieldTestCases() {
5865
UUID testId = UUID.randomUUID();
@@ -84,8 +91,9 @@ private static Stream<Arguments> provideLongFieldTestCases() {
8491
);
8592
}
8693

87-
// --------- TESTS ------------
88-
94+
// --------------------------------------
95+
// Tests
96+
// --------------------------------------
8997
@BeforeEach
9098
void setUp() {
9199
testId = UUID.randomUUID();
@@ -109,6 +117,9 @@ void setUp() {
109117
.build();
110118
}
111119

120+
// --------------------------------------
121+
// Tests: createBook
122+
// --------------------------------------
112123
@Test
113124
void testCreateBook_Success() {
114125

@@ -129,13 +140,6 @@ void testCreateBook_Success() {
129140
verify(testBookRepository, times(1)).save(any(Book.class));
130141
}
131142

132-
// CoPilot feedback:
133-
// This test will fail because BookRequest uses @value from Lombok with @notblank validation.
134-
// The @notblank constraint on the title field means that creating a BookRequest with a null
135-
// title should trigger validation failure at the DTO level, not allow the object to be
136-
// created. Either the test expectations are incorrect, or the DTO validation is not being
137-
// applied. The same issue affects tests on lines 105-116, 119-127, and 130-138.
138-
139143
@Test
140144
void testCreateBook_NullRequest_ThrowsException() {
141145
// Act & Assert
@@ -192,7 +196,6 @@ void testCreateBook_BlankTitle_ThrowsException() {
192196
);
193197
}
194198

195-
// --------- Repository failures
196199
@Test
197200
void testCreateBook_RepositoryFailure_ThrowsException() {
198201
// Arrange
@@ -213,7 +216,10 @@ void testCreateBook_RepositoryFailure_ThrowsException() {
213216
@ParameterizedTest(name = "{0}") // Display the test name
214217
@MethodSource("provideLongFieldTestCases")
215218
void testCreateBook_VeryLongFields_Success(
216-
String testName, BookRequest request, Book expectedBook) {
219+
String testName,
220+
BookRequest request,
221+
Book expectedBook
222+
) {
217223

218224
// Arrange
219225
when(testBookRepository.save(any(Book.class))).thenReturn(expectedBook);
@@ -260,4 +266,66 @@ void testCreateBook_SpecialCharactersInTitle_Success() {
260266
// Did the service perform the correct action on its dependency?
261267
verify(testBookRepository, times(1)).save(any(Book.class));
262268
}
269+
270+
// --------------------------------------------------------------------------------------------
271+
// Tests: deleteBookById(UUID)
272+
// -------------------------------------------------------------------------------------------
273+
274+
@Test
275+
void testDelete_Book_ShouldThrowException_WhenIdNotFound() {
276+
277+
// Arrange: As goal is to test what happens when the resource doesn't exist,
278+
// we intentionally simulate DB returning NO result
279+
when(testBookRepository.findById(testId)).thenReturn(Optional.empty());
280+
281+
// ACT and ASSERT: throw ResourceNotFoundException when calling the delete method.
282+
assertThrows(
283+
// custom exception to reflect business rules vs technical problem
284+
ResourceNotFoundException.class,
285+
() -> testBookService.deleteBookById(testId)
286+
);
287+
288+
// Assert: ensure the save method was NEVER called.
289+
// proves delete business logic halts immediately when the resource isn't found.
290+
verify(testBookRepository, never()).save(any(Book.class));
291+
}
292+
293+
@Test
294+
void testDeleteBookById_Success() {
295+
296+
// Arrange:
297+
persistedBook.setDeleted(false); // ensure starting state
298+
299+
when(testBookRepository.findById(testId))
300+
.thenReturn(Optional.of(persistedBook));
301+
302+
when(testBookRepository.save(any(Book.class)))
303+
.thenReturn(persistedBook);
304+
305+
// Act: call the service method we are testing
306+
testBookService.deleteBookById(testId);
307+
308+
// Assert: the entity was marked deleted
309+
assertTrue(persistedBook.isDeleted());
310+
311+
// Assert: repository methods were called correctly
312+
verify(testBookRepository, times(1)).findById(testId);
313+
verify(testBookRepository, times(1)).save(persistedBook);
314+
}
315+
316+
@Test
317+
void testDeleteBookById_ShouldDoNothing_WhenAlreadyDeleted() {
318+
319+
// Arrange:
320+
persistedBook.setDeleted(true); // ensure starting state
321+
when(testBookRepository.findById(testId))
322+
.thenReturn(Optional.of(persistedBook));
323+
324+
// Act: call the service method we are testing
325+
testBookService.deleteBookById(testId);
326+
327+
// Assert
328+
// Verify save was NEVER called (the if condition was false, so the if block was skipped)
329+
verify(testBookRepository, never()).save(any(Book.class));
330+
}
263331
}

0 commit comments

Comments
 (0)