From ef72cf6139fba81c148aa4a201b6257fc1d8b665 Mon Sep 17 00:00:00 2001 From: Nano Taboada Date: Sun, 15 Feb 2026 14:47:00 -0300 Subject: [PATCH 1/6] feat(domain)!: refactor from Books to Players domain model (#248) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace Book entity with Player entity representing Argentina 2022 FIFA World Cup squad. Domain Model Changes: - Entity: Book (ISBN-based) → Player (ID-based with squad numbers) - DTOs: BookDTO → PlayerDTO with comprehensive validation - Natural key: ISBN → Squad number (jersey numbers like Messi #10) - Date converter: UnixTimestampConverter (INTEGER) → IsoDateConverter (ISO-8601 TEXT) Database Migration: - Production: books-sqlite3.db → players-sqlite3.db (26 players pre-seeded) - Test: H2 in-memory → SQLite in-memory (maintains compatibility) - Schema: ddl.sql, dml.sql for test data initialization Architecture (all layers refactored): - Controllers: BooksController → PlayersController (comprehensive JavaDoc) - Services: BooksService → PlayersService (caching strategy unchanged) - Repositories: BooksRepository → PlayersRepository (added squad number search) - Fakes: BookFakes/BookDTOFakes → PlayerFakes/PlayerDTOFakes API Changes: - /books → /players endpoints - /books/search?description={keyword} → /players/search/league/{league} - Added: /players/search/squadnumber/{squadNumber} endpoint Test Improvements: - Naming: givenMethod_whenX_thenY → method_scenario_outcome - BDD JavaDoc: Added Given/When/Then structure to all tests - Coverage: Excluded converters package from JaCoCo/Codecov DevOps & Configuration: - Docker Compose: Updated STORAGE_PATH to players-sqlite3.db - Entrypoint script: Updated database paths for container initialization - CodeRabbit: Updated validation rules, test naming pattern, fake factories Documentation: - Updated copilot-instructions.md with test naming convention - Updated AGENTS.md and README.md with new endpoints - Comprehensive JavaDoc on all new classes (controllers, services, models) - Production database: 26 Argentina 2022 World Cup players BREAKING CHANGE: Complete API contract change from /books to /players endpoints. All Book-related endpoints removed. Clients must migrate to new Player API. --- .coderabbit.yaml | 11 +- .github/copilot-instructions.md | 28 ++ .vscode/java-formatter.xml | 56 +++ .vscode/settings.json | 9 +- AGENTS.md | 121 +++--- README.md | 60 +-- codecov.yml | 1 + compose.yaml | 2 +- pom.xml | 9 +- scripts/entrypoint.sh | 9 +- .../boot/controllers/BooksController.java | 154 ------- .../boot/controllers/PlayersController.java | 251 +++++++++++ .../boot/converters/IsoDateConverter.java | 89 ++++ .../java/samples/spring/boot/models/Book.java | 48 --- .../samples/spring/boot/models/BookDTO.java | 38 -- .../samples/spring/boot/models/Player.java | 66 +++ .../samples/spring/boot/models/PlayerDTO.java | 66 +++ .../boot/models/UnixTimestampConverter.java | 39 -- .../boot/repositories/BooksRepository.java | 32 -- .../boot/repositories/PlayersRepository.java | 84 ++++ .../spring/boot/services/BooksService.java | 116 ----- .../spring/boot/services/PlayersService.java | 225 ++++++++++ src/main/resources/application.properties | 5 +- .../spring/boot/test/BookDTOFakes.java | 157 ------- .../samples/spring/boot/test/BookFakes.java | 160 ------- .../spring/boot/test/PlayerDTOFakes.java | 205 +++++++++ .../samples/spring/boot/test/PlayerFakes.java | 208 +++++++++ ...Tests.java => PlayersControllerTests.java} | 405 ++++++++++-------- .../repositories/BooksRepositoryTests.java | 86 ---- .../repositories/PlayersRepositoryTests.java | 116 +++++ .../boot/test/services/BooksServiceTests.java | 265 ------------ .../test/services/PlayersServiceTests.java | 371 ++++++++++++++++ src/test/resources/application.properties | 17 +- src/test/resources/ddl.sql | 19 + src/test/resources/dml.sql | 37 ++ storage/books-sqlite3.db | Bin 53248 -> 0 bytes storage/players-sqlite3.db | Bin 0 -> 12288 bytes 37 files changed, 2197 insertions(+), 1368 deletions(-) create mode 100644 .vscode/java-formatter.xml delete mode 100644 src/main/java/ar/com/nanotaboada/java/samples/spring/boot/controllers/BooksController.java create mode 100644 src/main/java/ar/com/nanotaboada/java/samples/spring/boot/controllers/PlayersController.java create mode 100644 src/main/java/ar/com/nanotaboada/java/samples/spring/boot/converters/IsoDateConverter.java delete mode 100644 src/main/java/ar/com/nanotaboada/java/samples/spring/boot/models/Book.java delete mode 100644 src/main/java/ar/com/nanotaboada/java/samples/spring/boot/models/BookDTO.java create mode 100644 src/main/java/ar/com/nanotaboada/java/samples/spring/boot/models/Player.java create mode 100644 src/main/java/ar/com/nanotaboada/java/samples/spring/boot/models/PlayerDTO.java delete mode 100644 src/main/java/ar/com/nanotaboada/java/samples/spring/boot/models/UnixTimestampConverter.java delete mode 100644 src/main/java/ar/com/nanotaboada/java/samples/spring/boot/repositories/BooksRepository.java create mode 100644 src/main/java/ar/com/nanotaboada/java/samples/spring/boot/repositories/PlayersRepository.java delete mode 100644 src/main/java/ar/com/nanotaboada/java/samples/spring/boot/services/BooksService.java create mode 100644 src/main/java/ar/com/nanotaboada/java/samples/spring/boot/services/PlayersService.java delete mode 100644 src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/BookDTOFakes.java delete mode 100644 src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/BookFakes.java create mode 100644 src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/PlayerDTOFakes.java create mode 100644 src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/PlayerFakes.java rename src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/controllers/{BooksControllerTests.java => PlayersControllerTests.java} (50%) delete mode 100644 src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/repositories/BooksRepositoryTests.java create mode 100644 src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/repositories/PlayersRepositoryTests.java delete mode 100644 src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/services/BooksServiceTests.java create mode 100644 src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/services/PlayersServiceTests.java create mode 100644 src/test/resources/ddl.sql create mode 100644 src/test/resources/dml.sql delete mode 100644 storage/books-sqlite3.db create mode 100644 storage/players-sqlite3.db 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..ddad252 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#givenGetAll_whenServiceRetrieveAllReturnsPlayers_thenResponseIsOkAndResultIsPlayers # Run tests without rebuilding ./mvnw surefire:test @@ -105,14 +105,14 @@ This project uses **H2 in-memory database for tests** and **SQLite for runtime** ```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)**: @@ -181,34 +181,38 @@ 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 +│ └── IsoDateConverter.java # JPA converter for ISO-8601 dates + +└── converters/ # Infrastructure converters + └── IsoDateConverter.java # JPA converter 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 +221,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` | Update player | +| `DELETE` | `/players/{id}` | Delete player | | `GET` | `/actuator/health` | Health check | | `GET` | `/swagger-ui.html` | API documentation | @@ -265,7 +271,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 +281,7 @@ rm storage/books.db ./mvnw test -X # Run single test for debugging -./mvnw test -Dtest=BooksControllerTest#testGetAllBooks -X +./mvnw test -Dtest=PlayersControllerTests#givenGetAll_whenServiceRetrieveAllReturnsPlayers_thenResponseIsOkAndResultIsPlayers -X ``` ### Maven wrapper issues @@ -309,40 +315,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 \ -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 +372,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..dfb9326 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` - 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,10 @@ 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** - BDD style: `givenMethod_whenDependency[Method]_thenResult` **Coverage Targets:** @@ -247,7 +251,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 +268,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 +297,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 +315,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..0240ffc --- /dev/null +++ b/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/controllers/PlayersController.java @@ -0,0 +1,251 @@ +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.mvc.method.annotation.MvcUriComponentsBuilder; + +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; + +/** + * 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} - 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") +public class PlayersController { + + private final PlayersService playersService; + + public PlayersController(PlayersService playersService) { + this.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 = MvcUriComponentsBuilder + .fromMethodCall(MvcUriComponentsBuilder.on(PlayersController.class).getById(createdPlayer.getId())) + .build() + .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. Requires a valid player ID in the request body. + *

+ * + * @param playerDTO the complete player data (must include valid ID and pass validation) + * @return 204 No Content if successful, 404 Not Found if player doesn't exist, or 400 Bad Request if validation fails + */ + @PutMapping("/players") + @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(@RequestBody @Valid PlayerDTO playerDTO) { + 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 = "400", description = "Bad Request", 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..9c08a88 --- /dev/null +++ b/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/converters/IsoDateConverter.java @@ -0,0 +1,89 @@ +package ar.com.nanotaboada.java.samples.spring.boot.converters; + +import java.time.LocalDate; +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_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().format(ISO_FORMATTER) + "Z"; + } + + /** + * 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 LocalDate.parse(dateString.substring(0, 10)); + } + return LocalDate.parse(dateString); + } +} 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..669fed7 --- /dev/null +++ b/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/models/Player.java @@ -0,0 +1,66 @@ +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.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; + 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..fedc6c9 --- /dev/null +++ b/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/models/PlayerDTO.java @@ -0,0 +1,66 @@ +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.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: Must be a past date
  • + *
  • squadNumber: 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.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; + @Past + @JsonDeserialize(using = LocalDateDeserializer.class) + @JsonSerialize(using = LocalDateSerializer.class) + private LocalDate dateOfBirth; + @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..f5171c3 --- /dev/null +++ b/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/repositories/PlayersRepository.java @@ -0,0 +1,84 @@ +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.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 CrudRepository} for basic CRUD operations and defines custom queries for advanced search functionality. + *

+ * + *

Provided Methods:

+ *
    + *
  • Inherited from CrudRepository: save, findAll, findById, delete, 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.repository.CrudRepository + * @see Query + * Creation from Method Names + * @since 4.0.2025 + */ +@Repository +public interface PlayersRepository extends CrudRepository { + /** + * Finds a player by their unique identifier. + *

+ * This is a derived query method - Spring Data JPA automatically implements it based on the method name convention. + *

+ * + * @param id the unique identifier of the player + * @return an Optional containing the player if found, empty Optional otherwise + * @see Query + * Creation from Method Names + */ + // Non-default methods in interfaces are not shown in coverage reports https://www.jacoco.org/jacoco/trunk/doc/faq.html + Optional findById(Long id); + + /** + * 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..1be2923 --- /dev/null +++ b/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/services/PlayersService.java @@ -0,0 +1,225 @@ +package ar.com.nanotaboada.java.samples.spring.boot.services; + +import java.util.List; +import java.util.stream.StreamSupport; + +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.Player; +import ar.com.nanotaboada.java.samples.spring.boot.models.PlayerDTO; +import ar.com.nanotaboada.java.samples.spring.boot.repositories.PlayersRepository; +import lombok.RequiredArgsConstructor; + +/** + * 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)
  • + *
  • @CachePut: Create operations (update cache)
  • + *
  • @CacheEvict: Update/Delete operations (invalidate cache)
  • + *
+ * + * @see PlayersRepository + * @see PlayerDTO + * @see Player + * @see org.modelmapper.ModelMapper + * @since 4.0.2025 + */ +@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: Before creating, checks if a player with the same squad number already exists. + * Squad numbers are unique identifiers (jersey numbers). + *

+ * + * @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.CachePut + */ + @CachePut(value = "players", key = "#result.id", unless = "#result == null") + public PlayerDTO create(PlayerDTO playerDTO) { + // Check if squad number already exists + if (playersRepository.findBySquadNumber(playerDTO.getSquadNumber()).isPresent()) { + return null; // Conflict: squad number already taken + } + Player player = mapFrom(playerDTO); + Player savedPlayer = playersRepository.save(player); + return mapFrom(savedPlayer); + } + + /* + * ----------------------------------------------------------------------------------------------------------------------- + * 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. + *

+ * + * @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") + 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 StreamSupport.stream(playersRepository.findAll().spliterator(), false) + .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.CachePut + */ + @CachePut(value = "players", key = "#playerDTO.id") + public boolean update(PlayerDTO playerDTO) { + if (playerDTO.getId() != null && playersRepository.existsById(playerDTO.getId())) { + Player player = mapFrom(playerDTO); + playersRepository.save(player); + return true; + } else { + 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 + */ + @CacheEvict(value = "players", key = "#id") + public boolean delete(Long id) { + if (playersRepository.existsById(id)) { + playersRepository.deleteById(id); + return true; + } else { + 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..02b50cd --- /dev/null +++ b/src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/PlayerDTOFakes.java @@ -0,0 +1,205 @@ +package ar.com.nanotaboada.java.samples.spring.boot.test; + +import java.time.LocalDate; + +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 java.util.List createAll() { + return java.util.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", + "LW", "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", + "Villareal", "La Liga", false), + createPlayerDTOWithId(15L, "Gonzalo", "Ariel", "Montiel", LocalDate.of(1997, 1, 1), 4, "Right-Back", + "RB", "Nottingham Forrest", "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..741450c --- /dev/null +++ b/src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/PlayerFakes.java @@ -0,0 +1,208 @@ +package ar.com.nanotaboada.java.samples.spring.boot.test; + +import java.time.LocalDate; + +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 java.util.List createAll() { + return java.util.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", + "LW", "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", + "Villareal", "La Liga", false), + createPlayerWithId(15L, "Gonzalo", "Ariel", "Montiel", LocalDate.of(1997, 1, 1), 4, "Right-Back", "RB", + "Nottingham Forrest", "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/PlayersControllerTests.java similarity index 50% rename from src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/controllers/BooksControllerTests.java rename to src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/controllers/PlayersControllerTests.java index cb76cd4..e5fa275 100644 --- 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/PlayersControllerTests.java @@ -2,7 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -27,27 +27,27 @@ 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; +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(BooksController.class) +@WebMvcTest(PlayersController.class) @AutoConfigureCache -class BooksControllerTests { +class PlayersControllerTests { - private static final String PATH = "/books"; + private static final String PATH = "/players"; @Autowired private MockMvc application; @MockitoBean - private BooksService booksServiceMock; + private PlayersService playersServiceMock; @MockitoBean - private BooksRepository booksRepositoryMock; + private PlayersRepository playersRepositoryMock; /* * ------------------------------------------------------------------------- @@ -55,15 +55,22 @@ class BooksControllerTests { * ------------------------------------------------------------------------- */ + /** + * 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 givenPost_whenRequestBodyIsValidButExistingBook_thenResponseStatusIsConflict() + void post_validPlayer_returnsCreated() throws Exception { // Arrange - BookDTO bookDTO = BookDTOFakes.createOneValid(); - String body = new ObjectMapper().writeValueAsString(bookDTO); + PlayerDTO playerDTO = PlayerDTOFakes.createOneValid(); + PlayerDTO savedPlayer = PlayerDTOFakes.createOneValid(); + savedPlayer.setId(19L); // Simulating auto-generated ID + String body = new ObjectMapper().writeValueAsString(playerDTO); Mockito - .when(booksServiceMock.create(any(BookDTO.class))) - .thenReturn(false); // Existing + .when(playersServiceMock.create(any(PlayerDTO.class))) + .thenReturn(savedPlayer); MockHttpServletRequestBuilder request = MockMvcRequestBuilders .post(PATH) .content(body) @@ -73,24 +80,25 @@ void givenPost_whenRequestBodyIsValidButExistingBook_thenResponseStatusIsConflic .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.CONFLICT.value()); - + 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 givenPost_whenRequestBodyIsValidAndNonExistentBook_thenResponseStatusIsCreated() + void post_invalidPlayer_returnsBadRequest() 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); + PlayerDTO playerDTO = PlayerDTOFakes.createOneInvalid(); + String body = new ObjectMapper().writeValueAsString(playerDTO); MockHttpServletRequestBuilder request = MockMvcRequestBuilders .post(PATH) .content(body) @@ -100,21 +108,25 @@ void givenPost_whenRequestBodyIsValidAndNonExistentBook_thenResponseStatusIsCrea .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()); - + 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 givenPost_whenRequestBodyIsInvalidBook_thenResponseStatusIsBadRequest() + void post_squadNumberExists_returnsConflict() throws Exception { // Arrange - BookDTO bookDTO = BookDTOFakes.createOneInvalid(); - String body = new ObjectMapper().writeValueAsString(bookDTO); + PlayerDTO playerDTO = PlayerDTOFakes.createOneValid(); + String body = new ObjectMapper().writeValueAsString(playerDTO); + Mockito + .when(playersServiceMock.create(any(PlayerDTO.class))) + .thenReturn(null); // Conflict: squad number already exists MockHttpServletRequestBuilder request = MockMvcRequestBuilders .post(PATH) .content(body) @@ -125,8 +137,8 @@ void givenPost_whenRequestBodyIsInvalidBook_thenResponseStatusIsBadRequest() .andReturn() .getResponse(); // Assert - verify(booksServiceMock, never()).create(any(BookDTO.class)); - assertThat(response.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value()); + verify(playersServiceMock, times(1)).create(any(PlayerDTO.class)); + assertThat(response.getStatus()).isEqualTo(HttpStatus.CONFLICT.value()); } /* @@ -135,17 +147,22 @@ void givenPost_whenRequestBodyIsInvalidBook_thenResponseStatusIsBadRequest() * ------------------------------------------------------------------------- */ + /** + * 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 givenGetByIsbn_whenRequestPathVariableIsValidAndExistingISBN_thenResponseStatusIsOKAndResultIsBook() + void getById_playerExists_returnsOkWithPlayer() throws Exception { // Arrange - BookDTO bookDTO = BookDTOFakes.createOneValid(); - String isbn = bookDTO.getIsbn(); + PlayerDTO playerDTO = PlayerDTOFakes.createOneForUpdate(); + Long id = 1L; Mockito - .when(booksServiceMock.retrieveByIsbn(anyString())) - .thenReturn(bookDTO); // Existing + .when(playersServiceMock.retrieveById(1L)) + .thenReturn(playerDTO); MockHttpServletRequestBuilder request = MockMvcRequestBuilders - .get(PATH + "/{isbn}", isbn); + .get(PATH + "/{id}", id); // Act MockHttpServletResponse response = application .perform(request) @@ -153,41 +170,51 @@ void givenGetByIsbn_whenRequestPathVariableIsValidAndExistingISBN_thenResponseSt .getResponse(); response.setContentType("application/json;charset=UTF-8"); String content = response.getContentAsString(); - BookDTO result = new ObjectMapper().readValue(content, BookDTO.class); + PlayerDTO result = new ObjectMapper().readValue(content, PlayerDTO.class); // Assert - verify(booksServiceMock, times(1)).retrieveByIsbn(anyString()); + verify(playersServiceMock, times(1)).retrieveById(1L); assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value()); - assertThat(result).usingRecursiveComparison().isEqualTo(bookDTO); + 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 givenGetByIsbn_whenRequestPathVariableIsValidButNonExistentISBN_thenResponseStatusIsNotFound() + void getById_playerNotFound_returnsNotFound() throws Exception { // Arrange - String isbn = "9781484242216"; + Long id = 999L; Mockito - .when(booksServiceMock.retrieveByIsbn(anyString())) - .thenReturn(null); // Non-existent + .when(playersServiceMock.retrieveById(anyLong())) + .thenReturn(null); MockHttpServletRequestBuilder request = MockMvcRequestBuilders - .get(PATH + "/{isbn}", isbn); + .get(PATH + "/{id}", id); // Act MockHttpServletResponse response = application .perform(request) .andReturn() .getResponse(); // Assert - verify(booksServiceMock, times(1)).retrieveByIsbn(anyString()); + 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 givenGetAll_whenRequestPathIsBooks_thenResponseIsOkAndResultIsBooks() + void getAll_playersExist_returnsOkWithAllPlayers() throws Exception { // Arrange - List bookDTOs = BookDTOFakes.createManyValid(); + List playerDTOs = PlayerDTOFakes.createAll(); Mockito - .when(booksServiceMock.retrieveAll()) - .thenReturn(bookDTOs); + .when(playersServiceMock.retrieveAll()) + .thenReturn(playerDTOs); MockHttpServletRequestBuilder request = MockMvcRequestBuilders .get(PATH); // Act @@ -197,241 +224,271 @@ void givenGetAll_whenRequestPathIsBooks_thenResponseIsOkAndResultIsBooks() .getResponse(); response.setContentType("application/json;charset=UTF-8"); String content = response.getContentAsString(); - List result = new ObjectMapper().readValue(content, new TypeReference>() { + List result = new ObjectMapper().readValue(content, new TypeReference>() { }); // Assert - verify(booksServiceMock, times(1)).retrieveAll(); + verify(playersServiceMock, times(1)).retrieveAll(); assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value()); - assertThat(result).usingRecursiveComparison().isEqualTo(bookDTOs); + assertThat(result).hasSize(26); + assertThat(result).usingRecursiveComparison().isEqualTo(playerDTOs); } - /* - * ------------------------------------------------------------------------- - * HTTP PUT - * ------------------------------------------------------------------------- + /** + * 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 givenPut_whenRequestBodyIsValidAndExistingBook_thenResponseStatusIsNoContent() + void searchByLeague_matchingPlayersExist_returnsOkWithList() throws Exception { // Arrange - BookDTO bookDTO = BookDTOFakes.createOneValid(); - String body = new ObjectMapper().writeValueAsString(bookDTO); + List playerDTOs = PlayerDTOFakes.createAll().stream() + .filter(p -> p.getLeague().contains("Premier")) + .toList(); Mockito - .when(booksServiceMock.update(any(BookDTO.class))) - .thenReturn(true); // Existing + .when(playersServiceMock.searchByLeague(any())) + .thenReturn(playerDTOs); MockHttpServletRequestBuilder request = MockMvcRequestBuilders - .put(PATH) - .content(body) - .contentType(MediaType.APPLICATION_JSON); + .get(PATH + "/search/league/{league}", "Premier"); // 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)).update(any(BookDTO.class)); - assertThat(response.getStatus()).isEqualTo(HttpStatus.NO_CONTENT.value()); + 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 givenPut_whenRequestBodyIsValidButNonExistentBook_thenResponseStatusIsNotFound() + void searchByLeague_noMatches_returnsOkWithEmptyList() 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); + .when(playersServiceMock.searchByLeague(any())) + .thenReturn(List.of()); MockHttpServletRequestBuilder request = MockMvcRequestBuilders - .put(PATH) - .content(body) - .contentType(MediaType.APPLICATION_JSON); + .get(PATH + "/search/league/{league}", "NonexistentLeague"); // 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, never()).update(any(BookDTO.class)); - assertThat(response.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value()); + verify(playersServiceMock, times(1)).searchByLeague(any()); + assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value()); + assertThat(result).isEmpty(); } - /* - * ------------------------------------------------------------------------- - * HTTP DELETE - * ------------------------------------------------------------------------- + /** + * 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 givenDelete_whenPathVariableIsValidAndExistingISBN_thenResponseStatusIsNoContent() + void searchBySquadNumber_playerExists_returnsOkWithPlayer() throws Exception { // Arrange - BookDTO bookDTO = BookDTOFakes.createOneValid(); - String isbn = bookDTO.getIsbn(); + PlayerDTO playerDTO = PlayerDTOFakes.createAll().get(9); // Messi is at index 9 Mockito - .when(booksServiceMock.delete(anyString())) - .thenReturn(true); // Existing + .when(playersServiceMock.searchBySquadNumber(10)) + .thenReturn(playerDTO); MockHttpServletRequestBuilder request = MockMvcRequestBuilders - .delete(PATH + "/{isbn}", isbn); + .get(PATH + "/search/squadnumber/{squadNumber}", 10); // Act MockHttpServletResponse response = application .perform(request) .andReturn() .getResponse(); + response.setContentType("application/json;charset=UTF-8"); + String content = response.getContentAsString(); + PlayerDTO result = new ObjectMapper().readValue(content, PlayerDTO.class); // Assert - verify(booksServiceMock, times(1)).delete(anyString()); - assertThat(response.getStatus()).isEqualTo(HttpStatus.NO_CONTENT.value()); + 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 givenDelete_whenPathVariableIsValidButNonExistentISBN_thenResponseStatusIsNotFound() + void searchBySquadNumber_playerNotFound_returnsNotFound() throws Exception { // Arrange - BookDTO bookDTO = BookDTOFakes.createOneValid(); - String isbn = bookDTO.getIsbn(); Mockito - .when(booksServiceMock.delete(anyString())) - .thenReturn(false); // Non-existent + .when(playersServiceMock.searchBySquadNumber(99)) + .thenReturn(null); MockHttpServletRequestBuilder request = MockMvcRequestBuilders - .delete(PATH + "/{isbn}", isbn); + .get(PATH + "/search/squadnumber/{squadNumber}", 99); // Act MockHttpServletResponse response = application .perform(request) .andReturn() .getResponse(); // Assert - verify(booksServiceMock, times(1)).delete(anyString()); + 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 is called and the service successfully updates the player + * Then response status is 204 No Content + */ @Test - void givenDelete_whenPathVariableIsInvalidISBN_thenResponseStatusIsBadRequest() + void put_playerExists_returnsNoContent() throws Exception { // Arrange - BookDTO bookDTO = BookDTOFakes.createOneInvalid(); - String isbn = bookDTO.getIsbn(); + PlayerDTO playerDTO = PlayerDTOFakes.createOneValid(); + String body = new ObjectMapper().writeValueAsString(playerDTO); + Mockito + .when(playersServiceMock.update(any(PlayerDTO.class))) + .thenReturn(true); MockHttpServletRequestBuilder request = MockMvcRequestBuilders - .delete(PATH + "/{isbn}", isbn); + .put(PATH) + .content(body) + .contentType(MediaType.APPLICATION_JSON); // Act MockHttpServletResponse response = application .perform(request) .andReturn() .getResponse(); // Assert - verify(booksServiceMock, never()).delete(anyString()); - assertThat(response.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value()); + verify(playersServiceMock, times(1)).update(any(PlayerDTO.class)); + assertThat(response.getStatus()).isEqualTo(HttpStatus.NO_CONTENT.value()); } - /* - * ------------------------------------------------------------------------- - * HTTP GET /books/search - * ------------------------------------------------------------------------- + /** + * Given a player with the provided ID does not exist + * When PUT /players is called and the service returns false + * Then response status is 404 Not Found */ - @Test - void givenSearchByDescription_whenRequestParamIsValidAndMatchingBooksExist_thenResponseStatusIsOKAndResultIsBooks() + void put_playerNotFound_returnsNotFound() throws Exception { // Arrange - List bookDTOs = BookDTOFakes.createManyValid(); - String keyword = "Java"; + PlayerDTO playerDTO = PlayerDTOFakes.createOneValid(); + String body = new ObjectMapper().writeValueAsString(playerDTO); Mockito - .when(booksServiceMock.searchByDescription(anyString())) - .thenReturn(bookDTOs); + .when(playersServiceMock.update(any(PlayerDTO.class))) + .thenReturn(false); MockHttpServletRequestBuilder request = MockMvcRequestBuilders - .get(PATH + "/search") - .param("description", keyword); + .put(PATH) + .content(body) + .contentType(MediaType.APPLICATION_JSON); // 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); + 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 is called + * Then response status is 400 Bad Request and service is never called + */ @Test - void givenSearchByDescription_whenRequestParamIsValidAndNoMatchingBooks_thenResponseStatusIsOKAndResultIsEmptyList() + void put_invalidPlayer_returnsBadRequest() throws Exception { // Arrange - String keyword = "nonexistentkeyword"; - Mockito - .when(booksServiceMock.searchByDescription(anyString())) - .thenReturn(List.of()); + PlayerDTO playerDTO = PlayerDTOFakes.createOneInvalid(); + String body = new ObjectMapper().writeValueAsString(playerDTO); MockHttpServletRequestBuilder request = MockMvcRequestBuilders - .get(PATH + "/search") - .param("description", keyword); + .put(PATH) + .content(body) + .contentType(MediaType.APPLICATION_JSON); // 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(); + verify(playersServiceMock, never()).update(any(PlayerDTO.class)); + assertThat(response.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.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 givenSearchByDescription_whenRequestParamIsBlank_thenResponseStatusIsBadRequest() + void delete_playerExists_returnsNoContent() throws Exception { // Arrange + Long id = 1L; + Mockito + .when(playersServiceMock.delete(anyLong())) + .thenReturn(true); MockHttpServletRequestBuilder request = MockMvcRequestBuilders - .get(PATH + "/search") - .param("description", ""); + .delete(PATH + "/{id}", id); // Act MockHttpServletResponse response = application .perform(request) .andReturn() .getResponse(); // Assert - verify(booksServiceMock, never()).searchByDescription(anyString()); - assertThat(response.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value()); + 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 givenSearchByDescription_whenRequestParamIsMissing_thenResponseStatusIsBadRequest() + void delete_playerNotFound_returnsNotFound() throws Exception { // Arrange + Long id = 999L; + Mockito + .when(playersServiceMock.delete(anyLong())) + .thenReturn(false); MockHttpServletRequestBuilder request = MockMvcRequestBuilders - .get(PATH + "/search"); + .delete(PATH + "/{id}", id); // Act MockHttpServletResponse response = application .perform(request) .andReturn() .getResponse(); // Assert - verify(booksServiceMock, never()).searchByDescription(anyString()); - assertThat(response.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value()); + 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..c072f6c --- /dev/null +++ b/src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/repositories/PlayersRepositoryTests.java @@ -0,0 +1,116 @@ +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 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(); + leandro.setId(null); + Player saved = repository.save(leandro); + // Act + Optional actual = repository.findById(saved.getId()); + // Assert + assertTrue(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..7900905 --- /dev/null +++ b/src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/services/PlayersServiceTests.java @@ -0,0 +1,371 @@ +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 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().get(4); // Squad number 5 already exists + 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(); + } + + /* + * ----------------------------------------------------------------------------------------------------------------------- + * 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().get(9); // Messi is at index 9 + PlayerDTO playerDTO = PlayerDTOFakes.createAll().get(9); + 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(); + } + + /* + * ----------------------------------------------------------------------------------------------------------------------- + * 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..98cdb3e 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -1,11 +1,16 @@ # 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.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..02ab732 --- /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, + 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..9905769 --- /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', 'LW', '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', 'Villareal', 'La Liga', 0), +(15, 'Gonzalo', 'Ariel', 'Montiel', '1997-01-01T00:00:00.000Z', 4, 'Right-Back', 'RB', 'Nottingham Forrest', '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 b138738a5852d06c3a6a90c40a3e3a0a281e13b4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 53248 zcmeHQOKe=%c^*>Ki>AZ`MI!`3kz=<7EtHopiKGOr#eyGcT;t$@s-BNNaNc|H5!e>_#n-8c6VByNc}YMiboa}7f-5d*Kez9FRria2a>E=e`axR?xg+- zE$u~d?PdMNAZrGG()0HFuB|R#yS;XC?Us4P?RrW5qqdi{VtweYIP{tc zxB7Xn*qrf`2Z8E=$^Ry{hbRAM@)!EYD%AF8i-1MIB2Y!(y=e5<)EhffC!ME9e{gWe zJ-d8ne)-JVrTJ5*mzOTNVLNfIhw7@|ilfAfxBM1N)X-0RYGV-lVOO1tqEz9r{P*)g zl(o-ge$bXr+uk6Ew$%&nmaF=;Lb8Ofn^NShX>~b)rJI%)uVhUx4!zV%uDabYOVqj> zCrK3Ea9f+Hx}mKrN?4eiKkLk$br$AEe^hU!8@GC}lC3n0J>>>LG)z?6+wy{F;Khkb zBh@UL*?0Xg#b4zP27%vlX>d(HJvZ@mle{qXV)Qxz1TM;q-GSc@ww2qCV4=|&KP^j5 zqE0&GQ?-6-v`~4^^JBE8<7PpsIPg*Ak9DF6ag=q@l-T#QBq52EKMn?Q zbkPf2P=>xiDc;qi*mLpnmQfcycQ-vF?3Ejrle{@Yx7+pN+3N!@#K$vgB@xiw z_LeIZ7^!Di62(bM9F6GN#J1n*crhRa9A-dv;;3I6N|cS@S%usPhAQBRdSO%~5wx%m zWlsSl`fKVj%Cx75ya(mz6e;)J5Nj89w6BeW3}hZfPyym-i(28eve-{CT+kiVMkNN4 ze3AMwqb~SBJ=iV;+r+J!3$NVPU~d!o=!QF*oZ zdDCwID-W;0q_8Cy(GnCmqt0!!{mR280r)`PS_0a5$V9(8gf_In#>zvz^%U99iqm_E2QvM4?mbm&3u~BvQD8 zp`1E+EdRYlb39u!>hHZca(rrb=hV&!P5$AXU*orDPMtY>W@+i{ncKiXto0q(qofxN z*2VNh%~xSboe1UtRcLmcAI(g&j0wlCoZPxBRv` zpK)+|LEt6RY9(xA^d#!yhd++pkcNI*t;Nug3lTzzw?@^)*Y-6PGg^-x~UD+Vl`#KmoA*faK8KWJU=D?5P(;dPpk+=j;ZO;EsCq-KTQ{S<~A6Of)x_8_fIaL)?{Vh!4ndX>sU z%`#?N3^lq4)8cnSG@eD%7_s~T=dUOe3n^HjMY(uVSf-woiu&ViXg7L8yBB)8l+94j zEzoj^ww>vYp+K2n_%y^5IGCg`vF9??mLyjcj=wm?#I>!UH^aOJgBh?^t-wt%dBT8V z07h;6K-FQ}Sxf4my7LkAK`d~hU>ey$jCY?x1mtZu#Uzl%XdWi@gd&JyY~GS2*q%{$ z^<3G81y-@&?WGVR-j}&kfM^Oj`)D$n-a6CEop(C!0G!0cmiiPtvTdEm0q0kg-8? zz^$Q%EAX4KOT84uP=&gfnNy~3s2;Sjm{(-50;Hl6%+@U!sM z!McgHkM1(IoHE^DWKAnBRkWRty{-%|*lNfl;i^fuQV)>~W>|6IAd7`97;#9j)5u z+}iVhefX50UBS6+&;RZDznopv4x7yZi#`8`&8X3t?D@Yv|F`G=_Wa+T|I1OhJ^u$W z=QBS49|*6jU~X~$f3We-jq$gR#0TF$@GtoBOaJHXHxExtoozTzKRxp9&7I#MQ*G(= z!ra{a>CHf8Nhn2I&?8V#G*6X?fXucD zA`57BQ7F~$={^W`1uc$ypLPTmp9?xE=vRCI?2@28qjm#1mFOmwh=1_D!JK?FgsC)z zlf(=ZAg&677ZrEpt)D&k6XB?mq$unvR|URl6KQdz%b;quJ|b-ozx!cHtLpjS`I63! z8c1?`JcUbB(me7YH&Gm^JfPc2Cb?P7^e%CZ`*5T-L7!O0F34mfzfw) zbq%a0&~?BL;{SD1!S(EWEik$Kmh_XK=Ax+ad6Gf}h0qB~u~Av4r!tAMhH-I`j z^KX6U(8SdF#ykeV!~V{1mIGjZ`Siy1^~_c5}rJ+>1g2`Rc2bFAs3on#4%CzKdK7mxB&VIYBvy|`KD+ed0RvNzp!T!PJ zo!>45yRdL-VHGq-Qt#BN@H{JWeE}A2&{L}*w1Mc`s|s~t!I@ih78dV7sEWwDBq)*t zO(rDx>n)GONJ@^xMWIsi7j}{H3w;PezXsMZ#DP~ek>8X6qe@-TBxeLSz^IW*TGG*H z)MXkFXw&1{PYKpy89;LM5O#nzu(8lIKcw*$>w%vkB55cj=|TxjyZDjdV;YEW0W5g> zx&tCP3HXqL%|j>(G6u6%s_J9`7_A9xa3TuN7LfYQR>1bwVb-cuV@w*gcMF7> zRfG5q3rD^y$3Pd63r!DGL5yk#Ag$&`1||=bM~nf|G0_Tyg;XG@nH{6cW(=8!1s+r& zF$oDnT_7TkX#Cd#o+tGb?N*#W9eC(;I&Bn&ppB{aCHVicl2 z+WoTM?|aaPFkBIasSfv9bmo>voQuGea+4X&5T8Z=$_WTM9eWZjVW!^-ct#x%u)&65 zN)T^H&TR&5)$L;%zDVBy_H`yjkOQPz3`~H(Gffz(XpiE;aL~w~Z(la4PTHu3# z4}!0}suB)vOqh^3UkJ&!&kM%LEYuXja%0qiS+9pk1+2na8K*^U`Al7UHy~;ZtzdIYtng;-@gKF4{9J7Aep(gj(pY@i0sWSD1m{ zM|1;1Z5a2#K9yCoRTAc4c;Q>FuR~p+%QViQr6Wdz=1vyad>oS$`VR&;LSh&WjdD%& zMLxES3}nG3F=z}Pkm=jlJFd=&R5@IA=^(n}#`_c2g(3kZQ&hhmM(=oKBq zSJSk-QAH5JuFxyfNZM5th;ayWU;^RT-F+6M>e=hpZ=5uCr0itA@BsS8(T=8WVT3>i zF(z+lUI&2)GCHMarUe)th@F8N$b6yE%^NR>dYIdbH?bxQxWh#H1S|rd41tH<#KhEc<1DtSzkg!q8^u<2e);S|$Z3Qv9QKZwiZFLqV%W_hg;BWJE{E+1{wVAZi|thE7Y|KiB_g4E@-qv_ z`4KEZs;og*ja|mt!T4MEb9QYO@~dno-+uO~iK*GfNxDRy*I~)p*}J%OYBg$Y0`d}@ zwIGFtY~UXc-Kpu;87P)BztlXROA2A_4A_e)Q)m-{!x>9n?7k|I$=FEHmCJ6LPw7D9 zY>p|P!QZk$uXbKwQq6k6eW1jYp-ZSr#EZ22f)m9y-`S_a%7+BICJCN5dpM-0YqrWqi}? zewbnPcLyQR*o^CMueuD@hX)oe4dbZWBNDByb;0EB9#&RvQHt@HcebzNTazBP5WrrV zZ<=Txxs)}htL6bul|5%y;YWgh3!%1d%y7&~9uM7vK0QQ;%Y7mjh-hC`RnLv{Tw;o< zdarse0q@7g&2@D%!%i0~(NbTpAwXVT!s4D=q?)?oPy(*I(;dDCsS~MuiOOX#-7iK$ z+@rxeCTggc@_n!EyVxEUFVK05*lKJ7=q8{gZ5zGS4tMyVh8~<5vbBzoPC@5m$emR) zY7H1Nt%!DBRdE!h<_0Kt2#Gzaw3@N95Co%BDa70>VVsS!XPi+r9Iz#QG+I!lNA3k% z$kvc5cmxDpI*e|vn{g)ErOw}aag~ASNI-%qL0jJh8C}>Q8nU+jksxbeIR>FC+kBvd zAass*K4_f(-7Z3$=}^eGKw=G&tPh7atcXa91jX^19seS{mKMF7Y=k8%YXRa z`WR0k(jTBq)>s^D@N}Y-v`&4&2l9rp;o`-Kw`*I4k7sU?R?RV>Rh0OxBS$BuoW|2o z;)h4Iv&I~*on72u7K@7d%4yJ}4%FyRS<=27!B{*DRVPlNnsi)nQ-pqI`Vc5}6QsC7 z(ipY7I>?_6O>9`PAv7UXu(9A!PhaDJ6Z z0Dwwi|7RBFmKT?9W9v|HO5QQa1z(0siT;a~IE(`9x;9)KhO5;K9*;YouP$XcES^>u zTtC777zBbS!b4X-{EHxh2lJ*M;+Lq0G=y+)tu!-un~@cK@3H^F95cku%^#S!jm_Pr zY~kq#Wv(;OCE*&Zo?MDsS2pMH`Ndva$bD5`6OYsBDii0K6JirdURBNEROza5>C|YK z!Y9!O`lT1E>fNHh0DA~hq4YLX?}l-jE2o(i&od|-NPQ3G^Z1elQuS9*k6NEhizh6Lvhe*cH>eDQ@moEEl%e0QTX?Z#yswf9`$L~`Q4SP z>-6QoeZXyBHJ53}M?TfupTL}Ul@3)Tp?7~bBG6#(>a={=3F15g9I7tZ7hqL};BF!n zCe#0h=$)t(p2yZRPg}YL2N9FK)j9#jFWrJ{R^d?6&b=Zf-;gz-FC^)q)_#%2kK)BA zrE3F2XFz`ysUFa>N<}5lphl*k(CNw$t{8USfsi0D*16A3gDziaorTxfKqXKp7!eV&FKc#Vk9EYV>bW)XFlE83M>K^0gHe|z#?D~un1TLECLn*i-1MIBJjB(Fv0x)Pcg;+ z_~;!B=ac_7`Hzz)CjaYmYnH9ZB481)2v`Ix z0u}*_fJML}U=gqgSOhErpEm+uJuo#=qaq#4KheQ12x6V6e}Ztsr|O>|s%yOd$?3C; zM|XX)xO`;SC#UBQ*FQnhu0yp?nC6=@1 z{{M50@tLu6pEt<1K8t`w;LDD{d;aLLsW*0}PC8?wKRCGK(!3M~=Mwl$iE}+vS8I8% zh5EO_40m10NhdTY^ZR**y`RI?zRaFd5b-3QtsaQ4`F7Q}rj=YFuDNj~v+b(e4Z*2f zcVk`=y_@Oz%EPZc82wSbeYIQWB|e(hC*u-k#$*d^Pe@(Tz&y&m$_g~>G|()~weFy} znG8bU@gWut5oSJVfI_+?3J6&Q`Ae5t#F*E5cn-PyOdd(3duw|g7kTIqEe;|fsa+<% zT1b6l@)bNuo$bk_MZ(?3q{)R$^jUVnkRA7dmz5jBX#J^@G`Kf85%_;vhP=#|KN>oz zCO%5_>^ew{xH207op?k?x@)duEtOBb)|z|}UZTsFmFXm9|37jjzqw8!OcV#1mSDu5r|Y7bdfjw;u7m3cVxX<#3#z_*@o9?QlpZ~ z31Q<->--}6jiKA^dhzUaB$va-1<%uD87i6xej=*H{nG6`nkjNTB$9Ym*7iGd_vBuBWOAd2^4=XBZ@zKtOdW^O>(dgYxmR#UBAxWeFX zMB1}>bO@PpnE{iTeRGryw->1ym^=^0gpXcCOVEhDDWSlttqs9FS)J;IOUN*-0*SSf z{)wd{@|zdAKf(s!%c{U?v7nD5gEBrjagfiUR<%Pz>?C4{nf*3(@L2x4Fx7=ETA1_q zu8$m_n%z0IGeTp3Sf|@4a!FG+#7^Haobq*MgWEUH89Ta(cxd2{6kwuuNP>s-jtrVD z>|iC=#wx<#+vB;FJR>}k?=!g~l}si475O)%6kcR5 z)0RX?BTK4u3Et{QshGHvc~c<}V}v|IZua?;d&c z;NKqj&&JQc?E8_AYX95!&FuhVpZs=!yMOln1A8w(K3#IoC~~G^7U29Mw%^_hknbii z)BiH>1-N1E1sMC(?ge=1Ywtd=Hv*Kaw@Rj%&*w&f=gf@&W1q^60L?#q?SZ`uKs-F+ z_7NLq?*jPD?gBVs?gAM5-0lMS?mzv*1A7a=7w{HKclD4 z=KqJw9k+9I3ztw4q`u4Pn&)??cgg1e&rw70J6vS&6~T4K9YZyXhRy$voFGW}S)K)9 zJt=;*&o=-6uyO2ziRX{LdFWrqh99+mw%=O>ECLpRPlUk3d**h4u}|rCfbZVlYS?=L zjJK_F#9B+H`zv)Xz%%Av060dT_+EgQU-|X}dn16o5r9e5&2shAy%FGd$MuZ>P|{Cy zBfv{P{Q3iX7eFQTyuAw`i1J%8(6kA}_v|Erh(|DDG8JD+GhZ(p_uSOhEr76FTZMZh9p x5wHmS9}xKdFuw%=hyP!oTLA7|{iBA>|KH(Y>}mi2 diff --git a/storage/players-sqlite3.db b/storage/players-sqlite3.db new file mode 100644 index 0000000000000000000000000000000000000000..0d638750cff2eabcef87325f8455add98ee2d346 GIT binary patch literal 12288 zcmeHN&u`mC7N+dfrfk^`+orCgH9Uf0w1Uf)l%2#W5TKMSD?cPzm6Uk1yTx`Sj-*M3 z9A`+mu@4J$fjutvAJ|I{dk7XeEPCrzz4uV`Z|JRmL*GzxAkt2I-2y}Cx5)SAec$)K zN6Kz}UZo*HJuW;Pq8;t3mP%zlLrBxKD{!BM`}vlF7xJ_7O}_uF?TYrX|Lqd&HM6{> zEq?_MYEuX(1QY@a0fm40UN9O;2mYQG9W>f!o5XC%Z_>_oX@-!2RG5HXE zxPV}zF1lB5mn-EaYBcZFoaSTnp!^s)58H?L>Tq1GTyK9A?b)Lu2}cHS zh{msc9}?_cb{r$PKfJWQAjBf1e&4n)EN3AeU$C9Hu3aYrZd(lOFK{ zdO}c*x;;3+-LE+<)Z`v+5kU!ZzPK)fZ`?`cjZFxShcKP^eSAWRsKJ~Xz4FN!gui84 zduE{!5C2|?_@N-?0T*9k;g$~W`5_G18fDm@gnzV_(N@>jWO!J60}mN5KZny3Sjzrs z2Qv(@cTC$h?Hlpf*IJ~@eHXPtL7x)QYQ>lR?EQ?kCga-1Cp&t3Kyja!&&Gir=3y5X zEXy{nU9)gA9{8#ghPe9_2JmZ4oSGBDl0%Fh`f&c_qJvTmKj8ua)^doqc(+T$7pssm z3n{k@Gpmoj_WOiQAS!Voh%DuXY3-O+f`yeP?GHk91SyGV9zm|r%aC?Cs$o}9w~A4f z_Hp+;KuXKWif&}}%81bKL6^o=J|?d_RX;Q}E>mGsVws2({bRO9~Hxr)4ZGuryb z`kj<%?Bw)whF`~T0MT8_rB3%u+cK>L*~^iTVAba;E!B$htP@$gz`+R-Plo~Vi$fo@ z&H$^<+mKEJ(QX=_{!7Ig_PYZTNX3q6ZwTUn>?f?Z z44_*=wPED4I>hesVB8l@cR2c=WE(PAW+9OT8nhRhGDWr6A}5qF40@X2kFP?vtpZM) z5y?b&qZht}i8sjE8G(JHu(FkJ#n(O;Tl=q2?5@ZZ;1)T<&g&IP65|f^Is?K&c!CVj z&CnZmJj0bprqRlq#X1k=IqCpTZlSvcz!XQPv7FJat*y!|8(IDS5c^|<4)|#}h-jAh zC)7GKo#By02D*k#pgCFsX=+i#d`_f zI8X3%A)Y>djAA-=6D(Aq z27@b&5&y|K^hMrvt{IW(p9)nAeYiE2cvUAU0v-HY8@m3SR<&OrK!VssGB2H2cwiqeFHI zvjBb?K-E+3Lmqf#53WFl?Z}a!hVu+}$5fz+eH?~xt?s*0Y2(?DXzX_;dLA3=8*1sz zLMAysrD>;20yIUjU%?&eO>jm+gscMM!^}?bz7&h!25%z1w5Gc}4338)&U|}D${6zG zB=dDV7)tA7)R*2Gg|`x&etp`P-oQ0Ufu7cZ>zt9#r}v?WfaS7B0e_!QPaZI;+b}=l zt++aECQcB-Zw-iYgSWL}kQR literal 0 HcmV?d00001 From e08af5fee934f66a21e482dbf0bc3082899a2acc Mon Sep 17 00:00:00 2001 From: Nano Taboada Date: Sun, 15 Feb 2026 15:33:10 -0300 Subject: [PATCH 2/6] fix(tests): correct data inconsistencies and improve test configuration (#248) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Data Corrections: - Fix Di María position abbreviation (LW → RW to match "Right Winger") - Correct team name typos (Villareal → Villarreal, Nottingham Forrest → Nottingham Forest) - Apply corrections consistently across PlayerDTOFakes, PlayerFakes, dml.sql, and production database Configuration Improvements: - Add explicit Hibernate dialect (spring.jpa.database-platform=org.hibernate.community.dialect.SQLiteDialect) to test application.properties for consistency with production Documentation: - Update PlayersService class-level JavaDoc to accurately document @CacheEvict(allEntries=true) cache strategy - Add explanation of why allEntries=true is needed (retrieveAll() caches full list under single key) Addresses code review feedback ensuring test data accuracy and environment consistency. --- AGENTS.md | 10 +++---- .../samples/spring/boot/models/PlayerDTO.java | 8 ++++-- .../spring/boot/services/PlayersService.java | 25 ++++++++++++------ .../spring/boot/test/PlayerDTOFakes.java | 6 ++--- .../samples/spring/boot/test/PlayerFakes.java | 6 ++--- .../test/services/PlayersServiceTests.java | 19 +++++++++++++ src/test/resources/application.properties | 1 + src/test/resources/dml.sql | 6 ++--- storage/players-sqlite3.db | Bin 12288 -> 12288 bytes 9 files changed, 57 insertions(+), 24 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ddad252..491cfcc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,7 +48,7 @@ open target/site/jacoco/index.html ./mvnw test -Dtest=PlayersControllerTests # Run specific test method -./mvnw test -Dtest=PlayersControllerTests#givenGetAll_whenServiceRetrieveAllReturnsPlayers_thenResponseIsOkAndResultIsPlayers +./mvnw test -Dtest=PlayersControllerTests#getAll_playersExist_returnsOkWithAllPlayers # Run tests without rebuilding ./mvnw surefire:test @@ -99,7 +99,7 @@ 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)**: @@ -115,9 +115,9 @@ rm storage/players-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` @@ -281,7 +281,7 @@ rm storage/players-sqlite3.db ./mvnw test -X # Run single test for debugging -./mvnw test -Dtest=PlayersControllerTests#givenGetAll_whenServiceRetrieveAllReturnsPlayers_thenResponseIsOkAndResultIsPlayers -X +./mvnw test -Dtest=PlayersControllerTests#getAll_playersExist_returnsOkWithAllPlayers -X ``` ### Maven wrapper issues 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 index fedc6c9..40fede9 100644 --- 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 @@ -8,6 +8,7 @@ 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; @@ -25,8 +26,8 @@ *
    *
  • firstName: Required (not blank)
  • *
  • lastName: Required (not blank)
  • - *
  • dateOfBirth: Must be a past date
  • - *
  • squadNumber: Must be a positive integer
  • + *
  • 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)
  • *
@@ -38,6 +39,7 @@ * * @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 @@ -50,10 +52,12 @@ public class PlayerDTO { 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 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 index 1be2923..482c725 100644 --- 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 @@ -5,9 +5,9 @@ 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 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; @@ -31,10 +31,16 @@ *

Cache Strategy:

*
    *
  • @Cacheable: Retrieval operations (read-through cache)
  • - *
  • @CachePut: Create operations (update cache)
  • - *
  • @CacheEvict: Update/Delete operations (invalidate 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 @@ -67,9 +73,10 @@ public class PlayersService { * * @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.CachePut + * @see org.springframework.cache.annotation.CacheEvict */ - @CachePut(value = "players", key = "#result.id", unless = "#result == null") + @Transactional + @CacheEvict(value = "players", allEntries = true) public PlayerDTO create(PlayerDTO playerDTO) { // Check if squad number already exists if (playersRepository.findBySquadNumber(playerDTO.getSquadNumber()).isPresent()) { @@ -175,9 +182,10 @@ public PlayerDTO searchBySquadNumber(Integer squadNumber) { * * @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.CachePut + * @see org.springframework.cache.annotation.CacheEvict */ - @CachePut(value = "players", key = "#playerDTO.id") + @Transactional + @CacheEvict(value = "players", allEntries = true) public boolean update(PlayerDTO playerDTO) { if (playerDTO.getId() != null && playersRepository.existsById(playerDTO.getId())) { Player player = mapFrom(playerDTO); @@ -205,7 +213,8 @@ public boolean update(PlayerDTO playerDTO) { * @return true if the player was deleted successfully, false if not found * @see org.springframework.cache.annotation.CacheEvict */ - @CacheEvict(value = "players", key = "#id") + @Transactional + @CacheEvict(value = "players", allEntries = true) public boolean delete(Long id) { if (playersRepository.existsById(id)) { playersRepository.deleteById(id); 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 index 02b50cd..ceb260b 100644 --- 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 @@ -140,7 +140,7 @@ public static java.util.List createAll() { 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", - "LW", "SL Benfica", "Liga Portugal", true), + "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, @@ -157,9 +157,9 @@ public static java.util.List createAll() { 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", - "Villareal", "La Liga", false), + "Villarreal", "La Liga", false), createPlayerDTOWithId(15L, "Gonzalo", "Ariel", "Montiel", LocalDate.of(1997, 1, 1), 4, "Right-Back", - "RB", "Nottingham Forrest", "Premier League", false), + "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", 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 index 741450c..f67b5c8 100644 --- 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 @@ -143,7 +143,7 @@ public static java.util.List createAll() { 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", - "LW", "SL Benfica", "Liga Portugal", true), + "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, @@ -160,9 +160,9 @@ public static java.util.List createAll() { 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", - "Villareal", "La Liga", false), + "Villarreal", "La Liga", false), createPlayerWithId(15L, "Gonzalo", "Ariel", "Montiel", LocalDate.of(1997, 1, 1), 4, "Right-Back", "RB", - "Nottingham Forrest", "Premier League", false), + "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", 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 index 7900905..514a4c2 100644 --- 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 @@ -325,6 +325,25 @@ void update_playerNotFound_returnsFalse() { 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 diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index 98cdb3e..7e0c762 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -6,6 +6,7 @@ # 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 diff --git a/src/test/resources/dml.sql b/src/test/resources/dml.sql index 9905769..97aba6c 100644 --- a/src/test/resources/dml.sql +++ b/src/test/resources/dml.sql @@ -11,7 +11,7 @@ INSERT INTO players (id, firstName, middleName, lastName, dateOfBirth, squadNumb (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', 'LW', 'SL Benfica', 'Liga Portugal', 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), @@ -22,8 +22,8 @@ INSERT INTO players (id, firstName, middleName, lastName, dateOfBirth, squadNumb 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', 'Villareal', 'La Liga', 0), -(15, 'Gonzalo', 'Ariel', 'Montiel', '1997-01-01T00:00:00.000Z', 4, 'Right-Back', 'RB', 'Nottingham Forrest', 'Premier League', 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), diff --git a/storage/players-sqlite3.db b/storage/players-sqlite3.db index 0d638750cff2eabcef87325f8455add98ee2d346..14a1d8e2c04deab9f3c0ca90b04d311997833d6d 100644 GIT binary patch delta 134 zcmZojXh@hK&B#1a#+i|MW5N=C4N10C2L4`tbH4L@seH`53wdpMuJRP{aBwf>cISG> zHJi(f^D<{0r_^RWfm^H-8{#*oid|&pN#^HakQSG=WmFW@pX?;%A)8uU5>S+yo0(dq m;FFq|UYZ)n$HO2hDGpMhIC+JX&F05a?97Zon_tU{3IPCmDI`Sz delta 228 zcmZojXh@hK&B!uQ#+i|2W5N=CEh#n*2L4`tbH4L@seH`53wdpMuJRP{aBwf>cISG> zHHXWL^D<{0Cs5gD9f4b{vC+aj4APR)wv2{?($XA0sfl?hMfom?d6}s>0f|MaDXGPV zmX;>E24=cOmLUcPR$!!OU|XMq4npd1zma5>JnUV(7kmBO&7_1PKpPLw*T9lcp z;J7(e>>@Ky3O~>aaiA57f`*fwq&(z`Qj1Fhic)hkQ;QUUHl~-R2JrDPh)Rlsl*mtB TE@iX%i4+?%qtE8ovZ6u&)QUa3 From d0d1d04187419c9568df87f984f337eae9a3ccf3 Mon Sep 17 00:00:00 2001 From: Nano Taboada Date: Sun, 15 Feb 2026 16:09:08 -0300 Subject: [PATCH 3/6] refactor: apply comprehensive code review improvements (#248) REST API Improvements: - Change PUT endpoint from /players to /players/{id} for REST convention compliance - Add path/body ID mismatch validation (returns 400 Bad Request) - Update all documentation (AGENTS.md, README.md) to reflect /players/{id} pattern Repository & Data Layer: - Upgrade PlayersRepository from CrudRepository to JpaRepository per coding guidelines - Remove redundant findById() declaration (inherited from JpaRepository) - Add @Column(unique=true) to squadNumber field matching database UNIQUE constraint - Add UNIQUE constraint on squadNumber in ddl.sql schema Caching Optimizations: - Prevent caching null results in retrieveById() with unless="#result == null" - Avoid serving stale cache misses for non-existent players Date/Time Handling: - Fix IsoDateConverter to use OffsetDateTime.atOffset(ZoneOffset.UTC) instead of manual "Z" concatenation - Use ISO_OFFSET_DATE_TIME formatter for proper timezone-aware conversion - Improve round-trip consistency by parsing with same formatter Test Improvements: - Replace JUnit assertTrue() with AssertJ assertThat().isPresent() for consistency - Fix PUT test failures by setting IDs on PlayerDTO test fixtures - Add put_idMismatch_returnsBadRequest() test for ID validation - Add standard imports (List, Arrays) instead of fully qualified names in test fakes Configuration: - Add explicit spring.jpa.database-platform=SQLiteDialect to test properties - Ensure test/production Hibernate dialect consistency Code Quality: - Apply @RequiredArgsConstructor to PlayersController per Lombok guidelines - Remove extra whitespace in dml.sql - Fix AGENTS.md architecture tree (remove duplicate IsoDateConverter.java entry) All changes maintain backward compatibility while improving code quality, REST compliance, and cache behavior. --- AGENTS.md | 9 ++-- README.md | 2 +- .../boot/controllers/PlayersController.java | 23 ++++++---- .../boot/converters/IsoDateConverter.java | 16 ++++--- .../samples/spring/boot/models/Player.java | 11 +++++ .../boot/repositories/PlayersRepository.java | 24 +++------- .../spring/boot/services/PlayersService.java | 7 +-- .../spring/boot/test/PlayerDTOFakes.java | 6 ++- .../samples/spring/boot/test/PlayerFakes.java | 6 ++- .../controllers/PlayersControllerTests.java | 44 ++++++++++++++++--- .../repositories/PlayersRepositoryTests.java | 3 +- src/test/resources/ddl.sql | 2 +- src/test/resources/dml.sql | 2 +- 13 files changed, 97 insertions(+), 58 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 491cfcc..d693bb2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -191,11 +191,10 @@ src/main/java/ar/com/nanotaboada/java/samples/spring/boot/ ├── models/ # Domain models │ ├── Player.java # @Entity, JPA model -│ ├── PlayerDTO.java # Data Transfer Object, validation -│ └── IsoDateConverter.java # JPA converter for ISO-8601 dates +│ └── PlayerDTO.java # Data Transfer Object, validation └── converters/ # Infrastructure converters - └── IsoDateConverter.java # JPA converter + └── IsoDateConverter.java # JPA converter for ISO-8601 dates src/test/java/ # Test classes ├── PlayersControllerTests.java @@ -226,7 +225,7 @@ src/test/java/ # Test classes | `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` | Update player | +| `PUT` | `/players/{id}` | Update player | | `DELETE` | `/players/{id}` | Delete player | | `GET` | `/actuator/health` | Health check | | `GET` | `/swagger-ui.html` | API documentation | @@ -344,7 +343,7 @@ curl -X POST http://localhost:8080/players \ }' # Update player -curl -X PUT http://localhost:8080/players \ +curl -X PUT http://localhost:8080/players/1 \ -H "Content-Type: application/json" \ -d '{ "id": 1, diff --git a/README.md b/README.md index dfb9326..53ea5be 100644 --- a/README.md +++ b/README.md @@ -173,7 +173,7 @@ Interactive API documentation is available via Swagger UI at `http://localhost:9 - `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` - Update existing player +- `PUT /players/{id}` - Update existing player - `DELETE /players/{id}` - Remove player - `GET /actuator/health` - Health check 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 index 0240ffc..9c2d5c3 100644 --- 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 @@ -25,6 +25,7 @@ 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. @@ -59,14 +60,11 @@ */ @RestController @Tag(name = "Players") +@RequiredArgsConstructor public class PlayersController { private final PlayersService playersService; - public PlayersController(PlayersService playersService) { - this.playersService = playersService; - } - /* * ----------------------------------------------------------------------------------------------------------------------- * HTTP POST @@ -203,20 +201,27 @@ public ResponseEntity searchBySquadNumber(@PathVariable Integer squad /** * Updates an existing player resource (full update). *

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

* - * @param playerDTO the complete player data (must include valid ID and pass validation) - * @return 204 No Content if successful, 404 Not Found if player doesn't exist, or 400 Bad Request if validation fails + * @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") + @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(@RequestBody @Valid PlayerDTO playerDTO) { + 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() 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 index 9c08a88..e96d026 100644 --- 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 @@ -1,6 +1,8 @@ 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; @@ -26,7 +28,7 @@ * * *

Usage Example:

- * + * *
  * {
  *     @code
@@ -45,7 +47,7 @@
 @Converter
 public class IsoDateConverter implements AttributeConverter {
 
-    private static final DateTimeFormatter ISO_FORMATTER = DateTimeFormatter.ISO_DATE_TIME;
+    private static final DateTimeFormatter ISO_FORMATTER = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
 
     /**
      * Converts a {@link LocalDate} to an ISO-8601 formatted string for database
@@ -53,14 +55,14 @@ public class IsoDateConverter implements AttributeConverter {
      *
      * @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
+     * input is null
      */
     @Override
     public String convertToDatabaseColumn(LocalDate date) {
         if (date == null) {
             return null;
         }
-        return date.atStartOfDay().format(ISO_FORMATTER) + "Z";
+        return date.atStartOfDay().atOffset(ZoneOffset.UTC).format(ISO_FORMATTER);
     }
 
     /**
@@ -72,7 +74,7 @@ public String convertToDatabaseColumn(LocalDate date) {
      * 

* * @param dateString the ISO-8601 formatted string from the database (may be - * null or blank) + * null or blank) * @return the corresponding LocalDate, or null if input is null or blank */ @Override @@ -82,8 +84,8 @@ public LocalDate convertToEntityAttribute(String dateString) { } // Handle both "YYYY-MM-DD" and "YYYY-MM-DDTHH:mm:ss.SSSZ" formats if (dateString.contains("T")) { - return LocalDate.parse(dateString.substring(0, 10)); + return OffsetDateTime.parse(dateString, ISO_FORMATTER).toLocalDate(); } - return LocalDate.parse(dateString); + return LocalDate.parse(dateString, DateTimeFormatter.ISO_DATE); } } 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 index 669fed7..1a47dbb 100644 --- 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 @@ -8,6 +8,7 @@ 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; @@ -57,7 +58,17 @@ public class Player { @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; 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 index f5171c3..87cb125 100644 --- 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 @@ -3,8 +3,8 @@ 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.CrudRepository; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @@ -14,12 +14,12 @@ * 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 CrudRepository} for basic CRUD operations and defines custom queries for advanced search functionality. + * Extends {@link JpaRepository} for CRUD operations, batch operations, and query methods. *

* *

Provided Methods:

*
    - *
  • Inherited from CrudRepository: save, findAll, findById, delete, etc.
  • + *
  • 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)
  • *
@@ -31,28 +31,14 @@ * * * @see Player - * @see org.springframework.data.repository.CrudRepository + * @see org.springframework.data.jpa.repository.JpaRepository * @see Query * Creation from Method Names * @since 4.0.2025 */ @Repository -public interface PlayersRepository extends CrudRepository { - /** - * Finds a player by their unique identifier. - *

- * This is a derived query method - Spring Data JPA automatically implements it based on the method name convention. - *

- * - * @param id the unique identifier of the player - * @return an Optional containing the player if found, empty Optional otherwise - * @see Query - * Creation from Method Names - */ - // Non-default methods in interfaces are not shown in coverage reports https://www.jacoco.org/jacoco/trunk/doc/faq.html - Optional findById(Long id); +public interface PlayersRepository extends JpaRepository { /** * Finds players by league name using case-insensitive wildcard matching. 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 index 482c725..dad53c1 100644 --- 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 @@ -1,7 +1,6 @@ package ar.com.nanotaboada.java.samples.spring.boot.services; import java.util.List; -import java.util.stream.StreamSupport; import org.modelmapper.ModelMapper; import org.springframework.cache.annotation.CacheEvict; @@ -98,13 +97,14 @@ public PlayerDTO create(PlayerDTO playerDTO) { *

* 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") + @Cacheable(value = "players", key = "#id", unless = "#result == null") public PlayerDTO retrieveById(Long id) { return playersRepository.findById(id) .map(this::mapFrom) @@ -123,7 +123,8 @@ public PlayerDTO retrieveById(Long id) { */ @Cacheable(value = "players") public List retrieveAll() { - return StreamSupport.stream(playersRepository.findAll().spliterator(), false) + return playersRepository.findAll() + .stream() .map(this::mapFrom) .toList(); } 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 index ceb260b..8cadd71 100644 --- 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 @@ -1,6 +1,8 @@ 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; @@ -126,8 +128,8 @@ public static PlayerDTO createOneInvalid() { * Note: Repository tests query real in-memory DB directly (25 players * pre-seeded) */ - public static java.util.List createAll() { - return java.util.Arrays.asList( + 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), 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 index f67b5c8..5eea56c 100644 --- 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 @@ -1,6 +1,8 @@ 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; @@ -129,8 +131,8 @@ public static Player createOneInvalid() { * Note: Repository tests query real in-memory DB directly (25 players * pre-seeded) */ - public static java.util.List createAll() { - return java.util.Arrays.asList( + 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), 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 index e5fa275..84bbf30 100644 --- 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 @@ -357,7 +357,7 @@ void searchBySquadNumber_playerNotFound_returnsNotFound() /** * Given a player exists and valid update data is provided - * When PUT /players is called and the service successfully updates the player + * When PUT /players/{id} is called and the service successfully updates the player * Then response status is 204 No Content */ @Test @@ -365,12 +365,14 @@ 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 = new ObjectMapper().writeValueAsString(playerDTO); Mockito .when(playersServiceMock.update(any(PlayerDTO.class))) .thenReturn(true); MockHttpServletRequestBuilder request = MockMvcRequestBuilders - .put(PATH) + .put(PATH + "/{id}", id) .content(body) .contentType(MediaType.APPLICATION_JSON); // Act @@ -385,7 +387,7 @@ void put_playerExists_returnsNoContent() /** * Given a player with the provided ID does not exist - * When PUT /players is called and the service returns false + * When PUT /players/{id} is called and the service returns false * Then response status is 404 Not Found */ @Test @@ -393,12 +395,14 @@ 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 = new ObjectMapper().writeValueAsString(playerDTO); Mockito .when(playersServiceMock.update(any(PlayerDTO.class))) .thenReturn(false); MockHttpServletRequestBuilder request = MockMvcRequestBuilders - .put(PATH) + .put(PATH + "/{id}", id) .content(body) .contentType(MediaType.APPLICATION_JSON); // Act @@ -413,7 +417,7 @@ void put_playerNotFound_returnsNotFound() /** * Given invalid player data is provided (validation fails) - * When PUT /players is called + * When PUT /players/{id} is called * Then response status is 400 Bad Request and service is never called */ @Test @@ -421,9 +425,37 @@ void put_invalidPlayer_returnsBadRequest() throws Exception { // Arrange PlayerDTO playerDTO = PlayerDTOFakes.createOneInvalid(); + Long id = 1L; + String body = new 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 = new ObjectMapper().writeValueAsString(playerDTO); MockHttpServletRequestBuilder request = MockMvcRequestBuilders - .put(PATH) + .put(PATH + "/{id}", pathId) .content(body) .contentType(MediaType.APPLICATION_JSON); // Act 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 index c072f6c..1cf4010 100644 --- 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 @@ -1,7 +1,6 @@ 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; @@ -41,7 +40,7 @@ void findById_playerExists_returnsPlayer() { // Act Optional actual = repository.findById(saved.getId()); // Assert - assertTrue(actual.isPresent()); + assertThat(actual).isPresent(); assertThat(actual.get()).usingRecursiveComparison().isEqualTo(saved); } diff --git a/src/test/resources/ddl.sql b/src/test/resources/ddl.sql index 02ab732..ed4ac22 100644 --- a/src/test/resources/ddl.sql +++ b/src/test/resources/ddl.sql @@ -10,7 +10,7 @@ CREATE TABLE players ( middleName TEXT, lastName TEXT NOT NULL, dateOfBirth TEXT NOT NULL, - squadNumber INTEGER NOT NULL, + squadNumber INTEGER NOT NULL UNIQUE, position TEXT NOT NULL, abbrPosition TEXT NOT NULL, team TEXT NOT NULL, diff --git a/src/test/resources/dml.sql b/src/test/resources/dml.sql index 97aba6c..13f8558 100644 --- a/src/test/resources/dml.sql +++ b/src/test/resources/dml.sql @@ -32,6 +32,6 @@ INSERT INTO players (id, firstName, middleName, lastName, dateOfBirth, squadNumb (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), +(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); From aeece8e2d47427e74e782c26df0213359730a6df Mon Sep 17 00:00:00 2001 From: Nano Taboada Date: Sun, 15 Feb 2026 17:02:44 -0300 Subject: [PATCH 4/6] refactor(tests,service): add logging and improve test robustness (#248) - Add SLF4J logging to PlayersService (debug/info/warn levels) - Remove redundant setId(null) in PlayersRepositoryTests - Fix controller tests to assert Content-Type instead of overriding it - Use id variable consistently in getById test - Fix put_invalidPlayer test ID alignment (use DTO.getId()) - Add @TestConfiguration for ObjectMapper bean injection in tests - Use unnamed pattern for unused DataIntegrityViolationException --- README.md | 5 +- .../boot/controllers/PlayersController.java | 2 +- .../spring/boot/services/PlayersService.java | 38 +++++++++++-- .../controllers/PlayersControllerTests.java | 56 ++++++++++++------- .../repositories/PlayersRepositoryTests.java | 1 - 5 files changed, 72 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 53ea5be..3760bc5 100644 --- a/README.md +++ b/README.md @@ -243,7 +243,10 @@ open target/site/jacoco/index.html - **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: `givenMethod_whenDependency[Method]_thenResult` +- **Naming Convention** - `method_scenario_outcome` pattern: + - `getAll_playersExist_returnsOkWithAllPlayers()` + - `post_squadNumberExists_returnsConflict()` + - `findById_playerExists_returnsPlayer()` **Coverage Targets:** 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 index 9c2d5c3..dba218f 100644 --- 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 @@ -41,7 +41,7 @@ *
  • 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} - Update an existing player
  • + *
  • PUT {@code /players/{id}} - Update an existing player
  • *
  • DELETE {@code /players/{id}} - Delete a player by ID
  • * * 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 index dad53c1..3e3366e 100644 --- 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 @@ -5,6 +5,7 @@ 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; @@ -12,6 +13,7 @@ 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. @@ -46,6 +48,7 @@ * @see org.modelmapper.ModelMapper * @since 4.0.2025 */ +@Slf4j @Service @RequiredArgsConstructor public class PlayersService { @@ -66,8 +69,10 @@ public class PlayersService { * ID. The result is automatically cached using the player's ID as the cache key. *

    *

    - * Conflict Detection: Before creating, checks if a player with the same squad number already exists. - * Squad numbers are unique identifiers (jersey numbers). + * 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) @@ -77,13 +82,26 @@ public class PlayersService { @Transactional @CacheEvict(value = "players", allEntries = true) public PlayerDTO create(PlayerDTO playerDTO) { - // Check if squad number already exists + 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 } - Player player = mapFrom(playerDTO); - Player savedPlayer = playersRepository.save(player); - return mapFrom(savedPlayer); + + 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; + } } /* @@ -188,11 +206,15 @@ public PlayerDTO searchBySquadNumber(Integer squadNumber) { @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; } } @@ -217,10 +239,14 @@ public boolean update(PlayerDTO playerDTO) { @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; } } 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 index 84bbf30..0d2ea0d 100644 --- 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 @@ -14,7 +14,10 @@ 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; @@ -36,6 +39,7 @@ @DisplayName("HTTP Methods on Controller") @WebMvcTest(PlayersController.class) @AutoConfigureCache +@AutoConfigureJsonTesters class PlayersControllerTests { private static final String PATH = "/players"; @@ -49,6 +53,17 @@ class PlayersControllerTests { @MockitoBean private PlayersRepository playersRepositoryMock; + @Autowired + private ObjectMapper objectMapper; + + @TestConfiguration + static class ObjectMapperTestConfig { + @Bean + public ObjectMapper objectMapper() { + return new ObjectMapper().findAndRegisterModules(); + } + } + /* * ------------------------------------------------------------------------- * HTTP POST @@ -67,7 +82,7 @@ void post_validPlayer_returnsCreated() PlayerDTO playerDTO = PlayerDTOFakes.createOneValid(); PlayerDTO savedPlayer = PlayerDTOFakes.createOneValid(); savedPlayer.setId(19L); // Simulating auto-generated ID - String body = new ObjectMapper().writeValueAsString(playerDTO); + String body = objectMapper.writeValueAsString(playerDTO); Mockito .when(playersServiceMock.create(any(PlayerDTO.class))) .thenReturn(savedPlayer); @@ -80,7 +95,6 @@ void post_validPlayer_returnsCreated() .perform(request) .andReturn() .getResponse(); - response.setContentType("application/json;charset=UTF-8"); // Assert verify(playersServiceMock, times(1)).create(any(PlayerDTO.class)); assertThat(response.getStatus()).isEqualTo(HttpStatus.CREATED.value()); @@ -98,7 +112,7 @@ void post_invalidPlayer_returnsBadRequest() throws Exception { // Arrange PlayerDTO playerDTO = PlayerDTOFakes.createOneInvalid(); - String body = new ObjectMapper().writeValueAsString(playerDTO); + String body = objectMapper.writeValueAsString(playerDTO); MockHttpServletRequestBuilder request = MockMvcRequestBuilders .post(PATH) .content(body) @@ -123,7 +137,7 @@ void post_squadNumberExists_returnsConflict() throws Exception { // Arrange PlayerDTO playerDTO = PlayerDTOFakes.createOneValid(); - String body = new ObjectMapper().writeValueAsString(playerDTO); + String body = objectMapper.writeValueAsString(playerDTO); Mockito .when(playersServiceMock.create(any(PlayerDTO.class))) .thenReturn(null); // Conflict: squad number already exists @@ -159,7 +173,7 @@ void getById_playerExists_returnsOkWithPlayer() PlayerDTO playerDTO = PlayerDTOFakes.createOneForUpdate(); Long id = 1L; Mockito - .when(playersServiceMock.retrieveById(1L)) + .when(playersServiceMock.retrieveById(id)) .thenReturn(playerDTO); MockHttpServletRequestBuilder request = MockMvcRequestBuilders .get(PATH + "/{id}", id); @@ -168,11 +182,11 @@ void getById_playerExists_returnsOkWithPlayer() .perform(request) .andReturn() .getResponse(); - response.setContentType("application/json;charset=UTF-8"); String content = response.getContentAsString(); - PlayerDTO result = new ObjectMapper().readValue(content, PlayerDTO.class); + PlayerDTO result = objectMapper.readValue(content, PlayerDTO.class); // Assert - verify(playersServiceMock, times(1)).retrieveById(1L); + assertThat(response.getContentType()).contains("application/json"); + verify(playersServiceMock, times(1)).retrieveById(id); assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value()); assertThat(result).usingRecursiveComparison().isEqualTo(playerDTO); } @@ -222,11 +236,11 @@ void getAll_playersExist_returnsOkWithAllPlayers() .perform(request) .andReturn() .getResponse(); - response.setContentType("application/json;charset=UTF-8"); String content = response.getContentAsString(); - List result = new ObjectMapper().readValue(content, new TypeReference>() { + 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); @@ -255,11 +269,11 @@ void searchByLeague_matchingPlayersExist_returnsOkWithList() .perform(request) .andReturn() .getResponse(); - response.setContentType("application/json;charset=UTF-8"); String content = response.getContentAsString(); - List result = new ObjectMapper().readValue(content, new TypeReference>() { + 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); @@ -285,11 +299,11 @@ void searchByLeague_noMatches_returnsOkWithEmptyList() .perform(request) .andReturn() .getResponse(); - response.setContentType("application/json;charset=UTF-8"); String content = response.getContentAsString(); - List result = new ObjectMapper().readValue(content, new TypeReference>() { + 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(); @@ -315,10 +329,10 @@ void searchBySquadNumber_playerExists_returnsOkWithPlayer() .perform(request) .andReturn() .getResponse(); - response.setContentType("application/json;charset=UTF-8"); String content = response.getContentAsString(); - PlayerDTO result = new ObjectMapper().readValue(content, PlayerDTO.class); + 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); @@ -367,7 +381,7 @@ void put_playerExists_returnsNoContent() PlayerDTO playerDTO = PlayerDTOFakes.createOneValid(); playerDTO.setId(1L); // Set ID for update operation Long id = playerDTO.getId(); - String body = new ObjectMapper().writeValueAsString(playerDTO); + String body = objectMapper.writeValueAsString(playerDTO); Mockito .when(playersServiceMock.update(any(PlayerDTO.class))) .thenReturn(true); @@ -397,7 +411,7 @@ void put_playerNotFound_returnsNotFound() PlayerDTO playerDTO = PlayerDTOFakes.createOneValid(); playerDTO.setId(999L); // Set ID for update operation Long id = playerDTO.getId(); - String body = new ObjectMapper().writeValueAsString(playerDTO); + String body = objectMapper.writeValueAsString(playerDTO); Mockito .when(playersServiceMock.update(any(PlayerDTO.class))) .thenReturn(false); @@ -425,8 +439,8 @@ void put_invalidPlayer_returnsBadRequest() throws Exception { // Arrange PlayerDTO playerDTO = PlayerDTOFakes.createOneInvalid(); - Long id = 1L; - String body = new ObjectMapper().writeValueAsString(playerDTO); + Long id = playerDTO.getId(); + String body = objectMapper.writeValueAsString(playerDTO); MockHttpServletRequestBuilder request = MockMvcRequestBuilders .put(PATH + "/{id}", id) .content(body) @@ -453,7 +467,7 @@ void put_idMismatch_returnsBadRequest() PlayerDTO playerDTO = PlayerDTOFakes.createOneValid(); playerDTO.setId(999L); // Body has different ID Long pathId = 1L; // Path has different ID - String body = new ObjectMapper().writeValueAsString(playerDTO); + String body = objectMapper.writeValueAsString(playerDTO); MockHttpServletRequestBuilder request = MockMvcRequestBuilders .put(PATH + "/{id}", pathId) .content(body) 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 index 1cf4010..529a6cb 100644 --- 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 @@ -35,7 +35,6 @@ class PlayersRepositoryTests { void findById_playerExists_returnsPlayer() { // Arrange Player leandro = PlayerFakes.createOneValid(); - leandro.setId(null); Player saved = repository.save(leandro); // Act Optional actual = repository.findById(saved.getId()); From 4d1c0803040c6d81b65dfde5ecc83c5630a2d5c8 Mon Sep 17 00:00:00 2001 From: Nano Taboada Date: Sun, 15 Feb 2026 17:38:22 -0300 Subject: [PATCH 5/6] test: add coverage for race condition and null ID edge cases (#248) - Add test for DataIntegrityViolationException during concurrent creates - Add test for PUT with null body ID (validates ID from path) - Refactor URI construction to use ServletUriComponentsBuilder --- .../boot/controllers/PlayersController.java | 9 +++--- .../controllers/PlayersControllerTests.java | 30 +++++++++++++++++++ .../test/services/PlayersServiceTests.java | 28 +++++++++++++++++ 3 files changed, 63 insertions(+), 4 deletions(-) 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 index dba218f..d6f4129 100644 --- 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 @@ -14,7 +14,7 @@ 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.mvc.method.annotation.MvcUriComponentsBuilder; +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; @@ -97,9 +97,10 @@ public ResponseEntity post(@RequestBody @Valid PlayerDTO playerDTO) { if (createdPlayer == null) { return ResponseEntity.status(HttpStatus.CONFLICT).build(); } - URI location = MvcUriComponentsBuilder - .fromMethodCall(MvcUriComponentsBuilder.on(PlayersController.class).getById(createdPlayer.getId())) - .build() + URI location = ServletUriComponentsBuilder + .fromCurrentRequest() + .path("/{id}") + .buildAndExpand(createdPlayer.getId()) .toUri(); return ResponseEntity.status(HttpStatus.CREATED) .header(LOCATION, location.toString()) 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 index 0d2ea0d..81583fd 100644 --- 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 @@ -482,6 +482,36 @@ void put_idMismatch_returnsBadRequest() 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 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 index 514a4c2..4251d87 100644 --- 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 @@ -25,6 +25,7 @@ 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; +import org.springframework.dao.DataIntegrityViolationException; @DisplayName("CRUD Operations on Service") @ExtendWith(MockitoExtension.class) @@ -98,6 +99,33 @@ void create_squadNumberExists_returnsNull() { 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 From 40745a25ceb51c811cb5bffae25dd9f2f301f7f8 Mon Sep 17 00:00:00 2001 From: Nano Taboada Date: Sun, 15 Feb 2026 18:27:00 -0300 Subject: [PATCH 6/6] docs: remove incorrect 400 response from DELETE endpoint (#248) - Remove invalid Bad Request response code from DELETE OpenAPI spec - Refactor squad number lookup test to use stream filter vs hardcoded index --- .../boot/controllers/PlayersController.java | 1 - .../controllers/PlayersControllerTests.java | 5 ++++- .../boot/test/services/PlayersServiceTests.java | 17 +++++++++++++---- 3 files changed, 17 insertions(+), 6 deletions(-) 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 index d6f4129..267cd85 100644 --- 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 @@ -245,7 +245,6 @@ public ResponseEntity put(@PathVariable Long id, @RequestBody @Valid Playe @Operation(summary = "Deletes 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 delete(@PathVariable Long id) { 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 index 81583fd..cc64250 100644 --- 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 @@ -318,7 +318,10 @@ void searchByLeague_noMatches_returnsOkWithEmptyList() void searchBySquadNumber_playerExists_returnsOkWithPlayer() throws Exception { // Arrange - PlayerDTO playerDTO = PlayerDTOFakes.createAll().get(9); // Messi is at index 9 + PlayerDTO playerDTO = PlayerDTOFakes.createAll().stream() + .filter(player -> player.getSquadNumber() == 10) + .findFirst() + .orElseThrow(); Mockito .when(playersServiceMock.searchBySquadNumber(10)) .thenReturn(playerDTO); 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 index 4251d87..9a40917 100644 --- 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 @@ -18,6 +18,7 @@ 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; @@ -25,7 +26,6 @@ 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; -import org.springframework.dao.DataIntegrityViolationException; @DisplayName("CRUD Operations on Service") @ExtendWith(MockitoExtension.class) @@ -87,7 +87,10 @@ void create_noConflict_returnsPlayerDTO() { void create_squadNumberExists_returnsNull() { // Arrange PlayerDTO playerDTO = PlayerDTOFakes.createOneValid(); - Player existingPlayer = PlayerFakes.createAll().get(4); // Squad number 5 already exists + Player existingPlayer = PlayerFakes.createAll().stream() + .filter(player -> player.getSquadNumber() == 10) + .findFirst() + .orElseThrow(); Mockito .when(playersRepositoryMock.findBySquadNumber(playerDTO.getSquadNumber())) .thenReturn(Optional.of(existingPlayer)); @@ -264,8 +267,14 @@ void searchByLeague_noMatches_returnsEmptyList() { @Test void searchBySquadNumber_playerExists_returnsPlayerDTO() { // Arrange - Player player = PlayerFakes.createAll().get(9); // Messi is at index 9 - PlayerDTO playerDTO = PlayerDTOFakes.createAll().get(9); + 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));