From 235a16131207cb18e424e753d00df896bfe7e68a Mon Sep 17 00:00:00 2001 From: codesungrape Date: Thu, 13 Nov 2025 11:13:53 +0000 Subject: [PATCH 01/14] Reformatted with checkstyle --- README.md | 17 +- build.gradle | 109 +++- config/checkstyle/checkstyle.xml | 482 ++++++++++++++++++ .../hmcts/BookAPI/BookApiApplication.java | 13 - .../hmcts/BookAPI/dto/BookRequest.java | 27 - .../hmcts/bookapi/BookApiApplication.java | 20 + .../hmcts/bookapi/dto/BookRequest.java | 27 + .../{BookAPI => bookapi}/entity/Book.java | 2 +- .../repository/BookRepository.java | 6 +- .../service/BookService.java | 8 +- .../BookApiApplicationTests.java | 4 +- .../{BookAPI => bookapi}/BookServiceTest.java | 10 +- src/test/resources/application.properties | 16 + 13 files changed, 684 insertions(+), 57 deletions(-) create mode 100644 config/checkstyle/checkstyle.xml delete mode 100644 src/main/java/com/codesungrape/hmcts/BookAPI/BookApiApplication.java delete mode 100644 src/main/java/com/codesungrape/hmcts/BookAPI/dto/BookRequest.java create mode 100644 src/main/java/com/codesungrape/hmcts/bookapi/BookApiApplication.java create mode 100644 src/main/java/com/codesungrape/hmcts/bookapi/dto/BookRequest.java rename src/main/java/com/codesungrape/hmcts/{BookAPI => bookapi}/entity/Book.java (98%) rename src/main/java/com/codesungrape/hmcts/{BookAPI => bookapi}/repository/BookRepository.java (78%) rename src/main/java/com/codesungrape/hmcts/{BookAPI => bookapi}/service/BookService.java (90%) rename src/test/java/com/codesungrape/hmcts/{BookAPI => bookapi}/BookApiApplicationTests.java (60%) rename src/test/java/com/codesungrape/hmcts/{BookAPI => bookapi}/BookServiceTest.java (97%) create mode 100644 src/test/resources/application.properties diff --git a/README.md b/README.md index 3bd2403..29534fc 100644 --- a/README.md +++ b/README.md @@ -1 +1,16 @@ -# S_BookAPIJAVA \ No newline at end of file +# S_BookAPIJAVA + +### Gradle Daemon: Stop the Daemon and Force a Clean Run + +**Step 1: Forcefully stop all running Gradle daemons.** +Forcefully stop all running Gradle daemons. +This command tells Gradle to find any background processes it has running and terminate them. +```Bash +./gradlew --stop +``` + +**Step 2: Run a clean build.** +The clean task deletes the entire build directory. This removes any old, compiled artifacts and cached results, ensuring nothing stale is left over. We will combine it with the checkstyleMain task. +```Bash +./gradlew clean checkstyleMain +``` \ No newline at end of file diff --git a/build.gradle b/build.gradle index 3d04508..062f1ed 100644 --- a/build.gradle +++ b/build.gradle @@ -2,6 +2,12 @@ plugins { id 'java' id 'org.springframework.boot' version '3.5.7' id 'io.spring.dependency-management' version '1.1.7' + + // --- Quality Tooling --- + id 'jacoco' // Code coverage + id 'checkstyle' // Style rule enforcement + id 'com.github.spotbugs' version '6.0.7' // Static analysis for bugs + id 'com.diffplug.spotless' version '6.25.0' // Code formatting } group = 'com.codesungrape.hmcts' @@ -9,9 +15,11 @@ version = '0.0.1-SNAPSHOT' description = 'Demo project for Spring Boot' java { + // Toolchain is the modern way to define JDK version. + // It automatically handles source/target compatibility. toolchain { - languageVersion = JavaLanguageVersion.of(21) - } + languageVersion = JavaLanguageVersion.of(21) + } } configurations { @@ -31,11 +39,108 @@ dependencies { compileOnly 'org.projectlombok:lombok' runtimeOnly 'org.postgresql:postgresql' annotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testImplementation 'com.h2database:h2' testImplementation 'org.junit.jupiter:junit-jupiter-params' } +// ===== TESTING & COVERAGE CONFIGURATION ===== + tasks.named('test') { useJUnitPlatform() + finalizedBy jacocoTestReport // Always generate report after tests + + // Add this for better test output + testLogging { + events "passed", "skipped", "failed" + exceptionFormat "full" + showStandardStreams = false + } +} + +// Configure JaCoCo +jacoco { + toolVersion = '0.8.11' +} + +// Generate coverage reports +jacocoTestReport { + dependsOn test + reports { + xml.required = true // For CI systems like SonarQube/Codecov + html.required = true // For human-readable reports + csv.required = false + } +} + +// ENFORCE 100% coverage with branch coverage +jacocoTestCoverageVerification { + dependsOn jacocoTestReport + + violationRules { + rule { + element = 'CLASS' + + // Exclude common classes that don't need testing + excludes = [ + '**.*Application', // Spring Boot main class + '**.*ApplicationTests', // Test classes + '**.*Config', // Configuration classes + '**.*Configuration', // Alternative config naming + '**.config.*', // Config package + '**.dto.*', // DTOs + '**.entity.*', // JPA entities + '**.model.*', // Models (if used) + '**.*Constants', // Constant classes + '**.*Exception' // Custom exceptions (optional) + ] + limit { + counter = 'INSTRUCTION' + value = 'COVEREDRATIO' + minimum = 1.00 // 100% instruction coverage + } + limit { + counter = 'BRANCH' + value = 'COVEREDRATIO' + minimum = 1.00 // 100% branch coverage (if/else, switch, etc.) + } + } + } +} + +// ===== CODE QUALITY & STYLE CONFIGURATION ===== + +spotless { + java { + googleJavaFormat('1.17.0').aosp() // A popular, opinionated style + removeUnusedImports() + trimTrailingWhitespace() + endWithNewline() + } +} + +checkstyle { + toolVersion = '12.1.2' + maxWarnings = 0 // Fail build on any warning + configFile = file("${rootDir}/config/checkstyle/checkstyle.xml") +} + +spotbugs { + // Recommended to use a more recent tool version + toolVersion = '4.8.3' + // Fail the build on any identified issue + ignoreFailures = false +} + + +// ===== BUILD LIFECYCLE INTEGRATION ===== +// The standard 'check' task will now run all quality gates + +tasks.named('check') { + dependsOn jacocoTestCoverageVerification + dependsOn tasks.named('spotlessCheck') + dependsOn tasks.named('checkstyleMain') + dependsOn tasks.named('spotbugsMain') } diff --git a/config/checkstyle/checkstyle.xml b/config/checkstyle/checkstyle.xml new file mode 100644 index 0000000..db2957b --- /dev/null +++ b/config/checkstyle/checkstyle.xml @@ -0,0 +1,482 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/java/com/codesungrape/hmcts/BookAPI/BookApiApplication.java b/src/main/java/com/codesungrape/hmcts/BookAPI/BookApiApplication.java deleted file mode 100644 index cb2617c..0000000 --- a/src/main/java/com/codesungrape/hmcts/BookAPI/BookApiApplication.java +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index 76667b4..0000000 --- a/src/main/java/com/codesungrape/hmcts/BookAPI/dto/BookRequest.java +++ /dev/null @@ -1,27 +0,0 @@ -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/BookApiApplication.java b/src/main/java/com/codesungrape/hmcts/bookapi/BookApiApplication.java new file mode 100644 index 0000000..7c3a655 --- /dev/null +++ b/src/main/java/com/codesungrape/hmcts/bookapi/BookApiApplication.java @@ -0,0 +1,20 @@ +package com.codesungrape.hmcts.bookapi; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + + +/** + * Entry point for the Book API Spring Boot application. + * Starts the Spring ApplicationContext and embedded server. + */ +@SpringBootApplication +public class BookApiApplication { + /** + * * Main entry point — starts the Spring Boot application. + */ + 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..da7621c --- /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; + +/** + * DTO representing the required input for creating or replacing a Book resource. + * This class mirrors the OpenAPI 'BookInput' schema. + * + * @Value: Makes fields final, adds constructor, getters, equals, hashCode, toString. + * @NotBlank: Field required; empty/missing value returns 400 Bad Request in Spring. + * @JsonProperty: Maps snake_case JSON to camelCase Java using Jackson. + */ +public record BookRequest( + @JsonProperty("title") + @NotBlank(message = "Title is required") + String title, + + @JsonProperty("synopsis") + @NotBlank(message = "Synopsis is required") + String synopsis, + + @JsonProperty("author") + @NotBlank(message = "Author is required") + 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 similarity index 98% rename from src/main/java/com/codesungrape/hmcts/BookAPI/entity/Book.java rename to src/main/java/com/codesungrape/hmcts/bookapi/entity/Book.java index 43cd2cd..ce100ec 100644 --- a/src/main/java/com/codesungrape/hmcts/BookAPI/entity/Book.java +++ b/src/main/java/com/codesungrape/hmcts/bookapi/entity/Book.java @@ -1,4 +1,4 @@ -package com.codesungrape.hmcts.BookAPI.entity; +package com.codesungrape.hmcts.bookapi.entity; import jakarta.persistence.*; import java.util.UUID; diff --git a/src/main/java/com/codesungrape/hmcts/BookAPI/repository/BookRepository.java b/src/main/java/com/codesungrape/hmcts/bookapi/repository/BookRepository.java similarity index 78% rename from src/main/java/com/codesungrape/hmcts/BookAPI/repository/BookRepository.java rename to src/main/java/com/codesungrape/hmcts/bookapi/repository/BookRepository.java index 5bc5413..14099a7 100644 --- a/src/main/java/com/codesungrape/hmcts/BookAPI/repository/BookRepository.java +++ b/src/main/java/com/codesungrape/hmcts/bookapi/repository/BookRepository.java @@ -1,10 +1,10 @@ -package com.codesungrape.hmcts.BookAPI.repository; +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 com.codesungrape.hmcts.bookapi.entity.Book; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @@ -19,6 +19,6 @@ public interface BookRepository extends JpaRepository { List findAllByDeletedFalse(); // Custom query to find a specific, non-deleted book by ID. - Optional findByIdAndDeleteFalse(UUID id); + Optional findByIdAndDeletedFalse(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 similarity index 90% rename from src/main/java/com/codesungrape/hmcts/BookAPI/service/BookService.java rename to src/main/java/com/codesungrape/hmcts/bookapi/service/BookService.java index 5022908..6949407 100644 --- a/src/main/java/com/codesungrape/hmcts/BookAPI/service/BookService.java +++ b/src/main/java/com/codesungrape/hmcts/bookapi/service/BookService.java @@ -1,11 +1,11 @@ -package com.codesungrape.hmcts.BookAPI.service; +package com.codesungrape.hmcts.bookapi.service; -import com.codesungrape.hmcts.BookAPI.dto.BookRequest; -import com.codesungrape.hmcts.BookAPI.repository.BookRepository; +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; +import com.codesungrape.hmcts.bookapi.entity.Book; /** * Service layer responsible for all business logic related to the Book resource. diff --git a/src/test/java/com/codesungrape/hmcts/BookAPI/BookApiApplicationTests.java b/src/test/java/com/codesungrape/hmcts/bookapi/BookApiApplicationTests.java similarity index 60% rename from src/test/java/com/codesungrape/hmcts/BookAPI/BookApiApplicationTests.java rename to src/test/java/com/codesungrape/hmcts/bookapi/BookApiApplicationTests.java index eea05f7..f1653c7 100644 --- a/src/test/java/com/codesungrape/hmcts/BookAPI/BookApiApplicationTests.java +++ b/src/test/java/com/codesungrape/hmcts/bookapi/BookApiApplicationTests.java @@ -1,9 +1,11 @@ -package com.codesungrape.hmcts.BookAPI; +package com.codesungrape.hmcts.bookapi; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; @SpringBootTest +@ActiveProfiles("test") class BookApiApplicationTests { @Test diff --git a/src/test/java/com/codesungrape/hmcts/BookAPI/BookServiceTest.java b/src/test/java/com/codesungrape/hmcts/bookapi/BookServiceTest.java similarity index 97% rename from src/test/java/com/codesungrape/hmcts/BookAPI/BookServiceTest.java rename to src/test/java/com/codesungrape/hmcts/bookapi/BookServiceTest.java index 8d160c4..fad7d65 100644 --- a/src/test/java/com/codesungrape/hmcts/BookAPI/BookServiceTest.java +++ b/src/test/java/com/codesungrape/hmcts/bookapi/BookServiceTest.java @@ -1,9 +1,9 @@ -package com.codesungrape.hmcts.BookAPI; +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 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; diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties new file mode 100644 index 0000000..ca769db --- /dev/null +++ b/src/test/resources/application.properties @@ -0,0 +1,16 @@ +# Use the H2 in-memory database for tests +spring.datasource.url=jdbc:h2:mem:testdb +spring.datasource.driver-class-name=org.h2.Driver +spring.datasource.username=sa +spring.datasource.password=password + +# Tell Hibernate (the JPA provider) to use the H2 dialect +spring.jpa.database-platform=org.hibernate.dialect.H2Dialect + +# Very important for tests: Create the database schema when tests start, +# and drop it when they finish. This ensures every test run is clean. +spring.jpa.hibernate.ddl-auto=create-drop +spring.jpa.show-sql=false + +# Disable validation errors for missing production database +spring.sql.init.mode=never \ No newline at end of file From faa2e29084f1268ae58bb927a60dc08506fd8d07 Mon Sep 17 00:00:00 2001 From: codesungrape Date: Thu, 13 Nov 2025 11:50:02 +0000 Subject: [PATCH 02/14] Reformat files to comply with Checkstyle rules --- .../hmcts/bookapi/BookApiApplication.java | 4 +- .../hmcts/bookapi/entity/Book.java | 124 ++--- .../bookapi/repository/BookRepository.java | 20 +- .../hmcts/bookapi/service/BookService.java | 85 ++-- .../bookapi/BookApiApplicationTests.java | 6 +- .../hmcts/bookapi/BookServiceTest.java | 441 +++++++++--------- src/test/resources/application.properties | 3 - 7 files changed, 353 insertions(+), 330 deletions(-) diff --git a/src/main/java/com/codesungrape/hmcts/bookapi/BookApiApplication.java b/src/main/java/com/codesungrape/hmcts/bookapi/BookApiApplication.java index 7c3a655..46d845c 100644 --- a/src/main/java/com/codesungrape/hmcts/bookapi/BookApiApplication.java +++ b/src/main/java/com/codesungrape/hmcts/bookapi/BookApiApplication.java @@ -11,8 +11,8 @@ @SpringBootApplication public class BookApiApplication { /** - * * Main entry point — starts the Spring Boot application. - */ + * * Main entry point — starts the Spring Boot application. + */ public static void main(String[] args) { SpringApplication.run(BookApiApplication.class, args); } diff --git a/src/main/java/com/codesungrape/hmcts/bookapi/entity/Book.java b/src/main/java/com/codesungrape/hmcts/bookapi/entity/Book.java index ce100ec..1b60fa2 100644 --- a/src/main/java/com/codesungrape/hmcts/bookapi/entity/Book.java +++ b/src/main/java/com/codesungrape/hmcts/bookapi/entity/Book.java @@ -1,23 +1,39 @@ package com.codesungrape.hmcts.bookapi.entity; -import jakarta.persistence.*; -import java.util.UUID; -import lombok.*; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; import java.time.Instant; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; /** - * 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(); + * Represents the Book table in PostgreSQL as a JPA entity. + * Stores the persisted state of a Book resource. + * HMCTS Rule: IDs must be opaque; using UUID for distributed ID generation. + * + * @Entity: Marks the class as a JPA entity for Hibernate table mapping. + * @Table: Specifies the database table; HMCTS: lowercase, singular table name. + * Lombok Annotations: + * @Getter: Generates getters for all fields. + * @Setter: Generates setters for all fields. + * @NoArgsConstructor: Protected no-arg constructor for JPA instantiation. + * @AllArgsConstructor: Constructor with all fields for test convenience. + * @Builder: Adds builder pattern for easy object creation. + * Example: Book.builder() + * .title("A") + * .author("B") + * .build(); */ @Entity @Table(name = "book") @@ -28,49 +44,53 @@ @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; + @Id // Primary key of the table + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id", nullable = false) + private UUID id; - @Column(name = "author", nullable = false) - private String author; + @Column(name = "title", nullable = false) + private String title; - // 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; + @Column(name = "synopsis", nullable = false, columnDefinition = "TEXT") + private String synopsis; - // `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 = "author", nullable = false) + private String author; - @Column(name = "modified_at") - private java.time.Instant modifiedAt; + // Soft delete - makes DELETE operations idempotent (safe to repeat) + // 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; - // --- JPA lifecycle callbacks --- - @PrePersist - protected void onCreate() { - this.createdAt = java.time.Instant.now(); - } + // `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; - // --- 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() { + @Column(name = "modified_at") + private Instant modifiedAt; - this.modifiedAt = java.time.Instant.now(); - } + // --- JPA lifecycle callbacks --- + /** + * Sets createdAt before persisting a new Book record. + */ + @PrePersist + protected void onCreate() { + this.createdAt = Instant.now(); + } + // --- Business Logic Helper --- + // HMCTS requires business logic to live in services; setter hooks are allowed. + // Lifecycle callback: runs automatically before Hibernate updates a database record. -} \ No newline at end of file + /** + * Updates modifiedAt before updating an existing Book record. + */ + @PreUpdate + protected void onUpdate() { + this.modifiedAt = Instant.now(); + } +} diff --git a/src/main/java/com/codesungrape/hmcts/bookapi/repository/BookRepository.java b/src/main/java/com/codesungrape/hmcts/bookapi/repository/BookRepository.java index 14099a7..9614d7a 100644 --- a/src/main/java/com/codesungrape/hmcts/bookapi/repository/BookRepository.java +++ b/src/main/java/com/codesungrape/hmcts/bookapi/repository/BookRepository.java @@ -1,24 +1,28 @@ package com.codesungrape.hmcts.bookapi.repository; +import com.codesungrape.hmcts.bookapi.entity.Book; 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 interface for Book entities. + * Provides CRUD operations and custom queries for non-deleted books. + * Spring Data JPA automatically implements this interface at runtime. */ @Repository public interface BookRepository extends JpaRepository { - // Custom query to find books that have NOT been soft-deleted - List findAllByDeletedFalse(); + /** + * Custom query retrieves all Book records that have not been soft-deleted. + */ + List findAllByDeletedFalse(); - // Custom query to find a specific, non-deleted book by ID. - Optional findByIdAndDeletedFalse(UUID id); + /** + * Retrieves a single Book by ID, if it exists and has not been soft-deleted. + */ + Optional findByIdAndDeletedFalse(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 index 6949407..66369dd 100644 --- a/src/main/java/com/codesungrape/hmcts/bookapi/service/BookService.java +++ b/src/main/java/com/codesungrape/hmcts/bookapi/service/BookService.java @@ -1,11 +1,10 @@ package com.codesungrape.hmcts.bookapi.service; import com.codesungrape.hmcts.bookapi.dto.BookRequest; +import com.codesungrape.hmcts.bookapi.entity.Book; 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; +import org.springframework.stereotype.Service; /** * Service layer responsible for all business logic related to the Book resource. @@ -14,43 +13,47 @@ @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; + // Create a field to store the repo + private final BookRepository bookRepository; + + /** + * Creates a new Book entity from the given BookRequest DTO and persists it. + * + * @param request DTO containing book details (title, author, synopsis) + * @return The saved Book entity + * @throws NullPointerException if request is null + * @throws IllegalArgumentException if title is null or blank + * @throws IllegalStateException if the repository fails to save the book + */ + + public Book createBook(BookRequest request) { + // Validation check for business rules (e.g., uniqueness, if required) + if (request == null) { + throw new NullPointerException("BookRequest cannot be null"); } + + + // TODO: Leaving this here for now as i haven't implemented the Controller Layer yet + // 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.title() == null || request.title().isBlank()) { + throw new IllegalArgumentException("Book title cannot be null or blank"); + } + + // Map DTO to Entity + Book newBook = Book.builder() + .title(request.title()) + .author(request.author()) + .synopsis(request.synopsis()) + // ID and created_at are auto-generated by JPA/DB + .build(); + + Book savedBook = bookRepository.save(newBook); + + return savedBook; + } } \ 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 index f1653c7..4516e02 100644 --- a/src/test/java/com/codesungrape/hmcts/bookapi/BookApiApplicationTests.java +++ b/src/test/java/com/codesungrape/hmcts/bookapi/BookApiApplicationTests.java @@ -8,8 +8,8 @@ @ActiveProfiles("test") class BookApiApplicationTests { - @Test - void contextLoads() { - } + @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 index fad7d65..7c7dd9a 100644 --- a/src/test/java/com/codesungrape/hmcts/bookapi/BookServiceTest.java +++ b/src/test/java/com/codesungrape/hmcts/bookapi/BookServiceTest.java @@ -7,19 +7,18 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; 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 java.util.stream.Stream; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.when; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; /** @@ -36,227 +35,227 @@ @ExtendWith(MockitoExtension.class) class BookServiceTest { - // Arrange: Mock a fake BookRepository - @Mock - private BookRepository testBookRepository; + // Arrange: Mock a fake BookRepository + @Mock + private BookRepository testBookRepository; - // Service to Test: Real service with fake repo injected - @InjectMocks - private BookService testBookService; + // 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; + // Test data setup (HMCTS naming consistency enforced) + private BookRequest validBookRequest; + private Book persistedBook; + private UUID testId; - @BeforeEach - void setUp() { - testId = UUID.randomUUID(); + // Provide test data, static method: can be called without creating an object. + private static Stream provideLongFieldTestCases() { + UUID testId = UUID.randomUUID(); - validBookRequest = new BookRequest( - "The Great Java Gatsby", - "A story about unit testing and wealth.", - "F. Scott Spring" - ); + String longTitle = "A".repeat(500); + String longSynopsis = "A".repeat(1000); - // This simulates a Book object as it would look coming back from the DB - persistedBook = Book.builder() + return Stream.of( + Arguments.of( + "Very long title (500 chars)", + new BookRequest(longTitle, "Synopsis", "Author"), + 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() + .title(longTitle) + .synopsis("Synopsis") + .author("Author") + .build() + ), + Arguments.of( + "Very long synopsis (1000 chars)", + new BookRequest("Title", longSynopsis, "Author"), + 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)); - } + .title("Title") + .synopsis(longSynopsis) + .author("Author") + .build() + ) + ); + } + + // --------- TESTS ------------ + + @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.title()) + .synopsis(validBookRequest.synopsis()) + .author(validBookRequest.author()) + .deleted(false) + .createdAt(java.time.Instant.now()) + .build(); + } + + @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.title(), result.getTitle()); + assertEquals(validBookRequest.synopsis(), result.getSynopsis()); + assertEquals(validBookRequest.author(), result.getAuthor()); + + // Did the service perform the correct action on its dependency? + verify(testBookRepository, times(1)).save(any(Book.class)); + + } + + // CoPilot feedback: + //This test will fail because BookRequest uses @value from Lombok with @notblank validation. + //The @notblank constraint on the title field means that creating a BookRequest with a null + // title should trigger validation failure at the DTO level, not allow the object to be + // created. Either the test expectations are incorrect, or the DTO validation is not being + // applied. The same issue affects tests on lines 105-116, 119-127, and 130-138. + + @Test + void testCreateBook_NullRequest_ThrowsException() { + // Act & Assert + assertThrows(NullPointerException.class, () -> { + testBookService.createBook(null); + }); + } + + @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); + }); + } + + // ----- EDGE cases --------- + + @Test + void testCreateBook_RepositoryReturnsNull_HandlesGracefully() { + // Arrange + when(testBookRepository.save(any(Book.class))) + .thenReturn(null); + + // Act & assert + assertThrows(IllegalStateException.class, () -> { + testBookService.createBook(validBookRequest); + }); + } + + @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)); + } + + @Test + void testCreateBook_SpecialCharactersInTitle_Success() { + // Arrange + BookRequest specialRequest = new BookRequest( + "Test: A Book! @#$%^&*()", + "Synopsis", + "Author" + ); + + Book expectedBook = Book.builder() + .id(testId) + .title(specialRequest.title()) + .synopsis(specialRequest.synopsis()) + .author(specialRequest.author()) + .build(); + + when(testBookRepository.save(any(Book.class))) + .thenReturn(expectedBook); + + // Act + Book result = testBookService.createBook(specialRequest); + + // Assert + assertNotNull(result); + assertEquals(testId, result.getId()); + assertEquals(specialRequest.title(), result.getTitle()); + assertEquals(specialRequest.synopsis(), result.getSynopsis()); + assertEquals(specialRequest.author(), 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 diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index ca769db..aa00a89 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -3,14 +3,11 @@ spring.datasource.url=jdbc:h2:mem:testdb spring.datasource.driver-class-name=org.h2.Driver spring.datasource.username=sa spring.datasource.password=password - # Tell Hibernate (the JPA provider) to use the H2 dialect spring.jpa.database-platform=org.hibernate.dialect.H2Dialect - # Very important for tests: Create the database schema when tests start, # and drop it when they finish. This ensures every test run is clean. spring.jpa.hibernate.ddl-auto=create-drop spring.jpa.show-sql=false - # Disable validation errors for missing production database spring.sql.init.mode=never \ No newline at end of file From 4f9a0168d7f9771ce94726086442c640ae22ff53 Mon Sep 17 00:00:00 2001 From: codesungrape Date: Thu, 13 Nov 2025 15:21:54 +0000 Subject: [PATCH 03/14] Conform to HMCTS Checkstyle; add private constructor --- .../hmcts/bookapi/BookApiApplication.java | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/codesungrape/hmcts/bookapi/BookApiApplication.java b/src/main/java/com/codesungrape/hmcts/bookapi/BookApiApplication.java index 46d845c..14442f9 100644 --- a/src/main/java/com/codesungrape/hmcts/bookapi/BookApiApplication.java +++ b/src/main/java/com/codesungrape/hmcts/bookapi/BookApiApplication.java @@ -3,18 +3,26 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; - /** - * Entry point for the Book API Spring Boot application. - * Starts the Spring ApplicationContext and embedded server. + * Entry point for the Book API Spring Boot application. Starts the Spring ApplicationContext and + * embedded server. */ @SpringBootApplication public class BookApiApplication { - /** - * * Main entry point — starts the Spring Boot application. - */ - public static void main(String[] args) { - SpringApplication.run(BookApiApplication.class, args); - } + /** + * Placeholder. + */ + private BookApiApplication() { + // prevents instantiation + } + + /** + * Main entry point — starts the Spring Boot application. + */ + public static void main(String[] args) { + + SpringApplication.run(BookApiApplication.class, args); + + } } From b7c1b9be921491381f9d5e4bbcb50819c0c4cd4b Mon Sep 17 00:00:00 2001 From: codesungrape Date: Thu, 13 Nov 2025 15:45:11 +0000 Subject: [PATCH 04/14] Add informative comments for future ref --- .../com/codesungrape/hmcts/bookapi/BookApiApplication.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/codesungrape/hmcts/bookapi/BookApiApplication.java b/src/main/java/com/codesungrape/hmcts/bookapi/BookApiApplication.java index 14442f9..08da1e1 100644 --- a/src/main/java/com/codesungrape/hmcts/bookapi/BookApiApplication.java +++ b/src/main/java/com/codesungrape/hmcts/bookapi/BookApiApplication.java @@ -11,10 +11,11 @@ public class BookApiApplication { /** - * Placeholder. + * Private constructor to prevent instantiation. + * This avoids Checkstyle treating this as an instantiable utility class. */ private BookApiApplication() { - // prevents instantiation + // Intentionally empty — prevents accidental instantiation } /** From 35e12c1ff983d1ce946df51b0a03da0c38ecfff8 Mon Sep 17 00:00:00 2001 From: codesungrape Date: Thu, 13 Nov 2025 15:54:33 +0000 Subject: [PATCH 05/14] chore: align project formatting and config with HMCTS standards - Refactor all Java files to comply with HMCTS checkstyle rules - Expand wildcard imports and fix indentation/line-length violations - Add .editorconfig using HMCTS formatting conventions - Update .gitignore to include HMCTS-relevant patterns - Remove Spotless plugin from build.gradle (not used in HMCTS projects) - Ensure Checkstyle uses HMCTS-provided configuration files This brings the project into alignment with the HMCTS engineering standards and eliminates conflicting or redundant formatting tools. --- .editorconfig | 20 + .gitignore | 13 +- build.gradle | 16 +- config/checkstyle/checkstyle.xml | 704 ++++++------------ .../hmcts/bookapi/dto/BookRequest.java | 27 +- .../hmcts/bookapi/entity/Book.java | 107 ++- .../bookapi/repository/BookRepository.java | 29 +- .../hmcts/bookapi/service/BookService.java | 85 ++- .../bookapi/BookApiApplicationTests.java | 7 +- .../hmcts/bookapi/BookServiceTest.java | 463 ++++++------ 10 files changed, 626 insertions(+), 845 deletions(-) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..211795a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,20 @@ +# http://editorconfig.org +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true +max_line_length = 120 + +[*.java] +indent_size = 4 +ij_continuation_indent_size = 4 +ij_java_align_multiline_parameters = true +ij_java_align_multiline_parameters_in_calls = true +ij_java_call_parameters_new_line_after_left_paren = true +ij_java_call_parameters_right_paren_on_new_line = true +ij_java_call_parameters_wrap = on_every_item \ No newline at end of file diff --git a/.gitignore b/.gitignore index e58e323..bd62caf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,13 @@ HELP.md .gradle build/ +**/build/ +*.class !gradle/wrapper/gradle-wrapper.jar !**/src/main/**/build/ !**/src/test/**/build/ +bin/main/application.yaml +**/target ### STS ### @@ -26,6 +30,12 @@ bin/ out/ !**/src/main/**/out/ !**/src/test/**/out/ +.mirrord +.DS_Store + +# node_modules +package-lock.json +node_modules/ ### NetBeans ### /nbproject/private/ @@ -53,4 +63,5 @@ out/ *.pem # Environment variables -.env \ No newline at end of file +.env + diff --git a/build.gradle b/build.gradle index 062f1ed..16f0659 100644 --- a/build.gradle +++ b/build.gradle @@ -7,7 +7,6 @@ plugins { id 'jacoco' // Code coverage id 'checkstyle' // Style rule enforcement id 'com.github.spotbugs' version '6.0.7' // Static analysis for bugs - id 'com.diffplug.spotless' version '6.25.0' // Code formatting } group = 'com.codesungrape.hmcts' @@ -112,19 +111,10 @@ jacocoTestCoverageVerification { // ===== CODE QUALITY & STYLE CONFIGURATION ===== -spotless { - java { - googleJavaFormat('1.17.0').aosp() // A popular, opinionated style - removeUnusedImports() - trimTrailingWhitespace() - endWithNewline() - } -} - checkstyle { - toolVersion = '12.1.2' - maxWarnings = 0 // Fail build on any warning - configFile = file("${rootDir}/config/checkstyle/checkstyle.xml") + maxWarnings = 0 + toolVersion = '10.26.1' + getConfigDirectory().set(new File(rootDir, 'config/checkstyle')) } spotbugs { diff --git a/config/checkstyle/checkstyle.xml b/config/checkstyle/checkstyle.xml index db2957b..0ef5139 100644 --- a/config/checkstyle/checkstyle.xml +++ b/config/checkstyle/checkstyle.xml @@ -1,482 +1,252 @@ + "-//Checkstyle//DTD Checkstyle Configuration 1.3//EN" + "https://checkstyle.org/dtds/configuration_1_3.dtd"> - + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/java/com/codesungrape/hmcts/bookapi/dto/BookRequest.java b/src/main/java/com/codesungrape/hmcts/bookapi/dto/BookRequest.java index da7621c..30731f8 100644 --- a/src/main/java/com/codesungrape/hmcts/bookapi/dto/BookRequest.java +++ b/src/main/java/com/codesungrape/hmcts/bookapi/dto/BookRequest.java @@ -4,24 +4,13 @@ import jakarta.validation.constraints.NotBlank; /** - * DTO representing the required input for creating or replacing a Book resource. - * This class mirrors the OpenAPI 'BookInput' schema. - * - * @Value: Makes fields final, adds constructor, getters, equals, hashCode, toString. - * @NotBlank: Field required; empty/missing value returns 400 Bad Request in Spring. - * @JsonProperty: Maps snake_case JSON to camelCase Java using Jackson. + * DTO representing the required input for creating or replacing a Book resource. This class mirrors + * the OpenAPI 'BookInput' schema. @Value: Makes fields final, adds constructor, getters, equals, + * hashCode, toString. @NotBlank: Field required; empty/missing value returns 400 Bad Request in + * Spring. @JsonProperty: Maps snake_case JSON to camelCase Java using Jackson. */ public record BookRequest( - @JsonProperty("title") - @NotBlank(message = "Title is required") - String title, - - @JsonProperty("synopsis") - @NotBlank(message = "Synopsis is required") - String synopsis, - - @JsonProperty("author") - @NotBlank(message = "Author is required") - String author -) { -} \ No newline at end of file + @JsonProperty("title") @NotBlank(message = "Title is required") String title, + @JsonProperty("synopsis") @NotBlank(message = "Synopsis is required") String synopsis, + @JsonProperty("author") @NotBlank(message = "Author is required") String author) { +} diff --git a/src/main/java/com/codesungrape/hmcts/bookapi/entity/Book.java b/src/main/java/com/codesungrape/hmcts/bookapi/entity/Book.java index 1b60fa2..0daeacc 100644 --- a/src/main/java/com/codesungrape/hmcts/bookapi/entity/Book.java +++ b/src/main/java/com/codesungrape/hmcts/bookapi/entity/Book.java @@ -8,8 +8,10 @@ import jakarta.persistence.PrePersist; import jakarta.persistence.PreUpdate; import jakarta.persistence.Table; + import java.time.Instant; import java.util.UUID; + import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; @@ -18,22 +20,14 @@ import lombok.Setter; /** - * Represents the Book table in PostgreSQL as a JPA entity. - * Stores the persisted state of a Book resource. - * HMCTS Rule: IDs must be opaque; using UUID for distributed ID generation. - * - * @Entity: Marks the class as a JPA entity for Hibernate table mapping. - * @Table: Specifies the database table; HMCTS: lowercase, singular table name. - * Lombok Annotations: - * @Getter: Generates getters for all fields. - * @Setter: Generates setters for all fields. - * @NoArgsConstructor: Protected no-arg constructor for JPA instantiation. - * @AllArgsConstructor: Constructor with all fields for test convenience. - * @Builder: Adds builder pattern for easy object creation. - * Example: Book.builder() - * .title("A") - * .author("B") - * .build(); + * Represents the Book table in PostgreSQL as a JPA entity. Stores the persisted state of a Book + * resource. HMCTS Rule: IDs must be opaque; using UUID for distributed ID generation. @Entity: + * Marks the class as a JPA entity for Hibernate table mapping. @Table: Specifies the database + * table; HMCTS: lowercase, singular table name. Lombok Annotations: @Getter: Generates getters for + * all fields. @Setter: Generates setters for all fields. @NoArgsConstructor: Protected no-arg + * constructor for JPA instantiation. @AllArgsConstructor: Constructor with all fields for test + * convenience. @Builder: Adds builder pattern for easy object creation. Example: Book.builder() + * .title("A") .author("B") .build(); */ @Entity @Table(name = "book") @@ -44,53 +38,54 @@ @Builder // For convenience in creating instances public class Book { - @Id // Primary key of the table - @GeneratedValue(strategy = GenerationType.UUID) - @Column(name = "id", nullable = false) - private UUID id; + @Id // Primary key of the table + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id", nullable = false) + private UUID id; + + @Column(name = "title", nullable = false) + private String title; - @Column(name = "title", nullable = false) - private String title; + @Column(name = "synopsis", nullable = false, columnDefinition = "TEXT") + private String synopsis; - @Column(name = "synopsis", nullable = false, columnDefinition = "TEXT") - private String synopsis; + @Column(name = "author", nullable = false) + private String author; - @Column(name = "author", nullable = false) - private String author; + // Soft delete - makes DELETE operations idempotent (safe to repeat) + // 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; - // Soft delete - makes DELETE operations idempotent (safe to repeat) - // 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; - // `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 Instant modifiedAt; - @Column(name = "modified_at") - private Instant modifiedAt; + // --- JPA lifecycle callbacks --- - // --- JPA lifecycle callbacks --- - /** - * Sets createdAt before persisting a new Book record. - */ - @PrePersist - protected void onCreate() { - this.createdAt = Instant.now(); - } + /** + * Sets createdAt before persisting a new Book record. + */ + @PrePersist + protected void onCreate() { + this.createdAt = Instant.now(); + } - // --- Business Logic Helper --- - // HMCTS requires business logic to live in services; setter hooks are allowed. - // Lifecycle callback: runs automatically before Hibernate updates a database record. + // --- Business Logic Helper --- + // HMCTS requires business logic to live in services; setter hooks are allowed. + // Lifecycle callback: runs automatically before Hibernate updates a database record. - /** - * Updates modifiedAt before updating an existing Book record. - */ - @PreUpdate - protected void onUpdate() { - this.modifiedAt = Instant.now(); - } + /** + * Updates modifiedAt before updating an existing Book record. + */ + @PreUpdate + protected void onUpdate() { + this.modifiedAt = Instant.now(); + } } diff --git a/src/main/java/com/codesungrape/hmcts/bookapi/repository/BookRepository.java b/src/main/java/com/codesungrape/hmcts/bookapi/repository/BookRepository.java index 9614d7a..3550d6e 100644 --- a/src/main/java/com/codesungrape/hmcts/bookapi/repository/BookRepository.java +++ b/src/main/java/com/codesungrape/hmcts/bookapi/repository/BookRepository.java @@ -1,28 +1,27 @@ package com.codesungrape.hmcts.bookapi.repository; import com.codesungrape.hmcts.bookapi.entity.Book; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + import java.util.List; import java.util.Optional; import java.util.UUID; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; /** - * Repository interface for Book entities. - * Provides CRUD operations and custom queries for non-deleted books. - * Spring Data JPA automatically implements this interface at runtime. + * Repository interface for Book entities. Provides CRUD operations and custom queries for + * non-deleted books. Spring Data JPA automatically implements this interface at runtime. */ @Repository public interface BookRepository extends JpaRepository { - /** - * Custom query retrieves all Book records that have not been soft-deleted. - */ - List findAllByDeletedFalse(); - - /** - * Retrieves a single Book by ID, if it exists and has not been soft-deleted. - */ - Optional findByIdAndDeletedFalse(UUID id); + /** + * Custom query retrieves all Book records that have not been soft-deleted. + */ + List findAllByDeletedFalse(); -} \ No newline at end of file + /** + * Retrieves a single Book by ID, if it exists and has not been soft-deleted. + */ + Optional findByIdAndDeletedFalse(UUID id); +} diff --git a/src/main/java/com/codesungrape/hmcts/bookapi/service/BookService.java b/src/main/java/com/codesungrape/hmcts/bookapi/service/BookService.java index 66369dd..7cd2b18 100644 --- a/src/main/java/com/codesungrape/hmcts/bookapi/service/BookService.java +++ b/src/main/java/com/codesungrape/hmcts/bookapi/service/BookService.java @@ -13,47 +13,46 @@ @RequiredArgsConstructor // Lombok creates constructor for dependency injection public class BookService { - // Create a field to store the repo - private final BookRepository bookRepository; - - /** - * Creates a new Book entity from the given BookRequest DTO and persists it. - * - * @param request DTO containing book details (title, author, synopsis) - * @return The saved Book entity - * @throws NullPointerException if request is null - * @throws IllegalArgumentException if title is null or blank - * @throws IllegalStateException if the repository fails to save the book - */ - - 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"); + // Create a field to store the repo + private final BookRepository bookRepository; + + /** + * Creates a new Book entity from the given BookRequest DTO and persists it. + * + * @param request DTO containing book details (title, author, synopsis) + * @return The saved Book entity + * @throws NullPointerException if request is null + * @throws IllegalArgumentException if title is null or blank + * @throws IllegalStateException if the repository fails to save the book + */ + public Book createBook(BookRequest request) { + // Validation check for business rules (e.g., uniqueness, if required) + if (request == null) { + throw new NullPointerException("BookRequest cannot be null"); + } + + // TODO: Leaving this here for now as i haven't implemented the Controller Layer yet + // 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.title() == null || request.title().isBlank()) { + throw new IllegalArgumentException("Book title cannot be null or blank"); + } + + // Map DTO to Entity + Book newBook = + Book.builder() + .title(request.title()) + .author(request.author()) + .synopsis(request.synopsis()) + // ID and created_at are auto-generated by JPA/DB + .build(); + + Book savedBook = bookRepository.save(newBook); + + return savedBook; } - - - // TODO: 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.title() == null || request.title().isBlank()) { - throw new IllegalArgumentException("Book title cannot be null or blank"); - } - - // Map DTO to Entity - Book newBook = Book.builder() - .title(request.title()) - .author(request.author()) - .synopsis(request.synopsis()) - // ID and created_at are auto-generated by JPA/DB - .build(); - - Book savedBook = bookRepository.save(newBook); - - return savedBook; - } -} \ 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 index 4516e02..5befa77 100644 --- a/src/test/java/com/codesungrape/hmcts/bookapi/BookApiApplicationTests.java +++ b/src/test/java/com/codesungrape/hmcts/bookapi/BookApiApplicationTests.java @@ -8,8 +8,7 @@ @ActiveProfiles("test") class BookApiApplicationTests { - @Test - void contextLoads() { - } - + @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 index 7c7dd9a..7f19f7e 100644 --- a/src/test/java/com/codesungrape/hmcts/bookapi/BookServiceTest.java +++ b/src/test/java/com/codesungrape/hmcts/bookapi/BookServiceTest.java @@ -22,240 +22,249 @@ 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. - * + * @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; - - // 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"), + // 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; + + // 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() + ) + ); + } + + // --------- TESTS ------------ + + @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(longTitle) - .synopsis("Synopsis") - .author("Author") - .build() - ), - Arguments.of( - "Very long synopsis (1000 chars)", - new BookRequest("Title", longSynopsis, "Author"), + .title(validBookRequest.title()) + .synopsis(validBookRequest.synopsis()) + .author(validBookRequest.author()) + .deleted(false) + .createdAt(java.time.Instant.now()) + .build(); + } + + @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.title(), result.getTitle()); + assertEquals(validBookRequest.synopsis(), result.getSynopsis()); + assertEquals(validBookRequest.author(), result.getAuthor()); + + // Did the service perform the correct action on its dependency? + verify(testBookRepository, times(1)).save(any(Book.class)); + } + + // CoPilot feedback: + // This test will fail because BookRequest uses @value from Lombok with @notblank validation. + // The @notblank constraint on the title field means that creating a BookRequest with a null + // title should trigger validation failure at the DTO level, not allow the object to be + // created. Either the test expectations are incorrect, or the DTO validation is not being + // applied. The same issue affects tests on lines 105-116, 119-127, and 130-138. + + @Test + void testCreateBook_NullRequest_ThrowsException() { + // Act & Assert + assertThrows( + NullPointerException.class, + () -> { + testBookService.createBook(null); + } + ); + } + + @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); + } + ); + } + + // ----- EDGE cases --------- + + @Test + void testCreateBook_RepositoryReturnsNull_HandlesGracefully() { + // Arrange + when(testBookRepository.save(any(Book.class))).thenReturn(null); + + // Act & assert + assertThrows( + IllegalStateException.class, + () -> { + testBookService.createBook(validBookRequest); + } + ); + } + + @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)); + } + + @Test + void testCreateBook_SpecialCharactersInTitle_Success() { + // Arrange + BookRequest specialRequest = + new BookRequest("Test: A Book! @#$%^&*()", "Synopsis", "Author"); + + Book expectedBook = Book.builder() .id(testId) - .title("Title") - .synopsis(longSynopsis) - .author("Author") - .build() - ) - ); - } - - // --------- TESTS ------------ - - @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.title()) - .synopsis(validBookRequest.synopsis()) - .author(validBookRequest.author()) - .deleted(false) - .createdAt(java.time.Instant.now()) - .build(); - } - - @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.title(), result.getTitle()); - assertEquals(validBookRequest.synopsis(), result.getSynopsis()); - assertEquals(validBookRequest.author(), result.getAuthor()); - - // Did the service perform the correct action on its dependency? - verify(testBookRepository, times(1)).save(any(Book.class)); - - } - - // CoPilot feedback: - //This test will fail because BookRequest uses @value from Lombok with @notblank validation. - //The @notblank constraint on the title field means that creating a BookRequest with a null - // title should trigger validation failure at the DTO level, not allow the object to be - // created. Either the test expectations are incorrect, or the DTO validation is not being - // applied. The same issue affects tests on lines 105-116, 119-127, and 130-138. - - @Test - void testCreateBook_NullRequest_ThrowsException() { - // Act & Assert - assertThrows(NullPointerException.class, () -> { - testBookService.createBook(null); - }); - } - - @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); - }); - } - - // ----- EDGE cases --------- - - @Test - void testCreateBook_RepositoryReturnsNull_HandlesGracefully() { - // Arrange - when(testBookRepository.save(any(Book.class))) - .thenReturn(null); - - // Act & assert - assertThrows(IllegalStateException.class, () -> { - testBookService.createBook(validBookRequest); - }); - } - - @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)); - } - - @Test - void testCreateBook_SpecialCharactersInTitle_Success() { - // Arrange - BookRequest specialRequest = new BookRequest( - "Test: A Book! @#$%^&*()", - "Synopsis", - "Author" - ); - - Book expectedBook = Book.builder() - .id(testId) - .title(specialRequest.title()) - .synopsis(specialRequest.synopsis()) - .author(specialRequest.author()) - .build(); - - when(testBookRepository.save(any(Book.class))) - .thenReturn(expectedBook); - - // Act - Book result = testBookService.createBook(specialRequest); - - // Assert - assertNotNull(result); - assertEquals(testId, result.getId()); - assertEquals(specialRequest.title(), result.getTitle()); - assertEquals(specialRequest.synopsis(), result.getSynopsis()); - assertEquals(specialRequest.author(), 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 + .title(specialRequest.title()) + .synopsis(specialRequest.synopsis()) + .author(specialRequest.author()) + .build(); + + when(testBookRepository.save(any(Book.class))).thenReturn(expectedBook); + + // Act + Book result = testBookService.createBook(specialRequest); + + // Assert + assertNotNull(result); + assertEquals(testId, result.getId()); + assertEquals(specialRequest.title(), result.getTitle()); + assertEquals(specialRequest.synopsis(), result.getSynopsis()); + assertEquals(specialRequest.author(), result.getAuthor()); + + // Did the service perform the correct action on its dependency? + verify(testBookRepository, times(1)).save(any(Book.class)); + } +} From 1f299400a17bd991a1f314705fbd6aaab12d2f19 Mon Sep 17 00:00:00 2001 From: codesungrape Date: Thu, 13 Nov 2025 16:27:25 +0000 Subject: [PATCH 06/14] Update file to aggregate tasks to run with one check cmd --- build.gradle | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index 16f0659..244bdbb 100644 --- a/build.gradle +++ b/build.gradle @@ -124,13 +124,19 @@ spotbugs { ignoreFailures = false } +// Aggregate SpotBugs task +tasks.register("spotbugs") { + group = "verification" + description = "Run all SpotBugs analysis tasks" + dependsOn("spotbugsMain", "spotbugsTest") +} // ===== BUILD LIFECYCLE INTEGRATION ===== // The standard 'check' task will now run all quality gates tasks.named('check') { dependsOn jacocoTestCoverageVerification - dependsOn tasks.named('spotlessCheck') dependsOn tasks.named('checkstyleMain') - dependsOn tasks.named('spotbugsMain') + dependsOn tasks.named('checkstyleTest') + dependsOn tasks.named('spotbugs') } From 8ab98d68d646d46e27cadcd2dfa6e9711db2e7b6 Mon Sep 17 00:00:00 2001 From: codesungrape Date: Mon, 17 Nov 2025 09:35:14 +0000 Subject: [PATCH 07/14] Remove redundant ReturnNull check + update Javadoc string --- .../hmcts/bookapi/service/BookService.java | 1 - .../hmcts/bookapi/BookServiceTest.java | 39 ++++++++----------- 2 files changed, 16 insertions(+), 24 deletions(-) diff --git a/src/main/java/com/codesungrape/hmcts/bookapi/service/BookService.java b/src/main/java/com/codesungrape/hmcts/bookapi/service/BookService.java index 7cd2b18..7858129 100644 --- a/src/main/java/com/codesungrape/hmcts/bookapi/service/BookService.java +++ b/src/main/java/com/codesungrape/hmcts/bookapi/service/BookService.java @@ -23,7 +23,6 @@ public class BookService { * @return The saved Book entity * @throws NullPointerException if request is null * @throws IllegalArgumentException if title is null or blank - * @throws IllegalStateException if the repository fails to save the book */ public Book createBook(BookRequest request) { // Validation check for business rules (e.g., uniqueness, if required) diff --git a/src/test/java/com/codesungrape/hmcts/bookapi/BookServiceTest.java b/src/test/java/com/codesungrape/hmcts/bookapi/BookServiceTest.java index 7f19f7e..227e950 100644 --- a/src/test/java/com/codesungrape/hmcts/bookapi/BookServiceTest.java +++ b/src/test/java/com/codesungrape/hmcts/bookapi/BookServiceTest.java @@ -17,16 +17,23 @@ import java.util.UUID; import java.util.stream.Stream; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; -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. +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.times; + + +/**. + * Explains how this test class uses Mockito: + * - JUnit is extended using MockitoExtension + * - @Mock creates fake dependencies + * - @InjectMocks creates the real service with mocks injected + * - @BeforeEach runs before every test + * - @Test marks a test method */ // Annotation tells JUnit to use Mockito @@ -203,20 +210,6 @@ void testCreateBook_RepositoryFailure_ThrowsException() { // ----- EDGE cases --------- - @Test - void testCreateBook_RepositoryReturnsNull_HandlesGracefully() { - // Arrange - when(testBookRepository.save(any(Book.class))).thenReturn(null); - - // Act & assert - assertThrows( - IllegalStateException.class, - () -> { - testBookService.createBook(validBookRequest); - } - ); - } - @ParameterizedTest(name = "{0}") // Display the test name @MethodSource("provideLongFieldTestCases") void testCreateBook_VeryLongFields_Success( From 527e538ec08b275b30740acc68102b021754c7fb Mon Sep 17 00:00:00 2001 From: codesungrape Date: Mon, 17 Nov 2025 09:44:42 +0000 Subject: [PATCH 08/14] Update READme.md --- README.md | 179 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 176 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 29534fc..8ce8b03 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,128 @@ # S_BookAPIJAVA +# Code Quality & Static Analysis + +This project follows HMCTS engineering standards for formatting, style, static analysis, and code coverage. +The following tools are configured and integrated with Gradle: + +- Checkstyle – HMCTS Java code conventions + +- SpotBugs – static analysis for potential defects + +- JaCoCo – code coverage reporting + +- EditorConfig – consistent whitespace and formatting rules + + +---------- + +## Checkstyle (HMCTS rules) + +Checkstyle uses the HMCTS `checkstyle.xml`, enforcing naming, formatting, Javadoc, and structural rules. + +### Run Checkstyle + +`./gradlew checkstyleMain +./gradlew checkstyleTest` + +### Run all Checkstyle tasks + +`./gradlew checkstyle` + +### Checkstyle reports + +Reports are generated at: + +`build/reports/checkstyle/` + +---------- + +## SpotBugs (static analysis) + +SpotBugs analyses compiled bytecode and flags potential null pointer issues, performance problems, and common Java defects. + +### List SpotBugs tasks + +`./gradlew tasks --all | grep spotbugs` + +### Run SpotBugs on main code + +`./gradlew spotbugsMain` + +### Run SpotBugs on test code + +`./gradlew spotbugsTest` + +### Run all SpotBugs tasks + +`./gradlew spotbugs` + +### SpotBugs reports + +`build/reports/spotbugs/` + +---------- + +## JaCoCo (test coverage) + +JaCoCo generates unit test and integration test coverage reports in XML and HTML. + +### Run tests and generate JaCoCo reports + +`./gradlew test jacocoTestReport` + +### Coverage report location + +`build/reports/jacoco/test/jacocoTestReport.html` + +---------- + +## EditorConfig (formatting and whitespace) + +The project uses HMCTS `.editorconfig` rules, enforcing: + +- 2-space indentation for most files + +- 4-space indentation for `.java` files + +- `LF` line endings + +- UTF-8 charset + +- No trailing whitespace + +- A newline at the end of every file + + +Most IDEs (including IntelliJ) apply these rules automatically. + +---------- + +## Running all verification tasks + +To verify everything before committing: + +`./gradlew clean build` + +This runs: + +- Checkstyle + +- SpotBugs + +- Tests + +- JaCoCo + +- Compilation + +- Packaging + +---------- + ### Gradle Daemon: Stop the Daemon and Force a Clean Run -**Step 1: Forcefully stop all running Gradle daemons.** +**Step 1: Forcefully stop all running Gradle daemons.** Forcefully stop all running Gradle daemons. This command tells Gradle to find any background processes it has running and terminate them. ```Bash @@ -12,5 +132,58 @@ This command tells Gradle to find any background processes it has running and te **Step 2: Run a clean build.** The clean task deletes the entire build directory. This removes any old, compiled artifacts and cached results, ensuring nothing stale is left over. We will combine it with the checkstyleMain task. ```Bash -./gradlew clean checkstyleMain -``` \ No newline at end of file +./gradlew clean +``` + +---------- + +## IntelliJ Setup + +### Enable Checkstyle in IntelliJ + +1. Install the **Checkstyle-IDEA** plugin + +2. Open IntelliJ settings: + + `Settings → Tools → Checkstyle` + +3. Add the configuration file: + + `config/checkstyle/checkstyle.xml` + +4. Set it as the default configuration + +5. (Optional) Enable “Scan before check-in” + + +---------- + +### Enable EditorConfig support + +Verify the following setting is enabled: + +`Settings → Editor → Code Style → Enable EditorConfig support` + +---------- + +### Reformat code according to project rules + +Use IntelliJ’s reformat command: + +`Windows/Linux: Ctrl + Alt + L macOS: Cmd + Option + L` + +---------- + +## Summary + +This project aligns with HMCTS engineering standards: + +- HMCTS Checkstyle enforcement + +- SpotBugs static analysis + +- JaCoCo coverage reports + +- HMCTS EditorConfig formatting + +- Spotless removed (not used by HMCTS) From 0aa45215f56a85081a4992b14578d79d7e35ac63 Mon Sep 17 00:00:00 2001 From: codesungrape Date: Mon, 17 Nov 2025 10:41:38 +0000 Subject: [PATCH 09/14] Add CI workflow for merge to main --- .github/workflows/ci.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..507e7b6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,25 @@ +name: Build & Quality Checks + +on: + pull_request: + push: + branches: [ main ] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Make gradlew executable + run: chmod +x ./gradlew + + - name: Run Gradle checks + run: ./gradlew clean check From 8324889eb4bf7a8326849e85c80820e3819961af Mon Sep 17 00:00:00 2001 From: codesungrape Date: Mon, 17 Nov 2025 14:47:26 +0000 Subject: [PATCH 10/14] Apply grammatical typo/fixes from Copilot --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 8ce8b03..efa9508 100644 --- a/README.md +++ b/README.md @@ -22,10 +22,11 @@ Checkstyle uses the HMCTS `checkstyle.xml`, enforcing naming, formatting, Javado ### Run Checkstyle -`./gradlew checkstyleMain -./gradlew checkstyleTest` +`./gradlew checkstyleMain` +or +`./gradlew checkstyleTest` -### Run all Checkstyle tasks +### Run ALL Checkstyle tasks `./gradlew checkstyle` @@ -123,7 +124,6 @@ This runs: ### Gradle Daemon: Stop the Daemon and Force a Clean Run **Step 1: Forcefully stop all running Gradle daemons.** -Forcefully stop all running Gradle daemons. This command tells Gradle to find any background processes it has running and terminate them. ```Bash ./gradlew --stop @@ -132,7 +132,7 @@ This command tells Gradle to find any background processes it has running and te **Step 2: Run a clean build.** The clean task deletes the entire build directory. This removes any old, compiled artifacts and cached results, ensuring nothing stale is left over. We will combine it with the checkstyleMain task. ```Bash -./gradlew clean +./gradlew clean [checkstyleMain] ``` ---------- From 9346299458305d88afacee6abaac8752743c06b0 Mon Sep 17 00:00:00 2001 From: codesungrape Date: Mon, 17 Nov 2025 14:47:53 +0000 Subject: [PATCH 11/14] Apply copilot typo suggestion --- config/checkstyle/checkstyle.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/checkstyle/checkstyle.xml b/config/checkstyle/checkstyle.xml index 0ef5139..5659753 100644 --- a/config/checkstyle/checkstyle.xml +++ b/config/checkstyle/checkstyle.xml @@ -183,7 +183,7 @@ + value="GenericWhitespace ''{0}'' should be followed by whitespace."/> @@ -249,4 +249,4 @@ - \ No newline at end of file + From 69f17301443d0bb3956f98971665e3cee153a08c Mon Sep 17 00:00:00 2001 From: codesungrape Date: Mon, 17 Nov 2025 14:50:41 +0000 Subject: [PATCH 12/14] 200~Remove @ActiveProfiles since all envs currently use H2 --- .../com/codesungrape/hmcts/bookapi/BookApiApplicationTests.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/test/java/com/codesungrape/hmcts/bookapi/BookApiApplicationTests.java b/src/test/java/com/codesungrape/hmcts/bookapi/BookApiApplicationTests.java index 5befa77..d0f1015 100644 --- a/src/test/java/com/codesungrape/hmcts/bookapi/BookApiApplicationTests.java +++ b/src/test/java/com/codesungrape/hmcts/bookapi/BookApiApplicationTests.java @@ -5,7 +5,6 @@ import org.springframework.test.context.ActiveProfiles; @SpringBootTest -@ActiveProfiles("test") class BookApiApplicationTests { @Test From 039ffc5f41a18ae697778b861e870fd2ef4d8e6a Mon Sep 17 00:00:00 2001 From: codesungrape Date: Mon, 17 Nov 2025 14:51:49 +0000 Subject: [PATCH 13/14] Update Javadoc to reflect use of record vs lobok @Value --- .../hmcts/bookapi/dto/BookRequest.java | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/codesungrape/hmcts/bookapi/dto/BookRequest.java b/src/main/java/com/codesungrape/hmcts/bookapi/dto/BookRequest.java index 30731f8..42f9011 100644 --- a/src/main/java/com/codesungrape/hmcts/bookapi/dto/BookRequest.java +++ b/src/main/java/com/codesungrape/hmcts/bookapi/dto/BookRequest.java @@ -4,10 +4,16 @@ import jakarta.validation.constraints.NotBlank; /** - * DTO representing the required input for creating or replacing a Book resource. This class mirrors - * the OpenAPI 'BookInput' schema. @Value: Makes fields final, adds constructor, getters, equals, - * hashCode, toString. @NotBlank: Field required; empty/missing value returns 400 Bad Request in - * Spring. @JsonProperty: Maps snake_case JSON to camelCase Java using Jackson. + * DTO representing the required input for creating or replacing a Book resource. + * This record mirrors the OpenAPI 'BookInput' schema. + *As a Java record: + * - all fields are implicitly final + * - a canonical constructor is generated automatically + * - accessor methods (e.g., title()) are generated + * - equals, hashCode, and toString are automatically implemented + *`@NotBlank` ensures that each field is required; missing or empty values result in + * a 400 Bad Request response in Spring. + * `@JsonProperty` maps snake_case JSON to camelCase Java properties using Jackson. */ public record BookRequest( @JsonProperty("title") @NotBlank(message = "Title is required") String title, From 8fbc9518119d260aa1147fd7b71b9a8c8b50e605 Mon Sep 17 00:00:00 2001 From: codesungrape Date: Mon, 17 Nov 2025 14:57:37 +0000 Subject: [PATCH 14/14] Remove unused ActiveProfiles import --- .../com/codesungrape/hmcts/bookapi/BookApiApplicationTests.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/test/java/com/codesungrape/hmcts/bookapi/BookApiApplicationTests.java b/src/test/java/com/codesungrape/hmcts/bookapi/BookApiApplicationTests.java index d0f1015..a10fc14 100644 --- a/src/test/java/com/codesungrape/hmcts/bookapi/BookApiApplicationTests.java +++ b/src/test/java/com/codesungrape/hmcts/bookapi/BookApiApplicationTests.java @@ -2,7 +2,6 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; @SpringBootTest class BookApiApplicationTests {