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 @@
[](https://www.codefactor.io/repository/github/nanotaboada/java.samples.spring.boot)
[](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));