From 26ece9cc9bf78b5e34d98af01b278f9ab755a306 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 21:15:48 +0530 Subject: [PATCH] Add library-management module: console app evolved into a Spring Boot 4 REST API Stage 2 console classes and the shared LoanPolicy, then a Spring Boot 4.1.2 + JPA API (H2, @Version optimistic locking, problem+json errors). Tests write output/ transcripts while asserting the same numbers; scripts regenerate everything. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_017ic4b4DPL6dtpFg4U4LHc2 --- README.md | 1 + library-management/README.md | 77 ++++++++++ .../output/01-console-structured.txt | 33 ++++ library-management/output/02-loan-policy.txt | 6 + library-management/output/03-rest-api.txt | 57 +++++++ library-management/output/04-query-count.txt | 4 + .../output/05-race-with-version.txt | 6 + .../output/06-race-without-version.txt | 4 + library-management/output/07-curl-demo.txt | 40 +++++ .../output/08-open-in-view-warning.txt | 2 + library-management/output/09-borrow-sql.txt | 6 + library-management/pom.xml | 62 ++++++++ library-management/scripts/api-demo.sh | 23 +++ library-management/scripts/demo-input.txt | 14 ++ .../scripts/open-in-view-warning.sh | 9 ++ library-management/scripts/run-all.sh | 30 ++++ library-management/scripts/run.sh | 13 ++ library-management/scripts/sql-trace.sh | 17 +++ library-management/scripts/stop.sh | 4 + .../library/api/ApiExceptionHandler.java | 49 ++++++ .../java/com/ankurm/library/api/Book.java | 52 +++++++ .../java/com/ankurm/library/api/Dtos.java | 51 +++++++ .../com/ankurm/library/api/Exceptions.java | 29 ++++ .../library/api/LibraryApplication.java | 22 +++ .../ankurm/library/api/LibraryController.java | 52 +++++++ .../java/com/ankurm/library/api/Loan.java | 48 ++++++ .../com/ankurm/library/api/LoanService.java | 92 ++++++++++++ .../java/com/ankurm/library/api/Member.java | 29 ++++ .../com/ankurm/library/api/Repositories.java | 28 ++++ .../ankurm/library/console/ConsoleApp.java | 62 ++++++++ .../com/ankurm/library/console/Library.java | 118 +++++++++++++++ .../com/ankurm/library/core/LoanPolicy.java | 34 +++++ .../src/main/resources/application.properties | 4 + .../ankurm/library/api/BorrowRaceTest.java | 142 ++++++++++++++++++ .../ankurm/library/api/LibraryApiTest.java | 84 +++++++++++ .../ankurm/library/api/QueryCountTest.java | 69 +++++++++ .../com/ankurm/library/api/Transcript.java | 35 +++++ .../ankurm/library/core/LoanPolicyTest.java | 36 +++++ 38 files changed, 1444 insertions(+) create mode 100644 library-management/README.md create mode 100644 library-management/output/01-console-structured.txt create mode 100644 library-management/output/02-loan-policy.txt create mode 100644 library-management/output/03-rest-api.txt create mode 100644 library-management/output/04-query-count.txt create mode 100644 library-management/output/05-race-with-version.txt create mode 100644 library-management/output/06-race-without-version.txt create mode 100644 library-management/output/07-curl-demo.txt create mode 100644 library-management/output/08-open-in-view-warning.txt create mode 100644 library-management/output/09-borrow-sql.txt create mode 100644 library-management/pom.xml create mode 100755 library-management/scripts/api-demo.sh create mode 100644 library-management/scripts/demo-input.txt create mode 100755 library-management/scripts/open-in-view-warning.sh create mode 100755 library-management/scripts/run-all.sh create mode 100755 library-management/scripts/run.sh create mode 100755 library-management/scripts/sql-trace.sh create mode 100755 library-management/scripts/stop.sh create mode 100644 library-management/src/main/java/com/ankurm/library/api/ApiExceptionHandler.java create mode 100644 library-management/src/main/java/com/ankurm/library/api/Book.java create mode 100644 library-management/src/main/java/com/ankurm/library/api/Dtos.java create mode 100644 library-management/src/main/java/com/ankurm/library/api/Exceptions.java create mode 100644 library-management/src/main/java/com/ankurm/library/api/LibraryApplication.java create mode 100644 library-management/src/main/java/com/ankurm/library/api/LibraryController.java create mode 100644 library-management/src/main/java/com/ankurm/library/api/Loan.java create mode 100644 library-management/src/main/java/com/ankurm/library/api/LoanService.java create mode 100644 library-management/src/main/java/com/ankurm/library/api/Member.java create mode 100644 library-management/src/main/java/com/ankurm/library/api/Repositories.java create mode 100644 library-management/src/main/java/com/ankurm/library/console/ConsoleApp.java create mode 100644 library-management/src/main/java/com/ankurm/library/console/Library.java create mode 100644 library-management/src/main/java/com/ankurm/library/core/LoanPolicy.java create mode 100644 library-management/src/main/resources/application.properties create mode 100644 library-management/src/test/java/com/ankurm/library/api/BorrowRaceTest.java create mode 100644 library-management/src/test/java/com/ankurm/library/api/LibraryApiTest.java create mode 100644 library-management/src/test/java/com/ankurm/library/api/QueryCountTest.java create mode 100644 library-management/src/test/java/com/ankurm/library/api/Transcript.java create mode 100644 library-management/src/test/java/com/ankurm/library/core/LoanPolicyTest.java diff --git a/README.md b/README.md index ca16a62..4e94bcc 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ One module per article. This repository currently holds: | [`dynamic-programming`](dynamic-programming/) | Dynamic Programming in Java for Beginners: Memoization vs Tabulation (10 Classic Problems) | this commit | | [`fresher-qa`](fresher-qa/) | Top 50 Java Interview Questions for Freshers (2026) with Simple Answers | this commit | | [`stack-queue`](stack-queue/) | Stack and Queue Problems in Java: Balanced Parentheses, Next Greater Element, Min Stack | this commit | +| [`library-management`](library-management/) | Library Management System in Java: Console Version to Spring Boot REST API | this commit | ## `collections` module diff --git a/library-management/README.md b/library-management/README.md new file mode 100644 index 0000000..66b8161 --- /dev/null +++ b/library-management/README.md @@ -0,0 +1,77 @@ +# library-management + +Companion code for the ankurm.com article **Library Management System in Java: Console Version +to Spring Boot REST API**. One module, three stages: the same lending rules, first in plain Java, +then split into classes, then served over HTTP with Spring Boot 4 and JPA. + +Every number, status code and SQL line quoted in the article comes from a file in +[`output/`](output/). The tests write those files while asserting the same numbers, so the +transcripts cannot drift from the code. + +## Versions + +| Component | Version | Verified against | +|---|---|---| +| JDK | **25.0.5** (Temurin, LTS) | `java -version` | +| Spring Boot | **4.1.2** (4.1.0 went GA on 2026-06-10) | `maven-metadata.xml` on Maven Central | +| Spring Framework | 7.0.10 | `mvn dependency:list` | +| Hibernate ORM | 7.4.13.Final | `mvn dependency:list` | +| Spring Data JPA | 4.1.2 | `mvn dependency:list` | +| Tomcat (embedded) | 11.0.27 | `mvn dependency:list` | +| H2 | 2.4.240 | `mvn dependency:list` | +| Maven | 3.9.11 | `mvn -v` | + +## The three stages + +| Stage | Where | Run it | +|---|---|---| +| 1. One file, no build tool | `LibraryConsole.java` (attached to the article, not stored here) | `java LibraryConsole.java < scripts/demo-input.txt` | +| 2. Split into classes, rules extracted | `src/main/java/com/ankurm/library/console/` + `core/LoanPolicy.java` | `mvn -q compile && java -cp target/classes com.ankurm.library.console.ConsoleApp < scripts/demo-input.txt` | +| 3. Spring Boot REST API with JPA | `src/main/java/com/ankurm/library/api/` + `core/LoanPolicy.java` | `scripts/run.sh` | + +`core/LoanPolicy.java` (14-day loans, 3 active loans, Rs 2 a day fine) is shared by stages 2 and 3 +and did not change when the app moved to a database. + +## Quickstart + +```bash +export JAVA_HOME=/path/to/jdk-25 && export PATH="$JAVA_HOME/bin:$PATH" +mvn test # 6 tests; also regenerates output/02 to output/06 +scripts/run.sh # API on http://localhost:8080 (in-memory H2, clean library per start) +scripts/api-demo.sh # in another terminal: curl walk-through +``` + +The first Maven run must be online so the plugins are cached; `-o` works afterwards. +`scripts/run-all.sh` regenerates every file in `output/`. + +## Endpoints + +| Method and path | Purpose | Refusals | +|---|---|---| +| `POST /api/books` | add a book with N copies | 400 bad fields, 409 duplicate ISBN | +| `GET /api/books` | list books with copies available | | +| `POST /api/members` | register a member | 400 bad fields, 409 duplicate email | +| `POST /api/loans` | borrow: `{"memberId":1,"bookId":2}` | 404 unknown id, 409 no copies, 422 limit or duplicate loan | +| `POST /api/loans/{id}/return` | return, optional `{"returnedOn":"2026-11-20"}` | 409 already returned, 422 before borrow date | +| `GET /api/loans` | all loans, one SQL statement | | + +Errors are RFC 9457 `application/problem+json`. + +## Captured output + +| File | What it shows | Produced by | +|---|---|---| +| `01-console-structured.txt` | stage 2 run of `scripts/demo-input.txt` (identical to stage 1) | `run-all.sh` | +| `02-loan-policy.txt` | the fine table | `LoanPolicyTest` | +| `03-rest-api.txt` | every success and refusal over MockMvc | `LibraryApiTest` | +| `04-query-count.txt` | 11 SQL statements versus 1 for the same list | `QueryCountTest` | +| `05-race-with-version.txt` | 8 threads, last copy, `@Version`: one winner | `BorrowRaceTest` | +| `06-race-without-version.txt` | same race without `@Version`: 8 loans for 1 copy | `BorrowRaceTest` | +| `07-curl-demo.txt` | the same API on a real Tomcat, driven with curl (dates are the day it ran) | `scripts/api-demo.sh` | +| `08-open-in-view-warning.txt` | the Boot default this module overrides | `scripts/open-in-view-warning.sh` | +| `09-borrow-sql.txt` | the exact SQL one borrow sends, including `where id=? and version=?` | `scripts/sql-trace.sh` | + +Timing-dependent detail: in `05` the 7 refused borrows split between "optimistic-lock failure" +(a thread read the book before the winner committed) and "any other reason" (a thread started late +and found no copy left). Over repeated runs I saw splits ranging from 7/0 to 4/3, which is why the +transcript counts them separately. The invariants (1 winner, 1 loan, 0 copies left) are asserted. diff --git a/library-management/output/01-console-structured.txt b/library-management/output/01-console-structured.txt new file mode 100644 index 0000000..f7275f6 --- /dev/null +++ b/library-management/output/01-console-structured.txt @@ -0,0 +1,33 @@ +> add-book 978-0134685991|Effective Java|Joshua Bloch|2 +Added book: Effective Java (2 copies) +> add-book 978-0596009205|Head First Java|Kathy Sierra|1 +Added book: Head First Java (1 copy) +> add-book 978-0134685991|Effective Java|Joshua Bloch|2 +ERROR: ISBN 978-0134685991 already exists +> add-member Asha +Added member #1: Asha +> add-member Ravi +Added member #2: Ravi +> borrow 978-0596009205 1 2026-11-01 +Loan #1: Asha borrowed 'Head First Java', due 2026-11-15 +> borrow 978-0596009205 2 2026-11-01 +ERROR: no copies of 'Head First Java' are available +> borrow 978-0134685991 1 2026-11-01 +Loan #2: Asha borrowed 'Effective Java', due 2026-11-15 +> report + Effective Java by Joshua Bloch: 1 available + Head First Java by Kathy Sierra: 0 available + ON LOAN #1: 'Head First Java' -> Asha, due 2026-11-15 + ON LOAN #2: 'Effective Java' -> Asha, due 2026-11-15 +> return 1 2026-11-20 +Loan #1 returned on 2026-11-20, fine Rs 10 +> return 1 2026-11-21 +ERROR: loan #1 was already returned on 2026-11-20 +> borrow 978-0596009205 2 2026-11-21 +Loan #3: Ravi borrowed 'Head First Java', due 2026-12-05 +> report + Effective Java by Joshua Bloch: 1 available + Head First Java by Kathy Sierra: 0 available + ON LOAN #2: 'Effective Java' -> Asha, due 2026-11-15 + ON LOAN #3: 'Head First Java' -> Ravi, due 2026-12-05 +> quit diff --git a/library-management/output/02-loan-policy.txt b/library-management/output/02-loan-policy.txt new file mode 100644 index 0000000..d4f428a --- /dev/null +++ b/library-management/output/02-loan-policy.txt @@ -0,0 +1,6 @@ +borrowed 2026-11-01, due 2026-11-15 (14 days) +returned 2026-11-12 ( -3 days vs due) -> fine Rs 0 +returned 2026-11-15 ( +0 days vs due) -> fine Rs 0 +returned 2026-11-16 ( +1 days vs due) -> fine Rs 2 +returned 2026-11-20 ( +5 days vs due) -> fine Rs 10 +returned 2026-12-15 (+30 days vs due) -> fine Rs 60 diff --git a/library-management/output/03-rest-api.txt b/library-management/output/03-rest-api.txt new file mode 100644 index 0000000..ed3ccbc --- /dev/null +++ b/library-management/output/03-rest-api.txt @@ -0,0 +1,57 @@ +> POST /api/books {"isbn":"978-0134685991","title":"Effective Java","author":"Joshua Bloch","copies":2} +< 201 {"id":1,"isbn":"978-0134685991","title":"Effective Java","author":"Joshua Bloch","totalCopies":2,"availableCopies":2} + +> POST /api/books {"isbn":"978-0134685991","title":"Effective Java","author":"Joshua Bloch","copies":2} +< 409 {"detail":"ISBN 978-0134685991 already exists","instance":"/api/books","status":409,"title":"Conflict"} + +> POST /api/books {"isbn":"978-0000000000","title":"","author":"Nobody","copies":0} +< 400 {"detail":"request body failed validation","instance":"/api/books","status":400,"title":"Bad Request","errors":{"copies":"must be greater than or equal to 1","title":"must not be blank"}} + +> POST /api/books {"isbn":"978-0596009205","title":"Head First Java","author":"Kathy Sierra","copies":1} +< 201 {"id":2,"isbn":"978-0596009205","title":"Head First Java","author":"Kathy Sierra","totalCopies":1,"availableCopies":1} + +> POST /api/books {"isbn":"978-0132350884","title":"Clean Code","author":"Robert Martin","copies":1} +< 201 {"id":3,"isbn":"978-0132350884","title":"Clean Code","author":"Robert Martin","totalCopies":1,"availableCopies":1} + +> POST /api/books {"isbn":"978-0321356680","title":"Effective Java 2e","author":"Joshua Bloch","copies":1} +< 201 {"id":4,"isbn":"978-0321356680","title":"Effective Java 2e","author":"Joshua Bloch","totalCopies":1,"availableCopies":1} + +> POST /api/members {"name":"Asha","email":"asha@example.com"} +< 201 {"id":1,"name":"Asha","email":"asha@example.com"} + +> POST /api/members {"name":"Ravi","email":"ravi@example.com"} +< 201 {"id":2,"name":"Ravi","email":"ravi@example.com"} + +> POST /api/loans {"memberId":1,"bookId":2} +< 201 {"id":1,"bookTitle":"Head First Java","memberName":"Asha","borrowedOn":"2026-11-01","dueOn":"2026-11-15","returnedOn":null,"fine":0} + +> POST /api/loans {"memberId":2,"bookId":2} +< 409 {"detail":"no copies of 'Head First Java' are available","instance":"/api/loans","status":409,"title":"Conflict"} + +> POST /api/loans {"memberId":99,"bookId":1} +< 404 {"detail":"no member with id 99","instance":"/api/loans","status":404,"title":"Not Found"} + +> POST /api/loans/1/return {"returnedOn":"2026-11-20"} +< 200 {"id":1,"bookTitle":"Head First Java","memberName":"Asha","borrowedOn":"2026-11-01","dueOn":"2026-11-15","returnedOn":"2026-11-20","fine":10} + +> POST /api/loans/1/return {"returnedOn":"2026-11-21"} +< 409 {"detail":"loan 1 was already returned on 2026-11-20","instance":"/api/loans/1/return","status":409,"title":"Conflict"} + +> POST /api/loans {"memberId":1,"bookId":1} +< 201 {"id":2,"bookTitle":"Effective Java","memberName":"Asha","borrowedOn":"2026-11-01","dueOn":"2026-11-15","returnedOn":null,"fine":0} + +> POST /api/loans {"memberId":1,"bookId":3} +< 201 {"id":3,"bookTitle":"Clean Code","memberName":"Asha","borrowedOn":"2026-11-01","dueOn":"2026-11-15","returnedOn":null,"fine":0} + +> POST /api/loans {"memberId":1,"bookId":4} +< 201 {"id":4,"bookTitle":"Effective Java 2e","memberName":"Asha","borrowedOn":"2026-11-01","dueOn":"2026-11-15","returnedOn":null,"fine":0} + +> POST /api/loans {"memberId":1,"bookId":2} +< 422 {"detail":"Asha already has 3 active loans (max 3)","instance":"/api/loans","status":422,"title":"Unprocessable Content"} + +> GET /api/books +< 200 [{"id":1,"isbn":"978-0134685991","title":"Effective Java","author":"Joshua Bloch","totalCopies":2,"availableCopies":1},{"id":2,"isbn":"978-0596009205","title":"Head First Java","author":"Kathy Sierra","totalCopies":1,"availableCopies":1},{"id":3,"isbn":"978-0132350884","title":"Clean Code","author":"Robert Martin","totalCopies":1,"availableCopies":0},{"id":4,"isbn":"978-0321356680","title":"Effective Java 2e","author":"Joshua Bloch","totalCopies":1,"availableCopies":0}] + +> GET /api/loans +< 200 [{"id":1,"bookTitle":"Head First Java","memberName":"Asha","borrowedOn":"2026-11-01","dueOn":"2026-11-15","returnedOn":"2026-11-20","fine":10},{"id":2,"bookTitle":"Effective Java","memberName":"Asha","borrowedOn":"2026-11-01","dueOn":"2026-11-15","returnedOn":null,"fine":0},{"id":3,"bookTitle":"Clean Code","memberName":"Asha","borrowedOn":"2026-11-01","dueOn":"2026-11-15","returnedOn":null,"fine":0},{"id":4,"bookTitle":"Effective Java 2e","memberName":"Asha","borrowedOn":"2026-11-01","dueOn":"2026-11-15","returnedOn":null,"fine":0}] + diff --git a/library-management/output/04-query-count.txt b/library-management/output/04-query-count.txt new file mode 100644 index 0000000..e8093da --- /dev/null +++ b/library-management/output/04-query-count.txt @@ -0,0 +1,4 @@ +5 loans, each with its own book and its own member +findAllByOrderByIdAsc() then read book title + member name: 11 SQL statements +findAllWithDetails() (join fetch) then the same reads: 1 SQL statement +LoanService.listLoans() as called by GET /api/loans: 1 SQL statement diff --git a/library-management/output/05-race-with-version.txt b/library-management/output/05-race-with-version.txt new file mode 100644 index 0000000..6384b9b --- /dev/null +++ b/library-management/output/05-race-with-version.txt @@ -0,0 +1,6 @@ +8 threads borrow the last copy at once (Book has @Version) +successful borrows : 1 +refused by optimistic-lock failure : 6 +refused for any other reason : 1 +loan rows in DB : 1 +availableCopies : 0 diff --git a/library-management/output/06-race-without-version.txt b/library-management/output/06-race-without-version.txt new file mode 100644 index 0000000..243d4b5 --- /dev/null +++ b/library-management/output/06-race-without-version.txt @@ -0,0 +1,4 @@ +8 threads borrow the last copy at once (read, check, write: no @Version) +successful borrows : 8 +loan rows in DB : 8 <- one copy, 8 loans +availableCopies : 0 <- the counter says everything is fine diff --git a/library-management/output/07-curl-demo.txt b/library-management/output/07-curl-demo.txt new file mode 100644 index 0000000..1f75c61 --- /dev/null +++ b/library-management/output/07-curl-demo.txt @@ -0,0 +1,40 @@ +> POST /books {"isbn":"978-0134685991","title":"Effective Java","author":"Joshua Bloch","copies":2} +{"id":1,"isbn":"978-0134685991","title":"Effective Java","author":"Joshua Bloch","totalCopies":2,"availableCopies":2} +< HTTP 201 + +> POST /books {"isbn":"978-0596009205","title":"Head First Java","author":"Kathy Sierra","copies":1} +{"id":2,"isbn":"978-0596009205","title":"Head First Java","author":"Kathy Sierra","totalCopies":1,"availableCopies":1} +< HTTP 201 + +> POST /books {"isbn":"","title":"","author":"Nobody","copies":0} +{"detail":"request body failed validation","instance":"/api/books","status":400,"title":"Bad Request","errors":{"copies":"must be greater than or equal to 1","isbn":"must not be blank","title":"must not be blank"}} +< HTTP 400 + +> POST /members {"name":"Asha","email":"asha@example.com"} +{"id":1,"name":"Asha","email":"asha@example.com"} +< HTTP 201 + +> POST /members {"name":"Ravi","email":"not-an-email"} +{"detail":"request body failed validation","instance":"/api/members","status":400,"title":"Bad Request","errors":{"email":"must be a well-formed email address"}} +< HTTP 400 + +> POST /members {"name":"Ravi","email":"ravi@example.com"} +{"id":2,"name":"Ravi","email":"ravi@example.com"} +< HTTP 201 + +> POST /loans {"memberId":1,"bookId":2} +{"id":1,"bookTitle":"Head First Java","memberName":"Asha","borrowedOn":"2026-11-14","dueOn":"2026-11-28","returnedOn":null,"fine":0} +< HTTP 201 + +> POST /loans {"memberId":2,"bookId":2} +{"detail":"no copies of 'Head First Java' are available","instance":"/api/loans","status":409,"title":"Conflict"} +< HTTP 409 + +> POST /loans/1/return +{"id":1,"bookTitle":"Head First Java","memberName":"Asha","borrowedOn":"2026-11-14","dueOn":"2026-11-28","returnedOn":"2026-11-14","fine":0} +< HTTP 200 + +> GET /books +[{"id":1,"isbn":"978-0134685991","title":"Effective Java","author":"Joshua Bloch","totalCopies":2,"availableCopies":2},{"id":2,"isbn":"978-0596009205","title":"Head First Java","author":"Kathy Sierra","totalCopies":1,"availableCopies":1}] +< HTTP 200 + diff --git a/library-management/output/08-open-in-view-warning.txt b/library-management/output/08-open-in-view-warning.txt new file mode 100644 index 0000000..8a6ede3 --- /dev/null +++ b/library-management/output/08-open-in-view-warning.txt @@ -0,0 +1,2 @@ +WARN 2278 --- [ main] JpaBaseConfiguration$JpaWebConfiguration : spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during view rendering. Explicitly configure spring.jpa.open-in-view to disable this warning +INFO 2278 --- [ main] c.ankurm.library.api.LibraryApplication : Started LibraryApplication in 5.911 seconds (process running for 6.599) diff --git a/library-management/output/09-borrow-sql.txt b/library-management/output/09-borrow-sql.txt new file mode 100644 index 0000000..373fa40 --- /dev/null +++ b/library-management/output/09-borrow-sql.txt @@ -0,0 +1,6 @@ +select m1_0.id,m1_0.email,m1_0.name from member m1_0 where m1_0.id=? +select b1_0.id,b1_0.author,b1_0.available_copies,b1_0.isbn,b1_0.title,b1_0.total_copies,b1_0.version from book b1_0 where b1_0.id=? +select l1_0.id from loan l1_0 where l1_0.member_id=? and l1_0.book_id=? and l1_0.returned_on is null fetch first ? rows only +select count(l1_0.id) from loan l1_0 where l1_0.member_id=? and l1_0.returned_on is null +insert into loan (book_id,borrowed_on,due_on,fine,member_id,returned_on,id) values (?,?,?,?,?,?,default) +update book set author=?,available_copies=?,isbn=?,title=?,total_copies=?,version=? where id=? and version=? diff --git a/library-management/pom.xml b/library-management/pom.xml new file mode 100644 index 0000000..2f9806b --- /dev/null +++ b/library-management/pom.xml @@ -0,0 +1,62 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.1.2 + + + + com.ankurm.interviewlab + library-management + 1.0.0 + library-management + Library management: a plain-Java console app evolved into a Spring Boot 4 + JPA REST API + + + 25 + + + + + org.springframework.boot + spring-boot-starter-webmvc + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-validation + + + com.h2database + h2 + runtime + + + + org.springframework.boot + spring-boot-starter-webmvc-test + test + + + org.springframework.boot + spring-boot-starter-data-jpa-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/library-management/scripts/api-demo.sh b/library-management/scripts/api-demo.sh new file mode 100755 index 0000000..fd254ae --- /dev/null +++ b/library-management/scripts/api-demo.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Drives a running API (scripts/run.sh --detach) with curl: the same story as the console demo, +# against a real servlet container. Dates in the output are "today", so they differ per run. +set -uo pipefail +BASE=${BASE:-http://localhost:8080/api} +call() { # method path [json] + echo "> $1 $2${3:+ $3}" + if [ -n "${3:-}" ]; then + curl -s -X "$1" "$BASE$2" -H 'Content-Type: application/json' -d "$3" -w '\n< HTTP %{http_code}\n\n' + else + curl -s -X "$1" "$BASE$2" -w '\n< HTTP %{http_code}\n\n' + fi +} +call POST /books '{"isbn":"978-0134685991","title":"Effective Java","author":"Joshua Bloch","copies":2}' +call POST /books '{"isbn":"978-0596009205","title":"Head First Java","author":"Kathy Sierra","copies":1}' +call POST /books '{"isbn":"","title":"","author":"Nobody","copies":0}' +call POST /members '{"name":"Asha","email":"asha@example.com"}' +call POST /members '{"name":"Ravi","email":"not-an-email"}' +call POST /members '{"name":"Ravi","email":"ravi@example.com"}' +call POST /loans '{"memberId":1,"bookId":2}' +call POST /loans '{"memberId":2,"bookId":2}' +call POST /loans/1/return +call GET /books diff --git a/library-management/scripts/demo-input.txt b/library-management/scripts/demo-input.txt new file mode 100644 index 0000000..ac7d806 --- /dev/null +++ b/library-management/scripts/demo-input.txt @@ -0,0 +1,14 @@ +add-book 978-0134685991|Effective Java|Joshua Bloch|2 +add-book 978-0596009205|Head First Java|Kathy Sierra|1 +add-book 978-0134685991|Effective Java|Joshua Bloch|2 +add-member Asha +add-member Ravi +borrow 978-0596009205 1 2026-11-01 +borrow 978-0596009205 2 2026-11-01 +borrow 978-0134685991 1 2026-11-01 +report +return 1 2026-11-20 +return 1 2026-11-21 +borrow 978-0596009205 2 2026-11-21 +report +quit diff --git a/library-management/scripts/open-in-view-warning.sh b/library-management/scripts/open-in-view-warning.sh new file mode 100755 index 0000000..550546a --- /dev/null +++ b/library-management/scripts/open-in-view-warning.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Starts the API with NO application.properties, to capture the warning Spring Boot prints when +# spring.jpa.open-in-view is left at its default. Our real config sets it to false explicitly. +set -euo pipefail +cd "$(dirname "$0")/.." +mvn -B -q -DskipTests package +mkdir -p /tmp/empty-config +timeout 25 java -jar target/library-management-1.0.0.jar --spring.config.location=file:/tmp/empty-config/ --server.port=0 2>&1 \ + | grep -E 'open-in-view|Started LibraryApplication' | sed -E 's/^[0-9T:.+-]+ +//' || true diff --git a/library-management/scripts/run-all.sh b/library-management/scripts/run-all.sh new file mode 100755 index 0000000..dde1291 --- /dev/null +++ b/library-management/scripts/run-all.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Regenerates every file in output/. Needs JDK 25 and Maven; the first run must be online. +set -euo pipefail +cd "$(dirname "$0")/.." +rm -f output/*.txt + +# 01: the single-file console version (the copy attached to the article lives in the WordPress media library) +mkdir -p /tmp/lm && cp scripts/demo-input.txt /tmp/lm/ +echo "01 console (structured) ..." +mvn -B -q -DskipTests compile +java -cp target/classes com.ankurm.library.console.ConsoleApp < scripts/demo-input.txt 2>/dev/null > output/01-console-structured.txt + +# 02-06: written by the tests themselves, so each file is also an assertion +echo "02-06 tests ..." +mvn -B -q test + +# 07: the real server, driven with curl +echo "07 curl demo ..." +scripts/run.sh --detach +scripts/api-demo.sh > output/07-curl-demo.txt +scripts/stop.sh + +# 08: the Boot default we override +echo "08 open-in-view warning ..." +scripts/open-in-view-warning.sh > output/08-open-in-view-warning.txt + +# 09: the SQL one borrow produces +echo "09 borrow SQL ..." +scripts/sql-trace.sh > output/09-borrow-sql.txt +ls output diff --git a/library-management/scripts/run.sh b/library-management/scripts/run.sh new file mode 100755 index 0000000..b547ba2 --- /dev/null +++ b/library-management/scripts/run.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Starts the REST API on :8080 (in-memory H2, so every start is a clean library). +# Usage: scripts/run.sh foreground +# scripts/run.sh --detach background, waits until /api/books answers +set -euo pipefail +cd "$(dirname "$0")/.." +mvn -B -q -DskipTests package +if [ "${1:-}" = "--detach" ]; then + setsid nohup java -jar target/library-management-1.0.0.jar > /tmp/library-api.log 2>&1 < /dev/null & + for _ in $(seq 1 60); do curl -s -o /dev/null localhost:8080/api/books && exit 0; sleep 1; done + echo "API did not start; see /tmp/library-api.log" >&2; exit 1 +fi +exec java -jar target/library-management-1.0.0.jar diff --git a/library-management/scripts/sql-trace.sh b/library-management/scripts/sql-trace.sh new file mode 100755 index 0000000..03af1fa --- /dev/null +++ b/library-management/scripts/sql-trace.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Starts the API with Hibernate SQL logging, performs one borrow, and prints only the SQL that +# the borrow produced. Look for "version" in the UPDATE: that is optimistic locking at work. +set -euo pipefail +cd "$(dirname "$0")/.." +mvn -B -q -DskipTests package +LOG=/tmp/library-sql.log +setsid nohup java -jar target/library-management-1.0.0.jar --server.port=8081 --logging.level.org.hibernate.SQL=DEBUG > "$LOG" 2>&1 < /dev/null & +for _ in $(seq 1 60); do curl -s -o /dev/null localhost:8081/api/books && break; sleep 1; done +J='Content-Type: application/json' +curl -s -o /dev/null -H "$J" -d '{"isbn":"978-0134685991","title":"Effective Java","author":"Joshua Bloch","copies":2}' localhost:8081/api/books +curl -s -o /dev/null -H "$J" -d '{"name":"Asha","email":"asha@example.com"}' localhost:8081/api/members +START=$(wc -l < "$LOG") +curl -s -o /dev/null -H "$J" -d '{"memberId":1,"bookId":1}' localhost:8081/api/loans +sleep 1 +tail -n +"$((START + 1))" "$LOG" | grep 'org.hibernate.SQL' | sed -E 's/^.*org.hibernate.SQL +: //' +scripts/stop.sh diff --git a/library-management/scripts/stop.sh b/library-management/scripts/stop.sh new file mode 100755 index 0000000..bda3175 --- /dev/null +++ b/library-management/scripts/stop.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# Stops the API started by run.sh --detach. Only processes whose command is "java" match, so +# this script can never match (and kill) the shell that is running it. +for p in $(ps -eo pid,comm,args | awk '$2 == "java" && /library-management-1.0.0.jar/ {print $1}'); do kill "$p"; done diff --git a/library-management/src/main/java/com/ankurm/library/api/ApiExceptionHandler.java b/library-management/src/main/java/com/ankurm/library/api/ApiExceptionHandler.java new file mode 100644 index 0000000..16affc1 --- /dev/null +++ b/library-management/src/main/java/com/ankurm/library/api/ApiExceptionHandler.java @@ -0,0 +1,49 @@ +package com.ankurm.library.api; + +import com.ankurm.library.api.Exceptions.*; +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.Map; +import java.util.TreeMap; + +/** Turns exceptions into RFC 9457 problem+json bodies instead of stack traces. */ +@RestControllerAdvice +class ApiExceptionHandler { + + /** 400: names each bad field. Without this the 400 has an empty body in MockMvc. */ + @ExceptionHandler(MethodArgumentNotValidException.class) + ProblemDetail invalid(MethodArgumentNotValidException e) { + Map errors = new TreeMap<>(); + e.getBindingResult().getFieldErrors().forEach(f -> errors.putIfAbsent(f.getField(), f.getDefaultMessage())); + ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "request body failed validation"); + problem.setProperty("errors", errors); + return problem; + } + + @ExceptionHandler(NotFoundException.class) + ProblemDetail notFound(NotFoundException e) { + return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage()); + } + + @ExceptionHandler(ConflictException.class) + ProblemDetail conflict(ConflictException e) { + return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, e.getMessage()); + } + + @ExceptionHandler(RuleViolationException.class) + ProblemDetail rule(RuleViolationException e) { + return ProblemDetail.forStatusAndDetail(HttpStatus.UNPROCESSABLE_CONTENT, e.getMessage()); + } + + /** Two people changed the same Book row at once; the loser is told to retry. */ + @ExceptionHandler(ObjectOptimisticLockingFailureException.class) + ProblemDetail raced(ObjectOptimisticLockingFailureException e) { + return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, + "someone else changed this book at the same moment, please retry"); + } +} diff --git a/library-management/src/main/java/com/ankurm/library/api/Book.java b/library-management/src/main/java/com/ankurm/library/api/Book.java new file mode 100644 index 0000000..75d0de6 --- /dev/null +++ b/library-management/src/main/java/com/ankurm/library/api/Book.java @@ -0,0 +1,52 @@ +package com.ankurm.library.api; + +import jakarta.persistence.*; + +/** + * A title in the catalogue with a count of copies. The {@code @Version} column is what stops two + * people borrowing the last copy at the same moment: see BorrowRaceTest. + */ +@Entity +public class Book { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(unique = true, nullable = false) + private String isbn; + + @Column(nullable = false) + private String title; + + @Column(nullable = false) + private String author; + + private int totalCopies; + private int availableCopies; + + @Version + private long version; + + protected Book() { + } + + public Book(String isbn, String title, String author, int copies) { + this.isbn = isbn; + this.title = title; + this.author = author; + this.totalCopies = copies; + this.availableCopies = copies; + } + + public Long getId() { return id; } + public String getIsbn() { return isbn; } + public String getTitle() { return title; } + public String getAuthor() { return author; } + public int getTotalCopies() { return totalCopies; } + public int getAvailableCopies() { return availableCopies; } + public long getVersion() { return version; } + + void checkOut() { availableCopies--; } + void checkIn() { availableCopies++; } +} diff --git a/library-management/src/main/java/com/ankurm/library/api/Dtos.java b/library-management/src/main/java/com/ankurm/library/api/Dtos.java new file mode 100644 index 0000000..eba463a --- /dev/null +++ b/library-management/src/main/java/com/ankurm/library/api/Dtos.java @@ -0,0 +1,51 @@ +package com.ankurm.library.api; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import java.time.LocalDate; + +/** Request and response shapes. Entities never leave the service layer. */ +public final class Dtos { + + private Dtos() { + } + + public record BookRequest(@NotBlank String isbn, @NotBlank String title, @NotBlank String author, + @Min(1) int copies) { + } + + public record BookView(Long id, String isbn, String title, String author, int totalCopies, + int availableCopies) { + static BookView of(Book b) { + return new BookView(b.getId(), b.getIsbn(), b.getTitle(), b.getAuthor(), b.getTotalCopies(), + b.getAvailableCopies()); + } + } + + public record MemberRequest(@NotBlank String name, @NotBlank @Email String email) { + } + + public record MemberView(Long id, String name, String email) { + static MemberView of(Member m) { + return new MemberView(m.getId(), m.getName(), m.getEmail()); + } + } + + public record BorrowRequest(@NotNull Long memberId, @NotNull Long bookId) { + } + + /** returnedOn is optional: omit it to return "today", supply it to back-date a return. */ + public record ReturnRequest(LocalDate returnedOn) { + } + + public record LoanView(Long id, String bookTitle, String memberName, LocalDate borrowedOn, LocalDate dueOn, + LocalDate returnedOn, int fine) { + static LoanView of(Loan l) { + return new LoanView(l.getId(), l.getBook().getTitle(), l.getMember().getName(), l.getBorrowedOn(), + l.getDueOn(), l.getReturnedOn(), l.getFine()); + } + } +} diff --git a/library-management/src/main/java/com/ankurm/library/api/Exceptions.java b/library-management/src/main/java/com/ankurm/library/api/Exceptions.java new file mode 100644 index 0000000..616196c --- /dev/null +++ b/library-management/src/main/java/com/ankurm/library/api/Exceptions.java @@ -0,0 +1,29 @@ +package com.ankurm.library.api; + +/** Business-rule failures, each mapped to an HTTP status in ApiExceptionHandler. */ +public final class Exceptions { + + private Exceptions() { + } + + /** 404: the thing the caller named does not exist. */ + public static class NotFoundException extends RuntimeException { + public NotFoundException(String message) { + super(message); + } + } + + /** 409: the request is fine, but the current state of the library says no. */ + public static class ConflictException extends RuntimeException { + public ConflictException(String message) { + super(message); + } + } + + /** 422: the member is not allowed to do this under the lending rules. */ + public static class RuleViolationException extends RuntimeException { + public RuleViolationException(String message) { + super(message); + } + } +} diff --git a/library-management/src/main/java/com/ankurm/library/api/LibraryApplication.java b/library-management/src/main/java/com/ankurm/library/api/LibraryApplication.java new file mode 100644 index 0000000..6dd23ee --- /dev/null +++ b/library-management/src/main/java/com/ankurm/library/api/LibraryApplication.java @@ -0,0 +1,22 @@ +package com.ankurm.library.api; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; + +import java.time.Clock; + +/** Stage 3 entry point: {@code mvn spring-boot:run} serves the REST API on port 8080. */ +@SpringBootApplication +public class LibraryApplication { + + public static void main(String[] args) { + SpringApplication.run(LibraryApplication.class, args); + } + + /** Injected rather than calling LocalDate.now() so tests can control "today". */ + @Bean + Clock clock() { + return Clock.systemDefaultZone(); + } +} diff --git a/library-management/src/main/java/com/ankurm/library/api/LibraryController.java b/library-management/src/main/java/com/ankurm/library/api/LibraryController.java new file mode 100644 index 0000000..3682d8c --- /dev/null +++ b/library-management/src/main/java/com/ankurm/library/api/LibraryController.java @@ -0,0 +1,52 @@ +package com.ankurm.library.api; + +import com.ankurm.library.api.Dtos.*; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api") +public class LibraryController { + + private final LoanService service; + + LibraryController(LoanService service) { + this.service = service; + } + + @PostMapping("/books") + @ResponseStatus(HttpStatus.CREATED) + BookView addBook(@Valid @RequestBody BookRequest request) { + return service.addBook(request); + } + + @GetMapping("/books") + List books() { + return service.listBooks(); + } + + @PostMapping("/members") + @ResponseStatus(HttpStatus.CREATED) + MemberView addMember(@Valid @RequestBody MemberRequest request) { + return service.addMember(request); + } + + @PostMapping("/loans") + @ResponseStatus(HttpStatus.CREATED) + LoanView borrow(@Valid @RequestBody BorrowRequest request) { + return service.borrow(request); + } + + @PostMapping("/loans/{id}/return") + LoanView giveBack(@PathVariable Long id, @RequestBody(required = false) ReturnRequest request) { + return service.giveBack(id, request == null ? null : request.returnedOn()); + } + + @GetMapping("/loans") + List loans() { + return service.listLoans(); + } +} diff --git a/library-management/src/main/java/com/ankurm/library/api/Loan.java b/library-management/src/main/java/com/ankurm/library/api/Loan.java new file mode 100644 index 0000000..12e82dc --- /dev/null +++ b/library-management/src/main/java/com/ankurm/library/api/Loan.java @@ -0,0 +1,48 @@ +package com.ankurm.library.api; + +import jakarta.persistence.*; + +import java.time.LocalDate; + +/** One member borrowing one copy of one book. Both associations are LAZY on purpose. */ +@Entity +public class Loan { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + private Book book; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + private Member member; + + private LocalDate borrowedOn; + private LocalDate dueOn; + private LocalDate returnedOn; + private int fine; + + protected Loan() { + } + + public Loan(Book book, Member member, LocalDate borrowedOn, LocalDate dueOn) { + this.book = book; + this.member = member; + this.borrowedOn = borrowedOn; + this.dueOn = dueOn; + } + + public Long getId() { return id; } + public Book getBook() { return book; } + public Member getMember() { return member; } + public LocalDate getBorrowedOn() { return borrowedOn; } + public LocalDate getDueOn() { return dueOn; } + public LocalDate getReturnedOn() { return returnedOn; } + public int getFine() { return fine; } + + void markReturned(LocalDate on, int fine) { + this.returnedOn = on; + this.fine = fine; + } +} diff --git a/library-management/src/main/java/com/ankurm/library/api/LoanService.java b/library-management/src/main/java/com/ankurm/library/api/LoanService.java new file mode 100644 index 0000000..c9900a8 --- /dev/null +++ b/library-management/src/main/java/com/ankurm/library/api/LoanService.java @@ -0,0 +1,92 @@ +package com.ankurm.library.api; + +import com.ankurm.library.api.Dtos.*; +import com.ankurm.library.api.Exceptions.*; +import com.ankurm.library.core.LoanPolicy; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Clock; +import java.time.LocalDate; +import java.util.List; + +/** The same four operations as the console Library, now on top of a database. */ +@Service +public class LoanService { + + private final BookRepository books; + private final MemberRepository members; + private final LoanRepository loans; + private final Clock clock; + + LoanService(BookRepository books, MemberRepository members, LoanRepository loans, Clock clock) { + this.books = books; + this.members = members; + this.loans = loans; + this.clock = clock; + } + + @Transactional + public BookView addBook(BookRequest r) { + if (books.existsByIsbn(r.isbn())) { + throw new ConflictException("ISBN " + r.isbn() + " already exists"); + } + return BookView.of(books.save(new Book(r.isbn(), r.title(), r.author(), r.copies()))); + } + + @Transactional + public MemberView addMember(MemberRequest r) { + if (members.existsByEmail(r.email())) { + throw new ConflictException("email " + r.email() + " is already registered"); + } + return MemberView.of(members.save(new Member(r.name(), r.email()))); + } + + @Transactional(readOnly = true) + public List listBooks() { + return books.findAll().stream().map(BookView::of).toList(); + } + + @Transactional + public LoanView borrow(BorrowRequest r) { + Member member = members.findById(r.memberId()) + .orElseThrow(() -> new NotFoundException("no member with id " + r.memberId())); + Book book = books.findById(r.bookId()) + .orElseThrow(() -> new NotFoundException("no book with id " + r.bookId())); + if (book.getAvailableCopies() == 0) { + throw new ConflictException("no copies of '" + book.getTitle() + "' are available"); + } + if (loans.existsByMemberIdAndBookIdAndReturnedOnIsNull(member.getId(), book.getId())) { + throw new RuleViolationException(member.getName() + " already has '" + book.getTitle() + "' on loan"); + } + long active = loans.countByMemberIdAndReturnedOnIsNull(member.getId()); + if (!LoanPolicy.mayBorrow((int) active)) { + throw new RuleViolationException(member.getName() + " already has " + active + + " active loans (max " + LoanPolicy.MAX_ACTIVE_LOANS + ")"); + } + LocalDate today = LocalDate.now(clock); + book.checkOut(); // dirty checking turns this into UPDATE book ... WHERE id=? AND version=? + return LoanView.of(loans.save(new Loan(book, member, today, LoanPolicy.dueOn(today)))); + } + + @Transactional + public LoanView giveBack(Long loanId, LocalDate returnedOn) { + Loan loan = loans.findById(loanId).orElseThrow(() -> new NotFoundException("no loan with id " + loanId)); + if (loan.getReturnedOn() != null) { + throw new ConflictException("loan " + loanId + " was already returned on " + loan.getReturnedOn()); + } + LocalDate on = returnedOn != null ? returnedOn : LocalDate.now(clock); + if (on.isBefore(loan.getBorrowedOn())) { + throw new RuleViolationException("cannot return on " + on + ", before it was borrowed on " + + loan.getBorrowedOn()); + } + loan.getBook().checkIn(); + loan.markReturned(on, LoanPolicy.fine(loan.getDueOn(), on)); + return LoanView.of(loan); + } + + @Transactional(readOnly = true) + public List listLoans() { + return loans.findAllWithDetails().stream().map(LoanView::of).toList(); + } +} diff --git a/library-management/src/main/java/com/ankurm/library/api/Member.java b/library-management/src/main/java/com/ankurm/library/api/Member.java new file mode 100644 index 0000000..46bc64f --- /dev/null +++ b/library-management/src/main/java/com/ankurm/library/api/Member.java @@ -0,0 +1,29 @@ +package com.ankurm.library.api; + +import jakarta.persistence.*; + +@Entity +public class Member { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String name; + + @Column(unique = true, nullable = false) + private String email; + + protected Member() { + } + + public Member(String name, String email) { + this.name = name; + this.email = email; + } + + public Long getId() { return id; } + public String getName() { return name; } + public String getEmail() { return email; } +} diff --git a/library-management/src/main/java/com/ankurm/library/api/Repositories.java b/library-management/src/main/java/com/ankurm/library/api/Repositories.java new file mode 100644 index 0000000..c994461 --- /dev/null +++ b/library-management/src/main/java/com/ankurm/library/api/Repositories.java @@ -0,0 +1,28 @@ +package com.ankurm.library.api; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; + +interface BookRepository extends JpaRepository { + boolean existsByIsbn(String isbn); +} + +interface MemberRepository extends JpaRepository { + boolean existsByEmail(String email); +} + +interface LoanRepository extends JpaRepository { + + long countByMemberIdAndReturnedOnIsNull(Long memberId); + + boolean existsByMemberIdAndBookIdAndReturnedOnIsNull(Long memberId, Long bookId); + + /** The innocent-looking version: loans only, book and member are lazy proxies. */ + List findAllByOrderByIdAsc(); + + /** The same list in one SQL statement: JOIN FETCH pulls book and member in with each loan. */ + @Query("select l from Loan l join fetch l.book join fetch l.member order by l.id") + List findAllWithDetails(); +} diff --git a/library-management/src/main/java/com/ankurm/library/console/ConsoleApp.java b/library-management/src/main/java/com/ankurm/library/console/ConsoleApp.java new file mode 100644 index 0000000..fb2a349 --- /dev/null +++ b/library-management/src/main/java/com/ankurm/library/console/ConsoleApp.java @@ -0,0 +1,62 @@ +package com.ankurm.library.console; + +import java.time.LocalDate; +import java.util.Scanner; + +/** + * Stage 2 entry point. Reads one command per line from stdin, so a demo run is reproducible: + * {@code java -cp target/classes com.ankurm.library.console.ConsoleApp < scripts/demo-input.txt} + * + *
+ * add-book isbn|title|author|copies
+ * add-member name
+ * borrow isbn memberId [yyyy-mm-dd]
+ * return loanId [yyyy-mm-dd]
+ * report
+ * quit
+ * 
+ */ +public class ConsoleApp { + + public static void main(String[] args) { + Library library = new Library(); + Scanner in = new Scanner(System.in); + while (in.hasNextLine()) { + String line = in.nextLine().trim(); + if (line.isEmpty()) { + continue; + } + System.out.println("> " + line); + String[] cmd = line.split(" ", 2); + String rest = cmd.length > 1 ? cmd[1] : ""; + try { + switch (cmd[0]) { + case "add-book" -> { + String[] p = rest.split("\\|"); + System.out.println(library.addBook(p[0], p[1], p[2], Integer.parseInt(p[3]))); + } + case "add-member" -> System.out.println(library.addMember(rest)); + case "borrow" -> { + String[] p = rest.split(" "); + System.out.println(library.borrow(p[0], Integer.parseInt(p[1]), dateOrToday(p, 2))); + } + case "return" -> { + String[] p = rest.split(" "); + System.out.println(library.giveBack(Integer.parseInt(p[0]), dateOrToday(p, 1))); + } + case "report" -> library.report().forEach(System.out::println); + case "quit" -> { + return; + } + default -> System.out.println("ERROR: unknown command '" + cmd[0] + "'"); + } + } catch (RuntimeException e) { + System.out.println("ERROR: bad input (" + e.getClass().getSimpleName() + ")"); + } + } + } + + private static LocalDate dateOrToday(String[] parts, int index) { + return parts.length > index ? LocalDate.parse(parts[index]) : LocalDate.now(); + } +} diff --git a/library-management/src/main/java/com/ankurm/library/console/Library.java b/library-management/src/main/java/com/ankurm/library/console/Library.java new file mode 100644 index 0000000..4711686 --- /dev/null +++ b/library-management/src/main/java/com/ankurm/library/console/Library.java @@ -0,0 +1,118 @@ +package com.ankurm.library.console; + +import com.ankurm.library.core.LoanPolicy; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Stage 2: the console app's domain, split out of the single-file version. + * Everything lives in memory in plain collections; the rules come from {@link LoanPolicy}. + */ +public class Library { + + public static class Book { + final String isbn; + final String title; + final String author; + int availableCopies; + + Book(String isbn, String title, String author, int copies) { + this.isbn = isbn; + this.title = title; + this.author = author; + this.availableCopies = copies; + } + } + + public record Member(int id, String name) { + } + + public static class Loan { + final int id; + final Book book; + final Member member; + final LocalDate borrowedOn; + final LocalDate dueOn; + LocalDate returnedOn; + + Loan(int id, Book book, Member member, LocalDate borrowedOn) { + this.id = id; + this.book = book; + this.member = member; + this.borrowedOn = borrowedOn; + this.dueOn = LoanPolicy.dueOn(borrowedOn); + } + + boolean active() { + return returnedOn == null; + } + } + + private final Map books = new LinkedHashMap<>(); + private final Map members = new LinkedHashMap<>(); + private final List loans = new ArrayList<>(); + + public String addBook(String isbn, String title, String author, int copies) { + if (books.containsKey(isbn)) { + return "ERROR: ISBN " + isbn + " already exists"; + } + books.put(isbn, new Book(isbn, title, author, copies)); + return "Added book: " + title + " (" + copies + (copies == 1 ? " copy)" : " copies)"); + } + + public String addMember(String name) { + int id = members.size() + 1; + members.put(id, new Member(id, name)); + return "Added member #" + id + ": " + name; + } + + public String borrow(String isbn, int memberId, LocalDate on) { + Book book = books.get(isbn); + Member member = members.get(memberId); + if (book == null) { + return "ERROR: no such book " + isbn; + } + if (member == null) { + return "ERROR: no such member #" + memberId; + } + if (book.availableCopies == 0) { + return "ERROR: no copies of '" + book.title + "' are available"; + } + long active = loans.stream().filter(l -> l.active() && l.member.equals(member)).count(); + if (!LoanPolicy.mayBorrow((int) active)) { + return "ERROR: " + member.name() + " already has " + active + " active loans (max " + + LoanPolicy.MAX_ACTIVE_LOANS + ")"; + } + Loan loan = new Loan(loans.size() + 1, book, member, on); + loans.add(loan); + book.availableCopies--; + return "Loan #" + loan.id + ": " + member.name() + " borrowed '" + book.title + "', due " + loan.dueOn; + } + + public String giveBack(int loanId, LocalDate on) { + if (loanId < 1 || loanId > loans.size()) { + return "ERROR: no such loan #" + loanId; + } + Loan loan = loans.get(loanId - 1); + if (!loan.active()) { + return "ERROR: loan #" + loanId + " was already returned on " + loan.returnedOn; + } + loan.returnedOn = on; + loan.book.availableCopies++; + int fine = LoanPolicy.fine(loan.dueOn, on); + return "Loan #" + loanId + " returned on " + on + (fine > 0 ? ", fine Rs " + fine : ", no fine"); + } + + public List report() { + List lines = new ArrayList<>(); + books.values().forEach(b -> lines.add(" " + b.title + " by " + b.author + ": " + b.availableCopies + + " available")); + loans.stream().filter(Loan::active).forEach(l -> lines.add(" ON LOAN #" + l.id + ": '" + l.book.title + + "' -> " + l.member.name() + ", due " + l.dueOn)); + return lines; + } +} diff --git a/library-management/src/main/java/com/ankurm/library/core/LoanPolicy.java b/library-management/src/main/java/com/ankurm/library/core/LoanPolicy.java new file mode 100644 index 0000000..de6a4b7 --- /dev/null +++ b/library-management/src/main/java/com/ankurm/library/core/LoanPolicy.java @@ -0,0 +1,34 @@ +package com.ankurm.library.core; + +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; + +/** + * The library's lending rules, as pure functions with no framework in sight. + * + *

This is the one class that survives the move from the console app to the Spring Boot API + * completely unchanged. Both {@code console.Library} and {@code api.LoanService} call it. + */ +public final class LoanPolicy { + + public static final int LOAN_DAYS = 14; + public static final int MAX_ACTIVE_LOANS = 3; + public static final int FINE_PER_DAY = 2; // rupees + + private LoanPolicy() { + } + + public static LocalDate dueOn(LocalDate borrowedOn) { + return borrowedOn.plusDays(LOAN_DAYS); + } + + /** Fine in rupees: zero on or before the due date, then FINE_PER_DAY for each day late. */ + public static int fine(LocalDate dueOn, LocalDate returnedOn) { + long daysLate = ChronoUnit.DAYS.between(dueOn, returnedOn); + return daysLate > 0 ? (int) daysLate * FINE_PER_DAY : 0; + } + + public static boolean mayBorrow(int activeLoans) { + return activeLoans < MAX_ACTIVE_LOANS; + } +} diff --git a/library-management/src/main/resources/application.properties b/library-management/src/main/resources/application.properties new file mode 100644 index 0000000..baf2292 --- /dev/null +++ b/library-management/src/main/resources/application.properties @@ -0,0 +1,4 @@ +spring.application.name=library-management +spring.datasource.url=jdbc:h2:mem:library;DB_CLOSE_DELAY=-1 +spring.jpa.hibernate.ddl-auto=create +spring.jpa.open-in-view=false diff --git a/library-management/src/test/java/com/ankurm/library/api/BorrowRaceTest.java b/library-management/src/test/java/com/ankurm/library/api/BorrowRaceTest.java new file mode 100644 index 0000000..f7fd5c3 --- /dev/null +++ b/library-management/src/test/java/com/ankurm/library/api/BorrowRaceTest.java @@ -0,0 +1,142 @@ +package com.ankurm.library.api; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.transaction.support.TransactionTemplate; + +import com.ankurm.library.api.Dtos.BorrowRequest; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.*; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Eight people try to borrow the one remaining copy at the same instant. + * First with the real LoanService (Book has @Version), then with a version-less + * read-check-write that does exactly what a JPA entity without @Version would do. + */ +@SpringBootTest(properties = "spring.datasource.url=jdbc:h2:mem:race") +class BorrowRaceTest { + + private static final int THREADS = 8; + + @Autowired BookRepository books; + @Autowired MemberRepository members; + @Autowired LoanService service; + @Autowired JdbcClient jdbc; + @Autowired TransactionTemplate tx; + + @Test + void withVersionColumnExactlyOneBorrowerWins() throws Exception { + Book book = books.save(new Book("race-1", "Last Copy", "Somebody", 1)); + List memberIds = seedMembers("v"); + + List outcomes = runConcurrently(i -> service.borrow(new BorrowRequest(memberIds.get(i), book.getId()))); + + long wins = outcomes.stream().filter(o -> o.equals("OK")).count(); + long lost = outcomes.stream().filter(o -> o.equals("ObjectOptimisticLockingFailureException")).count(); + long loansInDb = jdbc.sql("select count(*) from loan where book_id = ?").param(book.getId()).query(Long.class).single(); + int available = books.findById(book.getId()).orElseThrow().getAvailableCopies(); + + assertEquals(1, wins); + assertEquals(1, loansInDb); + assertEquals(0, available); + + new Transcript("05-race-with-version.txt") + .line(THREADS + " threads borrow the last copy at once (Book has @Version)") + .line(row("successful borrows", wins)) + .line(row("refused by optimistic-lock failure", lost)) + .line(row("refused for any other reason", THREADS - wins - lost)) + .line(row("loan rows in DB", loansInDb)) + .line(row("availableCopies", available)) + .save(); + } + + @Test + void withoutVersionEveryoneWinsAndTheCopyIsLent() throws Exception { + long bookId = jdbc.sql("insert into book (isbn, title, author, total_copies, available_copies, version) " + + "values ('race-2', 'Naive Copy', 'Somebody', 1, 1, 0)").update() == 1 + ? jdbc.sql("select id from book where isbn = 'race-2'").query(Long.class).single() : -1; + List memberIds = seedMembers("n"); + CyclicBarrier readDone = new CyclicBarrier(THREADS); + + List outcomes = runConcurrently(i -> tx.executeWithoutResult(s -> { + int available = jdbc.sql("select available_copies from book where id = ?").param(bookId).query(Integer.class).single(); + if (available == 0) { + throw new IllegalStateException("none left"); + } + await(readDone); // everyone has now read available = 1 + jdbc.sql("update book set available_copies = ? where id = ?").params(available - 1, bookId).update(); + jdbc.sql("insert into loan (book_id, member_id, borrowed_on, due_on, fine) values (?, ?, ?, ?, 0)") + .params(bookId, memberIds.get(i), LocalDate.of(2026, 11, 1), LocalDate.of(2026, 11, 15)).update(); + })); + + long wins = outcomes.stream().filter(o -> o.equals("OK")).count(); + long loansInDb = jdbc.sql("select count(*) from loan where book_id = ?").param(bookId).query(Long.class).single(); + int available = jdbc.sql("select available_copies from book where id = ?").param(bookId).query(Integer.class).single(); + + assertEquals(THREADS, wins); + assertEquals(THREADS, loansInDb); + assertEquals(0, available); + + new Transcript("06-race-without-version.txt") + .line(THREADS + " threads borrow the last copy at once (read, check, write: no @Version)") + .line("successful borrows : " + wins) + .line("loan rows in DB : " + loansInDb + " <- one copy, " + loansInDb + " loans") + .line("availableCopies : " + available + " <- the counter says everything is fine") + .save(); + } + + private static String row(String label, long value) { + return String.format("%-36s: %d", label, value); + } + + private List seedMembers(String prefix) { + List ids = new ArrayList<>(); + for (int i = 0; i < THREADS; i++) { + ids.add(members.save(new Member(prefix + i, prefix + i + "@example.com")).getId()); + } + return ids; + } + + private static void await(CyclicBarrier barrier) { + try { + barrier.await(10, TimeUnit.SECONDS); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + private interface Action { + void run(int index) throws Exception; + } + + private List runConcurrently(Action action) throws Exception { + ExecutorService pool = Executors.newFixedThreadPool(THREADS); + CyclicBarrier start = new CyclicBarrier(THREADS); + List> futures = new ArrayList<>(); + for (int i = 0; i < THREADS; i++) { + int index = i; + futures.add(pool.submit(() -> { + await(start); + try { + action.run(index); + return "OK"; + } catch (Throwable e) { + return e.getClass().getSimpleName(); + } + })); + } + List outcomes = new ArrayList<>(); + for (Future f : futures) { + outcomes.add(f.get(30, TimeUnit.SECONDS)); + } + pool.shutdown(); + return outcomes; + } +} diff --git a/library-management/src/test/java/com/ankurm/library/api/LibraryApiTest.java b/library-management/src/test/java/com/ankurm/library/api/LibraryApiTest.java new file mode 100644 index 0000000..7a76ff2 --- /dev/null +++ b/library-management/src/test/java/com/ankurm/library/api/LibraryApiTest.java @@ -0,0 +1,84 @@ +package com.ankurm.library.api; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; + +/** The console demo's story, replayed over HTTP against the real controllers, service and H2. */ +@SpringBootTest(properties = "spring.datasource.url=jdbc:h2:mem:api") +@AutoConfigureMockMvc +class LibraryApiTest { + + /** Freezes "today" at 2026-11-01 so due dates in the transcript never drift. */ + @TestConfiguration + static class FixedClock { + @Bean + @Primary + Clock fixedClock() { + return Clock.fixed(Instant.parse("2026-11-01T06:00:00Z"), ZoneId.of("Asia/Kolkata")); + } + } + + @Autowired + MockMvc mvc; + + private final Transcript t = new Transcript("03-rest-api.txt"); + + private MvcResult call(String method, String path, String json, int expectedStatus) throws Exception { + var request = method.equals("GET") ? get(path) : post(path); + if (json != null) { + request = request.contentType(MediaType.APPLICATION_JSON).content(json); + } + MvcResult result = mvc.perform(request).andReturn(); + String body = result.getResponse().getContentAsString(); + t.line("> " + method + " " + path + (json != null ? " " + json : "")); + t.line("< " + result.getResponse().getStatus() + " " + (body.isEmpty() ? "(no body)" : body)); + t.line(""); + assertEquals(expectedStatus, result.getResponse().getStatus(), path + " -> " + body); + return result; + } + + @Test + void borrowReturnFineAndEveryWayItCanBeRefused() throws Exception { + call("POST", "/api/books", "{\"isbn\":\"978-0134685991\",\"title\":\"Effective Java\",\"author\":\"Joshua Bloch\",\"copies\":2}", 201); + call("POST", "/api/books", "{\"isbn\":\"978-0134685991\",\"title\":\"Effective Java\",\"author\":\"Joshua Bloch\",\"copies\":2}", 409); + call("POST", "/api/books", "{\"isbn\":\"978-0000000000\",\"title\":\"\",\"author\":\"Nobody\",\"copies\":0}", 400); + call("POST", "/api/books", "{\"isbn\":\"978-0596009205\",\"title\":\"Head First Java\",\"author\":\"Kathy Sierra\",\"copies\":1}", 201); + call("POST", "/api/books", "{\"isbn\":\"978-0132350884\",\"title\":\"Clean Code\",\"author\":\"Robert Martin\",\"copies\":1}", 201); + call("POST", "/api/books", "{\"isbn\":\"978-0321356680\",\"title\":\"Effective Java 2e\",\"author\":\"Joshua Bloch\",\"copies\":1}", 201); + call("POST", "/api/members", "{\"name\":\"Asha\",\"email\":\"asha@example.com\"}", 201); + call("POST", "/api/members", "{\"name\":\"Ravi\",\"email\":\"ravi@example.com\"}", 201); + + call("POST", "/api/loans", "{\"memberId\":1,\"bookId\":2}", 201); + call("POST", "/api/loans", "{\"memberId\":2,\"bookId\":2}", 409); + call("POST", "/api/loans", "{\"memberId\":99,\"bookId\":1}", 404); + call("POST", "/api/loans/1/return", "{\"returnedOn\":\"2026-11-20\"}", 200); + call("POST", "/api/loans/1/return", "{\"returnedOn\":\"2026-11-21\"}", 409); + + call("POST", "/api/loans", "{\"memberId\":1,\"bookId\":1}", 201); + call("POST", "/api/loans", "{\"memberId\":1,\"bookId\":3}", 201); + call("POST", "/api/loans", "{\"memberId\":1,\"bookId\":4}", 201); + call("POST", "/api/loans", "{\"memberId\":1,\"bookId\":2}", 422); + + MvcResult books = call("GET", "/api/books", null, 200); + assertTrue(books.getResponse().getContentAsString().contains("\"availableCopies\":1")); + call("GET", "/api/loans", null, 200); + t.save(); + } +} diff --git a/library-management/src/test/java/com/ankurm/library/api/QueryCountTest.java b/library-management/src/test/java/com/ankurm/library/api/QueryCountTest.java new file mode 100644 index 0000000..18dd645 --- /dev/null +++ b/library-management/src/test/java/com/ankurm/library/api/QueryCountTest.java @@ -0,0 +1,69 @@ +package com.ankurm.library.api; + +import jakarta.persistence.EntityManagerFactory; +import org.hibernate.SessionFactory; +import org.hibernate.stat.Statistics; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.LocalDate; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** Counts the SQL statements Hibernate actually sends. LAZY does not mean "free". */ +@SpringBootTest(properties = {"spring.datasource.url=jdbc:h2:mem:count", + "spring.jpa.properties.hibernate.generate_statistics=true"}) +class QueryCountTest { + + private static final int LOANS = 5; + + @Autowired BookRepository books; + @Autowired MemberRepository members; + @Autowired LoanRepository loans; + @Autowired LoanService service; + @Autowired EntityManagerFactory emf; + @Autowired TransactionTemplate tx; + + @Test + void listingLoansCostsOnePlusTwoNLazilyAndOneWithJoinFetch() { + for (int i = 1; i <= LOANS; i++) { + Book b = books.save(new Book("isbn-" + i, "Book " + i, "Author " + i, 1)); + Member m = members.save(new Member("Member " + i, "m" + i + "@example.com")); + loans.save(new Loan(b, m, LocalDate.of(2026, 11, 1), LocalDate.of(2026, 11, 15))); + } + Statistics stats = emf.unwrap(SessionFactory.class).getStatistics(); + + stats.clear(); + tx.executeWithoutResult(s -> loans.findAllByOrderByIdAsc() + .forEach(l -> { + l.getBook().getTitle(); + l.getMember().getName(); + })); + long lazy = stats.getPrepareStatementCount(); + + stats.clear(); + tx.executeWithoutResult(s -> loans.findAllWithDetails() + .forEach(l -> { + l.getBook().getTitle(); + l.getMember().getName(); + })); + long fetched = stats.getPrepareStatementCount(); + + stats.clear(); + service.listLoans(); + long viaService = stats.getPrepareStatementCount(); + + assertEquals(1 + 2 * LOANS, lazy); + assertEquals(1, fetched); + assertEquals(1, viaService); + + new Transcript("04-query-count.txt") + .line(LOANS + " loans, each with its own book and its own member") + .line("findAllByOrderByIdAsc() then read book title + member name: " + lazy + " SQL statements") + .line("findAllWithDetails() (join fetch) then the same reads: " + fetched + " SQL statement") + .line("LoanService.listLoans() as called by GET /api/loans: " + viaService + " SQL statement") + .save(); + } +} diff --git a/library-management/src/test/java/com/ankurm/library/api/Transcript.java b/library-management/src/test/java/com/ankurm/library/api/Transcript.java new file mode 100644 index 0000000..c9a1330 --- /dev/null +++ b/library-management/src/test/java/com/ankurm/library/api/Transcript.java @@ -0,0 +1,35 @@ +package com.ankurm.library.api; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * Writes {@code output/NN-name.txt}. Every test that produces a number quoted in the article + * also asserts it, so the transcript and the assertion can never disagree. + */ +public final class Transcript { + + private final StringBuilder text = new StringBuilder(); + private final String file; + + public Transcript(String file) { + this.file = file; + } + + public Transcript line(String s) { + text.append(s).append('\n'); + return this; + } + + public void save() { + try { + Path out = Path.of("output", file); + Files.createDirectories(out.getParent()); + Files.writeString(out, text.toString()); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/library-management/src/test/java/com/ankurm/library/core/LoanPolicyTest.java b/library-management/src/test/java/com/ankurm/library/core/LoanPolicyTest.java new file mode 100644 index 0000000..92ddcfb --- /dev/null +++ b/library-management/src/test/java/com/ankurm/library/core/LoanPolicyTest.java @@ -0,0 +1,36 @@ +package com.ankurm.library.core; + +import com.ankurm.library.api.Transcript; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.junit.jupiter.api.Assertions.*; + +class LoanPolicyTest { + + @Test + void fineIsTwoRupeesForEachDayAfterTheDueDate() { + LocalDate borrowed = LocalDate.of(2026, 11, 1); + LocalDate due = LoanPolicy.dueOn(borrowed); + assertEquals(LocalDate.of(2026, 11, 15), due); + + Transcript t = new Transcript("02-loan-policy.txt"); + t.line("borrowed " + borrowed + ", due " + due + " (" + LoanPolicy.LOAN_DAYS + " days)"); + int[][] cases = {{-3, 0}, {0, 0}, {1, 2}, {5, 10}, {30, 60}}; + for (int[] c : cases) { + LocalDate returned = due.plusDays(c[0]); + int fine = LoanPolicy.fine(due, returned); + assertEquals(c[1], fine); + t.line(String.format("returned %s (%+3d days vs due) -> fine Rs %d", returned, c[0], fine)); + } + t.save(); + } + + @Test + void thirdLoanIsAllowedFourthIsNot() { + assertTrue(LoanPolicy.mayBorrow(0)); + assertTrue(LoanPolicy.mayBorrow(2)); + assertFalse(LoanPolicy.mayBorrow(3)); + } +}