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/.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 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/README.md b/README.md index 3bd2403..efa9508 100644 --- a/README.md +++ b/README.md @@ -1 +1,189 @@ -# S_BookAPIJAVA \ No newline at end of file +# 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` +or +`./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.** +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] +``` + +---------- + +## 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) diff --git a/build.gradle b/build.gradle index 3d04508..244bdbb 100644 --- a/build.gradle +++ b/build.gradle @@ -2,6 +2,11 @@ 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 } group = 'com.codesungrape.hmcts' @@ -9,9 +14,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 +38,105 @@ 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 ===== + +checkstyle { + maxWarnings = 0 + toolVersion = '10.26.1' + getConfigDirectory().set(new File(rootDir, 'config/checkstyle')) +} + +spotbugs { + // Recommended to use a more recent tool version + toolVersion = '4.8.3' + // Fail the build on any identified issue + 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('checkstyleMain') + dependsOn tasks.named('checkstyleTest') + dependsOn tasks.named('spotbugs') } diff --git a/config/checkstyle/checkstyle.xml b/config/checkstyle/checkstyle.xml new file mode 100644 index 0000000..5659753 --- /dev/null +++ b/config/checkstyle/checkstyle.xml @@ -0,0 +1,252 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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/entity/Book.java b/src/main/java/com/codesungrape/hmcts/BookAPI/entity/Book.java deleted file mode 100644 index 43cd2cd..0000000 --- a/src/main/java/com/codesungrape/hmcts/BookAPI/entity/Book.java +++ /dev/null @@ -1,76 +0,0 @@ -package com.codesungrape.hmcts.BookAPI.entity; - -import jakarta.persistence.*; -import java.util.UUID; -import lombok.*; -import java.time.Instant; - -/** - * JPA Entity representing the Book table in PostgreSQL. - * This holds the persisted state of the resource. - * HMCTS Rule Check: IDs must be opaque strings. Using UUID for distributed ID generation. - * @Entity: Marks the class as a JPA entity - tells hibernate to map Java classes to database tables. - * @Table: Defines which database table this entity maps to. HMCTS Naming: Lowercase, singular table name is common practice. - * Lombok annotations: - * @Getter: Automatically generates getters for all fields. - * @Setter: Automatically generates setters. - * @AllArgsConstructor: Generates a no-argument constructor (required by JPA). - * JPA needs to instantiate the entity using reflection. 'PROTECTED' prevents misuse. - * @Builder: Adds a builder pattern for clean object creation. -* You can do Book.builder().title("A").author("B").build(); - */ -@Entity -@Table(name = "book") -@Getter -@Setter -@NoArgsConstructor(access = AccessLevel.PROTECTED) // For JPA/Hibernate requirements -@AllArgsConstructor // For easy construction in tests -@Builder // For convenience in creating instances -public class Book { - - @Id // Primary key of the table - @GeneratedValue(strategy = GenerationType.UUID) - @Column(name = "id", nullable = false) // maps the field to a database column named 'id' + 'nullable =false' database column cannot be NULL. - private UUID id; - - @Column(name = "title", nullable = false) - private String title; - - @Column(name = "synopsis", nullable = false, columnDefinition = "TEXT") - private String synopsis; - - @Column(name = "author", nullable = false) - private String author; - - // Soft delete - makes DELETE operations idempotent (safe to repeat) - // Soft delete - using @Builder.Default to ensure the builder - // respects this initialization if the field is not set explicitly. - @Column(name = "deleted", nullable = false) - @Builder.Default - private boolean deleted = false; - - // `createdAt` is null upon object creation. - // It will be set by the `onCreate()` method right before persistence. - @Column(name = "created_at", nullable = false, updatable = false) - private Instant createdAt; - - @Column(name = "modified_at") - private java.time.Instant modifiedAt; - - // --- JPA lifecycle callbacks --- - @PrePersist - protected void onCreate() { - this.createdAt = java.time.Instant.now(); - } - - // --- Business Logic Helper --- - // HMCTS mandates business logic in services, but a setter hook is acceptable. - // Lifecycle callback - special method runs automatically before Hibernate updates a record in the database. - @PreUpdate - protected void onUpdate() { - - this.modifiedAt = java.time.Instant.now(); - } - - -} \ No newline at end of file diff --git a/src/main/java/com/codesungrape/hmcts/BookAPI/repository/BookRepository.java b/src/main/java/com/codesungrape/hmcts/BookAPI/repository/BookRepository.java deleted file mode 100644 index 5bc5413..0000000 --- a/src/main/java/com/codesungrape/hmcts/BookAPI/repository/BookRepository.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.codesungrape.hmcts.BookAPI.repository; - -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -import com.codesungrape.hmcts.BookAPI.entity.Book; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -/** - * Repository interface for Book Entity. - * Spring Data JPA automatically provides CRUD operations based on the Entity and ID type. - */ -@Repository -public interface BookRepository extends JpaRepository { - - // Custom query to find books that have NOT been soft-deleted - List findAllByDeletedFalse(); - - // Custom query to find a specific, non-deleted book by ID. - Optional findByIdAndDeleteFalse(UUID id); - -} \ No newline at end of file diff --git a/src/main/java/com/codesungrape/hmcts/bookapi/BookApiApplication.java b/src/main/java/com/codesungrape/hmcts/bookapi/BookApiApplication.java new file mode 100644 index 0000000..08da1e1 --- /dev/null +++ b/src/main/java/com/codesungrape/hmcts/bookapi/BookApiApplication.java @@ -0,0 +1,29 @@ +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 { + + /** + * Private constructor to prevent instantiation. + * This avoids Checkstyle treating this as an instantiable utility class. + */ + private BookApiApplication() { + // Intentionally empty — prevents accidental instantiation + } + + /** + * 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..42f9011 --- /dev/null +++ b/src/main/java/com/codesungrape/hmcts/bookapi/dto/BookRequest.java @@ -0,0 +1,22 @@ +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 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, + @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 new file mode 100644 index 0000000..0daeacc --- /dev/null +++ b/src/main/java/com/codesungrape/hmcts/bookapi/entity/Book.java @@ -0,0 +1,91 @@ +package com.codesungrape.hmcts.bookapi.entity; + +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; + +/** + * 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") +@Getter +@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) // For JPA/Hibernate requirements +@AllArgsConstructor // For easy construction in tests +@Builder // For convenience in creating instances +public class Book { + + @Id // Primary key of the table + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id", nullable = false) + private UUID id; + + @Column(name = "title", nullable = false) + private String title; + + @Column(name = "synopsis", nullable = false, columnDefinition = "TEXT") + private String synopsis; + + @Column(name = "author", nullable = false) + private String author; + + // Soft delete - makes DELETE operations idempotent (safe to repeat) + // Using @Builder.Default to ensure the builder respects this initialization + // if the field is not set explicitly. + @Column(name = "deleted", nullable = false) + @Builder.Default + private boolean deleted = false; + + // `createdAt` is null upon object creation. + // It will be set by the `onCreate()` method right before persistence. + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "modified_at") + private Instant modifiedAt; + + // --- 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. + + /** + * 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 new file mode 100644 index 0000000..3550d6e --- /dev/null +++ b/src/main/java/com/codesungrape/hmcts/bookapi/repository/BookRepository.java @@ -0,0 +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; + +/** + * 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); +} 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 59% 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..7858129 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; +package com.codesungrape.hmcts.bookapi.service; -import com.codesungrape.hmcts.BookAPI.dto.BookRequest; -import com.codesungrape.hmcts.BookAPI.repository.BookRepository; -import org.springframework.stereotype.Service; // Marks a class as a Service Layer component. +import com.codesungrape.hmcts.bookapi.dto.BookRequest; +import com.codesungrape.hmcts.bookapi.entity.Book; +import com.codesungrape.hmcts.bookapi.repository.BookRepository; 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. @@ -17,40 +16,42 @@ public class BookService { // Create a field to store the repo private final BookRepository bookRepository; - // 1. CREATE Operation (POST /books) + /** + * 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 + */ 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 + // 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.getTitle() == null || request.getTitle().isBlank()) { + 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.getTitle()) - .author(request.getAuthor()) - .synopsis(request.getSynopsis()) + 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); - // Defensive check (even though it "shouldn't" happen aka follows JPA contract) - if (savedBook == null) { - throw new IllegalStateException("Failed to save book - repository returned null"); - } - return savedBook; } -} \ No newline at end of file +} diff --git a/src/test/java/com/codesungrape/hmcts/BookAPI/BookApiApplicationTests.java b/src/test/java/com/codesungrape/hmcts/bookapi/BookApiApplicationTests.java similarity index 64% rename from src/test/java/com/codesungrape/hmcts/BookAPI/BookApiApplicationTests.java rename to src/test/java/com/codesungrape/hmcts/bookapi/BookApiApplicationTests.java index eea05f7..a10fc14 100644 --- a/src/test/java/com/codesungrape/hmcts/BookAPI/BookApiApplicationTests.java +++ b/src/test/java/com/codesungrape/hmcts/bookapi/BookApiApplicationTests.java @@ -1,4 +1,4 @@ -package com.codesungrape.hmcts.BookAPI; +package com.codesungrape.hmcts.bookapi; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; @@ -6,8 +6,7 @@ @SpringBootTest 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 similarity index 53% rename from src/test/java/com/codesungrape/hmcts/BookAPI/BookServiceTest.java rename to src/test/java/com/codesungrape/hmcts/bookapi/BookServiceTest.java index 8d160c4..227e950 100644 --- a/src/test/java/com/codesungrape/hmcts/BookAPI/BookServiceTest.java +++ b/src/test/java/com/codesungrape/hmcts/bookapi/BookServiceTest.java @@ -1,36 +1,40 @@ -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; +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.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.never; import static org.mockito.Mockito.when; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -/** - * @ExtendWith(MockitoExtension.class): tells JUnit 5 to use Mockito's extension and automatically initializes all @Mock and @InjectMocks fields when running this test class. - * @Mock: Creates a fake version (mock) of the dependency. - * @InjectMocks: creates an instance of the real class under test. - * @BeforeEach: Runs before each test method in the class. - * @Test: Marks the method as a test case that JUnit should execute. - * - */ +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 @ExtendWith(MockitoExtension.class) @@ -49,29 +53,62 @@ class BookServiceTest { 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( + 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() + persistedBook = + Book.builder() .id(testId) - .title(validBookRequest.getTitle()) - .synopsis(validBookRequest.getSynopsis()) - .author(validBookRequest.getAuthor()) + .title(validBookRequest.title()) + .synopsis(validBookRequest.synopsis()) + .author(validBookRequest.author()) .deleted(false) .createdAt(java.time.Instant.now()) .build(); } - // --------- TESTS ------------ - @Test void testCreateBook_Success() { @@ -84,39 +121,44 @@ void testCreateBook_Success() { // 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()); + 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)); - - } - - @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 + // 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); - }); + assertThrows( + IllegalArgumentException.class, + () -> { + testBookService.createBook(invalidRequest); + } + ); // Verify repository was never called verify(testBookRepository, never()).save(any()); @@ -128,9 +170,12 @@ void testCreateBook_EmptyTitle_ThrowsException() { BookRequest invalidRequest = new BookRequest("", "Synopsis", "Author"); // Act & Assert - assertThrows(IllegalArgumentException.class, () -> { - testBookService.createBook(invalidRequest); - }); + assertThrows( + IllegalArgumentException.class, + () -> { + testBookService.createBook(invalidRequest); + } + ); } @Test @@ -139,9 +184,12 @@ void testCreateBook_BlankTitle_ThrowsException() { BookRequest invalidRequest = new BookRequest(" ", "Synopsis", "Author"); // Act & Assert - assertThrows(IllegalArgumentException.class, () -> { - testBookService.createBook(invalidRequest); - }); + assertThrows( + IllegalArgumentException.class, + () -> { + testBookService.createBook(invalidRequest); + } + ); } // --------- Repository failures @@ -149,39 +197,26 @@ void testCreateBook_BlankTitle_ThrowsException() { 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); + .thenThrow(new RuntimeException("Database connection failed")); // Act & assert - assertThrows(IllegalStateException.class, () -> { - testBookService.createBook(validBookRequest); - }); + assertThrows( + RuntimeException.class, + () -> { + testBookService.createBook(validBookRequest); + } + ); } // ----- EDGE cases --------- - @ParameterizedTest(name= "{0}") // Display the test name + @ParameterizedTest(name = "{0}") // Display the test name @MethodSource("provideLongFieldTestCases") void testCreateBook_VeryLongFields_Success( - String testName, - BookRequest request, - Book expectedBook - ) { + String testName, BookRequest request, Book expectedBook) { // Arrange - when(testBookRepository.save(any(Book.class))) - .thenReturn(expectedBook); + when(testBookRepository.save(any(Book.class))).thenReturn(expectedBook); // Act Book result = testBookService.createBook(request); @@ -196,55 +231,21 @@ void testCreateBook_VeryLongFields_Success( 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" - ); + BookRequest specialRequest = + new BookRequest("Test: A Book! @#$%^&*()", "Synopsis", "Author"); - Book expectedBook = Book.builder() + Book expectedBook = + Book.builder() .id(testId) - .title(specialRequest.getTitle()) - .synopsis(specialRequest.getSynopsis()) - .author(specialRequest.getAuthor()) + .title(specialRequest.title()) + .synopsis(specialRequest.synopsis()) + .author(specialRequest.author()) .build(); - when(testBookRepository.save(any(Book.class))) - .thenReturn(expectedBook); + when(testBookRepository.save(any(Book.class))).thenReturn(expectedBook); // Act Book result = testBookService.createBook(specialRequest); @@ -252,11 +253,11 @@ void testCreateBook_SpecialCharactersInTitle_Success() { // Assert assertNotNull(result); assertEquals(testId, result.getId()); - assertEquals(specialRequest.getTitle(), result.getTitle()); - assertEquals(specialRequest.getSynopsis(), result.getSynopsis()); - assertEquals(specialRequest.getAuthor(), result.getAuthor()); + 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 new file mode 100644 index 0000000..aa00a89 --- /dev/null +++ b/src/test/resources/application.properties @@ -0,0 +1,13 @@ +# 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