diff --git a/.coderabbit.yaml b/.coderabbit.yaml index c18746f..2c072f6 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -70,8 +70,8 @@ reviews: - Separate entities (@Entity) from DTOs - Verify proper Lombok annotations - Check JPA annotations for entities (@Table, @Column, @Id, etc.) - - Ensure validation annotations on DTOs (@NotBlank, @ISBN, @URL) - - Validate proper use of LocalDate converter for SQLite + - Ensure validation annotations on DTOs (@NotBlank, @Past, @Positive, @URL) + - Validate proper use of IsoDateConverter for SQLite (ISO-8601 TEXT format) - path: "src/main/java/**/Application.java" instructions: | @@ -84,13 +84,14 @@ reviews: - Test classes should use JUnit 5 (Jupiter) - Verify proper Spring test annotations (@WebMvcTest, @DataJpaTest, etc.) - Check use of @MockitoBean (Spring Boot 4.0 style) - - Ensure test naming follows given_when_then pattern + - Ensure test naming follows method_scenario_outcome pattern (concise) + - Validate BDD semantics in JavaDoc comments (Given/When/Then) - Validate use of AssertJ for fluent assertions - Check @DisplayName for readable test descriptions - Ensure @AutoConfigureCache when testing cached operations - - Verify test data uses fake factories (BookFakes, BookDTOFakes) + - Verify test data uses fake factories (PlayerFakes, PlayerDTOFakes) - - path: "src/test/java/**/BookFakes.java" + - path: "src/test/java/**/PlayerFakes.java" instructions: | - Verify test data factory pattern - Check consistency of test data generation diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 4b19066..05ad3da 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -24,6 +24,34 @@ - Example: `docs: optimize AI agent instructions for token efficiency (#259)` - Types: `feat`, `fix`, `chore`, `docs`, `test`, `refactor` +## Test Naming Convention + +**Pattern**: `method_scenario_outcome` + +- **method**: The method being tested (e.g., `post`, `findById`, `create`) +- **scenario**: The context or condition (e.g., `playerExists`, `invalidData`, `noMatches`) +- **outcome**: The expected result (e.g., `returnsPlayer`, `returnsConflict`, `returnsEmpty`) + +**Examples**: +```java +// Controller: post_squadNumberExists_returnsConflict() +// Service: create_noConflict_returnsPlayerDTO() +// Repository: findById_playerExists_returnsPlayer() +``` + +**JavaDoc**: Use proper BDD (Given/When/Then) structure in comments: +```java +/** + * Given a player with squad number 5 already exists in the database + * When POST /players is called with a new player using squad number 5 + * Then response status is 409 Conflict + */ +@Test +void post_squadNumberExists_returnsConflict() { ... } +``` + +**Benefits**: Concise method names for IDE test runners, full BDD context in JavaDoc for code readability. + ## Architecture at a Glance ``` diff --git a/.vscode/java-formatter.xml b/.vscode/java-formatter.xml new file mode 100644 index 0000000..996147e --- /dev/null +++ b/.vscode/java-formatter.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.vscode/settings.json b/.vscode/settings.json index c7306a1..e958eab 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,11 @@ { - "editor.rulers": [80], + "editor.rulers": [ + { + "column": 127 + } + ], + "editor.wordWrap": "off", + "editor.wordWrapColumn": 127, "editor.tabSize": 4, "editor.insertSpaces": true, "editor.detectIndentation": false, @@ -8,6 +14,7 @@ "editor.defaultFormatter": "redhat.java", "editor.inlayHints.enabled": "off" }, + "java.format.settings.url": ".vscode/java-formatter.xml", "java.configuration.updateBuildConfiguration": "automatic", "java.compile.nullAnalysis.mode": "automatic", "sonarlint.connectedMode.project": { diff --git a/AGENTS.md b/AGENTS.md index d831a3f..d693bb2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,10 +45,10 @@ Maven wrapper (`./mvnw`) is included, so Maven installation is optional. open target/site/jacoco/index.html # Run specific test class -./mvnw test -Dtest=BooksControllerTest +./mvnw test -Dtest=PlayersControllerTests # Run specific test method -./mvnw test -Dtest=BooksControllerTest#testGetAllBooks +./mvnw test -Dtest=PlayersControllerTests#getAll_playersExist_returnsOkWithAllPlayers # Run tests without rebuilding ./mvnw surefire:test @@ -99,25 +99,25 @@ java -jar target/java.samples.spring.boot-*.jar ### Database Management -This project uses **H2 in-memory database for tests** and **SQLite for runtime**. +This project uses **SQLite in-memory database for tests** and **SQLite for runtime**. **Runtime (SQLite)**: ```bash # Database auto-initializes on first startup -# Pre-seeded database ships in storage/books-sqlite3.db +# Pre-seeded database ships in storage/players-sqlite3.db # To reset database to seed state -rm storage/books-sqlite3.db +rm storage/players-sqlite3.db # WARNING: spring.jpa.hibernate.ddl-auto=none disables schema generation # Deleting the DB will cause startup failure - restore from backup or manually reinitialize -# Database location: storage/books-sqlite3.db +# Database location: storage/players-sqlite3.db ``` -**Tests (H2)**: +**Tests (SQLite)**: -- In-memory database per test run +- In-memory database per test run (jdbc:sqlite::memory:) - Automatically cleared after each test - Configuration in `src/test/resources/application.properties` @@ -181,34 +181,37 @@ src/main/java/ar/com/nanotaboada/java/samples/spring/boot/ ├── Application.java # @SpringBootApplication entry point ├── controllers/ # REST endpoints -│ └── BooksController.java # @RestController, OpenAPI annotations +│ └── PlayersController.java # @RestController, OpenAPI annotations ├── services/ # Business logic -│ └── BooksService.java # @Service, @Cacheable +│ └── PlayersService.java # @Service, @Cacheable ├── repositories/ # Data access -│ └── BooksRepository.java # @Repository, Spring Data JPA +│ └── PlayersRepository.java # @Repository, Spring Data JPA -└── models/ # Domain models - ├── Book.java # @Entity, JPA model - ├── BookDTO.java # Data Transfer Object, validation - └── UnixTimestampConverter.java # JPA converter +├── models/ # Domain models +│ ├── Player.java # @Entity, JPA model +│ └── PlayerDTO.java # Data Transfer Object, validation + +└── converters/ # Infrastructure converters + └── IsoDateConverter.java # JPA converter for ISO-8601 dates src/test/java/ # Test classes - ├── BooksControllerTest.java - ├── BooksServiceTest.java - └── BooksRepositoryTest.java + ├── PlayersControllerTests.java + ├── PlayersServiceTests.java + └── PlayersRepositoryTests.java ``` **Key patterns**: - Spring Boot 4 with Spring MVC - Spring Data JPA for database operations -- Custom validation annotations for ISBN and URL +- Custom validation annotations for PlayerDTO - OpenAPI 3.0 annotations for Swagger docs - `@Cacheable` for in-memory caching - DTOs with Bean Validation (JSR-380) - Actuator for health monitoring and metrics +- JPA derived queries and custom JPQL examples - Maven multi-module support ready ## API Endpoints @@ -217,11 +220,13 @@ src/test/java/ # Test classes | Method | Path | Description | |--------|------|-------------| -| `GET` | `/books` | Get all books | -| `GET` | `/books/{id}` | Get book by ID | -| `POST` | `/books` | Create new book | -| `PUT` | `/books/{id}` | Update book | -| `DELETE` | `/books/{id}` | Delete book | +| `GET` | `/players` | Get all players | +| `GET` | `/players/{id}` | Get player by ID | +| `GET` | `/players/search/league/{league}` | Search players by league | +| `GET` | `/players/search/squadnumber/{squadNumber}` | Get player by squad number | +| `POST` | `/players` | Create new player | +| `PUT` | `/players/{id}` | Update player | +| `DELETE` | `/players/{id}` | Delete player | | `GET` | `/actuator/health` | Health check | | `GET` | `/swagger-ui.html` | API documentation | @@ -265,7 +270,7 @@ java --version # Should be 25.x pkill -f "spring-boot:run" # Reset database -rm storage/books.db +rm storage/players-sqlite3.db ``` ### Test failures @@ -275,7 +280,7 @@ rm storage/books.db ./mvnw test -X # Run single test for debugging -./mvnw test -Dtest=BooksControllerTest#testGetAllBooks -X +./mvnw test -Dtest=PlayersControllerTests#getAll_playersExist_returnsOkWithAllPlayers -X ``` ### Maven wrapper issues @@ -309,40 +314,53 @@ Open - Interactive documentation with "T # Health check curl http://localhost:8080/actuator/health -# Get all books -curl http://localhost:8080/books +# Get all players +curl http://localhost:8080/players + +# Get player by ID +curl http://localhost:8080/players/1 + +# Search players by league (Premier League) +curl http://localhost:8080/players/search/league/Premier -# Get book by ID -curl http://localhost:8080/books/1 +# Get player by squad number (Messi #10) +curl http://localhost:8080/players/search/squadnumber/10 -# Create book -curl -X POST http://localhost:8080/books \ +# Create player +curl -X POST http://localhost:8080/players \ -H "Content-Type: application/json" \ -d '{ - "isbn": "9780132350884", - "title": "Clean Code", - "author": "Robert C. Martin", - "published": 1217548800, - "pages": 464, - "description": "A Handbook of Agile Software Craftsmanship", - "website": "https://www.pearson.com/en-us/subject-catalog/p/clean-code-a-handbook-of-agile-software-craftsmanship/P200000009044" + "firstName": "Leandro", + "middleName": "Daniel", + "lastName": "Paredes", + "dateOfBirth": "1994-06-29", + "squadNumber": 5, + "position": "Defensive Midfield", + "abbrPosition": "DM", + "team": "AS Roma", + "league": "Serie A", + "starting11": false }' -# Update book -curl -X PUT http://localhost:8080/books/1 \ +# Update player +curl -X PUT http://localhost:8080/players/1 \ -H "Content-Type: application/json" \ -d '{ - "isbn": "9780132350884", - "title": "Clean Code - Updated", - "author": "Robert C. Martin", - "published": 1217548800, - "pages": 464, - "description": "Updated description", - "website": "https://www.pearson.com/example" + "id": 1, + "firstName": "Emiliano", + "middleName": null, + "lastName": "Martínez", + "dateOfBirth": "1992-09-02", + "squadNumber": 23, + "position": "Goalkeeper", + "abbrPosition": "GK", + "team": "Aston Villa FC", + "league": "Premier League", + "starting11": true }' -# Delete book -curl -X DELETE http://localhost:8080/books/1 +# Delete player +curl -X DELETE http://localhost:8080/players/21 ``` ## Important Notes @@ -353,8 +371,10 @@ curl -X DELETE http://localhost:8080/books/1 - **Java version**: Must use JDK 25 for consistency with CI/CD - **Maven wrapper**: Always use `./mvnw` instead of `mvn` for consistency - **Database**: SQLite is for demo/development only - not production-ready -- **H2 for tests**: Tests use in-memory H2, runtime uses SQLite +- **SQLite for tests**: Tests use in-memory SQLite (jdbc:sqlite::memory:), runtime uses file-based SQLite - **OpenAPI annotations**: Required for all new endpoints (Swagger docs) - **Caching**: Uses Spring's `@Cacheable` - clears on updates/deletes -- **Validation**: Custom ISBN and URL validators in BookDTO -- **Unix timestamps**: Published dates stored as Unix timestamps (seconds since epoch) +- **Validation**: Bean Validation (JSR-380) annotations in PlayerDTO +- **ISO-8601 dates**: Dates stored as ISO-8601 strings for SQLite compatibility +- **Search methods**: Demonstrates JPA derived queries (findBySquadNumber) and custom JPQL (findByLeagueContainingIgnoreCase) +- **Squad numbers**: Jersey numbers (natural key) separate from database IDs diff --git a/README.md b/README.md index 0fd3d9d..3760bc5 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![CodeFactor](https://www.codefactor.io/repository/github/nanotaboada/java.samples.spring.boot/badge)](https://www.codefactor.io/repository/github/nanotaboada/java.samples.spring.boot) [![License: MIT](https://img.shields.io/badge/License-MIT-white.svg)](https://opensource.org/licenses/MIT) -Proof of Concept for a RESTful Web Service built with **Spring Boot 4** targeting **JDK 25 (LTS)**. This project demonstrates best practices for building a layered, testable, and maintainable API implementing CRUD operations for a Books resource. +Proof of Concept for a RESTful Web Service built with **Spring Boot 4** targeting **JDK 25 (LTS)**. This project demonstrates best practices for building a layered, testable, and maintainable API implementing CRUD operations for a Players resource (Argentina 2022 FIFA World Cup squad). ## Table of Contents @@ -33,10 +33,11 @@ Proof of Concept for a RESTful Web Service built with **Spring Boot 4** targetin ## Features -- 🔌 **RESTful API** - Full CRUD operations for Books resource +- 🔌 **RESTful API** - Full CRUD operations for Players resource - 📚 **Clean Architecture** - Layered design with clear separation of concerns -- 🚦 **Input Validation** - Custom constraints for ISBN and URL formats +- 🚦 **Input Validation** - Bean Validation (JSR-380) constraints - ⚡ **Performance Caching** - Optimized data retrieval with cache annotations +- 🔍 **Advanced Search** - League search with JPQL and squad number lookup with derived queries - 📝 **Interactive Documentation** - Live API exploration and testing interface - 🩺 **Health Monitoring** - Application health and metrics endpoints - ✅ **Comprehensive Testing** - High code coverage with automated reporting @@ -51,7 +52,7 @@ Proof of Concept for a RESTful Web Service built with **Spring Boot 4** targetin | **Runtime** | [Java](https://github.com/openjdk/jdk) (JDK 25 LTS) | | **Build Tool** | [Maven](https://github.com/apache/maven) | | **Database (Runtime)** | [SQLite](https://github.com/sqlite/sqlite) | -| **Database (Tests)** | [H2 Database](https://github.com/h2database/h2database) | +| **Database (Tests)** | [SQLite](https://github.com/sqlite/sqlite) (in-memory) | | **ORM** | [Hibernate](https://github.com/hibernate/hibernate-orm) / [Spring Data JPA](https://github.com/spring-projects/spring-data-jpa) | | **API Documentation** | [SpringDoc OpenAPI](https://github.com/springdoc/springdoc-openapi) | | **Testing** | [JUnit 5](https://github.com/junit-team/junit5) + [Mockito](https://github.com/mockito/mockito) + [AssertJ](https://github.com/assertj/assertj) | @@ -67,22 +68,23 @@ Proof of Concept for a RESTful Web Service built with **Spring Boot 4** targetin src/main/java/ar/com/nanotaboada/java/samples/spring/boot/ ├── Application.java # Main entry point, @SpringBootApplication ├── controllers/ # REST endpoints (@RestController) -│ └── BooksController.java +│ └── PlayersController.java ├── services/ # Business logic (@Service, caching) -│ └── BooksService.java +│ └── PlayersService.java ├── repositories/ # Data access (@Repository, Spring Data JPA) -│ └── BooksRepository.java -└── models/ # Domain entities & DTOs - ├── Book.java # JPA entity - ├── BookDTO.java # Data Transfer Object with validation - └── UnixTimestampConverter.java # JPA converter for LocalDate ↔ Unix timestamp +│ └── PlayersRepository.java +├── models/ # Domain entities & DTOs +│ ├── Player.java # JPA entity +│ └── PlayerDTO.java # Data Transfer Object with validation +└── converters/ # Infrastructure converters + └── IsoDateConverter.java # JPA converter for ISO-8601 dates src/test/java/.../test/ ├── controllers/ # Controller tests (@WebMvcTest) ├── services/ # Service layer tests ├── repositories/ # Repository tests (@DataJpaTest) -├── BookFakes.java # Test data factory for Book entities -└── BookDTOFakes.java # Test data factory for BookDTO +├── PlayerFakes.java # Test data factory for Player entities +└── PlayerDTOFakes.java # Test data factory for PlayerDTO ``` ## Architecture @@ -166,11 +168,13 @@ Interactive API documentation is available via Swagger UI at `http://localhost:9 **Quick Reference:** -- `GET /books` - List all books -- `GET /books/{isbn}` - Get book by ISBN -- `POST /books` - Create new book -- `PUT /books/{isbn}` - Update existing book -- `DELETE /books/{isbn}` - Remove book +- `GET /players` - List all players +- `GET /players/{id}` - Get player by ID +- `GET /players/search/league/{league}` - Search players by league +- `GET /players/search/squadnumber/{squadNumber}` - Get player by squad number +- `POST /players` - Create new player +- `PUT /players/{id}` - Update existing player +- `DELETE /players/{id}` - Remove player - `GET /actuator/health` - Health check For complete endpoint documentation with request/response schemas, explore the [interactive Swagger UI](http://localhost:9000/swagger/index.html). You can also access the OpenAPI JSON specification at `http://localhost:9000/v3/api-docs`. @@ -236,10 +240,13 @@ open target/site/jacoco/index.html **Test Structure:** - **Unit Tests** - `@WebMvcTest`, `@DataJpaTest` for isolated layer testing (with `@AutoConfigureCache` for caching support) -- **Test Database** - H2 in-memory database for fast, isolated test execution +- **Test Database** - SQLite in-memory (jdbc:sqlite::memory:) for fast, isolated test execution - **Mocking** - Mockito with `@MockitoBean` for dependency mocking - **Assertions** - AssertJ fluent assertions -- **Naming Convention** - BDD style: `given_when_then` +- **Naming Convention** - `method_scenario_outcome` pattern: + - `getAll_playersExist_returnsOkWithAllPlayers()` + - `post_squadNumberExists_returnsConflict()` + - `findById_playerExists_returnsPlayer()` **Coverage Targets:** @@ -247,7 +254,7 @@ open target/site/jacoco/index.html - Services: 100% - Repositories: Custom query methods (interfaces excluded by JaCoCo design) -> 💡 **Note:** Dates are stored as Unix timestamps (INTEGER) for robustness. A JPA `AttributeConverter` handles LocalDate ↔ epoch seconds conversion transparently (UTC-based). Tests use H2 in-memory database - the converter works seamlessly with both SQLite and H2. +> 💡 **Note:** Dates are stored as ISO-8601 strings for SQLite compatibility. A JPA `AttributeConverter` handles LocalDate ↔ ISO-8601 string conversion transparently. Tests use SQLite in-memory database (jdbc:sqlite::memory:) - the converter works seamlessly with both file-based and in-memory SQLite. ## Docker @@ -264,7 +271,7 @@ docker compose up -d - `9000` - Main API server - `9001` - Actuator management endpoints -> 💡 **Note:** The Docker container uses a pre-seeded SQLite database with sample book data. On first run, the database is copied from the image to a named volume (`java-samples-spring-boot_storage`) ensuring data persistence across container restarts. +> 💡 **Note:** The Docker container uses a pre-seeded SQLite database with Argentina 2022 FIFA World Cup squad data. On first run, the database is copied from the image to a named volume (`java-samples-spring-boot_storage`) ensuring data persistence across container restarts. ### Stop @@ -293,7 +300,7 @@ server.port=9000 management.server.port=9001 # Database Configuration (SQLite) -spring.datasource.url=jdbc:sqlite:storage/books-sqlite3.db +spring.datasource.url=jdbc:sqlite:storage/players-sqlite3.db spring.datasource.driver-class-name=org.sqlite.JDBC spring.jpa.database-platform=org.hibernate.community.dialect.SQLiteDialect spring.jpa.hibernate.ddl-auto=none @@ -311,14 +318,14 @@ springdoc.swagger-ui.path=/swagger/index.html Configuration in `src/test/resources/application.properties`: ```properties -# Test Database (H2 in-memory) -spring.datasource.url=jdbc:h2:mem:testdb -spring.datasource.driver-class-name=org.h2.Driver -spring.jpa.database-platform=org.hibernate.dialect.H2Dialect +# Test Database (SQLite in-memory) +spring.datasource.url=jdbc:sqlite::memory: +spring.datasource.driver-class-name=org.sqlite.JDBC +spring.jpa.database-platform=org.hibernate.community.dialect.SQLiteDialect spring.jpa.hibernate.ddl-auto=create-drop ``` -> 💡 **Note:** Tests use H2 in-memory database for fast, isolated execution. The Unix timestamp converter works with both SQLite (production) and H2 (tests). +> 💡 **Note:** Tests use SQLite in-memory database (jdbc:sqlite::memory:) for fast, isolated execution. The ISO-8601 date converter works identically with both file-based and in-memory SQLite. ## Command Summary diff --git a/codecov.yml b/codecov.yml index 00908c2..9ca0b04 100644 --- a/codecov.yml +++ b/codecov.yml @@ -4,6 +4,7 @@ ignore: - "test/**" - "src/main/resources/**" - "src/main/**/models/**" + - "src/main/**/converters/**" - "src/main/**/Application.java" coverage: status: diff --git a/compose.yaml b/compose.yaml index f62596c..89134ab 100644 --- a/compose.yaml +++ b/compose.yaml @@ -14,7 +14,7 @@ services: - storage:/storage/ environment: - SPRING_PROFILES_ACTIVE=production - - STORAGE_PATH=/storage/books-sqlite3.db + - STORAGE_PATH=/storage/players-sqlite3.db restart: unless-stopped volumes: diff --git a/pom.xml b/pom.xml index d5fd73f..69045fa 100644 --- a/pom.xml +++ b/pom.xml @@ -1,8 +1,8 @@ + xmlns="http://maven.apache.org/POM/4.0.0" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 @@ -14,7 +14,7 @@ org.springframework.boot spring-boot-starter-parent 4.0.0 - + ar.com.nanotaboada @@ -294,6 +294,7 @@ ar/com/nanotaboada/**/Application.class ar/com/nanotaboada/**/models/* + ar/com/nanotaboada/**/converters/* diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index 709925f..5f87856 100644 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -3,8 +3,8 @@ set -e echo "✔ Executing entrypoint script..." -IMAGE_STORAGE_PATH="/app/hold/books-sqlite3.db" -VOLUME_STORAGE_PATH="/storage/books-sqlite3.db" +IMAGE_STORAGE_PATH="/app/hold/players-sqlite3.db" +VOLUME_STORAGE_PATH="/storage/players-sqlite3.db" echo "✔ Starting container..." @@ -29,4 +29,9 @@ fi echo "✔ Ready!" echo "🚀 Launching app..." +echo "" +echo "🔌 Endpoints:" +echo " Health: http://localhost:9001/actuator/health" +echo " Players: http://localhost:9000/players" +echo "" exec "$@" diff --git a/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/controllers/BooksController.java b/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/controllers/BooksController.java deleted file mode 100644 index 356c650..0000000 --- a/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/controllers/BooksController.java +++ /dev/null @@ -1,154 +0,0 @@ -package ar.com.nanotaboada.java.samples.spring.boot.controllers; - -import static org.springframework.http.HttpHeaders.LOCATION; - -import java.net.URI; -import java.util.List; - -import org.hibernate.validator.constraints.ISBN; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder; - -import ar.com.nanotaboada.java.samples.spring.boot.models.BookDTO; -import ar.com.nanotaboada.java.samples.spring.boot.services.BooksService; -import io.swagger.v3.oas.annotations.OpenAPIDefinition; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.info.Contact; -import io.swagger.v3.oas.annotations.info.Info; -import io.swagger.v3.oas.annotations.info.License; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotBlank; - -@RestController -@Tag(name = "Books") -@OpenAPIDefinition(info = @Info(title = "java.samples.spring.boot", version = "1.0", description = "🧪 Proof of Concept for a RESTful Web Service made with JDK 21 (LTS) and Spring Boot 3", contact = @Contact(name = "GitHub", url = "https://github.com/nanotaboada/java.samples.spring.boot"), license = @License(name = "MIT License", url = "https://opensource.org/licenses/MIT"))) -public class BooksController { - - private final BooksService booksService; - - public BooksController(BooksService booksService) { - this.booksService = booksService; - } - - /* - * ------------------------------------------------------------------------- - * HTTP POST - * ------------------------------------------------------------------------- - */ - - @PostMapping("/books") - @Operation(summary = "Creates a new book") - @ApiResponses(value = { - @ApiResponse(responseCode = "201", description = "Created", content = @Content), - @ApiResponse(responseCode = "400", description = "Bad Request", content = @Content), - @ApiResponse(responseCode = "409", description = "Conflict", content = @Content) - }) - public ResponseEntity post(@RequestBody @Valid BookDTO bookDTO) { - boolean created = booksService.create(bookDTO); - if (!created) { - return ResponseEntity.status(HttpStatus.CONFLICT).build(); - } - URI location = MvcUriComponentsBuilder - .fromMethodCall(MvcUriComponentsBuilder.on(BooksController.class).getByIsbn(bookDTO.getIsbn())) - .build() - .toUri(); - return ResponseEntity.status(HttpStatus.CREATED) - .header(LOCATION, location.toString()) - .build(); - } - - /* - * ------------------------------------------------------------------------- - * HTTP GET - * ------------------------------------------------------------------------- - */ - - @GetMapping("/books/{isbn}") - @Operation(summary = "Retrieves a book by its ISBN") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "OK", content = @Content(mediaType = "application/json", schema = @Schema(implementation = BookDTO.class))), - @ApiResponse(responseCode = "404", description = "Not Found", content = @Content) - }) - public ResponseEntity getByIsbn(@PathVariable String isbn) { - BookDTO bookDTO = booksService.retrieveByIsbn(isbn); - return (bookDTO != null) - ? ResponseEntity.status(HttpStatus.OK).body(bookDTO) - : ResponseEntity.status(HttpStatus.NOT_FOUND).build(); - } - - @GetMapping("/books") - @Operation(summary = "Retrieves all books") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "OK", content = @Content(mediaType = "application/json", schema = @Schema(implementation = BookDTO[].class))) - }) - public ResponseEntity> getAll() { - List books = booksService.retrieveAll(); - return ResponseEntity.status(HttpStatus.OK).body(books); - } - - @GetMapping("/books/search") - @Operation(summary = "Searches books by description keyword") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "OK - Returns matching books (or empty array if none found)", content = @Content(mediaType = "application/json", schema = @Schema(implementation = BookDTO[].class))), - @ApiResponse(responseCode = "400", description = "Bad Request - Missing or blank description parameter", content = @Content) - }) - public ResponseEntity> searchByDescription( - @RequestParam @NotBlank(message = "Description parameter must not be blank") String description) { - List books = booksService.searchByDescription(description); - return ResponseEntity.status(HttpStatus.OK).body(books); - } - - /* - * ------------------------------------------------------------------------- - * HTTP PUT - * ------------------------------------------------------------------------- - */ - - @PutMapping("/books") - @Operation(summary = "Updates (entirely) a book by its ISBN") - @ApiResponses(value = { - @ApiResponse(responseCode = "204", description = "No Content", content = @Content), - @ApiResponse(responseCode = "400", description = "Bad Request", content = @Content), - @ApiResponse(responseCode = "404", description = "Not Found", content = @Content) - }) - public ResponseEntity put(@RequestBody @Valid BookDTO bookDTO) { - boolean updated = booksService.update(bookDTO); - return (updated) - ? ResponseEntity.status(HttpStatus.NO_CONTENT).build() - : ResponseEntity.status(HttpStatus.NOT_FOUND).build(); - } - - /* - * ------------------------------------------------------------------------- - * HTTP DELETE - * ------------------------------------------------------------------------- - */ - - @DeleteMapping("/books/{isbn}") - @Operation(summary = "Deletes a book by its ISBN") - @ApiResponses(value = { - @ApiResponse(responseCode = "204", description = "No Content", content = @Content), - @ApiResponse(responseCode = "400", description = "Bad Request", content = @Content), - @ApiResponse(responseCode = "404", description = "Not Found", content = @Content) - }) - public ResponseEntity delete(@PathVariable @ISBN String isbn) { - boolean deleted = booksService.delete(isbn); - return (deleted) - ? ResponseEntity.status(HttpStatus.NO_CONTENT).build() - : ResponseEntity.status(HttpStatus.NOT_FOUND).build(); - } -} diff --git a/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/controllers/PlayersController.java b/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/controllers/PlayersController.java new file mode 100644 index 0000000..267cd85 --- /dev/null +++ b/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/controllers/PlayersController.java @@ -0,0 +1,256 @@ +package ar.com.nanotaboada.java.samples.spring.boot.controllers; + +import static org.springframework.http.HttpHeaders.LOCATION; + +import java.net.URI; +import java.util.List; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; + +import ar.com.nanotaboada.java.samples.spring.boot.models.PlayerDTO; +import ar.com.nanotaboada.java.samples.spring.boot.services.PlayersService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +/** + * REST Controller for managing Player resources. + *

+ * Provides HTTP endpoints for CRUD operations and search functionality on the Argentina 2022 FIFA World Cup squad. + * All endpoints return JSON responses and follow RESTful conventions. + *

+ * + *

Base Path:

+ *
    + *
  • GET {@code /players} - Retrieve all players
  • + *
  • GET {@code /players/{id}} - Retrieve player by ID
  • + *
  • GET {@code /players/search/league/{league}} - Search players by league name
  • + *
  • GET {@code /players/search/squadnumber/{number}} - Search player by squad number
  • + *
  • POST {@code /players} - Create a new player
  • + *
  • PUT {@code /players/{id}} - Update an existing player
  • + *
  • DELETE {@code /players/{id}} - Delete a player by ID
  • + *
+ * + *

Response Codes:

+ *
    + *
  • 200 OK: Successful retrieval
  • + *
  • 201 Created: Successful creation (with Location header)
  • + *
  • 204 No Content: Successful update/delete
  • + *
  • 400 Bad Request: Validation failure
  • + *
  • 404 Not Found: Resource not found
  • + *
+ * + * @see PlayersService + * @see PlayerDTO + * @since 4.0.2025 + */ +@RestController +@Tag(name = "Players") +@RequiredArgsConstructor +public class PlayersController { + + private final PlayersService playersService; + + /* + * ----------------------------------------------------------------------------------------------------------------------- + * HTTP POST + * ----------------------------------------------------------------------------------------------------------------------- + */ + + /** + * Creates a new player resource. + *

+ * Validates the request body and creates a new player in the database. Returns a 201 Created response with a Location + * header pointing to the new resource. + *

+ *

+ * Conflict Detection: If a player with the same squad number already exists, returns 409 Conflict. + * Squad numbers must be unique (jersey numbers like Messi's #10). + *

+ * + * @param playerDTO the player data to create (validated with JSR-380 constraints) + * @return 201 Created with Location header, 400 Bad Request if validation fails, or 409 Conflict if squad number exists + */ + @PostMapping("/players") + @Operation(summary = "Creates a new player") + @ApiResponses(value = { + @ApiResponse(responseCode = "201", description = "Created", content = @Content), + @ApiResponse(responseCode = "400", description = "Bad Request - Validation failure", content = @Content), + @ApiResponse(responseCode = "409", description = "Conflict - Squad number already exists", content = @Content) + }) + public ResponseEntity post(@RequestBody @Valid PlayerDTO playerDTO) { + PlayerDTO createdPlayer = playersService.create(playerDTO); + if (createdPlayer == null) { + return ResponseEntity.status(HttpStatus.CONFLICT).build(); + } + URI location = ServletUriComponentsBuilder + .fromCurrentRequest() + .path("/{id}") + .buildAndExpand(createdPlayer.getId()) + .toUri(); + return ResponseEntity.status(HttpStatus.CREATED) + .header(LOCATION, location.toString()) + .build(); + } + + /* + * ----------------------------------------------------------------------------------------------------------------------- + * HTTP GET + * ----------------------------------------------------------------------------------------------------------------------- + */ + + /** + * Retrieves a single player by their unique identifier. + * + * @param id the unique identifier of the player + * @return 200 OK with player data, or 404 Not Found if player doesn't exist + */ + @GetMapping("/players/{id}") + @Operation(summary = "Retrieves a player by ID") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", content = @Content(mediaType = "application/json", schema = @Schema(implementation = PlayerDTO.class))), + @ApiResponse(responseCode = "404", description = "Not Found", content = @Content) + }) + public ResponseEntity getById(@PathVariable Long id) { + PlayerDTO playerDTO = playersService.retrieveById(id); + return (playerDTO != null) + ? ResponseEntity.status(HttpStatus.OK).body(playerDTO) + : ResponseEntity.status(HttpStatus.NOT_FOUND).build(); + } + + /** + * Retrieves all players in the squad. + *

+ * Returns the complete Argentina 2022 FIFA World Cup squad (26 players). + *

+ * + * @return 200 OK with array of all players (empty array if none found) + */ + @GetMapping("/players") + @Operation(summary = "Retrieves all players") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", content = @Content(mediaType = "application/json", schema = @Schema(implementation = PlayerDTO[].class))) + }) + public ResponseEntity> getAll() { + List players = playersService.retrieveAll(); + return ResponseEntity.status(HttpStatus.OK).body(players); + } + + /** + * Searches for players by league name (case-insensitive partial match). + *

+ * Example: {@code /players/search/league/Premier} matches "Premier League" + *

+ * + * @param league the league name to search for + * @return 200 OK with matching players (empty array if none found) + */ + @GetMapping("/players/search/league/{league}") + @Operation(summary = "Searches players by league name") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK - Returns matching players (or empty array if none found)", content = @Content(mediaType = "application/json", schema = @Schema(implementation = PlayerDTO[].class))) + }) + public ResponseEntity> searchByLeague(@PathVariable String league) { + List players = playersService.searchByLeague(league); + return ResponseEntity.status(HttpStatus.OK).body(players); + } + + /** + * Searches for a player by their squad number. + *

+ * Squad numbers are jersey numbers that users recognize (e.g., Messi is #10). + * Example: {@code /players/search/squadnumber/10} returns Lionel Messi + *

+ * + * @param squadNumber the squad number to search for (jersey number, typically 1-99) + * @return 200 OK with player data, or 404 Not Found if no player has that + * number + */ + @GetMapping("/players/search/squadnumber/{squadNumber}") + @Operation(summary = "Searches for a player by squad number") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", content = @Content(mediaType = "application/json", schema = @Schema(implementation = PlayerDTO.class))), + @ApiResponse(responseCode = "404", description = "Not Found", content = @Content) + }) + public ResponseEntity searchBySquadNumber(@PathVariable Integer squadNumber) { + PlayerDTO player = playersService.searchBySquadNumber(squadNumber); + return (player != null) + ? ResponseEntity.status(HttpStatus.OK).body(player) + : ResponseEntity.status(HttpStatus.NOT_FOUND).build(); + } + + /* + * ----------------------------------------------------------------------------------------------------------------------- + * HTTP PUT + * ----------------------------------------------------------------------------------------------------------------------- + */ + + /** + * Updates an existing player resource (full update). + *

+ * Performs a complete replacement of the player entity. The ID in the path must match the ID in the request body. + *

+ * + * @param id the unique identifier of the player to update + * @param playerDTO the complete player data (must pass validation) + * @return 204 No Content if successful, 404 Not Found if player doesn't exist, or 400 Bad Request if validation fails or + * ID mismatch + */ + @PutMapping("/players/{id}") + @Operation(summary = "Updates (entirely) a player by ID") + @ApiResponses(value = { + @ApiResponse(responseCode = "204", description = "No Content", content = @Content), + @ApiResponse(responseCode = "400", description = "Bad Request", content = @Content), + @ApiResponse(responseCode = "404", description = "Not Found", content = @Content) + }) + public ResponseEntity put(@PathVariable Long id, @RequestBody @Valid PlayerDTO playerDTO) { + // Ensure path ID matches body ID + if (playerDTO.getId() != null && !playerDTO.getId().equals(id)) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).build(); + } + playerDTO.setId(id); // Set ID from path to ensure consistency + boolean updated = playersService.update(playerDTO); + return (updated) + ? ResponseEntity.status(HttpStatus.NO_CONTENT).build() + : ResponseEntity.status(HttpStatus.NOT_FOUND).build(); + } + + /* + * ----------------------------------------------------------------------------------------------------------------------- + * HTTP DELETE + * ----------------------------------------------------------------------------------------------------------------------- + */ + + /** + * Deletes a player resource by their unique identifier. + * + * @param id the unique identifier of the player to delete + * @return 204 No Content if successful, or 404 Not Found if player doesn't exist + */ + @DeleteMapping("/players/{id}") + @Operation(summary = "Deletes a player by ID") + @ApiResponses(value = { + @ApiResponse(responseCode = "204", description = "No Content", content = @Content), + @ApiResponse(responseCode = "404", description = "Not Found", content = @Content) + }) + public ResponseEntity delete(@PathVariable Long id) { + boolean deleted = playersService.delete(id); + return (deleted) + ? ResponseEntity.status(HttpStatus.NO_CONTENT).build() + : ResponseEntity.status(HttpStatus.NOT_FOUND).build(); + } +} diff --git a/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/converters/IsoDateConverter.java b/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/converters/IsoDateConverter.java new file mode 100644 index 0000000..e96d026 --- /dev/null +++ b/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/converters/IsoDateConverter.java @@ -0,0 +1,91 @@ +package ar.com.nanotaboada.java.samples.spring.boot.converters; + +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +/** + * JPA AttributeConverter for converting between {@link LocalDate} and ISO-8601 + * date strings. + *

+ * This converter handles dates stored as TEXT in SQLite databases using the + * ISO-8601 format + * (e.g., "1992-09-02T00:00:00.000Z"). It ensures seamless conversion between + * Java's LocalDate + * and SQLite's TEXT-based date storage. + *

+ * + *

Database Column Conversion:

+ *
    + *
  • To Database: LocalDate → "YYYY-MM-DDTHH:mm:ss.SSSZ" (ISO-8601 with + * time component)
  • + *
  • From Database: "YYYY-MM-DDTHH:mm:ss.SSSZ" or "YYYY-MM-DD" → + * LocalDate
  • + *
+ * + *

Usage Example:

+ * + *
+ * {
+ *     @code
+ *     @Entity
+ *     public class Player {
+ *         @Convert(converter = IsoDateConverter.class)
+ *         private LocalDate dateOfBirth;
+ *     }
+ * }
+ * 
+ * + * @see jakarta.persistence.AttributeConverter + * @see java.time.LocalDate + * @since 4.0.2025 + */ +@Converter +public class IsoDateConverter implements AttributeConverter { + + private static final DateTimeFormatter ISO_FORMATTER = DateTimeFormatter.ISO_OFFSET_DATE_TIME; + + /** + * Converts a {@link LocalDate} to an ISO-8601 formatted string for database + * storage. + * + * @param date the LocalDate to convert (may be null) + * @return ISO-8601 formatted string (e.g., "1992-09-02T00:00:00Z"), or null if + * input is null + */ + @Override + public String convertToDatabaseColumn(LocalDate date) { + if (date == null) { + return null; + } + return date.atStartOfDay().atOffset(ZoneOffset.UTC).format(ISO_FORMATTER); + } + + /** + * Converts an ISO-8601 formatted string from the database to a + * {@link LocalDate}. + *

+ * Handles both full ISO-8601 format ("YYYY-MM-DDTHH:mm:ss.SSSZ") and simple + * date format ("YYYY-MM-DD"). + *

+ * + * @param dateString the ISO-8601 formatted string from the database (may be + * null or blank) + * @return the corresponding LocalDate, or null if input is null or blank + */ + @Override + public LocalDate convertToEntityAttribute(String dateString) { + if (dateString == null || dateString.isBlank()) { + return null; + } + // Handle both "YYYY-MM-DD" and "YYYY-MM-DDTHH:mm:ss.SSSZ" formats + if (dateString.contains("T")) { + return OffsetDateTime.parse(dateString, ISO_FORMATTER).toLocalDate(); + } + return LocalDate.parse(dateString, DateTimeFormatter.ISO_DATE); + } +} diff --git a/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/models/Book.java b/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/models/Book.java deleted file mode 100644 index 8e96980..0000000 --- a/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/models/Book.java +++ /dev/null @@ -1,48 +0,0 @@ -package ar.com.nanotaboada.java.samples.spring.boot.models; - -import java.time.LocalDate; - -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import com.fasterxml.jackson.databind.annotation.JsonSerialize; -import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer; -import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer; - -import jakarta.persistence.Column; -import jakarta.persistence.Convert; -import jakarta.persistence.Entity; -import jakarta.persistence.Id; -import jakarta.persistence.Table; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Entity -@Table(name = "books") -@Data -@NoArgsConstructor -@AllArgsConstructor -public class Book { - @Id - private String isbn; - private String title; - private String subtitle; - private String author; - private String publisher; - /** - * Stored as Unix timestamp (INTEGER) in SQLite for robustness. - * The converter handles LocalDate ↔ epoch seconds conversion. - */ - @JsonDeserialize(using = LocalDateDeserializer.class) - @JsonSerialize(using = LocalDateSerializer.class) - @Convert(converter = UnixTimestampConverter.class) - private LocalDate published; - private int pages; - /** - * Maximum length set to 8192 (8^4 = 2^13) characters. - * This power-of-two value provides ample space for book descriptions - * while remaining compatible with both H2 (VARCHAR) and SQLite (TEXT). - */ - @Column(length = 8192) - private String description; - private String website; -} diff --git a/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/models/BookDTO.java b/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/models/BookDTO.java deleted file mode 100644 index 8757428..0000000 --- a/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/models/BookDTO.java +++ /dev/null @@ -1,38 +0,0 @@ -package ar.com.nanotaboada.java.samples.spring.boot.models; - -import java.time.LocalDate; - -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Past; - -import org.hibernate.validator.constraints.ISBN; -import org.hibernate.validator.constraints.URL; - -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import com.fasterxml.jackson.databind.annotation.JsonSerialize; -import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer; -import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer; - -import lombok.Data; - -@Data -public class BookDTO { - @ISBN - @NotBlank - private String isbn; - @NotBlank - private String title; - private String subtitle; - @NotBlank - private String author; - private String publisher; - @Past - @JsonDeserialize(using = LocalDateDeserializer.class) - @JsonSerialize(using = LocalDateSerializer.class) - private LocalDate published; - private int pages; - @NotBlank - private String description; - @URL - private String website; -} diff --git a/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/models/Player.java b/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/models/Player.java new file mode 100644 index 0000000..1a47dbb --- /dev/null +++ b/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/models/Player.java @@ -0,0 +1,77 @@ +package ar.com.nanotaboada.java.samples.spring.boot.models; + +import java.time.LocalDate; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer; + +import ar.com.nanotaboada.java.samples.spring.boot.converters.IsoDateConverter; +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * JPA Entity representing a player in the Argentina 2022 FIFA World Cup squad. + *

+ * This entity maps to the {@code players} table in the database and contains + * biographical and team information for each player. + *

+ * + *

Key Features:

+ *
    + *
  • Auto-generated ID using IDENTITY strategy
  • + *
  • ISO-8601 date storage for SQLite compatibility + * ({@link IsoDateConverter})
  • + *
  • JSON serialization support for LocalDate fields
  • + *
+ * + * @see PlayerDTO + * @see IsoDateConverter + * @since 4.0.2025 + */ +@Entity +@Table(name = "players") +@Data +@NoArgsConstructor +@AllArgsConstructor +public class Player { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + private String firstName; + private String middleName; + private String lastName; + /** + * Stored as ISO-8601 TEXT in SQLite (e.g., "1992-09-02T00:00:00.000Z"). + * The converter handles LocalDate ↔ ISO string conversion. + */ + @JsonDeserialize(using = LocalDateDeserializer.class) + @JsonSerialize(using = LocalDateSerializer.class) + @Convert(converter = IsoDateConverter.class) + private LocalDate dateOfBirth; + + /** + * Squad number (jersey number) - unique natural key. + *

+ * Used for player lookups via /players/search/squadnumber/{squadNumber}. + * Database constraint enforces uniqueness. + *

+ */ + @Column(unique = true) + private Integer squadNumber; + + private String position; + private String abbrPosition; + private String team; + private String league; + private Boolean starting11; +} diff --git a/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/models/PlayerDTO.java b/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/models/PlayerDTO.java new file mode 100644 index 0000000..40fede9 --- /dev/null +++ b/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/models/PlayerDTO.java @@ -0,0 +1,70 @@ +package ar.com.nanotaboada.java.samples.spring.boot.models; + +import java.time.LocalDate; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Past; +import jakarta.validation.constraints.Positive; +import lombok.Data; + +/** + * Data Transfer Object (DTO) for Player entities. + *

+ * This DTO is used to transfer player data between the API layer and the + * service layer, + * providing a decoupled representation of the {@link Player} entity with + * validation rules. + *

+ * + *

Validation Constraints:

+ *
    + *
  • firstName: Required (not blank)
  • + *
  • lastName: Required (not blank)
  • + *
  • dateOfBirth: Required (not null) and must be a past date
  • + *
  • squadNumber: Required (not null) and must be a positive integer
  • + *
  • position: Required (not blank)
  • + *
  • team: Required (not blank)
  • + *
+ * + *

JSON Serialization:

+ *

+ * Uses Jackson to serialize/deserialize LocalDate fields to ISO-8601 format. + *

+ * + * @see Player + * @see jakarta.validation.constraints.NotBlank + * @see jakarta.validation.constraints.NotNull + * @see jakarta.validation.constraints.Past + * @see jakarta.validation.constraints.Positive + * @since 4.0.2025 + */ +@Data +public class PlayerDTO { + private Long id; + @NotBlank + private String firstName; + private String middleName; + @NotBlank + private String lastName; + @NotNull + @Past + @JsonDeserialize(using = LocalDateDeserializer.class) + @JsonSerialize(using = LocalDateSerializer.class) + private LocalDate dateOfBirth; + @NotNull + @Positive + private Integer squadNumber; + @NotBlank + private String position; + private String abbrPosition; + @NotBlank + private String team; + private String league; + private Boolean starting11; +} diff --git a/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/models/UnixTimestampConverter.java b/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/models/UnixTimestampConverter.java deleted file mode 100644 index dad5b78..0000000 --- a/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/models/UnixTimestampConverter.java +++ /dev/null @@ -1,39 +0,0 @@ -package ar.com.nanotaboada.java.samples.spring.boot.models; - -import java.time.Instant; -import java.time.LocalDate; -import java.time.ZoneOffset; - -import jakarta.persistence.AttributeConverter; -import jakarta.persistence.Converter; - -/** - * JPA AttributeConverter that converts between LocalDate and Unix timestamp - * (seconds since epoch). - * - * This converter stores dates as INTEGER in SQLite, which is more robust than - * TEXT-based date formats because: - * - No parsing ambiguity or locale-dependent formatting issues - * - Works consistently across all SQLite clients and tools - * - More efficient for date comparisons and indexing - * - Avoids the need for date_string_format in the JDBC connection URL - */ -@Converter -public class UnixTimestampConverter implements AttributeConverter { - - @Override - public Long convertToDatabaseColumn(LocalDate date) { - if (date == null) { - return null; - } - return date.atStartOfDay(ZoneOffset.UTC).toEpochSecond(); - } - - @Override - public LocalDate convertToEntityAttribute(Long timestamp) { - if (timestamp == null) { - return null; - } - return Instant.ofEpochSecond(timestamp).atZone(ZoneOffset.UTC).toLocalDate(); - } -} diff --git a/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/repositories/BooksRepository.java b/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/repositories/BooksRepository.java deleted file mode 100644 index f5f9748..0000000 --- a/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/repositories/BooksRepository.java +++ /dev/null @@ -1,32 +0,0 @@ -package ar.com.nanotaboada.java.samples.spring.boot.repositories; - -import java.util.List; -import java.util.Optional; - -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.CrudRepository; -import org.springframework.data.repository.query.Param; -import org.springframework.stereotype.Repository; - -import ar.com.nanotaboada.java.samples.spring.boot.models.Book; - -@Repository -public interface BooksRepository extends CrudRepository { - /** - * Query creation from method names
- * https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#repositories.query-methods.query-creation - */ - // Non-default methods in interfaces are not shown in coverage reports - // https://www.jacoco.org/jacoco/trunk/doc/faq.html - Optional findByIsbn(String isbn); - - /** - * Finds books whose description contains the given keyword (case-insensitive). - * SQLite handles TEXT fields natively, so no CAST operation is needed. - * - * @param keyword the keyword to search for in the description - * @return a list of books matching the search criteria - */ - @Query("SELECT b FROM Book b WHERE LOWER(b.description) LIKE LOWER(CONCAT('%', :keyword, '%'))") - List findByDescriptionContainingIgnoreCase(@Param("keyword") String keyword); -} diff --git a/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/repositories/PlayersRepository.java b/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/repositories/PlayersRepository.java new file mode 100644 index 0000000..87cb125 --- /dev/null +++ b/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/repositories/PlayersRepository.java @@ -0,0 +1,70 @@ +package ar.com.nanotaboada.java.samples.spring.boot.repositories; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import ar.com.nanotaboada.java.samples.spring.boot.models.Player; + +/** + * Spring Data JPA Repository for {@link Player} entities. + *

+ * Provides data access methods for the {@code players} table using Spring Data's repository abstraction. + * Extends {@link JpaRepository} for CRUD operations, batch operations, and query methods. + *

+ * + *

Provided Methods:

+ *
    + *
  • Inherited from JpaRepository: save, findAll (returns List), findById, delete, flush, etc.
  • + *
  • Custom Query Methods: League search with case-insensitive wildcard matching
  • + *
  • Derived Queries: findBySquadNumber (method name conventions)
  • + *
+ * + *

Query Strategies:

+ *
    + *
  • @Query: Explicit JPQL for complex searches (findByLeagueContainingIgnoreCase)
  • + *
  • Method Names: Spring Data derives queries from method names (Query Creation)
  • + *
+ * + * @see Player + * @see org.springframework.data.jpa.repository.JpaRepository + * @see Query + * Creation from Method Names + * @since 4.0.2025 + */ +@Repository +public interface PlayersRepository extends JpaRepository { + + /** + * Finds players by league name using case-insensitive wildcard matching. + *

+ * This method uses a custom JPQL query with LIKE operator for partial matches. + * For example, searching for "Premier" will match "Premier League". + *

+ * + * @param league the league name to search for (partial matches allowed) + * @return a list of players whose league name contains the search term (empty + * list if none found) + */ + @Query("SELECT p FROM Player p WHERE LOWER(p.league) LIKE LOWER(CONCAT('%', :league, '%'))") + List findByLeagueContainingIgnoreCase(@Param("league") String league); + + /** + * Finds a player by their squad number (exact match). + *

+ * This is a derived query method - Spring Data JPA generates the query automatically. + * Squad numbers are jersey numbers that users recognize (e.g., Messi is #10). + * This demonstrates Spring Data's method name query derivation with a natural key. + *

+ * + * @param squadNumber the squad number to search for (jersey number, typically + * 1-99) + * @return an Optional containing the player if found, empty Optional otherwise + */ + Optional findBySquadNumber(Integer squadNumber); +} diff --git a/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/services/BooksService.java b/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/services/BooksService.java deleted file mode 100644 index 70bed16..0000000 --- a/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/services/BooksService.java +++ /dev/null @@ -1,116 +0,0 @@ -package ar.com.nanotaboada.java.samples.spring.boot.services; - -import java.util.List; -import java.util.stream.StreamSupport; - -import lombok.RequiredArgsConstructor; - -import org.modelmapper.ModelMapper; -import org.springframework.cache.annotation.CacheEvict; -import org.springframework.cache.annotation.CachePut; -import org.springframework.cache.annotation.Cacheable; -import org.springframework.stereotype.Service; - -import ar.com.nanotaboada.java.samples.spring.boot.models.Book; -import ar.com.nanotaboada.java.samples.spring.boot.models.BookDTO; -import ar.com.nanotaboada.java.samples.spring.boot.repositories.BooksRepository; - -@Service -@RequiredArgsConstructor -public class BooksService { - - private final BooksRepository booksRepository; - private final ModelMapper modelMapper; - - /* - * ------------------------------------------------------------------------- - * Create - * ------------------------------------------------------------------------- - */ - - @CachePut(value = "books", key = "#bookDTO.isbn") - public boolean create(BookDTO bookDTO) { - if (booksRepository.existsById(bookDTO.getIsbn())) { - return false; - } else { - Book book = mapFrom(bookDTO); - booksRepository.save(book); - return true; - } - - } - - /* - * ------------------------------------------------------------------------- - * Retrieve - * ------------------------------------------------------------------------- - */ - - @Cacheable(value = "books", key = "#isbn") - public BookDTO retrieveByIsbn(String isbn) { - return booksRepository.findByIsbn(isbn) - .map(this::mapFrom) - .orElse(null); - } - - @Cacheable(value = "books") - public List retrieveAll() { - return StreamSupport.stream(booksRepository.findAll().spliterator(), false) - .map(this::mapFrom) - .toList(); - } - - /* - * ------------------------------------------------------------------------- - * Search - * ------------------------------------------------------------------------- - */ - - public List searchByDescription(String keyword) { - return booksRepository.findByDescriptionContainingIgnoreCase(keyword) - .stream() - .map(this::mapFrom) - .toList(); - } - - /* - * ------------------------------------------------------------------------- - * Update - * ------------------------------------------------------------------------- - */ - - @CachePut(value = "books", key = "#bookDTO.isbn") - public boolean update(BookDTO bookDTO) { - if (booksRepository.existsById(bookDTO.getIsbn())) { - Book book = mapFrom(bookDTO); - booksRepository.save(book); - return true; - } else { - return false; - } - } - - /* - * ------------------------------------------------------------------------- - * Delete - * ------------------------------------------------------------------------- - */ - - @CacheEvict(value = "books", key = "#isbn") - public boolean delete(String isbn) { - if (booksRepository.existsById(isbn)) { - booksRepository.deleteById(isbn); - return true; - } else { - return false; - } - } - - private BookDTO mapFrom(Book book) { - return modelMapper.map(book, BookDTO.class); - } - - private Book mapFrom(BookDTO dto) { - return modelMapper.map(dto, Book.class); - } -} diff --git a/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/services/PlayersService.java b/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/services/PlayersService.java new file mode 100644 index 0000000..3e3366e --- /dev/null +++ b/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/services/PlayersService.java @@ -0,0 +1,261 @@ +package ar.com.nanotaboada.java.samples.spring.boot.services; + +import java.util.List; + +import org.modelmapper.ModelMapper; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import ar.com.nanotaboada.java.samples.spring.boot.models.Player; +import ar.com.nanotaboada.java.samples.spring.boot.models.PlayerDTO; +import ar.com.nanotaboada.java.samples.spring.boot.repositories.PlayersRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Service layer for managing Player business logic. + *

+ * This service acts as an intermediary between the PlayersController and {@link PlayersRepository}, + * providing CRUD operations, search functionality, and caching. + *

+ * + *

Key Features:

+ *
    + *
  • Caching: Uses Spring Cache abstraction for improved performance
  • + *
  • DTO Mapping: Converts between {@link Player} entities and {@link PlayerDTO} objects
  • + *
  • Business Logic: Encapsulates domain-specific operations
  • + *
+ * + *

Cache Strategy:

+ *
    + *
  • @Cacheable: Retrieval operations (read-through cache)
  • + *
  • @CacheEvict(allEntries=true): Mutating operations (create/update/delete) - invalidates entire cache to maintain + * consistency
  • + *
+ * + *

+ * Why invalidate all entries? The {@code retrieveAll()} method caches the full player list under a single key. + * Individual cache evictions would leave this list stale. Using {@code allEntries=true} ensures both individual + * player caches and the list cache stay synchronized after any data modification. + *

+ * + * @see PlayersRepository + * @see PlayerDTO + * @see Player + * @see org.modelmapper.ModelMapper + * @since 4.0.2025 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class PlayersService { + + private final PlayersRepository playersRepository; + private final ModelMapper modelMapper; + + /* + * ----------------------------------------------------------------------------------------------------------------------- + * Create + * ----------------------------------------------------------------------------------------------------------------------- + */ + + /** + * Creates a new player and stores it in the database. + *

+ * This method converts the PlayerDTO to a Player entity, persists it, and returns the saved player with its auto-generated + * ID. The result is automatically cached using the player's ID as the cache key. + *

+ *

+ * Conflict Detection: Checks if a player with the same squad number already exists (optimization). + * Squad numbers are unique identifiers (jersey numbers). Database constraint ensures uniqueness even under + * concurrent operations. If a race condition occurs between check and save, DataIntegrityViolationException + * is caught and null is returned to indicate conflict. + *

+ * + * @param playerDTO the player data to create (must not be null) + * @return the created player with auto-generated ID, or null if squad number already exists + * @see org.springframework.cache.annotation.CacheEvict + */ + @Transactional + @CacheEvict(value = "players", allEntries = true) + public PlayerDTO create(PlayerDTO playerDTO) { + log.debug("Creating new player with squad number: {}", playerDTO.getSquadNumber()); + + // Check if squad number already exists (optimization to avoid unnecessary DB write) + if (playersRepository.findBySquadNumber(playerDTO.getSquadNumber()).isPresent()) { + log.warn("Cannot create player - squad number {} already exists", playerDTO.getSquadNumber()); + return null; // Conflict: squad number already taken + } + + try { + Player player = mapFrom(playerDTO); + Player savedPlayer = playersRepository.save(player); + PlayerDTO result = mapFrom(savedPlayer); + log.info("Player created successfully - ID: {}, Squad Number: {}", result.getId(), result.getSquadNumber()); + return result; + } catch (DataIntegrityViolationException _) { + // Handle race condition: concurrent request created player with same squad number + // between our check and save operation + log.warn("Cannot create player - squad number {} already exists (race condition)", playerDTO.getSquadNumber()); + return null; + } + } + + /* + * ----------------------------------------------------------------------------------------------------------------------- + * Retrieve + * ----------------------------------------------------------------------------------------------------------------------- + */ + + /** + * Retrieves a player by their unique identifier. + *

+ * This method uses caching to improve performance. If the player is found in the cache, it will be returned without + * hitting the database. Otherwise, it queries the database and caches the result. + * Null results (player not found) are not cached to avoid serving stale misses. + *

+ * + * @param id the unique identifier of the player (must not be null) + * @return the player DTO if found, null otherwise + * @see org.springframework.cache.annotation.Cacheable + */ + @Cacheable(value = "players", key = "#id", unless = "#result == null") + public PlayerDTO retrieveById(Long id) { + return playersRepository.findById(id) + .map(this::mapFrom) + .orElse(null); + } + + /** + * Retrieves all players from the database. + *

+ * This method returns the complete Argentina 2022 FIFA World Cup squad (26 players). + * Results are cached to improve performance on subsequent calls. + *

+ * + * @return a list of all players (empty list if none found) + * @see org.springframework.cache.annotation.Cacheable + */ + @Cacheable(value = "players") + public List retrieveAll() { + return playersRepository.findAll() + .stream() + .map(this::mapFrom) + .toList(); + } + + /* + * ----------------------------------------------------------------------------------------------------------------------- + * Search + * ----------------------------------------------------------------------------------------------------------------------- + */ + + /** + * Searches for players by league name (case-insensitive, partial match). + *

+ * This method performs a wildcard search on the league field, matching any player whose league name contains the search + * term (e.g., "Premier" matches "Premier League"). + *

+ * + * @param league the league name to search for (must not be null or blank) + * @return a list of matching players (empty list if none found) + */ + public List searchByLeague(String league) { + return playersRepository.findByLeagueContainingIgnoreCase(league) + .stream() + .map(this::mapFrom) + .toList(); + } + + /** + * Searches for a player by their squad number. + *

+ * This method performs an exact match on the squad number field. Squad numbers are jersey numbers that users recognize + * (e.g., Messi is #10). + *

+ * + * @param squadNumber the squad number to search for (jersey number, typically 1-99) + * @return the player DTO if found, null otherwise + */ + public PlayerDTO searchBySquadNumber(Integer squadNumber) { + return playersRepository.findBySquadNumber(squadNumber) + .map(this::mapFrom) + .orElse(null); + } + + /* + * ----------------------------------------------------------------------------------------------------------------------- + * Update + * ----------------------------------------------------------------------------------------------------------------------- + */ + + /** + * Updates an existing player's information. + *

+ * This method performs a full update (PUT semantics) of the player entity. If the player exists, it updates all fields and + * refreshes the cache. If the player doesn't exist, returns false without making changes. + *

+ * + * @param playerDTO the player data to update (must include a valid ID) + * @return true if the player was updated successfully, false if not found + * @see org.springframework.cache.annotation.CacheEvict + */ + @Transactional + @CacheEvict(value = "players", allEntries = true) + public boolean update(PlayerDTO playerDTO) { + log.debug("Updating player with ID: {}", playerDTO.getId()); + + if (playerDTO.getId() != null && playersRepository.existsById(playerDTO.getId())) { + Player player = mapFrom(playerDTO); + playersRepository.save(player); + log.info("Player updated successfully - ID: {}", playerDTO.getId()); + return true; + } else { + log.warn("Cannot update player - ID {} not found", playerDTO.getId()); + return false; + } + } + + /* + * ----------------------------------------------------------------------------------------------------------------------- + * Delete + * ----------------------------------------------------------------------------------------------------------------------- + */ + + /** + * Deletes a player by their unique identifier. + *

+ * This method removes the player from the database and evicts it from the cache. If the player doesn't exist, returns + * false without making changes. + *

+ * + * @param id the unique identifier of the player to delete (must not be null) + * @return true if the player was deleted successfully, false if not found + * @see org.springframework.cache.annotation.CacheEvict + */ + @Transactional + @CacheEvict(value = "players", allEntries = true) + public boolean delete(Long id) { + log.debug("Deleting player with ID: {}", id); + + if (playersRepository.existsById(id)) { + playersRepository.deleteById(id); + log.info("Player deleted successfully - ID: {}", id); + return true; + } else { + log.warn("Cannot delete player - ID {} not found", id); + return false; + } + } + + private PlayerDTO mapFrom(Player player) { + return modelMapper.map(player, PlayerDTO.class); + } + + private Player mapFrom(PlayerDTO dto) { + return modelMapper.map(dto, Player.class); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index f556a99..52a6bdf 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -16,10 +16,11 @@ springdoc.swagger-ui.path=/swagger/index.html # SQLite Database Configuration # Uses environment variable STORAGE_PATH if set, otherwise defaults to local path -# Dates are stored as Unix timestamps (INTEGER) for robustness - no date format config needed -spring.datasource.url=jdbc:sqlite:${STORAGE_PATH:storage/books-sqlite3.db} +# Contains players table with dates stored as ISO-8601 TEXT +spring.datasource.url=jdbc:sqlite:${STORAGE_PATH:storage/players-sqlite3.db} spring.datasource.driver-class-name=org.sqlite.JDBC spring.jpa.database-platform=org.hibernate.community.dialect.SQLiteDialect spring.jpa.hibernate.ddl-auto=none +spring.jpa.hibernate.naming.physical-strategy=org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl spring.jpa.show-sql=false spring.jpa.properties.hibernate.format_sql=true diff --git a/src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/BookDTOFakes.java b/src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/BookDTOFakes.java deleted file mode 100644 index d0d661a..0000000 --- a/src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/BookDTOFakes.java +++ /dev/null @@ -1,157 +0,0 @@ -package ar.com.nanotaboada.java.samples.spring.boot.test; - -import java.time.LocalDate; -import java.util.ArrayList; -import java.util.List; - -import ar.com.nanotaboada.java.samples.spring.boot.models.BookDTO; - -public final class BookDTOFakes { - - private BookDTOFakes() { - throw new UnsupportedOperationException("This is a utility class and cannot be instantiated"); - } - - public static BookDTO createOneValid() { - BookDTO bookDTO = new BookDTO(); - bookDTO.setIsbn("978-1484200773"); - bookDTO.setTitle("Pro Git"); - bookDTO.setSubtitle("Everything you neeed to know about Git"); - bookDTO.setAuthor("Scott Chacon and Ben Straub"); - bookDTO.setPublisher("lulu.com; First Edition"); - bookDTO.setPublished(LocalDate.of(2014, 11, 18)); - bookDTO.setPages(458); - bookDTO.setDescription( - """ - Pro Git (Second Edition) is your fully-updated guide to Git and its \ - usage in the modern world. Git has come a long way since it was first developed by \ - Linus Torvalds for Linux kernel development. It has taken the open source world by \ - storm since its inception in 2005, and this book teaches you how to use it like a \ - pro."""); - bookDTO.setWebsite("https://git-scm.com/book/en/v2"); - return bookDTO; - } - - public static BookDTO createOneInvalid() { - BookDTO bookDTO = new BookDTO(); - bookDTO.setIsbn("978-1234567890"); // Invalid (invalid ISBN) - bookDTO.setTitle("Title"); - bookDTO.setSubtitle("Sub Title"); - bookDTO.setAuthor("Author"); - bookDTO.setPublisher("Publisher"); - bookDTO.setPublished(LocalDate.now()); // Invalid (must be a past date) - bookDTO.setPages(123); - bookDTO.setDescription("Description"); - bookDTO.setWebsite("https://domain.com/"); - return bookDTO; - } - - public static List createManyValid() { - ArrayList bookDTOs = new ArrayList<>(); - BookDTO bookDTO9781838986698 = new BookDTO(); - bookDTO9781838986698.setIsbn("9781838986698"); - bookDTO9781838986698.setTitle("The Java Workshop"); - bookDTO9781838986698 - .setSubtitle("Learn object-oriented programming and kickstart your career in software development"); - bookDTO9781838986698.setAuthor("David Cuartielles, Andreas Göransson, Eric Foster-Johnson"); - bookDTO9781838986698.setPublisher("Packt Publishing"); - bookDTO9781838986698.setPublished(LocalDate.of(2019, 10, 31)); - bookDTO9781838986698.setPages(606); - bookDTO9781838986698.setDescription( - """ - Java is a versatile, popular programming language used across a wide range of \ - industries. Learning how to write effective Java code can take your career to \ - the next level, and The Java Workshop will help you do just that. This book is \ - designed to take the pain out of Java coding and teach you everything you need \ - to know to be productive in building real-world software. The Workshop starts by \ - showing you how to use classes, methods, and the built-in Collections API to \ - manipulate data structures effortlessly. You'll dive right into learning about \ - object-oriented programming by creating classes and interfaces and making use of \ - inheritance and polymorphism. After learning how to handle exceptions, you'll \ - study the modules, packages, and libraries that help you organize your code. As \ - you progress, you'll discover how to connect to external databases and web \ - servers, work with regular expressions, and write unit tests to validate your \ - code. You'll also be introduced to functional programming and see how to \ - implement it using lambda functions. By the end of this Workshop, you'll be \ - well-versed with key Java concepts and have the knowledge and confidence to \ - tackle your own ambitious projects with Java."""); - bookDTO9781838986698.setWebsite("https://www.packtpub.com/free-ebook/the-java-workshop/9781838986698"); - bookDTOs.add(bookDTO9781838986698); - BookDTO bookDTO9781789613476 = new BookDTO(); - bookDTO9781789613476.setIsbn("9781789613476"); - bookDTO9781789613476.setTitle("Hands-On Microservices with Spring Boot and Spring Cloud"); - bookDTO9781789613476 - .setSubtitle("Build and deploy Java microservices using Spring Cloud, Istio, and Kubernetes"); - bookDTO9781789613476.setAuthor("Magnus Larsson"); - bookDTO9781789613476.setPublisher("Packt Publishing"); - bookDTO9781789613476.setPublished(LocalDate.of(2019, 9, 20)); - bookDTO9781789613476.setPages(668); - bookDTO9781789613476.setDescription( - """ - Microservices architecture allows developers to build and maintain applications \ - with ease, and enterprises are rapidly adopting it to build software using \ - Spring Boot as their default framework. With this book, you'll learn how to \ - efficiently build and deploy microservices using Spring Boot. This microservices \ - book will take you through tried and tested approaches to building distributed \ - systems and implementing microservices architecture in your organization. \ - Starting with a set of simple cooperating microservices developed using Spring \ - Boot, you'll learn how you can add functionalities such as persistence, make \ - your microservices reactive, and describe their APIs using Swagger/OpenAPI. As \ - you advance, you'll understand how to add different services from Spring Cloud \ - to your microservice system. The book also demonstrates how to deploy your \ - microservices using Kubernetes and manage them with Istio for improved security \ - and traffic management. Finally, you'll explore centralized log management using \ - the EFK stack and monitor microservices using Prometheus and Grafana. By the end \ - of this book, you'll be able to build microservices that are scalable and robust \ - using Spring Boot and Spring Cloud."""); - bookDTO9781789613476.setWebsite( - "https://www.packtpub.com/free-ebook/hands-on-microservices-with-spring-boot-and-spring-cloud/9781789613476"); - bookDTOs.add(bookDTO9781789613476); - BookDTO bookDTO9781838555726 = new BookDTO(); - bookDTO9781838555726.setIsbn("9781838555726"); - bookDTO9781838555726.setTitle("Mastering Kotlin"); - bookDTO9781838555726.setSubtitle( - "Learn advanced Kotlin programming techniques to build apps for Android, iOS, and the web"); - bookDTO9781838555726.setAuthor("Nate Ebel"); - bookDTO9781838555726.setPublisher("Packt Publishing"); - bookDTO9781838555726.setPublished(LocalDate.of(2019, 10, 11)); - bookDTO9781838555726.setPages(434); - bookDTO9781838555726.setDescription( - """ - Using Kotlin without taking advantage of its power and interoperability is like \ - owning a sports car and never taking it out of the garage. While documentation \ - and introductory resources can help you learn the basics of Kotlin, the fact \ - that it's a new language means that there are limited learning resources and \ - code bases available in comparison to Java and other established languages. This \ - Kotlin book will show you how to leverage software designs and concepts that \ - have made Java the most dominant enterprise programming language. You'll \ - understand how Kotlin is a modern approach to object-oriented programming (OOP). \ - This book will take you through the vast array of features that Kotlin provides \ - over other languages. These features include seamless interoperability with \ - Java, efficient syntax, built-in functional programming constructs, and support \ - for creating your own DSL. Finally, you will gain an understanding of \ - implementing practical design patterns and best practices to help you master the \ - Kotlin language. By the end of the book, you'll have obtained an advanced \ - understanding of Kotlin in order to be able to build production-grade \ - applications."""); - bookDTO9781838555726.setWebsite("https://www.packtpub.com/free-ebook/mastering-kotlin/9781838555726"); - bookDTOs.add(bookDTO9781838555726); - BookDTO bookDTO9781484242216 = new BookDTO(); - bookDTO9781484242216.setIsbn("9781484242216"); - bookDTO9781484242216.setTitle("Rethinking Productivity in Software Engineering"); - bookDTO9781484242216.setAuthor("Caitlin Sadowski, Thomas Zimmermann"); - bookDTO9781484242216.setPublisher("Apress"); - bookDTO9781484242216.setPublished(LocalDate.of(2019, 5, 7)); - bookDTO9781484242216.setPages(301); - bookDTO9781484242216.setDescription( - """ - Get the most out of this foundational reference and improve the productivity of \ - your software teams. This open access book collects the wisdom of the 2017 \ - "Dagstuhl" seminar on productivity in software engineering, a meeting of \ - community leaders, who came together with the goal of rethinking traditional \ - definitions and measures of productivity."""); - bookDTO9781484242216.setWebsite("https://link.springer.com/book/10.1007/978-1-4842-4221-6"); - bookDTOs.add(bookDTO9781484242216); - return bookDTOs; - } -} diff --git a/src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/BookFakes.java b/src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/BookFakes.java deleted file mode 100644 index f591bce..0000000 --- a/src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/BookFakes.java +++ /dev/null @@ -1,160 +0,0 @@ -package ar.com.nanotaboada.java.samples.spring.boot.test; - -import java.time.LocalDate; -import java.util.ArrayList; -import java.util.List; - -import ar.com.nanotaboada.java.samples.spring.boot.models.Book; - -public final class BookFakes { - - private BookFakes() { - throw new UnsupportedOperationException("This is a utility class and cannot be instantiated"); - } - - public static Book createOneValid() { - Book book = new Book(); - book.setIsbn("9781484200773"); - book.setTitle("Pro Git"); - book.setSubtitle("Everything You Need to Know About Git"); - book.setAuthor("Scott Chacon, Ben Straub"); - book.setPublisher("Apress"); - book.setPublished(LocalDate.of(2014, 11, 18)); - book.setPages(456); - book.setDescription( - """ - Pro Git is your fully-updated guide to Git and its usage in the modern world. \ - Git has come a long way since it was first developed by Linus Torvalds for Linux \ - kernel development. It has taken the open source world by storm since its \ - inception in 2005, and this book teaches you how to use it like a pro. Effective \ - and well-implemented version control is a necessity for successful web projects, \ - whether large or small. This book will help you master the fundamentals of Git, \ - including branching and merging, creating and managing repositories, customizing \ - your workflow, and using Git in a team environment. You'll also learn advanced \ - topics such as Git internals, debugging, automation, and customization."""); - book.setWebsite("https://git-scm.com/book/en/v2"); - return book; - } - - public static Book createOneInvalid() { - Book book = new Book(); - book.setIsbn("9781234567890"); // Invalid (invalid ISBN) - book.setTitle("Title"); - book.setSubtitle("Sub Title"); - book.setAuthor("Author"); - book.setPublisher("Publisher"); - book.setPublished(LocalDate.now()); // Invalid (must be a past date) - book.setPages(123); - book.setDescription("Description"); - book.setWebsite("https://domain.com/"); - return book; - } - - public static List createManyValid() { - ArrayList books = new ArrayList<>(); - Book book9781838986698 = new Book(); - book9781838986698.setIsbn("9781838986698"); - book9781838986698.setTitle("The Java Workshop"); - book9781838986698 - .setSubtitle("Learn object-oriented programming and kickstart your career in software development"); - book9781838986698.setAuthor("David Cuartielles, Andreas Göransson, Eric Foster-Johnson"); - book9781838986698.setPublisher("Packt Publishing"); - book9781838986698.setPublished(LocalDate.of(2019, 10, 31)); - book9781838986698.setPages(606); - book9781838986698.setDescription( - """ - Java is a versatile, popular programming language used across a wide range of \ - industries. Learning how to write effective Java code can take your career to \ - the next level, and The Java Workshop will help you do just that. This book is \ - designed to take the pain out of Java coding and teach you everything you need \ - to know to be productive in building real-world software. The Workshop starts by \ - showing you how to use classes, methods, and the built-in Collections API to \ - manipulate data structures effortlessly. You'll dive right into learning about \ - object-oriented programming by creating classes and interfaces and making use of \ - inheritance and polymorphism. After learning how to handle exceptions, you'll \ - study the modules, packages, and libraries that help you organize your code. As \ - you progress, you'll discover how to connect to external databases and web \ - servers, work with regular expressions, and write unit tests to validate your \ - code. You'll also be introduced to functional programming and see how to \ - implement it using lambda functions. By the end of this Workshop, you'll be \ - well-versed with key Java concepts and have the knowledge and confidence to \ - tackle your own ambitious projects with Java."""); - book9781838986698.setWebsite("https://www.packtpub.com/free-ebook/the-java-workshop/9781838986698"); - books.add(book9781838986698); - Book book9781789613476 = new Book(); - book9781789613476.setIsbn("9781789613476"); - book9781789613476.setTitle("Hands-On Microservices with Spring Boot and Spring Cloud"); - book9781789613476.setSubtitle("Build and deploy Java microservices using Spring Cloud, Istio, and Kubernetes"); - book9781789613476.setAuthor("Magnus Larsson"); - book9781789613476.setPublisher("Packt Publishing"); - book9781789613476.setPublished(LocalDate.of(2019, 9, 20)); - book9781789613476.setPages(668); - book9781789613476.setDescription( - """ - Microservices architecture allows developers to build and maintain applications \ - with ease, and enterprises are rapidly adopting it to build software using \ - Spring Boot as their default framework. With this book, you'll learn how to \ - efficiently build and deploy microservices using Spring Boot. This microservices \ - book will take you through tried and tested approaches to building distributed \ - systems and implementing microservices architecture in your organization. \ - Starting with a set of simple cooperating microservices developed using Spring \ - Boot, you'll learn how you can add functionalities such as persistence, make \ - your microservices reactive, and describe their APIs using Swagger/OpenAPI. As \ - you advance, you'll understand how to add different services from Spring Cloud \ - to your microservice system. The book also demonstrates how to deploy your \ - microservices using Kubernetes and manage them with Istio for improved security \ - and traffic management. Finally, you'll explore centralized log management using \ - the EFK stack and monitor microservices using Prometheus and Grafana. By the end \ - of this book, you'll be able to build microservices that are scalable and robust \ - using Spring Boot and Spring Cloud."""); - book9781789613476.setWebsite( - "https://www.packtpub.com/free-ebook/hands-on-microservices-with-spring-boot-and-spring-cloud/9781789613476"); - books.add(book9781789613476); - Book book9781838555726 = new Book(); - book9781838555726.setIsbn("9781838555726"); - book9781838555726.setTitle("Mastering Kotlin"); - book9781838555726.setSubtitle( - "Learn advanced Kotlin programming techniques to build apps for Android, iOS, and the web"); - book9781838555726.setAuthor("Nate Ebel"); - book9781838555726.setPublisher("Packt Publishing"); - book9781838555726.setPublished(LocalDate.of(2019, 10, 11)); - book9781838555726.setPages(434); - book9781838555726.setDescription( - """ - Using Kotlin without taking advantage of its power and interoperability is like \ - owning a sports car and never taking it out of the garage. While documentation \ - and introductory resources can help you learn the basics of Kotlin, the fact \ - that it's a new language means that there are limited learning resources and \ - code bases available in comparison to Java and other established languages. This \ - Kotlin book will show you how to leverage software designs and concepts that \ - have made Java the most dominant enterprise programming language. You'll \ - understand how Kotlin is a modern approach to object-oriented programming (OOP). \ - This book will take you through the vast array of features that Kotlin provides \ - over other languages. These features include seamless interoperability with \ - Java, efficient syntax, built-in functional programming constructs, and support \ - for creating your own DSL. Finally, you will gain an understanding of \ - implementing practical design patterns and best practices to help you master the \ - Kotlin language. By the end of the book, you'll have obtained an advanced \ - understanding of Kotlin in order to be able to build production-grade \ - applications."""); - book9781838555726.setWebsite("https://www.packtpub.com/free-ebook/mastering-kotlin/9781838555726"); - books.add(book9781838555726); - Book book9781484242216 = new Book(); - book9781484242216.setIsbn("9781484242216"); - book9781484242216.setTitle("Rethinking Productivity in Software Engineering"); - book9781484242216.setAuthor("Caitlin Sadowski, Thomas Zimmermann"); - book9781484242216.setPublisher("Apress"); - book9781484242216.setPublished(LocalDate.of(2019, 5, 7)); - book9781484242216.setPages(301); - book9781484242216.setDescription( - """ - Get the most out of this foundational reference and improve the productivity of \ - your software teams. This open access book collects the wisdom of the 2017 \ - "Dagstuhl" seminar on productivity in software engineering, a meeting of \ - community leaders, who came together with the goal of rethinking traditional \ - definitions and measures of productivity."""); - book9781484242216.setWebsite("https://link.springer.com/book/10.1007/978-1-4842-4221-6"); - books.add(book9781484242216); - return books; - } -} diff --git a/src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/PlayerDTOFakes.java b/src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/PlayerDTOFakes.java new file mode 100644 index 0000000..8cadd71 --- /dev/null +++ b/src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/PlayerDTOFakes.java @@ -0,0 +1,207 @@ +package ar.com.nanotaboada.java.samples.spring.boot.test; + +import java.time.LocalDate; +import java.util.Arrays; +import java.util.List; + +import ar.com.nanotaboada.java.samples.spring.boot.models.PlayerDTO; + +public final class PlayerDTOFakes { + + private PlayerDTOFakes() { + throw new UnsupportedOperationException("This is a utility class and cannot be instantiated"); + } + + /** + * Leandro Paredes - Test data for CREATE operations + * + * Usage: + * - Service tests: Mock expected data for playersService.create() + * - Controller tests: Mock expected data for POST /players + * + * Note: Not pre-seeded in test DB (ID 19 slot is empty) + */ + public static PlayerDTO createOneValid() { + PlayerDTO playerDTO = new PlayerDTO(); + playerDTO.setId(null); // Will be auto-generated as 19 + playerDTO.setFirstName("Leandro"); + playerDTO.setMiddleName("Daniel"); + playerDTO.setLastName("Paredes"); + playerDTO.setDateOfBirth(LocalDate.of(1994, 6, 29)); + playerDTO.setSquadNumber(5); + playerDTO.setPosition("Defensive Midfield"); + playerDTO.setAbbrPosition("DM"); + playerDTO.setTeam("AS Roma"); + playerDTO.setLeague("Serie A"); + playerDTO.setStarting11(false); + return playerDTO; + } + + /** + * Damián Martínez - Player ID 1 BEFORE update + * + * Usage: + * - Service tests: Mock expected data for playersService.retrieveById(1L) + * - Controller tests: Mock expected data for GET /players/1 + * + * Note: Repository tests query DB directly (pre-seeded in dml.sql) + */ + public static PlayerDTO createOneForUpdate() { + PlayerDTO playerDTO = new PlayerDTO(); + playerDTO.setId(1L); + playerDTO.setFirstName("Damián"); + playerDTO.setMiddleName("Emiliano"); + playerDTO.setLastName("Martínez"); + playerDTO.setDateOfBirth(LocalDate.of(1992, 9, 2)); + playerDTO.setSquadNumber(23); + playerDTO.setPosition("Goalkeeper"); + playerDTO.setAbbrPosition("GK"); + playerDTO.setTeam("Aston Villa FC"); + playerDTO.setLeague("Premier League"); + playerDTO.setStarting11(true); + return playerDTO; + } + + /** + * Emiliano Martínez - Expected result AFTER updating player ID 1 + * + * Usage: + * - Service tests: Mock expected data after playersService.update() + * - Controller tests: Mock expected data after PUT /players/1 + * + * Update changes: + * - firstName: "Damián" → "Emiliano" + * - middleName: "Emiliano" → null + */ + public static PlayerDTO createOneUpdated() { + PlayerDTO playerDTO = new PlayerDTO(); + playerDTO.setId(1L); + playerDTO.setFirstName("Emiliano"); + playerDTO.setMiddleName(null); + playerDTO.setLastName("Martínez"); + playerDTO.setDateOfBirth(LocalDate.of(1992, 9, 2)); + playerDTO.setSquadNumber(23); + playerDTO.setPosition("Goalkeeper"); + playerDTO.setAbbrPosition("GK"); + playerDTO.setTeam("Aston Villa FC"); + playerDTO.setLeague("Premier League"); + playerDTO.setStarting11(true); + return playerDTO; + } + + /** + * Invalid player data - Test data for validation failure scenarios + * + * Usage: + * - Controller tests: Verify validation annotations work + * (@NotBlank, @Past, @Positive) + * + * Violations: blank names, future date, negative squad number, blank fields + */ + public static PlayerDTO createOneInvalid() { + PlayerDTO playerDTO = new PlayerDTO(); + playerDTO.setId(999L); + playerDTO.setFirstName(""); // Invalid (blank) + playerDTO.setMiddleName(null); + playerDTO.setLastName(""); // Invalid (blank) + playerDTO.setDateOfBirth(LocalDate.now()); // Invalid (must be a past date) + playerDTO.setSquadNumber(-1); // Invalid (must be positive) + playerDTO.setPosition(""); // Invalid (blank) + playerDTO.setAbbrPosition(null); + playerDTO.setTeam(""); // Invalid (blank) + playerDTO.setLeague(null); + playerDTO.setStarting11(null); + return playerDTO; + } + + /** + * ALL 26 players - Complete Argentina 2022 FIFA World Cup squad + * + * Usage: + * - Service tests: Mock expected data for playersService.retrieveAll() + * - Controller tests: Mock expected data for GET /players + * + * Includes: + * - 25 players pre-seeded in test DB (IDs 1-26, excluding 19) + * - Leandro Paredes (ID 19, created during tests) + * + * Note: Repository tests query real in-memory DB directly (25 players + * pre-seeded) + */ + public static List createAll() { + return Arrays.asList( + // Starting 11 + createPlayerDTOWithId(1L, "Damián", "Emiliano", "Martínez", LocalDate.of(1992, 9, 2), 23, "Goalkeeper", + "GK", "Aston Villa FC", "Premier League", true), + createPlayerDTOWithId(2L, "Nahuel", null, "Molina", LocalDate.of(1998, 4, 6), 26, "Right-Back", "RB", + "Atlético Madrid", "La Liga", true), + createPlayerDTOWithId(3L, "Cristian", "Gabriel", "Romero", LocalDate.of(1998, 4, 27), 13, "Centre-Back", + "CB", "Tottenham Hotspur", "Premier League", true), + createPlayerDTOWithId(4L, "Nicolás", "Hernán Gonzalo", "Otamendi", LocalDate.of(1988, 2, 12), 19, + "Centre-Back", "CB", "SL Benfica", "Liga Portugal", true), + createPlayerDTOWithId(5L, "Nicolás", "Alejandro", "Tagliafico", LocalDate.of(1992, 8, 31), 3, + "Left-Back", "LB", "Olympique Lyon", "Ligue 1", true), + createPlayerDTOWithId(6L, "Ángel", "Fabián", "Di María", LocalDate.of(1988, 2, 14), 11, "Right Winger", + "RW", "SL Benfica", "Liga Portugal", true), + createPlayerDTOWithId(7L, "Rodrigo", "Javier", "de Paul", LocalDate.of(1994, 5, 24), 7, + "Central Midfield", "CM", "Atlético Madrid", "La Liga", true), + createPlayerDTOWithId(8L, "Enzo", "Jeremías", "Fernández", LocalDate.of(2001, 1, 17), 24, + "Central Midfield", "CM", "Chelsea FC", "Premier League", true), + createPlayerDTOWithId(9L, "Alexis", null, "Mac Allister", LocalDate.of(1998, 12, 24), 20, + "Central Midfield", "CM", "Liverpool FC", "Premier League", true), + createPlayerDTOWithId(10L, "Lionel", "Andrés", "Messi", LocalDate.of(1987, 6, 24), 10, "Right Winger", + "RW", "Inter Miami CF", "Major League Soccer", true), + createPlayerDTOWithId(11L, "Julián", null, "Álvarez", LocalDate.of(2000, 1, 31), 9, "Centre-Forward", + "CF", "Manchester City", "Premier League", true), + // Substitutes + createPlayerDTOWithId(12L, "Franco", "Daniel", "Armani", LocalDate.of(1986, 10, 16), 1, "Goalkeeper", + "GK", "River Plate", "Copa de la Liga", false), + createPlayerDTOWithId(13L, "Gerónimo", null, "Rulli", LocalDate.of(1992, 5, 20), 12, "Goalkeeper", "GK", + "Ajax Amsterdam", "Eredivisie", false), + createPlayerDTOWithId(14L, "Juan", "Marcos", "Foyth", LocalDate.of(1998, 1, 12), 2, "Right-Back", "RB", + "Villarreal", "La Liga", false), + createPlayerDTOWithId(15L, "Gonzalo", "Ariel", "Montiel", LocalDate.of(1997, 1, 1), 4, "Right-Back", + "RB", "Nottingham Forest", "Premier League", false), + createPlayerDTOWithId(16L, "Germán", "Alejo", "Pezzella", LocalDate.of(1991, 6, 27), 6, "Centre-Back", + "CB", "Real Betis Balompié", "La Liga", false), + createPlayerDTOWithId(17L, "Marcos", "Javier", "Acuña", LocalDate.of(1991, 10, 28), 8, "Left-Back", + "LB", "Sevilla FC", "La Liga", false), + createPlayerDTOWithId(18L, "Lisandro", null, "Martínez", LocalDate.of(1998, 1, 18), 25, "Centre-Back", + "CB", "Manchester United", "Premier League", false), + // Leandro Paredes (ID 19) - created during tests + createPlayerDTOWithId(19L, "Leandro", "Daniel", "Paredes", LocalDate.of(1994, 6, 29), 5, + "Defensive Midfield", "DM", "AS Roma", "Serie A", false), + createPlayerDTOWithId(20L, "Exequiel", "Alejandro", "Palacios", LocalDate.of(1998, 10, 5), 14, + "Central Midfield", "CM", "Bayer 04 Leverkusen", "Bundesliga", false), + createPlayerDTOWithId(21L, "Alejandro", "Darío", "Gómez", LocalDate.of(1988, 2, 15), 17, "Left Winger", + "LW", "AC Monza", "Serie A", false), + createPlayerDTOWithId(22L, "Guido", null, "Rodríguez", LocalDate.of(1994, 4, 12), 18, + "Defensive Midfield", "DM", "Real Betis Balompié", "La Liga", false), + createPlayerDTOWithId(23L, "Ángel", "Martín", "Correa", LocalDate.of(1995, 3, 9), 15, "Right Winger", + "RW", "Atlético Madrid", "La Liga", false), + createPlayerDTOWithId(24L, "Thiago", "Ezequiel", "Almada", LocalDate.of(2001, 4, 26), 16, + "Attacking Midfield", "AM", "Atlanta United FC", "Major League Soccer", false), + createPlayerDTOWithId(25L, "Paulo", "Exequiel", "Dybala", LocalDate.of(1993, 11, 15), 21, + "Second Striker", "SS", "AS Roma", "Serie A", false), + createPlayerDTOWithId(26L, "Lautaro", "Javier", "Martínez", LocalDate.of(1997, 8, 22), 22, + "Centre-Forward", "CF", "Inter Milan", "Serie A", false)); + } + + private static PlayerDTO createPlayerDTOWithId(Long id, String firstName, String middleName, String lastName, + LocalDate dateOfBirth, Integer squadNumber, String position, + String abbrPosition, String team, String league, Boolean starting11) { + PlayerDTO playerDTO = new PlayerDTO(); + playerDTO.setId(id); + playerDTO.setFirstName(firstName); + playerDTO.setMiddleName(middleName); + playerDTO.setLastName(lastName); + playerDTO.setDateOfBirth(dateOfBirth); + playerDTO.setSquadNumber(squadNumber); + playerDTO.setPosition(position); + playerDTO.setAbbrPosition(abbrPosition); + playerDTO.setTeam(team); + playerDTO.setLeague(league); + playerDTO.setStarting11(starting11); + return playerDTO; + } +} diff --git a/src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/PlayerFakes.java b/src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/PlayerFakes.java new file mode 100644 index 0000000..5eea56c --- /dev/null +++ b/src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/PlayerFakes.java @@ -0,0 +1,210 @@ +package ar.com.nanotaboada.java.samples.spring.boot.test; + +import java.time.LocalDate; +import java.util.Arrays; +import java.util.List; + +import ar.com.nanotaboada.java.samples.spring.boot.models.Player; + +public final class PlayerFakes { + + private PlayerFakes() { + throw new UnsupportedOperationException("This is a utility class and cannot be instantiated"); + } + + /** + * Leandro Paredes - Test data for CREATE operations + * + * Usage: + * - Repository tests: Insert into real in-memory DB (gets ID 19) + * - Service tests: Mock expected data for playersService.create() + * - Controller tests: Mock expected data for POST /players + * + * Note: Not pre-seeded in test DB (ID 19 slot is empty) + */ + public static Player createOneValid() { + Player player = new Player(); + player.setId(null); // Will be auto-generated as 19 + player.setFirstName("Leandro"); + player.setMiddleName("Daniel"); + player.setLastName("Paredes"); + player.setDateOfBirth(LocalDate.of(1994, 6, 29)); + player.setSquadNumber(5); + player.setPosition("Defensive Midfield"); + player.setAbbrPosition("DM"); + player.setTeam("AS Roma"); + player.setLeague("Serie A"); + player.setStarting11(false); + return player; + } + + /** + * Damián Martínez - Player ID 1 BEFORE update + * + * Usage: + * - Service tests: Mock expected data for playersService.retrieveById(1L) + * - Controller tests: Mock expected data for GET /players/1 + * + * Note: Repository tests query DB directly (pre-seeded in dml.sql) + */ + public static Player createOneForUpdate() { + Player player = new Player(); + player.setId(1L); + player.setFirstName("Damián"); + player.setMiddleName("Emiliano"); + player.setLastName("Martínez"); + player.setDateOfBirth(LocalDate.of(1992, 9, 2)); + player.setSquadNumber(23); + player.setPosition("Goalkeeper"); + player.setAbbrPosition("GK"); + player.setTeam("Aston Villa FC"); + player.setLeague("Premier League"); + player.setStarting11(true); + return player; + } + + /** + * Emiliano Martínez - Expected result AFTER updating player ID 1 + * + * Usage: + * - Service tests: Mock expected data after playersService.update() + * - Controller tests: Mock expected data after PUT /players/1 + * + * Update changes: + * - firstName: "Damián" → "Emiliano" + * - middleName: "Emiliano" → null + * + * Note: Repository tests should query DB directly for before/after states + */ + public static Player createOneUpdated() { + Player player = new Player(); + player.setId(1L); + player.setFirstName("Emiliano"); + player.setMiddleName(null); + player.setLastName("Martínez"); + player.setDateOfBirth(LocalDate.of(1992, 9, 2)); + player.setSquadNumber(23); + player.setPosition("Goalkeeper"); + player.setAbbrPosition("GK"); + player.setTeam("Aston Villa FC"); + player.setLeague("Premier League"); + player.setStarting11(true); + return player; + } + + /** + * Invalid player data - Test data for validation failure scenarios + * + * Usage: + * - Controller tests: Verify validation annotations work + * (@NotBlank, @Past, @Positive) + * + * Violations: blank names, future date, negative squad number, blank fields + */ + public static Player createOneInvalid() { + Player player = new Player(); + player.setId(999L); + player.setFirstName(""); // Invalid (blank) + player.setMiddleName(null); + player.setLastName(""); // Invalid (blank) + player.setDateOfBirth(LocalDate.now()); // Invalid (must be a past date) + player.setSquadNumber(-1); // Invalid (must be positive) + player.setPosition(""); // Invalid (blank) + player.setAbbrPosition(null); + player.setTeam(""); // Invalid (blank) + player.setLeague(null); + player.setStarting11(null); + return player; + } + + /** + * ALL 26 players - Complete Argentina 2022 FIFA World Cup squad + * + * Usage: + * - Service tests: Mock expected data for playersService.retrieveAll() + * - Controller tests: Mock expected data for GET /players + * + * Includes: + * - 25 players pre-seeded in test DB (IDs 1-26, excluding 19) + * - Leandro Paredes (ID 19, created during tests) + * + * Note: Repository tests query real in-memory DB directly (25 players + * pre-seeded) + */ + public static List createAll() { + return Arrays.asList( + // Starting 11 + createPlayerWithId(1L, "Damián", "Emiliano", "Martínez", LocalDate.of(1992, 9, 2), 23, "Goalkeeper", + "GK", "Aston Villa FC", "Premier League", true), + createPlayerWithId(2L, "Nahuel", null, "Molina", LocalDate.of(1998, 4, 6), 26, "Right-Back", "RB", + "Atlético Madrid", "La Liga", true), + createPlayerWithId(3L, "Cristian", "Gabriel", "Romero", LocalDate.of(1998, 4, 27), 13, "Centre-Back", + "CB", "Tottenham Hotspur", "Premier League", true), + createPlayerWithId(4L, "Nicolás", "Hernán Gonzalo", "Otamendi", LocalDate.of(1988, 2, 12), 19, + "Centre-Back", "CB", "SL Benfica", "Liga Portugal", true), + createPlayerWithId(5L, "Nicolás", "Alejandro", "Tagliafico", LocalDate.of(1992, 8, 31), 3, "Left-Back", + "LB", "Olympique Lyon", "Ligue 1", true), + createPlayerWithId(6L, "Ángel", "Fabián", "Di María", LocalDate.of(1988, 2, 14), 11, "Right Winger", + "RW", "SL Benfica", "Liga Portugal", true), + createPlayerWithId(7L, "Rodrigo", "Javier", "de Paul", LocalDate.of(1994, 5, 24), 7, "Central Midfield", + "CM", "Atlético Madrid", "La Liga", true), + createPlayerWithId(8L, "Enzo", "Jeremías", "Fernández", LocalDate.of(2001, 1, 17), 24, + "Central Midfield", "CM", "Chelsea FC", "Premier League", true), + createPlayerWithId(9L, "Alexis", null, "Mac Allister", LocalDate.of(1998, 12, 24), 20, + "Central Midfield", "CM", "Liverpool FC", "Premier League", true), + createPlayerWithId(10L, "Lionel", "Andrés", "Messi", LocalDate.of(1987, 6, 24), 10, "Right Winger", + "RW", "Inter Miami CF", "Major League Soccer", true), + createPlayerWithId(11L, "Julián", null, "Álvarez", LocalDate.of(2000, 1, 31), 9, "Centre-Forward", "CF", + "Manchester City", "Premier League", true), + // Substitutes + createPlayerWithId(12L, "Franco", "Daniel", "Armani", LocalDate.of(1986, 10, 16), 1, "Goalkeeper", "GK", + "River Plate", "Copa de la Liga", false), + createPlayerWithId(13L, "Gerónimo", null, "Rulli", LocalDate.of(1992, 5, 20), 12, "Goalkeeper", "GK", + "Ajax Amsterdam", "Eredivisie", false), + createPlayerWithId(14L, "Juan", "Marcos", "Foyth", LocalDate.of(1998, 1, 12), 2, "Right-Back", "RB", + "Villarreal", "La Liga", false), + createPlayerWithId(15L, "Gonzalo", "Ariel", "Montiel", LocalDate.of(1997, 1, 1), 4, "Right-Back", "RB", + "Nottingham Forest", "Premier League", false), + createPlayerWithId(16L, "Germán", "Alejo", "Pezzella", LocalDate.of(1991, 6, 27), 6, "Centre-Back", + "CB", "Real Betis Balompié", "La Liga", false), + createPlayerWithId(17L, "Marcos", "Javier", "Acuña", LocalDate.of(1991, 10, 28), 8, "Left-Back", "LB", + "Sevilla FC", "La Liga", false), + createPlayerWithId(18L, "Lisandro", null, "Martínez", LocalDate.of(1998, 1, 18), 25, "Centre-Back", + "CB", "Manchester United", "Premier League", false), + // Leandro Paredes (ID 19) - created during tests + createPlayerWithId(19L, "Leandro", "Daniel", "Paredes", LocalDate.of(1994, 6, 29), 5, + "Defensive Midfield", "DM", "AS Roma", "Serie A", false), + createPlayerWithId(20L, "Exequiel", "Alejandro", "Palacios", LocalDate.of(1998, 10, 5), 14, + "Central Midfield", "CM", "Bayer 04 Leverkusen", "Bundesliga", false), + createPlayerWithId(21L, "Alejandro", "Darío", "Gómez", LocalDate.of(1988, 2, 15), 17, "Left Winger", + "LW", "AC Monza", "Serie A", false), + createPlayerWithId(22L, "Guido", null, "Rodríguez", LocalDate.of(1994, 4, 12), 18, "Defensive Midfield", + "DM", "Real Betis Balompié", "La Liga", false), + createPlayerWithId(23L, "Ángel", "Martín", "Correa", LocalDate.of(1995, 3, 9), 15, "Right Winger", "RW", + "Atlético Madrid", "La Liga", false), + createPlayerWithId(24L, "Thiago", "Ezequiel", "Almada", LocalDate.of(2001, 4, 26), 16, + "Attacking Midfield", "AM", "Atlanta United FC", "Major League Soccer", false), + createPlayerWithId(25L, "Paulo", "Exequiel", "Dybala", LocalDate.of(1993, 11, 15), 21, "Second Striker", + "SS", "AS Roma", "Serie A", false), + createPlayerWithId(26L, "Lautaro", "Javier", "Martínez", LocalDate.of(1997, 8, 22), 22, + "Centre-Forward", "CF", "Inter Milan", "Serie A", false)); + } + + private static Player createPlayerWithId(Long id, String firstName, String middleName, String lastName, + LocalDate dateOfBirth, Integer squadNumber, String position, + String abbrPosition, String team, String league, Boolean starting11) { + Player player = new Player(); + player.setId(id); + player.setFirstName(firstName); + player.setMiddleName(middleName); + player.setLastName(lastName); + player.setDateOfBirth(dateOfBirth); + player.setSquadNumber(squadNumber); + player.setPosition(position); + player.setAbbrPosition(abbrPosition); + player.setTeam(team); + player.setLeague(league); + player.setStarting11(starting11); + return player; + } +} diff --git a/src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/controllers/BooksControllerTests.java b/src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/controllers/BooksControllerTests.java deleted file mode 100644 index cb76cd4..0000000 --- a/src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/controllers/BooksControllerTests.java +++ /dev/null @@ -1,437 +0,0 @@ -package ar.com.nanotaboada.java.samples.spring.boot.test.controllers; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; - -import java.util.List; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.cache.test.autoconfigure.AutoConfigureCache; -import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.mock.web.MockHttpServletResponse; -import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; - -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; - -import ar.com.nanotaboada.java.samples.spring.boot.controllers.BooksController; -import ar.com.nanotaboada.java.samples.spring.boot.models.BookDTO; -import ar.com.nanotaboada.java.samples.spring.boot.repositories.BooksRepository; -import ar.com.nanotaboada.java.samples.spring.boot.services.BooksService; -import ar.com.nanotaboada.java.samples.spring.boot.test.BookDTOFakes; - -@DisplayName("HTTP Methods on Controller") -@WebMvcTest(BooksController.class) -@AutoConfigureCache -class BooksControllerTests { - - private static final String PATH = "/books"; - - @Autowired - private MockMvc application; - - @MockitoBean - private BooksService booksServiceMock; - - @MockitoBean - private BooksRepository booksRepositoryMock; - - /* - * ------------------------------------------------------------------------- - * HTTP POST - * ------------------------------------------------------------------------- - */ - - @Test - void givenPost_whenRequestBodyIsValidButExistingBook_thenResponseStatusIsConflict() - throws Exception { - // Arrange - BookDTO bookDTO = BookDTOFakes.createOneValid(); - String body = new ObjectMapper().writeValueAsString(bookDTO); - Mockito - .when(booksServiceMock.create(any(BookDTO.class))) - .thenReturn(false); // Existing - MockHttpServletRequestBuilder request = MockMvcRequestBuilders - .post(PATH) - .content(body) - .contentType(MediaType.APPLICATION_JSON); - // Act - MockHttpServletResponse response = application - .perform(request) - .andReturn() - .getResponse(); - // Assert - verify(booksServiceMock, times(1)).create(any(BookDTO.class)); - assertThat(response.getStatus()).isEqualTo(HttpStatus.CONFLICT.value()); - - } - - @Test - void givenPost_whenRequestBodyIsValidAndNonExistentBook_thenResponseStatusIsCreated() - throws Exception { - // Arrange - BookDTO bookDTO = BookDTOFakes.createOneValid(); - String body = new ObjectMapper().writeValueAsString(bookDTO); - Mockito - .when(booksServiceMock.create(any(BookDTO.class))) - .thenReturn(true); // Non-existent - Mockito - .when(booksServiceMock.create(any(BookDTO.class))) - .thenReturn(true); - MockHttpServletRequestBuilder request = MockMvcRequestBuilders - .post(PATH) - .content(body) - .contentType(MediaType.APPLICATION_JSON); - // Act - MockHttpServletResponse response = application - .perform(request) - .andReturn() - .getResponse(); - response.setContentType("application/json;charset=UTF-8"); - // Assert - verify(booksServiceMock, times(1)).create(any(BookDTO.class)); - assertThat(response.getStatus()).isEqualTo(HttpStatus.CREATED.value()); - assertThat(response.getHeader(HttpHeaders.LOCATION)).isNotNull(); - assertThat(response.getHeader(HttpHeaders.LOCATION)).contains(PATH + "/" + bookDTO.getIsbn()); - - } - - @Test - void givenPost_whenRequestBodyIsInvalidBook_thenResponseStatusIsBadRequest() - throws Exception { - // Arrange - BookDTO bookDTO = BookDTOFakes.createOneInvalid(); - String body = new ObjectMapper().writeValueAsString(bookDTO); - MockHttpServletRequestBuilder request = MockMvcRequestBuilders - .post(PATH) - .content(body) - .contentType(MediaType.APPLICATION_JSON); - // Act - MockHttpServletResponse response = application - .perform(request) - .andReturn() - .getResponse(); - // Assert - verify(booksServiceMock, never()).create(any(BookDTO.class)); - assertThat(response.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value()); - } - - /* - * ------------------------------------------------------------------------- - * HTTP GET - * ------------------------------------------------------------------------- - */ - - @Test - void givenGetByIsbn_whenRequestPathVariableIsValidAndExistingISBN_thenResponseStatusIsOKAndResultIsBook() - throws Exception { - // Arrange - BookDTO bookDTO = BookDTOFakes.createOneValid(); - String isbn = bookDTO.getIsbn(); - Mockito - .when(booksServiceMock.retrieveByIsbn(anyString())) - .thenReturn(bookDTO); // Existing - MockHttpServletRequestBuilder request = MockMvcRequestBuilders - .get(PATH + "/{isbn}", isbn); - // Act - MockHttpServletResponse response = application - .perform(request) - .andReturn() - .getResponse(); - response.setContentType("application/json;charset=UTF-8"); - String content = response.getContentAsString(); - BookDTO result = new ObjectMapper().readValue(content, BookDTO.class); - // Assert - verify(booksServiceMock, times(1)).retrieveByIsbn(anyString()); - assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value()); - assertThat(result).usingRecursiveComparison().isEqualTo(bookDTO); - } - - @Test - void givenGetByIsbn_whenRequestPathVariableIsValidButNonExistentISBN_thenResponseStatusIsNotFound() - throws Exception { - // Arrange - String isbn = "9781484242216"; - Mockito - .when(booksServiceMock.retrieveByIsbn(anyString())) - .thenReturn(null); // Non-existent - MockHttpServletRequestBuilder request = MockMvcRequestBuilders - .get(PATH + "/{isbn}", isbn); - // Act - MockHttpServletResponse response = application - .perform(request) - .andReturn() - .getResponse(); - // Assert - verify(booksServiceMock, times(1)).retrieveByIsbn(anyString()); - assertThat(response.getStatus()).isEqualTo(HttpStatus.NOT_FOUND.value()); - } - - @Test - void givenGetAll_whenRequestPathIsBooks_thenResponseIsOkAndResultIsBooks() - throws Exception { - // Arrange - List bookDTOs = BookDTOFakes.createManyValid(); - Mockito - .when(booksServiceMock.retrieveAll()) - .thenReturn(bookDTOs); - MockHttpServletRequestBuilder request = MockMvcRequestBuilders - .get(PATH); - // Act - MockHttpServletResponse response = application - .perform(request) - .andReturn() - .getResponse(); - response.setContentType("application/json;charset=UTF-8"); - String content = response.getContentAsString(); - List result = new ObjectMapper().readValue(content, new TypeReference>() { - }); - // Assert - verify(booksServiceMock, times(1)).retrieveAll(); - assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value()); - assertThat(result).usingRecursiveComparison().isEqualTo(bookDTOs); - } - - /* - * ------------------------------------------------------------------------- - * HTTP PUT - * ------------------------------------------------------------------------- - */ - - @Test - void givenPut_whenRequestBodyIsValidAndExistingBook_thenResponseStatusIsNoContent() - throws Exception { - // Arrange - BookDTO bookDTO = BookDTOFakes.createOneValid(); - String body = new ObjectMapper().writeValueAsString(bookDTO); - Mockito - .when(booksServiceMock.update(any(BookDTO.class))) - .thenReturn(true); // Existing - MockHttpServletRequestBuilder request = MockMvcRequestBuilders - .put(PATH) - .content(body) - .contentType(MediaType.APPLICATION_JSON); - // Act - MockHttpServletResponse response = application - .perform(request) - .andReturn() - .getResponse(); - // Assert - verify(booksServiceMock, times(1)).update(any(BookDTO.class)); - assertThat(response.getStatus()).isEqualTo(HttpStatus.NO_CONTENT.value()); - } - - @Test - void givenPut_whenRequestBodyIsValidButNonExistentBook_thenResponseStatusIsNotFound() - throws Exception { - // Arrange - BookDTO bookDTO = BookDTOFakes.createOneValid(); - String body = new ObjectMapper().writeValueAsString(bookDTO); - Mockito - .when(booksServiceMock.update(any(BookDTO.class))) - .thenReturn(false); // Non-existent - MockHttpServletRequestBuilder request = MockMvcRequestBuilders - .put(PATH) - .content(body) - .contentType(MediaType.APPLICATION_JSON); - // Act - MockHttpServletResponse response = application - .perform(request) - .andReturn() - .getResponse(); - // Assert - verify(booksServiceMock, times(1)).update(any(BookDTO.class)); - assertThat(response.getStatus()).isEqualTo(HttpStatus.NOT_FOUND.value()); - } - - @Test - void givenPut_whenRequestBodyIsInvalidBook_thenResponseStatusIsBadRequest() - throws Exception { - // Arrange - BookDTO bookDTO = BookDTOFakes.createOneInvalid(); - String body = new ObjectMapper().writeValueAsString(bookDTO); - MockHttpServletRequestBuilder request = MockMvcRequestBuilders - .put(PATH) - .content(body) - .contentType(MediaType.APPLICATION_JSON); - // Act - MockHttpServletResponse response = application - .perform(request) - .andReturn() - .getResponse(); - // Assert - verify(booksServiceMock, never()).update(any(BookDTO.class)); - assertThat(response.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value()); - } - - /* - * ------------------------------------------------------------------------- - * HTTP DELETE - * ------------------------------------------------------------------------- - */ - - @Test - void givenDelete_whenPathVariableIsValidAndExistingISBN_thenResponseStatusIsNoContent() - throws Exception { - // Arrange - BookDTO bookDTO = BookDTOFakes.createOneValid(); - String isbn = bookDTO.getIsbn(); - Mockito - .when(booksServiceMock.delete(anyString())) - .thenReturn(true); // Existing - MockHttpServletRequestBuilder request = MockMvcRequestBuilders - .delete(PATH + "/{isbn}", isbn); - // Act - MockHttpServletResponse response = application - .perform(request) - .andReturn() - .getResponse(); - // Assert - verify(booksServiceMock, times(1)).delete(anyString()); - assertThat(response.getStatus()).isEqualTo(HttpStatus.NO_CONTENT.value()); - } - - @Test - void givenDelete_whenPathVariableIsValidButNonExistentISBN_thenResponseStatusIsNotFound() - throws Exception { - // Arrange - BookDTO bookDTO = BookDTOFakes.createOneValid(); - String isbn = bookDTO.getIsbn(); - Mockito - .when(booksServiceMock.delete(anyString())) - .thenReturn(false); // Non-existent - MockHttpServletRequestBuilder request = MockMvcRequestBuilders - .delete(PATH + "/{isbn}", isbn); - // Act - MockHttpServletResponse response = application - .perform(request) - .andReturn() - .getResponse(); - // Assert - verify(booksServiceMock, times(1)).delete(anyString()); - assertThat(response.getStatus()).isEqualTo(HttpStatus.NOT_FOUND.value()); - } - - @Test - void givenDelete_whenPathVariableIsInvalidISBN_thenResponseStatusIsBadRequest() - throws Exception { - // Arrange - BookDTO bookDTO = BookDTOFakes.createOneInvalid(); - String isbn = bookDTO.getIsbn(); - MockHttpServletRequestBuilder request = MockMvcRequestBuilders - .delete(PATH + "/{isbn}", isbn); - // Act - MockHttpServletResponse response = application - .perform(request) - .andReturn() - .getResponse(); - // Assert - verify(booksServiceMock, never()).delete(anyString()); - assertThat(response.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value()); - } - - /* - * ------------------------------------------------------------------------- - * HTTP GET /books/search - * ------------------------------------------------------------------------- - */ - - @Test - void givenSearchByDescription_whenRequestParamIsValidAndMatchingBooksExist_thenResponseStatusIsOKAndResultIsBooks() - throws Exception { - // Arrange - List bookDTOs = BookDTOFakes.createManyValid(); - String keyword = "Java"; - Mockito - .when(booksServiceMock.searchByDescription(anyString())) - .thenReturn(bookDTOs); - MockHttpServletRequestBuilder request = MockMvcRequestBuilders - .get(PATH + "/search") - .param("description", keyword); - // Act - MockHttpServletResponse response = application - .perform(request) - .andReturn() - .getResponse(); - response.setContentType("application/json;charset=UTF-8"); - String content = response.getContentAsString(); - List result = new ObjectMapper().readValue(content, new TypeReference>() { - }); - // Assert - verify(booksServiceMock, times(1)).searchByDescription(anyString()); - assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value()); - assertThat(result).usingRecursiveComparison().isEqualTo(bookDTOs); - } - - @Test - void givenSearchByDescription_whenRequestParamIsValidAndNoMatchingBooks_thenResponseStatusIsOKAndResultIsEmptyList() - throws Exception { - // Arrange - String keyword = "nonexistentkeyword"; - Mockito - .when(booksServiceMock.searchByDescription(anyString())) - .thenReturn(List.of()); - MockHttpServletRequestBuilder request = MockMvcRequestBuilders - .get(PATH + "/search") - .param("description", keyword); - // Act - MockHttpServletResponse response = application - .perform(request) - .andReturn() - .getResponse(); - response.setContentType("application/json;charset=UTF-8"); - String content = response.getContentAsString(); - List result = new ObjectMapper().readValue(content, new TypeReference>() { - }); - // Assert - verify(booksServiceMock, times(1)).searchByDescription(anyString()); - assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value()); - assertThat(result).isEmpty(); - } - - @Test - void givenSearchByDescription_whenRequestParamIsBlank_thenResponseStatusIsBadRequest() - throws Exception { - // Arrange - MockHttpServletRequestBuilder request = MockMvcRequestBuilders - .get(PATH + "/search") - .param("description", ""); - // Act - MockHttpServletResponse response = application - .perform(request) - .andReturn() - .getResponse(); - // Assert - verify(booksServiceMock, never()).searchByDescription(anyString()); - assertThat(response.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value()); - } - - @Test - void givenSearchByDescription_whenRequestParamIsMissing_thenResponseStatusIsBadRequest() - throws Exception { - // Arrange - MockHttpServletRequestBuilder request = MockMvcRequestBuilders - .get(PATH + "/search"); - // Act - MockHttpServletResponse response = application - .perform(request) - .andReturn() - .getResponse(); - // Assert - verify(booksServiceMock, never()).searchByDescription(anyString()); - assertThat(response.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value()); - } -} diff --git a/src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/controllers/PlayersControllerTests.java b/src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/controllers/PlayersControllerTests.java new file mode 100644 index 0000000..cc64250 --- /dev/null +++ b/src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/controllers/PlayersControllerTests.java @@ -0,0 +1,573 @@ +package ar.com.nanotaboada.java.samples.spring.boot.test.controllers; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.cache.test.autoconfigure.AutoConfigureCache; +import org.springframework.boot.test.autoconfigure.json.AutoConfigureJsonTesters; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Bean; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import ar.com.nanotaboada.java.samples.spring.boot.controllers.PlayersController; +import ar.com.nanotaboada.java.samples.spring.boot.models.PlayerDTO; +import ar.com.nanotaboada.java.samples.spring.boot.repositories.PlayersRepository; +import ar.com.nanotaboada.java.samples.spring.boot.services.PlayersService; +import ar.com.nanotaboada.java.samples.spring.boot.test.PlayerDTOFakes; + +@DisplayName("HTTP Methods on Controller") +@WebMvcTest(PlayersController.class) +@AutoConfigureCache +@AutoConfigureJsonTesters +class PlayersControllerTests { + + private static final String PATH = "/players"; + + @Autowired + private MockMvc application; + + @MockitoBean + private PlayersService playersServiceMock; + + @MockitoBean + private PlayersRepository playersRepositoryMock; + + @Autowired + private ObjectMapper objectMapper; + + @TestConfiguration + static class ObjectMapperTestConfig { + @Bean + public ObjectMapper objectMapper() { + return new ObjectMapper().findAndRegisterModules(); + } + } + + /* + * ------------------------------------------------------------------------- + * HTTP POST + * ------------------------------------------------------------------------- + */ + + /** + * Given valid player data is provided + * When POST /players is called and the service successfully creates the player + * Then response status is 201 Created and Location header points to the new resource + */ + @Test + void post_validPlayer_returnsCreated() + throws Exception { + // Arrange + PlayerDTO playerDTO = PlayerDTOFakes.createOneValid(); + PlayerDTO savedPlayer = PlayerDTOFakes.createOneValid(); + savedPlayer.setId(19L); // Simulating auto-generated ID + String body = objectMapper.writeValueAsString(playerDTO); + Mockito + .when(playersServiceMock.create(any(PlayerDTO.class))) + .thenReturn(savedPlayer); + MockHttpServletRequestBuilder request = MockMvcRequestBuilders + .post(PATH) + .content(body) + .contentType(MediaType.APPLICATION_JSON); + // Act + MockHttpServletResponse response = application + .perform(request) + .andReturn() + .getResponse(); + // Assert + verify(playersServiceMock, times(1)).create(any(PlayerDTO.class)); + assertThat(response.getStatus()).isEqualTo(HttpStatus.CREATED.value()); + assertThat(response.getHeader(HttpHeaders.LOCATION)).isNotNull(); + assertThat(response.getHeader(HttpHeaders.LOCATION)).contains(PATH + "/19"); + } + + /** + * Given invalid player data is provided (validation fails) + * When POST /players is called + * Then response status is 400 Bad Request and service is never called + */ + @Test + void post_invalidPlayer_returnsBadRequest() + throws Exception { + // Arrange + PlayerDTO playerDTO = PlayerDTOFakes.createOneInvalid(); + String body = objectMapper.writeValueAsString(playerDTO); + MockHttpServletRequestBuilder request = MockMvcRequestBuilders + .post(PATH) + .content(body) + .contentType(MediaType.APPLICATION_JSON); + // Act + MockHttpServletResponse response = application + .perform(request) + .andReturn() + .getResponse(); + // Assert + verify(playersServiceMock, never()).create(any(PlayerDTO.class)); + assertThat(response.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value()); + } + + /** + * Given a player with the same squad number already exists + * When POST /players is called and the service detects a conflict + * Then response status is 409 Conflict + */ + @Test + void post_squadNumberExists_returnsConflict() + throws Exception { + // Arrange + PlayerDTO playerDTO = PlayerDTOFakes.createOneValid(); + String body = objectMapper.writeValueAsString(playerDTO); + Mockito + .when(playersServiceMock.create(any(PlayerDTO.class))) + .thenReturn(null); // Conflict: squad number already exists + MockHttpServletRequestBuilder request = MockMvcRequestBuilders + .post(PATH) + .content(body) + .contentType(MediaType.APPLICATION_JSON); + // Act + MockHttpServletResponse response = application + .perform(request) + .andReturn() + .getResponse(); + // Assert + verify(playersServiceMock, times(1)).create(any(PlayerDTO.class)); + assertThat(response.getStatus()).isEqualTo(HttpStatus.CONFLICT.value()); + } + + /* + * ------------------------------------------------------------------------- + * HTTP GET + * ------------------------------------------------------------------------- + */ + + /** + * Given a player with ID 1 exists (Damián Martínez) + * When GET /players/1 is called and the service returns the player + * Then response status is 200 OK and the player data is returned + */ + @Test + void getById_playerExists_returnsOkWithPlayer() + throws Exception { + // Arrange + PlayerDTO playerDTO = PlayerDTOFakes.createOneForUpdate(); + Long id = 1L; + Mockito + .when(playersServiceMock.retrieveById(id)) + .thenReturn(playerDTO); + MockHttpServletRequestBuilder request = MockMvcRequestBuilders + .get(PATH + "/{id}", id); + // Act + MockHttpServletResponse response = application + .perform(request) + .andReturn() + .getResponse(); + String content = response.getContentAsString(); + PlayerDTO result = objectMapper.readValue(content, PlayerDTO.class); + // Assert + assertThat(response.getContentType()).contains("application/json"); + verify(playersServiceMock, times(1)).retrieveById(id); + assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value()); + assertThat(result).usingRecursiveComparison().isEqualTo(playerDTO); + } + + /** + * Given a player with ID 999 does not exist + * When GET /players/999 is called and the service returns null + * Then response status is 404 Not Found + */ + @Test + void getById_playerNotFound_returnsNotFound() + throws Exception { + // Arrange + Long id = 999L; + Mockito + .when(playersServiceMock.retrieveById(anyLong())) + .thenReturn(null); + MockHttpServletRequestBuilder request = MockMvcRequestBuilders + .get(PATH + "/{id}", id); + // Act + MockHttpServletResponse response = application + .perform(request) + .andReturn() + .getResponse(); + // Assert + verify(playersServiceMock, times(1)).retrieveById(anyLong()); + assertThat(response.getStatus()).isEqualTo(HttpStatus.NOT_FOUND.value()); + } + + /** + * Given 26 players exist in the database + * When GET /players is called and the service returns all players + * Then response status is 200 OK and all 26 players are returned + */ + @Test + void getAll_playersExist_returnsOkWithAllPlayers() + throws Exception { + // Arrange + List playerDTOs = PlayerDTOFakes.createAll(); + Mockito + .when(playersServiceMock.retrieveAll()) + .thenReturn(playerDTOs); + MockHttpServletRequestBuilder request = MockMvcRequestBuilders + .get(PATH); + // Act + MockHttpServletResponse response = application + .perform(request) + .andReturn() + .getResponse(); + String content = response.getContentAsString(); + List result = objectMapper.readValue(content, new TypeReference>() { + }); + // Assert + assertThat(response.getContentType()).contains("application/json"); + verify(playersServiceMock, times(1)).retrieveAll(); + assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value()); + assertThat(result).hasSize(26); + assertThat(result).usingRecursiveComparison().isEqualTo(playerDTOs); + } + + /** + * Given 7 players exist in Premier League + * When GET /players/search/league/Premier is called and the service returns matching players + * Then response status is 200 OK and 7 players are returned + */ + @Test + void searchByLeague_matchingPlayersExist_returnsOkWithList() + throws Exception { + // Arrange + List playerDTOs = PlayerDTOFakes.createAll().stream() + .filter(p -> p.getLeague().contains("Premier")) + .toList(); + Mockito + .when(playersServiceMock.searchByLeague(any())) + .thenReturn(playerDTOs); + MockHttpServletRequestBuilder request = MockMvcRequestBuilders + .get(PATH + "/search/league/{league}", "Premier"); + // Act + MockHttpServletResponse response = application + .perform(request) + .andReturn() + .getResponse(); + String content = response.getContentAsString(); + List result = objectMapper.readValue(content, new TypeReference>() { + }); + // Assert + assertThat(response.getContentType()).contains("application/json"); + verify(playersServiceMock, times(1)).searchByLeague(any()); + assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value()); + assertThat(result).hasSize(7); + assertThat(result).usingRecursiveComparison().isEqualTo(playerDTOs); + } + + /** + * Given no players exist in "NonexistentLeague" + * When GET /players/search/league/NonexistentLeague is called and the service returns an empty list + * Then response status is 200 OK and an empty list is returned + */ + @Test + void searchByLeague_noMatches_returnsOkWithEmptyList() + throws Exception { + // Arrange + Mockito + .when(playersServiceMock.searchByLeague(any())) + .thenReturn(List.of()); + MockHttpServletRequestBuilder request = MockMvcRequestBuilders + .get(PATH + "/search/league/{league}", "NonexistentLeague"); + // Act + MockHttpServletResponse response = application + .perform(request) + .andReturn() + .getResponse(); + String content = response.getContentAsString(); + List result = objectMapper.readValue(content, new TypeReference>() { + }); + // Assert + assertThat(response.getContentType()).contains("application/json"); + verify(playersServiceMock, times(1)).searchByLeague(any()); + assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value()); + assertThat(result).isEmpty(); + } + + /** + * Given a player with squad number 10 exists (Messi) + * When GET /players/search/squadnumber/10 is called and the service returns the player + * Then response status is 200 OK and the player data is returned + */ + @Test + void searchBySquadNumber_playerExists_returnsOkWithPlayer() + throws Exception { + // Arrange + PlayerDTO playerDTO = PlayerDTOFakes.createAll().stream() + .filter(player -> player.getSquadNumber() == 10) + .findFirst() + .orElseThrow(); + Mockito + .when(playersServiceMock.searchBySquadNumber(10)) + .thenReturn(playerDTO); + MockHttpServletRequestBuilder request = MockMvcRequestBuilders + .get(PATH + "/search/squadnumber/{squadNumber}", 10); + // Act + MockHttpServletResponse response = application + .perform(request) + .andReturn() + .getResponse(); + String content = response.getContentAsString(); + PlayerDTO result = objectMapper.readValue(content, PlayerDTO.class); + // Assert + assertThat(response.getContentType()).contains("application/json"); + verify(playersServiceMock, times(1)).searchBySquadNumber(10); + assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value()); + assertThat(result).usingRecursiveComparison().isEqualTo(playerDTO); + assertThat(result.getSquadNumber()).isEqualTo(10); + } + + /** + * Given no player with squad number 99 exists + * When GET /players/search/squadnumber/99 is called and the service returns null + * Then response status is 404 Not Found + */ + @Test + void searchBySquadNumber_playerNotFound_returnsNotFound() + throws Exception { + // Arrange + Mockito + .when(playersServiceMock.searchBySquadNumber(99)) + .thenReturn(null); + MockHttpServletRequestBuilder request = MockMvcRequestBuilders + .get(PATH + "/search/squadnumber/{squadNumber}", 99); + // Act + MockHttpServletResponse response = application + .perform(request) + .andReturn() + .getResponse(); + // Assert + verify(playersServiceMock, times(1)).searchBySquadNumber(99); + assertThat(response.getStatus()).isEqualTo(HttpStatus.NOT_FOUND.value()); + } + + /* + * ------------------------------------------------------------------------- + * HTTP PUT + * ------------------------------------------------------------------------- + */ + + /** + * Given a player exists and valid update data is provided + * When PUT /players/{id} is called and the service successfully updates the player + * Then response status is 204 No Content + */ + @Test + void put_playerExists_returnsNoContent() + throws Exception { + // Arrange + PlayerDTO playerDTO = PlayerDTOFakes.createOneValid(); + playerDTO.setId(1L); // Set ID for update operation + Long id = playerDTO.getId(); + String body = objectMapper.writeValueAsString(playerDTO); + Mockito + .when(playersServiceMock.update(any(PlayerDTO.class))) + .thenReturn(true); + MockHttpServletRequestBuilder request = MockMvcRequestBuilders + .put(PATH + "/{id}", id) + .content(body) + .contentType(MediaType.APPLICATION_JSON); + // Act + MockHttpServletResponse response = application + .perform(request) + .andReturn() + .getResponse(); + // Assert + verify(playersServiceMock, times(1)).update(any(PlayerDTO.class)); + assertThat(response.getStatus()).isEqualTo(HttpStatus.NO_CONTENT.value()); + } + + /** + * Given a player with the provided ID does not exist + * When PUT /players/{id} is called and the service returns false + * Then response status is 404 Not Found + */ + @Test + void put_playerNotFound_returnsNotFound() + throws Exception { + // Arrange + PlayerDTO playerDTO = PlayerDTOFakes.createOneValid(); + playerDTO.setId(999L); // Set ID for update operation + Long id = playerDTO.getId(); + String body = objectMapper.writeValueAsString(playerDTO); + Mockito + .when(playersServiceMock.update(any(PlayerDTO.class))) + .thenReturn(false); + MockHttpServletRequestBuilder request = MockMvcRequestBuilders + .put(PATH + "/{id}", id) + .content(body) + .contentType(MediaType.APPLICATION_JSON); + // Act + MockHttpServletResponse response = application + .perform(request) + .andReturn() + .getResponse(); + // Assert + verify(playersServiceMock, times(1)).update(any(PlayerDTO.class)); + assertThat(response.getStatus()).isEqualTo(HttpStatus.NOT_FOUND.value()); + } + + /** + * Given invalid player data is provided (validation fails) + * When PUT /players/{id} is called + * Then response status is 400 Bad Request and service is never called + */ + @Test + void put_invalidPlayer_returnsBadRequest() + throws Exception { + // Arrange + PlayerDTO playerDTO = PlayerDTOFakes.createOneInvalid(); + Long id = playerDTO.getId(); + String body = objectMapper.writeValueAsString(playerDTO); + MockHttpServletRequestBuilder request = MockMvcRequestBuilders + .put(PATH + "/{id}", id) + .content(body) + .contentType(MediaType.APPLICATION_JSON); + // Act + MockHttpServletResponse response = application + .perform(request) + .andReturn() + .getResponse(); + // Assert + verify(playersServiceMock, never()).update(any(PlayerDTO.class)); + assertThat(response.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value()); + } + + /** + * Given the path ID does not match the body ID + * When PUT /players/{id} is called + * Then response status is 400 Bad Request and service is never called + */ + @Test + void put_idMismatch_returnsBadRequest() + throws Exception { + // Arrange + PlayerDTO playerDTO = PlayerDTOFakes.createOneValid(); + playerDTO.setId(999L); // Body has different ID + Long pathId = 1L; // Path has different ID + String body = objectMapper.writeValueAsString(playerDTO); + MockHttpServletRequestBuilder request = MockMvcRequestBuilders + .put(PATH + "/{id}", pathId) + .content(body) + .contentType(MediaType.APPLICATION_JSON); + // Act + MockHttpServletResponse response = application + .perform(request) + .andReturn() + .getResponse(); + // Assert + verify(playersServiceMock, never()).update(any(PlayerDTO.class)); + assertThat(response.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value()); + } + + /** + * Given the body ID is null (ID only in path) + * When PUT /players/{id} is called + * Then the ID is set from the path and the update proceeds normally + */ + @Test + void put_nullBodyId_setsIdFromPath() + throws Exception { + // Arrange + PlayerDTO playerDTO = PlayerDTOFakes.createOneValid(); + playerDTO.setId(null); // Body has null ID + Long pathId = 1L; + String body = objectMapper.writeValueAsString(playerDTO); + Mockito + .when(playersServiceMock.update(any(PlayerDTO.class))) + .thenReturn(true); + MockHttpServletRequestBuilder request = MockMvcRequestBuilders + .put(PATH + "/{id}", pathId) + .content(body) + .contentType(MediaType.APPLICATION_JSON); + // Act + MockHttpServletResponse response = application + .perform(request) + .andReturn() + .getResponse(); + // Assert + verify(playersServiceMock, times(1)).update(any(PlayerDTO.class)); + assertThat(response.getStatus()).isEqualTo(HttpStatus.NO_CONTENT.value()); + } + + /* + * ------------------------------------------------------------------------- + * HTTP DELETE + * ------------------------------------------------------------------------- + */ + + /** + * Given a player with ID 1 exists + * When DELETE /players/1 is called and the service successfully deletes the player + * Then response status is 204 No Content + */ + @Test + void delete_playerExists_returnsNoContent() + throws Exception { + // Arrange + Long id = 1L; + Mockito + .when(playersServiceMock.delete(anyLong())) + .thenReturn(true); + MockHttpServletRequestBuilder request = MockMvcRequestBuilders + .delete(PATH + "/{id}", id); + // Act + MockHttpServletResponse response = application + .perform(request) + .andReturn() + .getResponse(); + // Assert + verify(playersServiceMock, times(1)).delete(anyLong()); + assertThat(response.getStatus()).isEqualTo(HttpStatus.NO_CONTENT.value()); + } + + /** + * Given a player with ID 999 does not exist + * When DELETE /players/999 is called and the service returns false + * Then response status is 404 Not Found + */ + @Test + void delete_playerNotFound_returnsNotFound() + throws Exception { + // Arrange + Long id = 999L; + Mockito + .when(playersServiceMock.delete(anyLong())) + .thenReturn(false); + MockHttpServletRequestBuilder request = MockMvcRequestBuilders + .delete(PATH + "/{id}", id); + // Act + MockHttpServletResponse response = application + .perform(request) + .andReturn() + .getResponse(); + // Assert + verify(playersServiceMock, times(1)).delete(anyLong()); + assertThat(response.getStatus()).isEqualTo(HttpStatus.NOT_FOUND.value()); + } +} diff --git a/src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/repositories/BooksRepositoryTests.java b/src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/repositories/BooksRepositoryTests.java deleted file mode 100644 index 8237184..0000000 --- a/src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/repositories/BooksRepositoryTests.java +++ /dev/null @@ -1,86 +0,0 @@ -package ar.com.nanotaboada.java.samples.spring.boot.test.repositories; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.List; -import java.util.Optional; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.cache.test.autoconfigure.AutoConfigureCache; -import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; - -import ar.com.nanotaboada.java.samples.spring.boot.models.Book; -import ar.com.nanotaboada.java.samples.spring.boot.repositories.BooksRepository; -import ar.com.nanotaboada.java.samples.spring.boot.test.BookFakes; - -@DisplayName("Derived Query Methods on Repository") -@DataJpaTest -@AutoConfigureCache -class BooksRepositoryTests { - - @Autowired - private BooksRepository repository; - - @Test - void givenFindByIsbn_whenISBNAlreadyExists_thenShouldReturnExistingBook() { - // Arrange - Book existing = BookFakes.createOneValid(); - repository.save(existing); // Exists - // Act - Optional actual = repository.findByIsbn(existing.getIsbn()); - // Assert - assertTrue(actual.isPresent()); - assertThat(actual.get()).usingRecursiveComparison().isEqualTo(existing); - } - - @Test - void givenFindByIsbn_whenISBNDoesNotExist_thenShouldReturnEmptyOptional() { - // Arrange - Book expected = BookFakes.createOneValid(); - // Act - Optional actual = repository.findByIsbn(expected.getIsbn()); - // Assert - assertThat(actual).isEmpty(); - } - - @Test - void givenFindByDescriptionContainingIgnoreCase_whenKeywordMatchesDescription_thenShouldReturnMatchingBooks() { - // Arrange - List books = BookFakes.createManyValid(); - for (Book book : books) { - repository.save(book); - } - // Act - List actual = repository.findByDescriptionContainingIgnoreCase("Java"); - // Assert - assertThat(actual).isNotEmpty(); - assertThat(actual).allMatch(book -> book.getDescription().toLowerCase().contains("java")); - } - - @Test - void givenFindByDescriptionContainingIgnoreCase_whenKeywordDoesNotMatch_thenShouldReturnEmptyList() { - // Arrange - Book book = BookFakes.createOneValid(); - repository.save(book); - // Act - List actual = repository.findByDescriptionContainingIgnoreCase("nonexistentkeyword"); - // Assert - assertThat(actual).isEmpty(); - } - - @Test - void givenFindByDescriptionContainingIgnoreCase_whenKeywordIsDifferentCase_thenShouldStillMatch() { - // Arrange - Book book = BookFakes.createOneValid(); - book.setDescription("This book covers Advanced PRAGMATISM topics"); - repository.save(book); - // Act - List actual = repository.findByDescriptionContainingIgnoreCase("pragmatism"); - // Assert - assertThat(actual).hasSize(1); - assertThat(actual.get(0).getIsbn()).isEqualTo(book.getIsbn()); - } -} diff --git a/src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/repositories/PlayersRepositoryTests.java b/src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/repositories/PlayersRepositoryTests.java new file mode 100644 index 0000000..529a6cb --- /dev/null +++ b/src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/repositories/PlayersRepositoryTests.java @@ -0,0 +1,114 @@ +package ar.com.nanotaboada.java.samples.spring.boot.test.repositories; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.cache.test.autoconfigure.AutoConfigureCache; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; +import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase.Replace; + +import ar.com.nanotaboada.java.samples.spring.boot.models.Player; +import ar.com.nanotaboada.java.samples.spring.boot.repositories.PlayersRepository; +import ar.com.nanotaboada.java.samples.spring.boot.test.PlayerFakes; + +@DisplayName("Derived Query Methods on Repository") +@DataJpaTest +@AutoConfigureTestDatabase(replace = Replace.NONE) +@AutoConfigureCache +class PlayersRepositoryTests { + + @Autowired + private PlayersRepository repository; + + /** + * Given Leandro Paredes is created and saved to the database with an auto-generated ID + * When findById() is called with that ID + * Then the player is returned + */ + @Test + void findById_playerExists_returnsPlayer() { + // Arrange + Player leandro = PlayerFakes.createOneValid(); + Player saved = repository.save(leandro); + // Act + Optional actual = repository.findById(saved.getId()); + // Assert + assertThat(actual).isPresent(); + assertThat(actual.get()).usingRecursiveComparison().isEqualTo(saved); + } + + /** + * Given the database does not contain a player with ID 999 + * When findById(999) is called + * Then an empty Optional is returned + */ + @Test + void findById_playerNotFound_returnsEmpty() { + // Act + Optional actual = repository.findById(999L); + // Assert + assertThat(actual).isEmpty(); + } + + /** + * Given the database contains 7 players in Premier League (pre-seeded in-memory database) + * When findByLeagueContainingIgnoreCase("Premier") is called + * Then a list of matching players is returned + */ + @Test + void findByLeague_matchingPlayersExist_returnsList() { + // Act + List actual = repository.findByLeagueContainingIgnoreCase("Premier"); + // Assert + assertThat(actual).isNotEmpty() + .allMatch(player -> player.getLeague().toLowerCase().contains("premier")); + } + + /** + * Given the database does not contain players in "nonexistentleague" + * When findByLeagueContainingIgnoreCase("nonexistentleague") is called + * Then an empty list is returned + */ + @Test + void findByLeague_noMatches_returnsEmptyList() { + // Act + List actual = repository.findByLeagueContainingIgnoreCase("nonexistentleague"); + // Assert + assertThat(actual).isEmpty(); + } + + /** + * Given the database contains Messi with squad number 10 (pre-seeded at ID 10) + * When findBySquadNumber(10) is called + * Then Messi's player record is returned + */ + @Test + void findBySquadNumber_playerExists_returnsPlayer() { + // Act + Optional actual = repository.findBySquadNumber(10); + // Assert + assertThat(actual).isPresent(); + assertThat(actual.get()) + .extracting(Player::getSquadNumber, Player::getLastName) + .containsExactly(10, "Messi"); + } + + /** + * Given the database does not contain a player with squad number 99 + * When findBySquadNumber(99) is called + * Then an empty Optional is returned + */ + @Test + void findBySquadNumber_playerNotFound_returnsEmpty() { + // Act + Optional actual = repository.findBySquadNumber(99); + // Assert + assertThat(actual).isEmpty(); + } +} diff --git a/src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/services/BooksServiceTests.java b/src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/services/BooksServiceTests.java deleted file mode 100644 index 930e213..0000000 --- a/src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/services/BooksServiceTests.java +++ /dev/null @@ -1,265 +0,0 @@ -package ar.com.nanotaboada.java.samples.spring.boot.test.services; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; - -import java.util.List; -import java.util.Optional; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.Mockito; -import org.mockito.junit.jupiter.MockitoExtension; -import org.modelmapper.ModelMapper; - -import ar.com.nanotaboada.java.samples.spring.boot.models.Book; -import ar.com.nanotaboada.java.samples.spring.boot.models.BookDTO; -import ar.com.nanotaboada.java.samples.spring.boot.repositories.BooksRepository; -import ar.com.nanotaboada.java.samples.spring.boot.services.BooksService; -import ar.com.nanotaboada.java.samples.spring.boot.test.BookDTOFakes; -import ar.com.nanotaboada.java.samples.spring.boot.test.BookFakes; - -@DisplayName("CRUD Operations on Service") -@ExtendWith(MockitoExtension.class) -class BooksServiceTests { - - @Mock - private BooksRepository booksRepositoryMock; - - @Mock - private ModelMapper modelMapperMock; - - @InjectMocks - private BooksService booksService; - - /* - * ------------------------------------------------------------------------- - * Create - * ------------------------------------------------------------------------- - */ - - @Test - void givenCreate_whenRepositoryExistsByIdReturnsTrue_thenRepositoryNeverSaveBookAndResultIsFalse() { - // Arrange - BookDTO bookDTO = BookDTOFakes.createOneInvalid(); - Mockito - .when(booksRepositoryMock.existsById(anyString())) - .thenReturn(true); - // Act - boolean result = booksService.create(bookDTO); - // Assert - verify(booksRepositoryMock, never()).save(any(Book.class)); - verify(modelMapperMock, never()).map(bookDTO, Book.class); - assertThat(result).isFalse(); - } - - @Test - void givenCreate_whenRepositoryExistsByIdReturnsFalse_thenRepositorySaveBookAndResultIsTrue() { - // Arrange - Book book = BookFakes.createOneValid(); - BookDTO bookDTO = BookDTOFakes.createOneValid(); - Mockito - .when(booksRepositoryMock.existsById(anyString())) - .thenReturn(false); - Mockito - .when(modelMapperMock.map(bookDTO, Book.class)) - .thenReturn(book); - // Act - boolean result = booksService.create(bookDTO); - // Assert - verify(booksRepositoryMock, times(1)).save(any(Book.class)); - verify(modelMapperMock, times(1)).map(bookDTO, Book.class); - assertThat(result).isTrue(); - } - - /* - * ------------------------------------------------------------------------- - * Retrieve - * ------------------------------------------------------------------------- - */ - - @Test - void givenRetrieveByIsbn_whenRepositoryFindByIdReturnsBook_thenResultIsEqualToBook() { - // Arrange - Book book = BookFakes.createOneValid(); - BookDTO bookDTO = BookDTOFakes.createOneValid(); - Mockito - .when(booksRepositoryMock.findByIsbn(anyString())) - .thenReturn(Optional.of(book)); - Mockito - .when(modelMapperMock.map(book, BookDTO.class)) - .thenReturn(bookDTO); - // Act - BookDTO result = booksService.retrieveByIsbn(bookDTO.getIsbn()); - // Assert - verify(booksRepositoryMock, times(1)).findByIsbn(anyString()); - verify(modelMapperMock, times(1)).map(book, BookDTO.class); - assertThat(result).usingRecursiveComparison().isEqualTo(bookDTO); - } - - @Test - void givenRetrieveByIsbn_whenRepositoryFindByIdReturnsEmpty_thenResultIsNull() { - // Arrange - String isbn = "9781484242216"; - Mockito - .when(booksRepositoryMock.findByIsbn(anyString())) - .thenReturn(Optional.empty()); - // Act - BookDTO result = booksService.retrieveByIsbn(isbn); - // Assert - verify(booksRepositoryMock, times(1)).findByIsbn(anyString()); - verify(modelMapperMock, never()).map(any(Book.class), any(BookDTO.class)); - assertThat(result).isNull(); - } - - @Test - void givenRetrieveAll_whenRepositoryFindAllReturnsBooks_thenResultIsEqualToBooks() { - // Arrange - List books = BookFakes.createManyValid(); - List bookDTOs = BookDTOFakes.createManyValid(); - Mockito - .when(booksRepositoryMock.findAll()) - .thenReturn(books); - for (int index = 0; index < books.size(); index++) { - Mockito - .when(modelMapperMock.map(books.get(index), BookDTO.class)) - .thenReturn(bookDTOs.get(index)); - } - // Act - List result = booksService.retrieveAll(); - // Assert - verify(booksRepositoryMock, times(1)).findAll(); - for (Book book : books) { - verify(modelMapperMock, times(1)).map(book, BookDTO.class); - } - assertThat(result).usingRecursiveComparison().isEqualTo(bookDTOs); - } - - /* - * ------------------------------------------------------------------------- - * Update - * ------------------------------------------------------------------------- - */ - - @Test - void givenUpdate_whenRepositoryExistsByIdReturnsTrue_thenRepositorySaveBookAndResultIsTrue() { - // Arrange - Book book = BookFakes.createOneValid(); - BookDTO bookDTO = BookDTOFakes.createOneValid(); - - Mockito - .when(booksRepositoryMock.existsById(anyString())) - .thenReturn(true); - Mockito - .when(modelMapperMock.map(bookDTO, Book.class)) - .thenReturn(book); - // Act - boolean result = booksService.update(bookDTO); - // Assert - verify(booksRepositoryMock, times(1)).save(any(Book.class)); - verify(modelMapperMock, times(1)).map(bookDTO, Book.class); - assertThat(result).isTrue(); - } - - @Test - void givenUpdate_whenRepositoryExistsByIdReturnsFalse_thenRepositoryNeverSaveBookAndResultIsFalse() { - // Arrange - BookDTO bookDTO = BookDTOFakes.createOneValid(); - Mockito - .when(booksRepositoryMock.existsById(anyString())) - .thenReturn(false); - // Act - boolean result = booksService.update(bookDTO); - // Assert - verify(booksRepositoryMock, never()).save(any(Book.class)); - verify(modelMapperMock, never()).map(bookDTO, Book.class); - assertThat(result).isFalse(); - } - - /* - * ------------------------------------------------------------------------- - * Delete - * ------------------------------------------------------------------------- - */ - - @Test - void givenDelete_whenRepositoryExistsByIdReturnsFalse_thenRepositoryNeverDeleteBookAndResultIsFalse() { - // Arrange - BookDTO bookDTO = BookDTOFakes.createOneInvalid(); - Mockito - .when(booksRepositoryMock.existsById(anyString())) - .thenReturn(false); - // Act - boolean result = booksService.delete(bookDTO.getIsbn()); - // Assert - verify(booksRepositoryMock, never()).deleteById(anyString()); - verify(modelMapperMock, never()).map(bookDTO, Book.class); - assertThat(result).isFalse(); - } - - @Test - void givenDelete_whenRepositoryExistsByIdReturnsTrue_thenRepositoryDeleteBookAndResultIsTrue() { - // Arrange - BookDTO bookDTO = BookDTOFakes.createOneValid(); - Mockito - .when(booksRepositoryMock.existsById(anyString())) - .thenReturn(true); - // Act - boolean result = booksService.delete(bookDTO.getIsbn()); - // Assert - verify(booksRepositoryMock, times(1)).deleteById(anyString()); - verify(modelMapperMock, never()).map(bookDTO, Book.class); - assertThat(result).isTrue(); - } - - /* - * ------------------------------------------------------------------------- - * Search - * ------------------------------------------------------------------------- - */ - - @Test - void givenSearchByDescription_whenRepositoryReturnsMatchingBooks_thenResultIsEqualToBooks() { - // Arrange - List books = BookFakes.createManyValid(); - List bookDTOs = BookDTOFakes.createManyValid(); - String keyword = "Java"; - Mockito - .when(booksRepositoryMock.findByDescriptionContainingIgnoreCase(keyword)) - .thenReturn(books); - for (int index = 0; index < books.size(); index++) { - Mockito - .when(modelMapperMock.map(books.get(index), BookDTO.class)) - .thenReturn(bookDTOs.get(index)); - } - // Act - List result = booksService.searchByDescription(keyword); - // Assert - verify(booksRepositoryMock, times(1)).findByDescriptionContainingIgnoreCase(keyword); - for (Book book : books) { - verify(modelMapperMock, times(1)).map(book, BookDTO.class); - } - assertThat(result).usingRecursiveComparison().isEqualTo(bookDTOs); - } - - @Test - void givenSearchByDescription_whenRepositoryReturnsEmptyList_thenResultIsEmptyList() { - // Arrange - String keyword = "nonexistentkeyword"; - Mockito - .when(booksRepositoryMock.findByDescriptionContainingIgnoreCase(keyword)) - .thenReturn(List.of()); - // Act - List result = booksService.searchByDescription(keyword); - // Assert - verify(booksRepositoryMock, times(1)).findByDescriptionContainingIgnoreCase(keyword); - assertThat(result).isEmpty(); - } -} diff --git a/src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/services/PlayersServiceTests.java b/src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/services/PlayersServiceTests.java new file mode 100644 index 0000000..9a40917 --- /dev/null +++ b/src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/services/PlayersServiceTests.java @@ -0,0 +1,427 @@ +package ar.com.nanotaboada.java.samples.spring.boot.test.services; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.modelmapper.ModelMapper; +import org.springframework.dao.DataIntegrityViolationException; + +import ar.com.nanotaboada.java.samples.spring.boot.models.Player; +import ar.com.nanotaboada.java.samples.spring.boot.models.PlayerDTO; +import ar.com.nanotaboada.java.samples.spring.boot.repositories.PlayersRepository; +import ar.com.nanotaboada.java.samples.spring.boot.services.PlayersService; +import ar.com.nanotaboada.java.samples.spring.boot.test.PlayerDTOFakes; +import ar.com.nanotaboada.java.samples.spring.boot.test.PlayerFakes; + +@DisplayName("CRUD Operations on Service") +@ExtendWith(MockitoExtension.class) +class PlayersServiceTests { + + @Mock + private PlayersRepository playersRepositoryMock; + + @Mock + private ModelMapper modelMapperMock; + + @InjectMocks + private PlayersService playersService; + + /* + * ----------------------------------------------------------------------------------------------------------------------- + * Create + * ----------------------------------------------------------------------------------------------------------------------- + */ + + /** + * Given the repository does not contain a player with the same squad number + * When create() is called with valid player data + * Then the player is saved and a PlayerDTO is returned + */ + @Test + void create_noConflict_returnsPlayerDTO() { + // Arrange + Player player = PlayerFakes.createOneValid(); + PlayerDTO playerDTO = PlayerDTOFakes.createOneValid(); + Mockito + .when(playersRepositoryMock.findBySquadNumber(playerDTO.getSquadNumber())) + .thenReturn(Optional.empty()); // No conflict + Mockito + .when(modelMapperMock.map(playerDTO, Player.class)) + .thenReturn(player); + Mockito + .when(playersRepositoryMock.save(any(Player.class))) + .thenReturn(player); + Mockito + .when(modelMapperMock.map(player, PlayerDTO.class)) + .thenReturn(playerDTO); + // Act + PlayerDTO result = playersService.create(playerDTO); + // Assert + verify(playersRepositoryMock, times(1)).findBySquadNumber(playerDTO.getSquadNumber()); + verify(playersRepositoryMock, times(1)).save(any(Player.class)); + verify(modelMapperMock, times(1)).map(playerDTO, Player.class); + verify(modelMapperMock, times(1)).map(player, PlayerDTO.class); + assertThat(result).isEqualTo(playerDTO); + } + + /** + * Given the repository finds an existing player with the same squad number + * When create() is called + * Then null is returned (conflict detected) + */ + @Test + void create_squadNumberExists_returnsNull() { + // Arrange + PlayerDTO playerDTO = PlayerDTOFakes.createOneValid(); + Player existingPlayer = PlayerFakes.createAll().stream() + .filter(player -> player.getSquadNumber() == 10) + .findFirst() + .orElseThrow(); + Mockito + .when(playersRepositoryMock.findBySquadNumber(playerDTO.getSquadNumber())) + .thenReturn(Optional.of(existingPlayer)); + // Act + PlayerDTO result = playersService.create(playerDTO); + // Assert + verify(playersRepositoryMock, times(1)).findBySquadNumber(playerDTO.getSquadNumber()); + verify(playersRepositoryMock, never()).save(any(Player.class)); + assertThat(result).isNull(); + } + + /** + * Given a race condition occurs where another request creates the same squad number + * When create() is called and save() throws DataIntegrityViolationException + * Then null is returned (conflict detected via exception) + */ + @Test + void create_raceConditionOnSave_returnsNull() { + // Arrange + PlayerDTO playerDTO = PlayerDTOFakes.createOneValid(); + Player player = PlayerFakes.createOneValid(); + Mockito + .when(playersRepositoryMock.findBySquadNumber(playerDTO.getSquadNumber())) + .thenReturn(Optional.empty()); // No conflict initially + Mockito + .when(modelMapperMock.map(playerDTO, Player.class)) + .thenReturn(player); + Mockito + .when(playersRepositoryMock.save(any(Player.class))) + .thenThrow(new DataIntegrityViolationException("Unique constraint violation")); + // Act + PlayerDTO result = playersService.create(playerDTO); + // Assert + verify(playersRepositoryMock, times(1)).findBySquadNumber(playerDTO.getSquadNumber()); + verify(playersRepositoryMock, times(1)).save(any(Player.class)); + assertThat(result).isNull(); + } + + /* + * ----------------------------------------------------------------------------------------------------------------------- + * Retrieve + * ----------------------------------------------------------------------------------------------------------------------- + */ + + /** + * Given the repository finds an existing player with ID 1 (Damián Martínez) + * When retrieveById(1) is called + * Then the corresponding PlayerDTO is returned + */ + @Test + void retrieveById_playerExists_returnsPlayerDTO() { + // Arrange + Player player = PlayerFakes.createOneForUpdate(); + PlayerDTO playerDTO = PlayerDTOFakes.createOneForUpdate(); + Mockito + .when(playersRepositoryMock.findById(1L)) + .thenReturn(Optional.of(player)); + Mockito + .when(modelMapperMock.map(player, PlayerDTO.class)) + .thenReturn(playerDTO); + // Act + PlayerDTO result = playersService.retrieveById(1L); + // Assert + verify(playersRepositoryMock, times(1)).findById(1L); + verify(modelMapperMock, times(1)).map(player, PlayerDTO.class); + assertThat(result).isEqualTo(playerDTO); + } + + /** + * Given the repository does not find a player with ID 999 + * When retrieveById(999) is called + * Then null is returned + */ + @Test + void retrieveById_playerNotFound_returnsNull() { + // Arrange + Mockito + .when(playersRepositoryMock.findById(anyLong())) + .thenReturn(Optional.empty()); + // Act + PlayerDTO result = playersService.retrieveById(999L); + // Assert + verify(playersRepositoryMock, times(1)).findById(anyLong()); + verify(modelMapperMock, never()).map(any(Player.class), any()); + assertThat(result).isNull(); + } + + /** + * Given the repository returns all 26 players + * When retrieveAll() is called + * Then a list of 26 PlayerDTOs is returned + */ + @Test + void retrieveAll_playersExist_returnsListOfPlayerDTOs() { + // Arrange + List players = PlayerFakes.createAll(); + List playerDTOs = PlayerDTOFakes.createAll(); + Mockito + .when(playersRepositoryMock.findAll()) + .thenReturn(players); + // Mock modelMapper to convert each player correctly + for (int i = 0; i < players.size(); i++) { + Mockito + .when(modelMapperMock.map(players.get(i), PlayerDTO.class)) + .thenReturn(playerDTOs.get(i)); + } + // Act + List result = playersService.retrieveAll(); + // Assert + verify(playersRepositoryMock, times(1)).findAll(); + assertThat(result).hasSize(26); + } + + /* + * ----------------------------------------------------------------------------------------------------------------------- + * Search + * ----------------------------------------------------------------------------------------------------------------------- + */ + + /** + * Given the repository returns 7 players matching "Premier" league + * When searchByLeague("Premier") is called + * Then a list of 7 PlayerDTOs is returned + */ + @Test + void searchByLeague_matchingPlayersExist_returnsListOfPlayerDTOs() { + // Arrange + List players = PlayerFakes.createAll().stream() + .filter(p -> p.getLeague().contains("Premier")) + .toList(); + List playerDTOs = PlayerDTOFakes.createAll().stream() + .filter(p -> p.getLeague().contains("Premier")) + .toList(); + Mockito + .when(playersRepositoryMock.findByLeagueContainingIgnoreCase(any())) + .thenReturn(players); + // Mock modelMapper to convert each player correctly + for (int i = 0; i < players.size(); i++) { + Mockito + .when(modelMapperMock.map(players.get(i), PlayerDTO.class)) + .thenReturn(playerDTOs.get(i)); + } + // Act + List result = playersService.searchByLeague("Premier"); + // Assert + verify(playersRepositoryMock, times(1)).findByLeagueContainingIgnoreCase(any()); + assertThat(result).hasSize(7); + } + + /** + * Given the repository returns an empty list for "NonexistentLeague" + * When searchByLeague("NonexistentLeague") is called + * Then an empty list is returned + */ + @Test + void searchByLeague_noMatches_returnsEmptyList() { + // Arrange + Mockito + .when(playersRepositoryMock.findByLeagueContainingIgnoreCase(any())) + .thenReturn(List.of()); + // Act + List result = playersService.searchByLeague("NonexistentLeague"); + // Assert + verify(playersRepositoryMock, times(1)).findByLeagueContainingIgnoreCase(any()); + verify(modelMapperMock, never()).map(any(Player.class), any()); + assertThat(result).isEmpty(); + } + + /** + * Given the repository finds Messi with squad number 10 + * When searchBySquadNumber(10) is called + * Then the corresponding PlayerDTO is returned + */ + @Test + void searchBySquadNumber_playerExists_returnsPlayerDTO() { + // Arrange + Player player = PlayerFakes.createAll().stream() + .filter(p -> p.getSquadNumber() == 10) + .findFirst() + .orElseThrow(); + PlayerDTO playerDTO = PlayerDTOFakes.createAll().stream() + .filter(p -> p.getSquadNumber() == 10) + .findFirst() + .orElseThrow(); + Mockito + .when(playersRepositoryMock.findBySquadNumber(10)) + .thenReturn(Optional.of(player)); + Mockito + .when(modelMapperMock.map(player, PlayerDTO.class)) + .thenReturn(playerDTO); + // Act + PlayerDTO result = playersService.searchBySquadNumber(10); + // Assert + verify(playersRepositoryMock, times(1)).findBySquadNumber(10); + verify(modelMapperMock, times(1)).map(player, PlayerDTO.class); + assertThat(result).isEqualTo(playerDTO); + assertThat(result.getSquadNumber()).isEqualTo(10); + } + + /** + * Given the repository does not find a player with squad number 99 + * When searchBySquadNumber(99) is called + * Then null is returned + */ + @Test + void searchBySquadNumber_playerNotFound_returnsNull() { + // Arrange + Mockito + .when(playersRepositoryMock.findBySquadNumber(99)) + .thenReturn(Optional.empty()); + // Act + PlayerDTO result = playersService.searchBySquadNumber(99); + // Assert + verify(playersRepositoryMock, times(1)).findBySquadNumber(99); + verify(modelMapperMock, never()).map(any(Player.class), any()); + assertThat(result).isNull(); + } + + /* + * ----------------------------------------------------------------------------------------------------------------------- + * Update + * ----------------------------------------------------------------------------------------------------------------------- + */ + + /** + * Given the repository confirms player with ID 1 exists + * When update() is called with updated data (Damián→Emiliano Martínez) + * Then the player is saved and true is returned + */ + @Test + void update_playerExists_savesAndReturnsTrue() { + // Arrange + Player playerUpdated = PlayerFakes.createOneUpdated(); + PlayerDTO playerDTO = PlayerDTOFakes.createOneUpdated(); + Mockito + .when(playersRepositoryMock.existsById(1L)) + .thenReturn(true); + Mockito + .when(modelMapperMock.map(playerDTO, Player.class)) + .thenReturn(playerUpdated); + // Act + boolean result = playersService.update(playerDTO); + // Assert + verify(playersRepositoryMock, times(1)).existsById(1L); + verify(playersRepositoryMock, times(1)).save(any(Player.class)); + verify(modelMapperMock, times(1)).map(playerDTO, Player.class); + assertThat(result).isTrue(); + } + + /** + * Given the repository confirms player with ID 999 does not exist + * When update() is called + * Then false is returned without saving + */ + @Test + void update_playerNotFound_returnsFalse() { + // Arrange + PlayerDTO playerDTO = PlayerDTOFakes.createOneValid(); + playerDTO.setId(999L); + Mockito + .when(playersRepositoryMock.existsById(999L)) + .thenReturn(false); + // Act + boolean result = playersService.update(playerDTO); + // Assert + verify(playersRepositoryMock, times(1)).existsById(999L); + verify(playersRepositoryMock, never()).save(any(Player.class)); + verify(modelMapperMock, never()).map(playerDTO, Player.class); + assertThat(result).isFalse(); + } + + /** + * Given a PlayerDTO has null ID + * When update() is called + * Then false is returned without checking repository or saving + */ + @Test + void update_nullId_returnsFalse() { + // Arrange + PlayerDTO playerDTO = PlayerDTOFakes.createOneValid(); + playerDTO.setId(null); + // Act + boolean result = playersService.update(playerDTO); + // Assert + verify(playersRepositoryMock, never()).existsById(any()); + verify(playersRepositoryMock, never()).save(any(Player.class)); + verify(modelMapperMock, never()).map(any(), any()); + assertThat(result).isFalse(); + } + + /* + * ----------------------------------------------------------------------------------------------------------------------- + * Delete + * ----------------------------------------------------------------------------------------------------------------------- + */ + + /** + * Given the repository confirms player with ID 21 exists (Alejandro Gómez) + * When delete(21) is called + * Then the player is deleted and true is returned + */ + @Test + void delete_playerExists_deletesAndReturnsTrue() { + // Arrange + Mockito + .when(playersRepositoryMock.existsById(21L)) + .thenReturn(true); + // Act + boolean result = playersService.delete(21L); + // Assert + verify(playersRepositoryMock, times(1)).existsById(21L); + verify(playersRepositoryMock, times(1)).deleteById(21L); + assertThat(result).isTrue(); + } + + /** + * Given the repository confirms player with ID 999 does not exist + * When delete(999) is called + * Then false is returned without deleting + */ + @Test + void delete_playerNotFound_returnsFalse() { + // Arrange + Mockito + .when(playersRepositoryMock.existsById(999L)) + .thenReturn(false); + // Act + boolean result = playersService.delete(999L); + // Assert + verify(playersRepositoryMock, times(1)).existsById(999L); + verify(playersRepositoryMock, never()).deleteById(anyLong()); + assertThat(result).isFalse(); + } +} diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index 4cdc0f5..7e0c762 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -1,11 +1,17 @@ # Test Configuration -# Uses H2 in-memory database for fast test execution +# Uses SQLite in-memory database for fast test execution +# Maintains compatibility with production (same database engine) +# Database is initialized with ddl.sql and dml.sql (all 26 players except Leandro Paredes) -# H2 Database Configuration (Test Only) -spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE -spring.datasource.driver-class-name=org.h2.Driver -spring.jpa.database-platform=org.hibernate.dialect.H2Dialect -spring.jpa.hibernate.ddl-auto=create-drop +# SQLite In-Memory Database Configuration (Test Only) +spring.datasource.url=jdbc:sqlite::memory: +spring.datasource.driver-class-name=org.sqlite.JDBC +spring.jpa.database-platform=org.hibernate.community.dialect.SQLiteDialect +spring.jpa.hibernate.ddl-auto=none +spring.jpa.hibernate.naming.physical-strategy=org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl +spring.sql.init.mode=always +spring.sql.init.schema-locations=classpath:ddl.sql +spring.sql.init.data-locations=classpath:dml.sql spring.jpa.show-sql=false spring.jpa.properties.hibernate.format_sql=true diff --git a/src/test/resources/ddl.sql b/src/test/resources/ddl.sql new file mode 100644 index 0000000..ed4ac22 --- /dev/null +++ b/src/test/resources/ddl.sql @@ -0,0 +1,19 @@ +-- Test Database Schema (DDL - Data Definition Language) +-- SQLite in-memory database for testing +-- Matches production schema exactly (compatibility guaranteed) + +DROP TABLE IF EXISTS players; + +CREATE TABLE players ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + firstName TEXT NOT NULL, + middleName TEXT, + lastName TEXT NOT NULL, + dateOfBirth TEXT NOT NULL, + squadNumber INTEGER NOT NULL UNIQUE, + position TEXT NOT NULL, + abbrPosition TEXT NOT NULL, + team TEXT NOT NULL, + league TEXT NOT NULL, + starting11 INTEGER NOT NULL +); diff --git a/src/test/resources/dml.sql b/src/test/resources/dml.sql new file mode 100644 index 0000000..13f8558 --- /dev/null +++ b/src/test/resources/dml.sql @@ -0,0 +1,37 @@ +-- Test Database Data (DML - Data Manipulation Language) +-- Contains all 26 players from production database EXCEPT Leandro Paredes (ID 19) +-- Leandro Paredes will be created during tests (for POST/create operations) +-- Damián Emiliano Martínez (ID 1) will be updated during tests +-- Alejandro Gómez (ID 21) will be deleted during tests + +-- Starting 11 (IDs 1-11, excluding 19 if applicable) +INSERT INTO players (id, firstName, middleName, lastName, dateOfBirth, squadNumber, position, abbrPosition, team, league, starting11) VALUES +(1, 'Damián', 'Emiliano', 'Martínez', '1992-09-02T00:00:00.000Z', 23, 'Goalkeeper', 'GK', 'Aston Villa FC', 'Premier League', 1), +(2, 'Nahuel', NULL, 'Molina', '1998-04-06T00:00:00.000Z', 26, 'Right-Back', 'RB', 'Atlético Madrid', 'La Liga', 1), +(3, 'Cristian', 'Gabriel', 'Romero', '1998-04-27T00:00:00.000Z', 13, 'Centre-Back', 'CB', 'Tottenham Hotspur', 'Premier League', 1), +(4, 'Nicolás', 'Hernán Gonzalo', 'Otamendi', '1988-02-12T00:00:00.000Z', 19, 'Centre-Back', 'CB', 'SL Benfica', 'Liga Portugal', 1), +(5, 'Nicolás', 'Alejandro', 'Tagliafico', '1992-08-31T00:00:00.000Z', 3, 'Left-Back', 'LB', 'Olympique Lyon', 'Ligue 1', 1), +(6, 'Ángel', 'Fabián', 'Di María', '1988-02-14T00:00:00.000Z', 11, 'Right Winger', 'RW', 'SL Benfica', 'Liga Portugal', 1), +(7, 'Rodrigo', 'Javier', 'de Paul', '1994-05-24T00:00:00.000Z', 7, 'Central Midfield', 'CM', 'Atlético Madrid', 'La Liga', 1), +(8, 'Enzo', 'Jeremías', 'Fernández', '2001-01-17T00:00:00.000Z', 24, 'Central Midfield', 'CM', 'Chelsea FC', 'Premier League', 1), +(9, 'Alexis', NULL, 'Mac Allister', '1998-12-24T00:00:00.000Z', 20, 'Central Midfield', 'CM', 'Liverpool FC', 'Premier League', 1), +(10, 'Lionel', 'Andrés', 'Messi', '1987-06-24T00:00:00.000Z', 10, 'Right Winger', 'RW', 'Inter Miami CF', 'Major League Soccer', 1), +(11, 'Julián', NULL, 'Álvarez', '2000-01-31T00:00:00.000Z', 9, 'Centre-Forward', 'CF', 'Manchester City', 'Premier League', 1); + +-- Substitutes (IDs 12-26, excluding 19) +INSERT INTO players (id, firstName, middleName, lastName, dateOfBirth, squadNumber, position, abbrPosition, team, league, starting11) VALUES +(12, 'Franco', 'Daniel', 'Armani', '1986-10-16T00:00:00.000Z', 1, 'Goalkeeper', 'GK', 'River Plate', 'Copa de la Liga', 0), +(13, 'Gerónimo', NULL, 'Rulli', '1992-05-20T00:00:00.000Z', 12, 'Goalkeeper', 'GK', 'Ajax Amsterdam', 'Eredivisie', 0), +(14, 'Juan', 'Marcos', 'Foyth', '1998-01-12T00:00:00.000Z', 2, 'Right-Back', 'RB', 'Villarreal', 'La Liga', 0), +(15, 'Gonzalo', 'Ariel', 'Montiel', '1997-01-01T00:00:00.000Z', 4, 'Right-Back', 'RB', 'Nottingham Forest', 'Premier League', 0), +(16, 'Germán', 'Alejo', 'Pezzella', '1991-06-27T00:00:00.000Z', 6, 'Centre-Back', 'CB', 'Real Betis Balompié', 'La Liga', 0), +(17, 'Marcos', 'Javier', 'Acuña', '1991-10-28T00:00:00.000Z', 8, 'Left-Back', 'LB', 'Sevilla FC', 'La Liga', 0), +(18, 'Lisandro', NULL, 'Martínez', '1998-01-18T00:00:00.000Z', 25, 'Centre-Back', 'CB', 'Manchester United', 'Premier League', 0), +-- ID 19 (Leandro Paredes) intentionally skipped - will be created during tests +(20, 'Exequiel', 'Alejandro', 'Palacios', '1998-10-05T00:00:00.000Z', 14, 'Central Midfield', 'CM', 'Bayer 04 Leverkusen', 'Bundesliga', 0), +(21, 'Alejandro', 'Darío', 'Gómez', '1988-02-15T00:00:00.000Z', 17, 'Left Winger', 'LW', 'AC Monza', 'Serie A', 0), +(22, 'Guido', NULL, 'Rodríguez', '1994-04-12T00:00:00.000Z', 18, 'Defensive Midfield', 'DM', 'Real Betis Balompié', 'La Liga', 0), +(23, 'Ángel', 'Martín', 'Correa', '1995-03-09T00:00:00.000Z', 15, 'Right Winger', 'RW', 'Atlético Madrid', 'La Liga', 0), +(24, 'Thiago', 'Ezequiel', 'Almada', '2001-04-26T00:00:00.000Z', 16, 'Attacking Midfield', 'AM', 'Atlanta United FC', 'Major League Soccer', 0), +(25, 'Paulo', 'Exequiel', 'Dybala', '1993-11-15T00:00:00.000Z', 21, 'Second Striker', 'SS', 'AS Roma', 'Serie A', 0), +(26, 'Lautaro', 'Javier', 'Martínez', '1997-08-22T00:00:00.000Z', 22, 'Centre-Forward', 'CF', 'Inter Milan', 'Serie A', 0); diff --git a/storage/books-sqlite3.db b/storage/books-sqlite3.db deleted file mode 100644 index b138738..0000000 Binary files a/storage/books-sqlite3.db and /dev/null differ diff --git a/storage/players-sqlite3.db b/storage/players-sqlite3.db new file mode 100644 index 0000000..14a1d8e Binary files /dev/null and b/storage/players-sqlite3.db differ