From e1196da1979b2ee9de4e6c582574afd611bac790 Mon Sep 17 00:00:00 2001 From: xsreality Date: Sun, 25 Aug 2024 01:45:31 +0200 Subject: [PATCH] Add check-in functionality for borrowed books Implemented a new endpoint to allow patrons to check in borrowed books. Updated Hold and Book domain classes and added corresponding tests to ensure correct behavior and validations during check-in. --- .../borrow/application/CirculationDesk.java | 28 ++++++++++++- .../borrow/application/HoldInformation.java | 11 +++-- src/main/java/example/borrow/domain/Book.java | 7 +++- .../example/borrow/domain/BookRepository.java | 6 ++- src/main/java/example/borrow/domain/Hold.java | 23 ++++++++++- .../CirculationDeskController.java | 7 ++++ .../borrow/CirculationDeskControllerIT.java | 14 ++++++- .../example/borrow/CirculationDeskTest.java | 41 +++++++++++++++++++ src/test/resources/borrow.sql | 10 +++-- 9 files changed, 134 insertions(+), 13 deletions(-) diff --git a/src/main/java/example/borrow/application/CirculationDesk.java b/src/main/java/example/borrow/application/CirculationDesk.java index 6b17d58..3eb36c6 100644 --- a/src/main/java/example/borrow/application/CirculationDesk.java +++ b/src/main/java/example/borrow/application/CirculationDesk.java @@ -50,8 +50,32 @@ public HoldInformation checkout(Hold.Checkout command) { return HoldInformation.from( hold.checkout(command) - .then(holds::save) - ); + .then(holds::save)); + } + + public HoldInformation checkin(Hold.Checkin command) { + var hold = holds.findById(command.holdId()) + .orElseThrow(() -> new IllegalArgumentException("Hold not found!")); + + if (!hold.isCheckedOut()) { + throw new IllegalArgumentException("Book is not checked out"); + } + + if (!hold.isHeldBy(command.patronId())) { + throw new IllegalArgumentException("Hold belongs to a different patron"); + } + + return HoldInformation.from( + hold.checkin(command) + .then(holds::save)); + } + + @ApplicationModuleListener + public void handle(Hold.BookCheckedIn event) { + books.findCheckedOutBook(new Book.Barcode(event.inventoryNumber())) + .map(Book::markAvailable) + .map(books::save) + .orElseThrow(() -> new IllegalArgumentException("Book not checked out?")); } @ApplicationModuleListener diff --git a/src/main/java/example/borrow/application/HoldInformation.java b/src/main/java/example/borrow/application/HoldInformation.java index 6bb9c93..0cccb3f 100644 --- a/src/main/java/example/borrow/application/HoldInformation.java +++ b/src/main/java/example/borrow/application/HoldInformation.java @@ -13,14 +13,16 @@ public class HoldInformation { private final String patronId; private final LocalDate dateOfHold; private final LocalDate dateOfCheckout; + private final LocalDate dateOfCheckin; private final Hold.HoldStatus holdStatus; - private HoldInformation(String id, String bookBarcode, String patronId, LocalDate dateOfHold, LocalDate dateOfCheckout, Hold.HoldStatus holdStatus) { + private HoldInformation(String id, String bookBarcode, String patronId, LocalDate dateOfHold, LocalDate dateOfCheckout, LocalDate dateOfCheckin, Hold.HoldStatus holdStatus) { this.id = id; this.bookBarcode = bookBarcode; this.patronId = patronId; this.dateOfHold = dateOfHold; this.dateOfCheckout = dateOfCheckout; + this.dateOfCheckin = dateOfCheckin; this.holdStatus = holdStatus; } @@ -29,6 +31,9 @@ public static HoldInformation from(Hold hold) { hold.getId().id().toString(), hold.getOnBook().barcode(), hold.getHeldBy().email(), - hold.getDateOfHold(), hold.getDateOfCheckout(), hold.getStatus()); + hold.getDateOfHold(), + hold.getDateOfCheckout(), + hold.getDateOfCheckin(), + hold.getStatus()); } -} +} \ No newline at end of file diff --git a/src/main/java/example/borrow/domain/Book.java b/src/main/java/example/borrow/domain/Book.java index 96caa87..3789f27 100644 --- a/src/main/java/example/borrow/domain/Book.java +++ b/src/main/java/example/borrow/domain/Book.java @@ -62,6 +62,11 @@ public Book markCheckedOut() { return this; } + public Book markAvailable() { + this.status = BookStatus.AVAILABLE; + return this; + } + public record BookId(UUID id) implements Identifier { } @@ -82,4 +87,4 @@ public enum BookStatus implements ValueObject { public record AddBook(Barcode barcode, String title, String isbn) { } -} +} \ No newline at end of file diff --git a/src/main/java/example/borrow/domain/BookRepository.java b/src/main/java/example/borrow/domain/BookRepository.java index 11a76c8..b502b21 100644 --- a/src/main/java/example/borrow/domain/BookRepository.java +++ b/src/main/java/example/borrow/domain/BookRepository.java @@ -23,4 +23,8 @@ public interface BookRepository extends CrudRepository { Optional findByInventoryNumber(Book.Barcode inventoryNumber); -} + @Query(""" + SELECT b FROM Book b WHERE b.inventoryNumber = :inventoryNumber AND b.status = 'CHECKED_OUT' + """) + Optional findCheckedOutBook(Book.Barcode inventoryNumber); +} \ No newline at end of file diff --git a/src/main/java/example/borrow/domain/Hold.java b/src/main/java/example/borrow/domain/Hold.java index 4d83453..8fad8ea 100644 --- a/src/main/java/example/borrow/domain/Hold.java +++ b/src/main/java/example/borrow/domain/Hold.java @@ -5,6 +5,7 @@ import org.springframework.data.domain.AbstractAggregateRoot; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.UUID; import java.util.function.UnaryOperator; @@ -43,6 +44,8 @@ public class Hold extends AbstractAggregateRoot { private LocalDate dateOfCheckout; + private LocalDate dateOfCheckin; + @Enumerated(EnumType.STRING) private HoldStatus status; @@ -70,6 +73,12 @@ public Hold checkout(Checkout command) { return this; } + public Hold checkin(Checkin command) { + this.status = HoldStatus.RETURNED; + this.dateOfCheckin = command.dateOfCheckin(); + return this; + } + public Hold then(UnaryOperator function) { return function.apply(this); } @@ -78,11 +87,15 @@ public boolean isHeldBy(PatronId patronId) { return this.heldBy.equals(patronId); } + public boolean isCheckedOut() { + return status == HoldStatus.ACTIVE; + } + public record HoldId(UUID id) implements Identifier { } public enum HoldStatus { - HOLDING, ACTIVE, COMPLETED + HOLDING, ACTIVE, RETURNED } /// @@ -96,6 +109,8 @@ public record Checkout(HoldId holdId, LocalDate dateOfCheckout, PatronId patronI } + public record Checkin(HoldId holdId, LocalDate dateOfCheckin, PatronId patronId) {} + /// // Events /// @@ -111,4 +126,10 @@ public record BookPlacedOnHold(UUID holdId, String inventoryNumber, LocalDate dateOfHold) { } + + @DomainEvent + public record BookCheckedIn(UUID holdId, + String inventoryNumber, + LocalDateTime dateOfCheckin) { + } } diff --git a/src/main/java/example/borrow/infrastructure/CirculationDeskController.java b/src/main/java/example/borrow/infrastructure/CirculationDeskController.java index 2cf745c..04d42e0 100644 --- a/src/main/java/example/borrow/infrastructure/CirculationDeskController.java +++ b/src/main/java/example/borrow/infrastructure/CirculationDeskController.java @@ -39,6 +39,13 @@ ResponseEntity checkoutBook(@PathVariable("id") UUID holdId, @A return ResponseEntity.ok(hold); } + @PostMapping("/borrow/holds/{id}/checkin") + ResponseEntity checkinBook(@PathVariable("id") UUID holdId, @Authenticated UserAccount userAccount) { + var command = new Hold.Checkin(new Hold.HoldId(holdId), LocalDate.now(), new PatronId(userAccount.email())); + var hold = circulationDesk.checkin(command); + return ResponseEntity.ok(hold); + } + @GetMapping("/borrow/holds/{id}") ResponseEntity viewSingleHold(@PathVariable("id") UUID holdId) { return circulationDesk.locate(holdId) diff --git a/src/test/java/example/borrow/CirculationDeskControllerIT.java b/src/test/java/example/borrow/CirculationDeskControllerIT.java index 3a64214..39537da 100644 --- a/src/test/java/example/borrow/CirculationDeskControllerIT.java +++ b/src/test/java/example/borrow/CirculationDeskControllerIT.java @@ -67,4 +67,16 @@ void checkoutBookRestCall() throws Exception { .andExpect(jsonPath("$.dateOfCheckout").isNotEmpty()) .andExpect(jsonPath("$.holdStatus", equalTo("ACTIVE"))); } -} + + @Test + void checkinBookRestCall() throws Exception { + mockMvc.perform(post("/borrow/holds/018dc74a-9c4e-743f-916f-e152f190d13e/checkin") + .with(jwt().jwt(jwt -> jwt.claim("email", "john.wick@continental.com")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id", equalTo("018dc74a-9c4e-743f-916f-e152f190d13e"))) + .andExpect(jsonPath("$.bookBarcode", equalTo("55667788"))) + .andExpect(jsonPath("$.patronId", equalTo("john.wick@continental.com"))) + .andExpect(jsonPath("$.dateOfCheckin").isNotEmpty()) + .andExpect(jsonPath("$.holdStatus", equalTo("RETURNED"))); + } +} \ No newline at end of file diff --git a/src/test/java/example/borrow/CirculationDeskTest.java b/src/test/java/example/borrow/CirculationDeskTest.java index 63570a0..0368683 100644 --- a/src/test/java/example/borrow/CirculationDeskTest.java +++ b/src/test/java/example/borrow/CirculationDeskTest.java @@ -110,4 +110,45 @@ void bookStatusUpdatedWhenCheckoutBook() { // Assert assertThat(book.getStatus()).isEqualTo(ISSUED); } + + @Test + void patronCanCheckinBook() { + // Arrange + var patronId = new PatronId("john.wick@continental.com"); + var book = Book.addBook(new Book.AddBook(new Book.Barcode("12345"), "Test Book", "1234567890")); + var hold = Hold.placeHold(new Hold.PlaceHold(book.getInventoryNumber(), LocalDate.now(), patronId)); + hold.checkout(new Hold.Checkout(hold.getId(), LocalDate.now(), patronId)); + + var checkinCommand = new Hold.Checkin(hold.getId(), LocalDate.now(), patronId); + + when(holdRepository.findById(hold.getId())).thenReturn(Optional.of(hold)); + when(holdRepository.save(any())).thenReturn(hold); + + // Act + var holdInformation = circulationDesk.checkin(checkinCommand); + + // Assert + assertThat(holdInformation.getId()).isEqualTo(hold.getId().id().toString()); + assertThat(holdInformation.getDateOfCheckin()).isNotNull(); + assertThat(hold.getStatus()).isEqualTo(Hold.HoldStatus.RETURNED); + } + + @Test + void patronCannotCheckinBookHeldBySomeoneElse() { + // Arrange + var patronId = new PatronId("john.wick@continental.com"); + var otherPatronId = new PatronId("winston@continental.com"); + var book = Book.addBook(new Book.AddBook(new Book.Barcode("12345"), "Test Book", "1234567890")); + var hold = Hold.placeHold(new Hold.PlaceHold(book.getInventoryNumber(), LocalDate.now(), patronId)); + hold.checkout(new Hold.Checkout(hold.getId(), LocalDate.now(), patronId)); + + var checkinCommand = new Hold.Checkin(hold.getId(), LocalDate.now(), otherPatronId); + + when(holdRepository.findById(hold.getId())).thenReturn(Optional.of(hold)); + + // Act & Assert + assertThatIllegalArgumentException() + .isThrownBy(() -> circulationDesk.checkin(checkinCommand)) + .withMessage("Hold belongs to a different patron"); + } } diff --git a/src/test/resources/borrow.sql b/src/test/resources/borrow.sql index 4455e8a..d64b133 100644 --- a/src/test/resources/borrow.sql +++ b/src/test/resources/borrow.sql @@ -2,8 +2,10 @@ INSERT INTO borrow_books (id, version, title, barcode, isbn, status) VALUES ('018dc771-7b96-776b-980d-caf7c6b2c00b', 0, 'Sapiens', '13268510', '9780062316097', 'AVAILABLE'), ('018dc771-6e03-7f3b-adc1-0b9f9810bde4', 0, 'Moby-Dick', '64321704', '9780763630188', 'AVAILABLE'), ('018dc771-97e4-7e1e-921f-50d3397d6b32', 0, 'To Kill a Mockingbird', '49031878', '9780446310789', 'ON_HOLD'), - ('018dc771-bd5f-71c5-b481-e9b9e8268c6c', 0, '1984', '37040952', '9780451520500', 'ISSUED'); + ('018dc771-bd5f-71c5-b481-e9b9e8268c6c', 0, '1984', '37040952', '9780451520500', 'ISSUED'), + ('018dc771-ce5a-7d2f-b591-fa9f98375c1d', 0, 'The Great Gatsby', '55667788', '9780743273565', 'ISSUED'); -INSERT INTO borrow_holds (id, version, book_barcode, patron_id, date_of_hold, status) -VALUES ('018dc74a-4830-75cf-a194-5e9815727b02', 0, '49031878', 'john.wick@continental.com', '2023-03-11', 'HOLDING'), - ('018dc74a-8b3d-732e-806f-d210f079c0cc', 0, '37040952', 'winston@continental.com', '2023-03-24', 'ACTIVE'); +INSERT INTO borrow_holds (id, version, book_barcode, patron_id, date_of_hold, date_of_checkout, status) +VALUES ('018dc74a-4830-75cf-a194-5e9815727b02', 0, '49031878', 'john.wick@continental.com', '2023-03-11', null, 'HOLDING'), + ('018dc74a-8b3d-732e-806f-d210f079c0cc', 0, '37040952', 'winston@continental.com', '2023-03-24', null, 'ACTIVE'), + ('018dc74a-9c4e-743f-916f-e152f190d13e', 0, '55667788', 'john.wick@continental.com', '2023-03-24', '2023-03-25', 'ACTIVE'); \ No newline at end of file