diff --git a/build.gradle b/build.gradle index 9b4d7bf..3d04508 100644 --- a/build.gradle +++ b/build.gradle @@ -33,6 +33,7 @@ dependencies { annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testImplementation 'org.junit.jupiter:junit-jupiter-params' } tasks.named('test') { diff --git a/src/main/java/com/codesungrape/hmcts/BookAPI/BookApiApplication.java b/src/main/java/com/codesungrape/hmcts/BookAPI/BookApiApplication.java new file mode 100644 index 0000000..cb2617c --- /dev/null +++ b/src/main/java/com/codesungrape/hmcts/BookAPI/BookApiApplication.java @@ -0,0 +1,13 @@ +package com.codesungrape.hmcts.BookAPI; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class BookApiApplication { + + public static void main(String[] args) { + SpringApplication.run(BookApiApplication.class, args); + } + +} diff --git a/src/main/java/com/codesungrape/hmcts/BookAPI/dto/BookRequest.java b/src/main/java/com/codesungrape/hmcts/BookAPI/dto/BookRequest.java new file mode 100644 index 0000000..76667b4 --- /dev/null +++ b/src/main/java/com/codesungrape/hmcts/BookAPI/dto/BookRequest.java @@ -0,0 +1,27 @@ +package com.codesungrape.hmcts.BookAPI.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.NotBlank; +import lombok.Value; + +/** + * DTO representing the required input for creating or replacing a Book resource. + * This class mirrors the OpenAPI 'BookInput' schema. + * @Value: Makes all fields 'final' (immutable), generates constructor, getters, and equals/hashCode/toString. + * @NotBlank: Enforces the required status from your OpenAPI schema. If the field is missing or an empty string, Spring will return a 400 Bad Request. + * @JsonProperty: Jackson library - maps snake_case JSON (HMCTS rules) to camelCase Java + */ +@Value +public class BookRequest { + @NotBlank(message= "Title is required") + @JsonProperty("title") + String title; + + @NotBlank(message = "Synopsis is required") + @JsonProperty("synopsis") + String synopsis; + + @NotBlank(message = "Author is required") + @JsonProperty("author") + String author; +} \ No newline at end of file diff --git a/src/main/java/com/codesungrape/hmcts/BookAPI/entity/Book.java b/src/main/java/com/codesungrape/hmcts/BookAPI/entity/Book.java new file mode 100644 index 0000000..43cd2cd --- /dev/null +++ b/src/main/java/com/codesungrape/hmcts/BookAPI/entity/Book.java @@ -0,0 +1,76 @@ +package com.codesungrape.hmcts.BookAPI.entity; + +import jakarta.persistence.*; +import java.util.UUID; +import lombok.*; +import java.time.Instant; + +/** + * JPA Entity representing the Book table in PostgreSQL. + * This holds the persisted state of the resource. + * HMCTS Rule Check: IDs must be opaque strings. Using UUID for distributed ID generation. + * @Entity: Marks the class as a JPA entity - tells hibernate to map Java classes to database tables. + * @Table: Defines which database table this entity maps to. HMCTS Naming: Lowercase, singular table name is common practice. + * Lombok annotations: + * @Getter: Automatically generates getters for all fields. + * @Setter: Automatically generates setters. + * @AllArgsConstructor: Generates a no-argument constructor (required by JPA). + * JPA needs to instantiate the entity using reflection. 'PROTECTED' prevents misuse. + * @Builder: Adds a builder pattern for clean object creation. +* You can do Book.builder().title("A").author("B").build(); + */ +@Entity +@Table(name = "book") +@Getter +@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) // For JPA/Hibernate requirements +@AllArgsConstructor // For easy construction in tests +@Builder // For convenience in creating instances +public class Book { + + @Id // Primary key of the table + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id", nullable = false) // maps the field to a database column named 'id' + 'nullable =false' database column cannot be NULL. + private UUID id; + + @Column(name = "title", nullable = false) + private String title; + + @Column(name = "synopsis", nullable = false, columnDefinition = "TEXT") + private String synopsis; + + @Column(name = "author", nullable = false) + private String author; + + // Soft delete - makes DELETE operations idempotent (safe to repeat) + // Soft delete - using @Builder.Default to ensure the builder + // respects this initialization if the field is not set explicitly. + @Column(name = "deleted", nullable = false) + @Builder.Default + private boolean deleted = false; + + // `createdAt` is null upon object creation. + // It will be set by the `onCreate()` method right before persistence. + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "modified_at") + private java.time.Instant modifiedAt; + + // --- JPA lifecycle callbacks --- + @PrePersist + protected void onCreate() { + this.createdAt = java.time.Instant.now(); + } + + // --- Business Logic Helper --- + // HMCTS mandates business logic in services, but a setter hook is acceptable. + // Lifecycle callback - special method runs automatically before Hibernate updates a record in the database. + @PreUpdate + protected void onUpdate() { + + this.modifiedAt = java.time.Instant.now(); + } + + +} \ No newline at end of file diff --git a/src/main/java/com/codesungrape/hmcts/BookAPI/repository/BookRepository.java b/src/main/java/com/codesungrape/hmcts/BookAPI/repository/BookRepository.java new file mode 100644 index 0000000..5bc5413 --- /dev/null +++ b/src/main/java/com/codesungrape/hmcts/BookAPI/repository/BookRepository.java @@ -0,0 +1,24 @@ +package com.codesungrape.hmcts.BookAPI.repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import com.codesungrape.hmcts.BookAPI.entity.Book; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +/** + * Repository interface for Book Entity. + * Spring Data JPA automatically provides CRUD operations based on the Entity and ID type. + */ +@Repository +public interface BookRepository extends JpaRepository { + + // Custom query to find books that have NOT been soft-deleted + List findAllByDeletedFalse(); + + // Custom query to find a specific, non-deleted book by ID. + Optional findByIdAndDeleteFalse(UUID id); + +} \ No newline at end of file diff --git a/src/main/java/com/codesungrape/hmcts/BookAPI/service/BookService.java b/src/main/java/com/codesungrape/hmcts/BookAPI/service/BookService.java new file mode 100644 index 0000000..5022908 --- /dev/null +++ b/src/main/java/com/codesungrape/hmcts/BookAPI/service/BookService.java @@ -0,0 +1,56 @@ +package com.codesungrape.hmcts.BookAPI.service; + +import com.codesungrape.hmcts.BookAPI.dto.BookRequest; +import com.codesungrape.hmcts.BookAPI.repository.BookRepository; +import org.springframework.stereotype.Service; // Marks a class as a Service Layer component. +import lombok.RequiredArgsConstructor; + +import com.codesungrape.hmcts.BookAPI.entity.Book; + +/** + * Service layer responsible for all business logic related to the Book resource. + */ +@Service +@RequiredArgsConstructor // Lombok creates constructor for dependency injection +public class BookService { + + // Create a field to store the repo + private final BookRepository bookRepository; + + // 1. CREATE Operation (POST /books) + public Book createBook(BookRequest request) { + // Validation check for business rules (e.g., uniqueness, if required) + if (request == null) { + throw new NullPointerException("BookRequest cannot be null"); + } + + + // REVISIT: Leaving this here for now as i haven't implemented the Controller Layer yet + // The service layer is duplicating validation that already exists in the + // BookRequest DTO with @notblank annotations. Since the DTO has validation + // constraints, this manual check is redundant when Spring's validation + // framework is properly configured in the controller layer. + // Consider removing this duplication or adding a comment explaining + // why service-level validation is necessary in addition to DTO validation. + if (request.getTitle() == null || request.getTitle().isBlank()) { + throw new IllegalArgumentException("Book title cannot be null or blank"); + } + + // Map DTO to Entity + Book newBook = Book.builder() + .title(request.getTitle()) + .author(request.getAuthor()) + .synopsis(request.getSynopsis()) + // ID and created_at are auto-generated by JPA/DB + .build(); + + Book savedBook = bookRepository.save(newBook); + + // Defensive check (even though it "shouldn't" happen aka follows JPA contract) + if (savedBook == null) { + throw new IllegalStateException("Failed to save book - repository returned null"); + } + + return savedBook; + } +} \ No newline at end of file diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml new file mode 100644 index 0000000..11e45a7 --- /dev/null +++ b/src/main/resources/application.yaml @@ -0,0 +1,3 @@ +spring: + application: + name: BookAPI diff --git a/src/main/resources/openapi.yml b/src/main/resources/openapi.yml new file mode 100644 index 0000000..4684e21 --- /dev/null +++ b/src/main/resources/openapi.yml @@ -0,0 +1,254 @@ +openapi: 3.0.3 +# -------------------------------------------- +# Info +info: + title: Book Collection API + version: v1.0.0 + description: A simple API to manage a collection of books. + termsOfService: 'https://github.com/methods/S_BookAPIV.2' + contact: + email: booksAPI@example.com + license: + name: MIT License + url: 'https://github.com/methods/S_BookAPIV.2/blob/main/LICENSE.md' + +# -------------------------------------------- +# Server +servers: + - url: http://localhost:5000 + description: Development server + +# -------------------------------------------- +# Tags +tags: + - name: Books + description: Operations related to books + - name: Reservations + description: Operations related to book reservations + - name: Authentication + description: Operations related to user registration and login + +# -------------------------------------------- +# Components +components: + # ... securitySchemes remain the same ... + securitySchemes: + ApiKeyAuth: + type: apiKey + in: header + name: X-API-KEY + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: "Enter JWT Bearer token" + + parameters: + BookId: + name: book_id + in: path + required: true + description: The unique identifier of the book (standard UUID format). + schema: + type: string + format: uuid # UPDATED: Using UUID format + example: "a1b2c3d4-e5f6-7890-1234-567890abcdef" # UPDATED: Example UUID + ReservationId: + name: res_id + in: path + required: true + description: The unique identifier of the reservation (standard UUID format). + schema: + type: string + format: uuid # UPDATED: Using UUID format + example: "b1c2d3e4-f5a6-7890-1234-567890fedcba" # UPDATED: Example UUID + Offset: + # ... remains the same ... + name: offset + in: query + description: The number of items to skip before starting to collect the result set. Must be a positive number and not exceed the system maximum. + required: false + schema: + type: integer + default: 0 + minimum: 0 + Limit: + # ... remains the same ... + name: limit + in: query + description: The maximum number of items to return. Must be a positive number and not exceed the system maximum. + required: false + schema: + type: integer + default: 20 + minimum: 1 + maximum: 1000 + + schemas: + # --- Book Schemas --- + BookInput: + # ... remains the same ... + type: object + required: + - title + - synopsis + - author + properties: + title: + type: string + example: "The Hitchhiker's Guide to the Galaxy" + synopsis: + type: string + example: "Seconds before the Earth is demolished to make way for a galactic freeway..." + author: + type: string + example: "Douglas Adams" + + # Schema for the HATEOAS links object (No change to format, but target IDs will be UUIDs) + Links: + # ... remains the same ... + type: object + required: + - self + - reservations + - reviews + properties: + self: + type: string + description: Link to the book resource itself. + format: uri + reservations: + type: string + description: Link to reservations for this book. + format: uri + reviews: + type: string + description: Link to reviews for this book. + format: uri + + # Schema for the full Book object as returned by the server + BookOutput: + allOf: + - $ref: '#/components/schemas/BookInput' + properties: + id: + type: string + format: uuid # UPDATED: Explicit UUID format + description: The unique identifier for the book, generated by the server (UUID). + readOnly: true + example: "a1b2c3d4-e5f6-7890-1234-567890abcdef" # UPDATED: Example UUID + links: + $ref: '#/components/schemas/Links' + readOnly: true + + # Schema for the full Books Database Object as returned by the server + BookListResponse: + # ... remains the same ... + type: object + properties: + total_count: + type: integer + description: Total number of books + example: 3 + items: + type: array + items: + $ref: '#/components/schemas/BookOutput' + + # ----- AUTH schemas ------------- + + UserOutput: + type: object + properties: + id: + type: string + format: uuid # UPDATED: User ID is now UUID + readOnly: true + example: "c1d2e3f4-g5h6-7890-1234-567890abcdef" # UPDATED: Example UUID + email: + type: string + format: email + readOnly: true + example: "newuser@example.com" + + + # ------- Reservation schemas ---------- + + # ... ReservationUserOutput remains the same ... + # Schema for the PUT /reservations/{res_id} request body + ReservationUserOutput: + type: object + properties: + forenames: + type: string + example: "John" + middlenames: + type: string + example: "Fitzgerald" + surname: + type: string + example: "Doe" + + # ... ReservationLinks remains the same ... + ReservationLinks: + type: object + properties: + self: + type: string + format: uri + description: A link to the reservation resource itself. + book: + type: string + format: uri + description: A link to the parent book resource. + readOnly: true + + # Schema for the full Reservation object returned by the API + ReservationOutput: + type: object + properties: + id: + type: string + format: uuid # UPDATED: Reservation ID is UUID + description: The unique identifier for the reservation (UUID). + example: "b1c2d3e4-f5a6-7890-1234-567890fedcba" # UPDATED: Example UUID + state: + type: string + description: The current state of the reservation. + example: "reserved" + readOnly: true + user_id: + type: string + format: uuid # UPDATED: User ID is UUID + description: The ID of the user who made the reservation (UUID). + example: "c1d2e3f4-g5h6-7890-1234-567890abcdef" # UPDATED: Example UUID + book_id: + type: string + format: uuid # UPDATED: Book ID is UUID + description: The ID of the book being reserved (UUID). + example: "a1b2c3d4-e5f6-7890-1234-567890abcdef" # UPDATED: Example UUID + links: + $ref: '#/components/schemas/ReservationLinks' + reservationDate: + type: string + format: date-time + description: The timestamp when the reservation was made. + example: "2023-10-27T10:00:00Z" + + # Schema for a paginated list of reservations + ReservationListResponse: + type: object + properties: + total_count: + type: integer + description: Total number of reservations for this book + example: 5 + items: + type: array + items: + $ref: '#/components/schemas/ReservationOutput' + + + # ------ ERROR schemas ---------- + # Error examples that mention invalid ID formats would also need adjustment + # ... (omitted for brevity, but this is the final check) \ No newline at end of file diff --git a/src/test/java/com/codesungrape/hmcts/BookAPI/BookApiApplicationTests.java b/src/test/java/com/codesungrape/hmcts/BookAPI/BookApiApplicationTests.java new file mode 100644 index 0000000..eea05f7 --- /dev/null +++ b/src/test/java/com/codesungrape/hmcts/BookAPI/BookApiApplicationTests.java @@ -0,0 +1,13 @@ +package com.codesungrape.hmcts.BookAPI; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class BookApiApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/src/test/java/com/codesungrape/hmcts/BookAPI/BookServiceTest.java b/src/test/java/com/codesungrape/hmcts/BookAPI/BookServiceTest.java new file mode 100644 index 0000000..8d160c4 --- /dev/null +++ b/src/test/java/com/codesungrape/hmcts/BookAPI/BookServiceTest.java @@ -0,0 +1,262 @@ +package com.codesungrape.hmcts.BookAPI; + +import com.codesungrape.hmcts.BookAPI.dto.BookRequest; +import com.codesungrape.hmcts.BookAPI.entity.Book; +import com.codesungrape.hmcts.BookAPI.repository.BookRepository; +import com.codesungrape.hmcts.BookAPI.service.BookService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.provider.Arguments; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import java.util.stream.Stream; + +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * @ExtendWith(MockitoExtension.class): tells JUnit 5 to use Mockito's extension and automatically initializes all @Mock and @InjectMocks fields when running this test class. + * @Mock: Creates a fake version (mock) of the dependency. + * @InjectMocks: creates an instance of the real class under test. + * @BeforeEach: Runs before each test method in the class. + * @Test: Marks the method as a test case that JUnit should execute. + * + */ + + +// Annotation tells JUnit to use Mockito +@ExtendWith(MockitoExtension.class) +class BookServiceTest { + + // Arrange: Mock a fake BookRepository + @Mock + private BookRepository testBookRepository; + + // Service to Test: Real service with fake repo injected + @InjectMocks + private BookService testBookService; + + // Test data setup (HMCTS naming consistency enforced) + private BookRequest validBookRequest; + private Book persistedBook; + private UUID testId; + + @BeforeEach + void setUp() { + testId = UUID.randomUUID(); + + validBookRequest = new BookRequest( + "The Great Java Gatsby", + "A story about unit testing and wealth.", + "F. Scott Spring" + ); + + // This simulates a Book object as it would look coming back from the DB + persistedBook = Book.builder() + .id(testId) + .title(validBookRequest.getTitle()) + .synopsis(validBookRequest.getSynopsis()) + .author(validBookRequest.getAuthor()) + .deleted(false) + .createdAt(java.time.Instant.now()) + .build(); + } + + // --------- TESTS ------------ + + @Test + void testCreateBook_Success() { + + // Arrange: tell the mock repository what to do when called + when(testBookRepository.save(any(Book.class))).thenReturn(persistedBook); + + // Act: call the service method we are testing + Book result = testBookService.createBook(validBookRequest); + + // Assert: Check the outcome + assertNotNull(result); + assertEquals(testId, result.getId()); + assertEquals(validBookRequest.getTitle(), result.getTitle()); + assertEquals(validBookRequest.getSynopsis(), result.getSynopsis()); + assertEquals(validBookRequest.getAuthor(), result.getAuthor()); + + // Did the service perform the correct action on its dependency? + verify(testBookRepository, times(1)).save(any(Book.class)); + + } + + @Test + void testCreateBook_NullRequest_ThrowsException() { + // Act & Assert + assertThrows(NullPointerException.class, () -> { + testBookService.createBook(null); + }); + } + + // CoPilot feedback: + //This test will fail because BookRequest uses @value from Lombok with @notblank validation. + //The @notblank constraint on the title field means that creating a BookRequest with a null + // title should trigger validation failure at the DTO level, not allow the object to be + // created. Either the test expectations are incorrect, or the DTO validation is not being + // applied. The same issue affects tests on lines 105-116, 119-127, and 130-138. + + @Test + void testCreateBook_NullTitle_ThrowsException() { + // Arrange + BookRequest invalidRequest = new BookRequest(null, "Synopsis", "Author"); + + // Act & Assert + assertThrows(IllegalArgumentException.class, () -> { + testBookService.createBook(invalidRequest); + }); + + // Verify repository was never called + verify(testBookRepository, never()).save(any()); + } + + @Test + void testCreateBook_EmptyTitle_ThrowsException() { + // Arrange + BookRequest invalidRequest = new BookRequest("", "Synopsis", "Author"); + + // Act & Assert + assertThrows(IllegalArgumentException.class, () -> { + testBookService.createBook(invalidRequest); + }); + } + + @Test + void testCreateBook_BlankTitle_ThrowsException() { + // Arrange + BookRequest invalidRequest = new BookRequest(" ", "Synopsis", "Author"); + + // Act & Assert + assertThrows(IllegalArgumentException.class, () -> { + testBookService.createBook(invalidRequest); + }); + } + + // --------- Repository failures + @Test + void testCreateBook_RepositoryFailure_ThrowsException() { + // Arrange + when(testBookRepository.save(any(Book.class))) + .thenThrow(new RuntimeException("Database connection failed")); + + // Act & assert + assertThrows(RuntimeException.class, () -> { + testBookService.createBook(validBookRequest); + }); + } + + @Test + void testCreateBook_RepositoryReturnsNull_HandlesGracefully() { + // Arrange + when(testBookRepository.save(any(Book.class))) + .thenReturn(null); + + // Act & assert + assertThrows(IllegalStateException.class, () -> { + testBookService.createBook(validBookRequest); + }); + } + + // ----- EDGE cases --------- + + @ParameterizedTest(name= "{0}") // Display the test name + @MethodSource("provideLongFieldTestCases") + void testCreateBook_VeryLongFields_Success( + String testName, + BookRequest request, + Book expectedBook + ) { + + // Arrange + when(testBookRepository.save(any(Book.class))) + .thenReturn(expectedBook); + + // Act + Book result = testBookService.createBook(request); + + // Assert + assertNotNull(result); + assertEquals(expectedBook.getId(), result.getId()); + assertEquals(expectedBook.getTitle(), result.getTitle()); + assertEquals(expectedBook.getSynopsis(), result.getSynopsis()); + assertEquals(expectedBook.getAuthor(), result.getAuthor()); + + verify(testBookRepository, times(1)).save(any(Book.class)); + } + + // Provide test data, static method: can be called without creating an object. + private static Stream provideLongFieldTestCases() { + UUID testId = UUID.randomUUID(); + + String longTitle = "A".repeat(500); + String longSynopsis = "A".repeat(1000); + + return Stream.of( + Arguments.of( + "Very long title (500 chars)", + new BookRequest(longTitle, "Synopsis", "Author"), + Book.builder() + .id(testId) + .title(longTitle) + .synopsis("Synopsis") + .author("Author") + .build() + ), + Arguments.of( + "Very long synopsis (1000 chars)", + new BookRequest("Title", longSynopsis, "Author"), + Book.builder() + .id(testId) + .title("Title") + .synopsis(longSynopsis) + .author("Author") + .build() + ) + ); + } + + @Test + void testCreateBook_SpecialCharactersInTitle_Success() { + // Arrange + BookRequest specialRequest = new BookRequest( + "Test: A Book! @#$%^&*()", + "Synopsis", + "Author" + ); + + Book expectedBook = Book.builder() + .id(testId) + .title(specialRequest.getTitle()) + .synopsis(specialRequest.getSynopsis()) + .author(specialRequest.getAuthor()) + .build(); + + when(testBookRepository.save(any(Book.class))) + .thenReturn(expectedBook); + + // Act + Book result = testBookService.createBook(specialRequest); + + // Assert + assertNotNull(result); + assertEquals(testId, result.getId()); + assertEquals(specialRequest.getTitle(), result.getTitle()); + assertEquals(specialRequest.getSynopsis(), result.getSynopsis()); + assertEquals(specialRequest.getAuthor(), result.getAuthor()); + + // Did the service perform the correct action on its dependency? + verify(testBookRepository, times(1)).save(any(Book.class)); + } +} \ No newline at end of file