From 650e8331d3a58b9d6f38944bed0cd734fae1fe26 Mon Sep 17 00:00:00 2001 From: Claude Orchestrator Date: Thu, 31 Jul 2025 22:44:46 +0000 Subject: [PATCH] Comprehensive refactoring, testing, documentation, and security improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major improvements: - Refactored controllers with proper error handling and validation - Added comprehensive test coverage (unit, integration, controller tests) - Enhanced documentation with detailed README and Javadoc comments - Fixed bugs including null handling, validation, and proper HTTP responses - Implemented security measures with Spring Security and validation - Added global exception handler for consistent error responses - Updated models with proper validation annotations and constraints - Improved service layer with transaction management and error handling - Added H2 database configuration for development and testing - Created proper project structure with configuration classes πŸ€– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- README.md | 197 +++++++++++++++- build.gradle | 5 +- .../studentify/config/SecurityConfig.java | 53 +++++ .../controller/HealthController.java | 29 ++- .../controller/StudentController.java | 79 ++++--- .../exception/GlobalExceptionHandler.java | 103 ++++++++ .../fpalanturing/studentify/model/Health.java | 50 +++- .../studentify/model/Student.java | 87 +++++-- .../studentify/model/StudentDao.java | 49 +++- .../repository/StudentRepository.java | 19 +- .../studentify/service/StudentService.java | 23 +- .../service/StudentServiceDatabase.java | 58 ++++- src/main/resources/application.properties | 24 ++ .../fpalanturing/studentify/HealthTests.java | 71 ++++-- .../controller/HealthControllerTest.java | 51 ++++ .../controller/StudentControllerTest.java | 208 +++++++++++++++++ .../integration/StudentIntegrationTest.java | 134 +++++++++++ .../service/StudentServiceDatabaseTest.java | 220 ++++++++++++++++++ .../resources/application-test.properties | 17 ++ 19 files changed, 1378 insertions(+), 99 deletions(-) create mode 100644 src/main/java/es/fpalanturing/studentify/config/SecurityConfig.java create mode 100644 src/main/java/es/fpalanturing/studentify/exception/GlobalExceptionHandler.java create mode 100644 src/test/java/es/fpalanturing/studentify/controller/HealthControllerTest.java create mode 100644 src/test/java/es/fpalanturing/studentify/controller/StudentControllerTest.java create mode 100644 src/test/java/es/fpalanturing/studentify/integration/StudentIntegrationTest.java create mode 100644 src/test/java/es/fpalanturing/studentify/service/StudentServiceDatabaseTest.java create mode 100644 src/test/resources/application-test.properties diff --git a/README.md b/README.md index 1db0218..2d78197 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,193 @@ -# API de estudiantes en Spring Boot -Api con dos endpoints REST: /api/health y /api/students tal como el proyecto PHP. -Ahora mismo tiene una base de datos InMemory para pruebas pero se configurara para usar una base de datos en MySQL. Con esta configuraciΓ³n cuando se reinicia el servidor los datos se pierden. +# Studentify - Student Management API -# Gradle -El proyecto usa Gradle como herramienta de compilaciΓ³n. El proyecto incluye gradlew y gradlew.bat para compilarlos en Linux y Windows respectivamente, sin la necesidad de instalar Gradle en el equipo. \ No newline at end of file +A Spring Boot REST API for managing student records with health monitoring capabilities. + +## Features + +- **Student Management**: Create and retrieve student records +- **Health Monitoring**: API health check endpoint +- **Data Validation**: Comprehensive input validation with meaningful error messages +- **Database Integration**: JPA/Hibernate with MySQL support (H2 for testing) +- **Comprehensive Testing**: Unit tests, integration tests, and controller tests +- **Security**: Input validation and error handling + +## API Endpoints + +### Students + +#### GET `/api/students` +Retrieves all students from the system. + +**Response:** +```json +[ + { + "id": 1, + "name": "John Doe", + "email": "john.doe@example.com" + } +] +``` + +#### POST `/api/students` +Creates a new student. + +**Request Body:** +```json +{ + "name": "Jane Smith", + "email": "jane.smith@example.com" +} +``` + +**Response:** `201 Created` +```json +{ + "id": 2, + "name": "Jane Smith", + "email": "jane.smith@example.com" +} +``` + +**Validation Rules:** +- `name`: Required, 2-100 characters +- `email`: Required, valid email format, unique, max 255 characters + +### Health Check + +#### GET `/api/health` +Returns API health status. + +**Response:** +```json +{ + "status": "success", + "message": "API is healthy", + "timestamp": "2025-01-31T10:30:45" +} +``` + +## Technology Stack + +- **Java 21** - Programming language +- **Spring Boot 3.4.1** - Application framework +- **Spring Data JPA** - Data persistence +- **MySQL** - Production database +- **H2** - In-memory database for testing +- **JUnit 5** - Testing framework +- **Mockito** - Mocking framework +- **Gradle** - Build tool + +## Getting Started + +### Prerequisites + +- Java 21 or higher +- MySQL database (for production) + +### Building the Project + +The project uses Gradle with wrapper scripts, so you don't need to install Gradle separately. + +**Linux/macOS:** +```bash +./gradlew build +``` + +**Windows:** +```cmd +gradlew.bat build +``` + +### Running the Application + +**Development (with H2 in-memory database):** +```bash +./gradlew bootRun +``` + +**Production (configure MySQL first):** +1. Update `application.properties` with your MySQL connection details +2. Run: `./gradlew bootRun --args='--spring.profiles.active=production'` + +### Running Tests + +**All tests:** +```bash +./gradlew test +``` + +**Integration tests only:** +```bash +./gradlew test --tests "*Integration*" +``` + +## Database Configuration + +### Development (H2 - Default) +The application uses H2 in-memory database by default for development and testing. + +### Production (MySQL) +Add these properties to `application.properties`: + +```properties +spring.datasource.url=jdbc:mysql://localhost:3306/studentify +spring.datasource.username=your_username +spring.datasource.password=your_password +spring.jpa.hibernate.ddl-auto=update +spring.jpa.database-platform=org.hibernate.dialect.MySQLDialect +``` + +## Project Structure + +``` +src/ +β”œβ”€β”€ main/java/es/fpalanturing/studentify/ +β”‚ β”œβ”€β”€ controller/ # REST controllers +β”‚ β”œβ”€β”€ model/ # Entity and DTO classes +β”‚ β”œβ”€β”€ repository/ # Data access layer +β”‚ └── service/ # Business logic layer +└── test/java/es/fpalanturing/studentify/ + β”œβ”€β”€ controller/ # Controller unit tests + β”œβ”€β”€ service/ # Service unit tests + └── integration/ # Integration tests +``` + +## Error Handling + +The API provides meaningful error responses: + +- `400 Bad Request` - Invalid input data +- `500 Internal Server Error` - Server errors (duplicate email, database issues) + +## Security Considerations + +- Input validation on all endpoints +- Email uniqueness constraint +- Proper error handling without exposing sensitive information +- Data sanitization (trimming whitespace) + +## Development + +### Code Style +- Follow Java naming conventions +- Use Javadoc for public methods and classes +- Maintain comprehensive test coverage +- Handle exceptions appropriately + +### Testing Strategy +- **Unit Tests**: Test individual components in isolation +- **Integration Tests**: Test complete request-response flow +- **Controller Tests**: Test REST endpoints with MockMvc + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Add tests for new functionality +4. Ensure all tests pass +5. Submit a pull request + +## License + +This project is developed for educational purposes. \ No newline at end of file diff --git a/build.gradle b/build.gradle index b8c9521..13badb4 100644 --- a/build.gradle +++ b/build.gradle @@ -26,13 +26,16 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-security' runtimeOnly 'com.mysql:mysql-connector-j' - //runtimeOnly 'com.h2database:h2' + runtimeOnly 'com.h2database:h2' implementation 'org.springframework.boot:spring-boot-starter-web' developmentOnly 'org.springframework.boot:spring-boot-devtools' annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' providedRuntime 'org.springframework.boot:spring-boot-starter-tomcat' testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } diff --git a/src/main/java/es/fpalanturing/studentify/config/SecurityConfig.java b/src/main/java/es/fpalanturing/studentify/config/SecurityConfig.java new file mode 100644 index 0000000..d4de31e --- /dev/null +++ b/src/main/java/es/fpalanturing/studentify/config/SecurityConfig.java @@ -0,0 +1,53 @@ +package es.fpalanturing.studentify.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.header.writers.ReferrerPolicyHeaderWriter; + +/** + * Security configuration for the Studentify application. + * Configures basic security headers and CORS settings. + */ +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + /** + * Configures the security filter chain with basic security measures. + * @param http HttpSecurity configuration + * @return SecurityFilterChain with configured security settings + * @throws Exception if configuration fails + */ + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + // Disable CSRF for API endpoints (consider enabling for production with proper CSRF tokens) + .csrf(csrf -> csrf.disable()) + + // Configure authorization - all requests are permitted for this demo API + .authorizeHttpRequests(authz -> authz + .requestMatchers("/api/**").permitAll() + .requestMatchers("/h2-console/**").permitAll() // Allow H2 console access in development + .anyRequest().authenticated() + ) + + // Configure security headers + .headers(headers -> headers + .frameOptions().deny() // Prevent clickjacking + .contentTypeOptions().and() // Prevent MIME sniffing + .httpStrictTransportSecurity(hstsConfig -> hstsConfig + .maxAgeInSeconds(31536000) // 1 year + .includeSubdomains(true) + ) + .referrerPolicy(ReferrerPolicyHeaderWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN) + ); + + // Special configuration for H2 console (development only) + http.headers().frameOptions().sameOrigin(); + + return http.build(); + } +} \ No newline at end of file diff --git a/src/main/java/es/fpalanturing/studentify/controller/HealthController.java b/src/main/java/es/fpalanturing/studentify/controller/HealthController.java index e597aa4..42a2718 100644 --- a/src/main/java/es/fpalanturing/studentify/controller/HealthController.java +++ b/src/main/java/es/fpalanturing/studentify/controller/HealthController.java @@ -1,18 +1,33 @@ package es.fpalanturing.studentify.controller; import es.fpalanturing.studentify.model.Health; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; - +/** + * REST controller for health check operations. + * Provides endpoint to verify API health status. + */ @RestController +@RequestMapping("/api") public class HealthController { - - @GetMapping("/api/health") - public Health ShowHealth() { - Health health = new Health("success","API is healthy"); - return health; + private static final String SUCCESS_STATUS = "success"; + private static final String HEALTHY_MESSAGE = "API is healthy"; + + /** + * Health check endpoint to verify API availability. + * @return ResponseEntity containing health status information + */ + @GetMapping("/health") + public ResponseEntity getHealthStatus() { + try { + Health health = new Health(SUCCESS_STATUS, HEALTHY_MESSAGE); + return ResponseEntity.ok(health); + } catch (Exception e) { + return ResponseEntity.internalServerError().build(); + } } - } diff --git a/src/main/java/es/fpalanturing/studentify/controller/StudentController.java b/src/main/java/es/fpalanturing/studentify/controller/StudentController.java index 14cf812..3bdd452 100644 --- a/src/main/java/es/fpalanturing/studentify/controller/StudentController.java +++ b/src/main/java/es/fpalanturing/studentify/controller/StudentController.java @@ -1,42 +1,67 @@ package es.fpalanturing.studentify.controller; -import org.springframework.web.bind.annotation.RestController; - import es.fpalanturing.studentify.model.Student; import es.fpalanturing.studentify.model.StudentDao; import es.fpalanturing.studentify.service.StudentService; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; -import java.util.List; - -import org.springframework.beans.BeanUtils; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; - - +import jakarta.validation.Valid; +/** + * REST controller for managing student operations. + * Provides endpoints for creating and retrieving students. + */ @RestController -class StudentController { +@RequestMapping("/api/students") +public class StudentController { - private StudentService service; - StudentController(StudentService service) { - this.service = service; + private final StudentService service; + /** + * Constructor for StudentController. + * @param service the student service to handle business logic + */ + public StudentController(StudentService service) { + this.service = service; } - @GetMapping("/api/students") - Iterable readAllStudents() { - return service.readAllStudents(); + /** + * Retrieves all students from the system. + * @return ResponseEntity containing list of all students + */ + @GetMapping + public ResponseEntity> getAllStudents() { + try { + Iterable students = service.readAllStudents(); + return ResponseEntity.ok(students); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } } - @PostMapping("/api/students") - Student postMethodName(@RequestBody StudentDao studentDao) { - - Student newStudent = new Student(studentDao.getName(),studentDao.getEmail()); - Student createdStudent = service.createStudent(newStudent); - return createdStudent; + + /** + * Creates a new student in the system. + * @param studentDao the student data transfer object containing student information + * @return ResponseEntity containing the created student or error status + */ + @PostMapping + public ResponseEntity createStudent(@Valid @RequestBody StudentDao studentDao) { + try { + if (studentDao == null || studentDao.getName() == null || studentDao.getEmail() == null) { + return ResponseEntity.badRequest().build(); + } + + if (studentDao.getName().trim().isEmpty() || studentDao.getEmail().trim().isEmpty()) { + return ResponseEntity.badRequest().build(); + } + + Student newStudent = new Student(studentDao.getName().trim(), studentDao.getEmail().trim()); + Student createdStudent = service.createStudent(newStudent); + return ResponseEntity.status(HttpStatus.CREATED).body(createdStudent); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } } - - - } diff --git a/src/main/java/es/fpalanturing/studentify/exception/GlobalExceptionHandler.java b/src/main/java/es/fpalanturing/studentify/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..8616dd7 --- /dev/null +++ b/src/main/java/es/fpalanturing/studentify/exception/GlobalExceptionHandler.java @@ -0,0 +1,103 @@ +package es.fpalanturing.studentify.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; + +/** + * Global exception handler for the Studentify application. + * Provides centralized error handling and meaningful error responses. + */ +@RestControllerAdvice +public class GlobalExceptionHandler { + + /** + * Handles validation errors from @Valid annotations. + * @param ex the validation exception + * @return ResponseEntity with detailed validation error information + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidationErrors(MethodArgumentNotValidException ex) { + Map errorResponse = new HashMap<>(); + Map fieldErrors = new HashMap<>(); + + ex.getBindingResult().getAllErrors().forEach(error -> { + String fieldName = ((FieldError) error).getField(); + String errorMessage = error.getDefaultMessage(); + fieldErrors.put(fieldName, errorMessage); + }); + + errorResponse.put("timestamp", LocalDateTime.now()); + errorResponse.put("status", HttpStatus.BAD_REQUEST.value()); + errorResponse.put("error", "Validation Failed"); + errorResponse.put("message", "Invalid input data"); + errorResponse.put("fieldErrors", fieldErrors); + + return ResponseEntity.badRequest().body(errorResponse); + } + + /** + * Handles illegal argument exceptions. + * @param ex the illegal argument exception + * @return ResponseEntity with error information + */ + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleIllegalArgumentException(IllegalArgumentException ex) { + Map errorResponse = new HashMap<>(); + + errorResponse.put("timestamp", LocalDateTime.now()); + errorResponse.put("status", HttpStatus.BAD_REQUEST.value()); + errorResponse.put("error", "Bad Request"); + errorResponse.put("message", ex.getMessage()); + + return ResponseEntity.badRequest().body(errorResponse); + } + + /** + * Handles general runtime exceptions. + * @param ex the runtime exception + * @return ResponseEntity with error information + */ + @ExceptionHandler(RuntimeException.class) + public ResponseEntity> handleRuntimeException(RuntimeException ex) { + Map errorResponse = new HashMap<>(); + + errorResponse.put("timestamp", LocalDateTime.now()); + errorResponse.put("status", HttpStatus.INTERNAL_SERVER_ERROR.value()); + errorResponse.put("error", "Internal Server Error"); + + // Don't expose sensitive information in production + String message = ex.getMessage(); + if (message != null && message.contains("already exists")) { + errorResponse.put("message", message); + } else { + errorResponse.put("message", "An internal error occurred"); + } + + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse); + } + + /** + * Handles all other exceptions. + * @param ex the general exception + * @return ResponseEntity with generic error information + */ + @ExceptionHandler(Exception.class) + public ResponseEntity> handleGeneralException(Exception ex) { + Map errorResponse = new HashMap<>(); + + errorResponse.put("timestamp", LocalDateTime.now()); + errorResponse.put("status", HttpStatus.INTERNAL_SERVER_ERROR.value()); + errorResponse.put("error", "Internal Server Error"); + errorResponse.put("message", "An unexpected error occurred"); + + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse); + } +} \ No newline at end of file diff --git a/src/main/java/es/fpalanturing/studentify/model/Health.java b/src/main/java/es/fpalanturing/studentify/model/Health.java index f9f4d7d..88959a9 100644 --- a/src/main/java/es/fpalanturing/studentify/model/Health.java +++ b/src/main/java/es/fpalanturing/studentify/model/Health.java @@ -1,42 +1,70 @@ package es.fpalanturing.studentify.model; -import java.text.DateFormat; -import java.text.SimpleDateFormat; -import java.util.Date; - -//import jakarta.persistence.Entity; - +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +/** + * Model representing health status information for the API. + * Used to provide system health check responses. + */ public class Health { + private static final DateTimeFormatter TIMESTAMP_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss"); + private String status; private String message; private String timestamp; + /** + * Constructor to create a Health object with status and message. + * Automatically sets the current timestamp. + * @param status the health status (e.g., "success", "error") + * @param message descriptive message about the health status + */ public Health(String status, String message) { this.status = status; this.message = message; - Date now = new Date(); - DateFormat formatter = new SimpleDateFormat(); - this.timestamp = formatter.format(now); + this.timestamp = LocalDateTime.now().format(TIMESTAMP_FORMATTER); } + /** + * Gets the timestamp when the health check was performed. + * @return ISO formatted timestamp string + */ public String getTimestamp() { return timestamp; } + /** + * Gets the health status. + * @return the status string + */ public String getStatus() { return status; } + + /** + * Sets the health status. + * @param status the status to set + */ public void setStatus(String status) { this.status = status; } + + /** + * Gets the health message. + * @return the descriptive message + */ public String getMessage() { - return message; } + + /** + * Sets the health message. + * @param message the message to set + */ public void setMessage(String message) { this.message = message; } - } diff --git a/src/main/java/es/fpalanturing/studentify/model/Student.java b/src/main/java/es/fpalanturing/studentify/model/Student.java index b23d30c..8bc61ff 100644 --- a/src/main/java/es/fpalanturing/studentify/model/Student.java +++ b/src/main/java/es/fpalanturing/studentify/model/Student.java @@ -1,48 +1,103 @@ package es.fpalanturing.studentify.model; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; +import jakarta.persistence.*; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +/** + * Entity representing a Student in the system. + * Mapped to the student table in the database. + */ @Entity +@Table(name = "students") public class Student { @Id - @GeneratedValue(strategy = GenerationType.AUTO) - private int id; - @Column(nullable=false) + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 100) + @NotBlank(message = "Name is required") + @Size(min = 2, max = 100, message = "Name must be between 2 and 100 characters") private String name; - @Column(nullable=false,unique=true) + + @Column(nullable = false, unique = true, length = 255) + @NotBlank(message = "Email is required") + @Email(message = "Email must be valid") + @Size(max = 255, message = "Email must not exceed 255 characters") private String email; - public Student(String name,String email) { - this.email = email; + /** + * Constructor with parameters. + * @param name the student's name + * @param email the student's email address + */ + public Student(String name, String email) { this.name = name; + this.email = email; } + + /** + * Default constructor required by JPA. + */ protected Student() { - } - public int getId() { + + /** + * Gets the student's unique identifier. + * @return the student's ID + */ + public Long getId() { return id; } - public void setId(int id) { + + /** + * Sets the student's unique identifier. + * @param id the student's ID + */ + public void setId(Long id) { this.id = id; } + + /** + * Gets the student's name. + * @return the student's name + */ public String getName() { return name; } + + /** + * Sets the student's name. + * @param name the student's name + */ public void setName(String name) { this.name = name; } + + /** + * Gets the student's email address. + * @return the student's email + */ public String getEmail() { return email; } + + /** + * Sets the student's email address. + * @param email the student's email + */ public void setEmail(String email) { this.email = email; } - - + @Override + public String toString() { + return "Student{" + + "id=" + id + + ", name='" + name + '\'' + + ", email='" + email + '\'' + + '}'; + } } diff --git a/src/main/java/es/fpalanturing/studentify/model/StudentDao.java b/src/main/java/es/fpalanturing/studentify/model/StudentDao.java index 0c2b02e..ffc9466 100644 --- a/src/main/java/es/fpalanturing/studentify/model/StudentDao.java +++ b/src/main/java/es/fpalanturing/studentify/model/StudentDao.java @@ -1,24 +1,69 @@ package es.fpalanturing.studentify.model; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** + * Data Transfer Object for Student information. + * Used for API request/response handling. + */ public class StudentDao { + + @NotBlank(message = "Name is required") + @Size(min = 2, max = 100, message = "Name must be between 2 and 100 characters") private String name; + + @NotBlank(message = "Email is required") + @Email(message = "Email must be valid") + @Size(max = 255, message = "Email must not exceed 255 characters") private String email; + + /** + * Default constructor required for JSON deserialization. + */ + public StudentDao() { + } + + /** + * Constructor with parameters. + * @param name the student's name + * @param email the student's email address + */ public StudentDao(String name, String email) { this.name = name; this.email = email; } + + /** + * Gets the student's name. + * @return the student's name + */ public String getName() { return name; } + + /** + * Sets the student's name. + * @param name the student's name + */ public void setName(String name) { this.name = name; } + + /** + * Gets the student's email. + * @return the student's email address + */ public String getEmail() { return email; } + + /** + * Sets the student's email. + * @param email the student's email address + */ public void setEmail(String email) { this.email = email; } - - } diff --git a/src/main/java/es/fpalanturing/studentify/repository/StudentRepository.java b/src/main/java/es/fpalanturing/studentify/repository/StudentRepository.java index af7b805..dd68a1b 100644 --- a/src/main/java/es/fpalanturing/studentify/repository/StudentRepository.java +++ b/src/main/java/es/fpalanturing/studentify/repository/StudentRepository.java @@ -5,7 +5,24 @@ import es.fpalanturing.studentify.model.Student; +/** + * Repository interface for Student entity data access operations. + * Extends CrudRepository to provide basic CRUD operations for Student entities. + */ @Repository -public interface StudentRepository extends CrudRepository { +public interface StudentRepository extends CrudRepository { + /** + * Finds a student by email address. + * @param email the email to search for + * @return Optional containing the student if found, empty otherwise + */ + java.util.Optional findByEmail(String email); + + /** + * Checks if a student exists with the given email. + * @param email the email to check + * @return true if a student with the email exists, false otherwise + */ + boolean existsByEmail(String email); } diff --git a/src/main/java/es/fpalanturing/studentify/service/StudentService.java b/src/main/java/es/fpalanturing/studentify/service/StudentService.java index 7065a87..436e11b 100644 --- a/src/main/java/es/fpalanturing/studentify/service/StudentService.java +++ b/src/main/java/es/fpalanturing/studentify/service/StudentService.java @@ -1,12 +1,25 @@ package es.fpalanturing.studentify.service; -import java.util.List; - import es.fpalanturing.studentify.model.Student; +/** + * Service interface for managing student operations. + * Defines the contract for student business logic operations. + */ public interface StudentService { - public Iterable readAllStudents(); - public Student createStudent(Student student); - + /** + * Retrieves all students from the system. + * @return Iterable containing all students in the system + */ + Iterable readAllStudents(); + + /** + * Creates a new student in the system. + * @param student the student to create + * @return the created student with assigned ID + * @throws IllegalArgumentException if student data is invalid + * @throws RuntimeException if operation fails + */ + Student createStudent(Student student); } diff --git a/src/main/java/es/fpalanturing/studentify/service/StudentServiceDatabase.java b/src/main/java/es/fpalanturing/studentify/service/StudentServiceDatabase.java index b8486fd..14b4ae9 100644 --- a/src/main/java/es/fpalanturing/studentify/service/StudentServiceDatabase.java +++ b/src/main/java/es/fpalanturing/studentify/service/StudentServiceDatabase.java @@ -1,32 +1,72 @@ package es.fpalanturing.studentify.service; -import java.util.List; - +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import es.fpalanturing.studentify.model.Student; import es.fpalanturing.studentify.repository.StudentRepository; +/** + * Database implementation of the StudentService interface. + * Handles all student-related business logic and database operations. + */ @Service +@Transactional public class StudentServiceDatabase implements StudentService { - private StudentRepository repository; + private final StudentRepository repository; + + /** + * Constructor for dependency injection. + * @param repository the student repository for database operations + */ public StudentServiceDatabase(StudentRepository repository) { this.repository = repository; } + /** + * Retrieves all students from the database. + * @return Iterable containing all students + * @throws RuntimeException if database operation fails + */ @Override + @Transactional(readOnly = true) public Iterable readAllStudents() { - // TODO Auto-generated method stub - - return (List) this.repository.findAll(); + try { + return repository.findAll(); + } catch (Exception e) { + throw new RuntimeException("Failed to retrieve students from database", e); + } } + /** + * Creates a new student in the database. + * @param student the student entity to create + * @return the created student with generated ID + * @throws IllegalArgumentException if student data is invalid + * @throws RuntimeException if student with email already exists or database operation fails + */ @Override public Student createStudent(Student student) { - Student createdStudent = repository.save(student); - return createdStudent; + if (student == null) { + throw new IllegalArgumentException("Student cannot be null"); + } + + if (student.getName() == null || student.getName().trim().isEmpty()) { + throw new IllegalArgumentException("Student name cannot be null or empty"); + } + + if (student.getEmail() == null || student.getEmail().trim().isEmpty()) { + throw new IllegalArgumentException("Student email cannot be null or empty"); + } + try { + return repository.save(student); + } catch (DataIntegrityViolationException e) { + throw new RuntimeException("Student with email " + student.getEmail() + " already exists", e); + } catch (Exception e) { + throw new RuntimeException("Failed to create student", e); + } } - } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 43897e5..020969d 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1,25 @@ spring.application.name=studentify + +# Default profile uses H2 in-memory database for development +spring.datasource.url=jdbc:h2:mem:testdb +spring.datasource.driverClassName=org.h2.Driver +spring.datasource.username=sa +spring.datasource.password= + +# JPA Configuration +spring.jpa.database-platform=org.hibernate.dialect.H2Dialect +spring.jpa.hibernate.ddl-auto=create-drop +spring.jpa.show-sql=false +spring.jpa.properties.hibernate.format_sql=true + +# H2 Console (for development only) +spring.h2.console.enabled=true +spring.h2.console.path=/h2-console + +# Server Configuration +server.error.include-message=always +server.error.include-binding-errors=always + +# Logging +logging.level.es.fpalanturing.studentify=DEBUG +logging.level.org.springframework.security=DEBUG diff --git a/src/test/java/es/fpalanturing/studentify/HealthTests.java b/src/test/java/es/fpalanturing/studentify/HealthTests.java index 91eb711..a29fff5 100644 --- a/src/test/java/es/fpalanturing/studentify/HealthTests.java +++ b/src/test/java/es/fpalanturing/studentify/HealthTests.java @@ -1,69 +1,110 @@ package es.fpalanturing.studentify; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.*; -import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; import es.fpalanturing.studentify.model.Health; +/** + * Unit tests for Health model class. + * Tests the functionality of health status objects. + */ @SpringBootTest public class HealthTests { + private Health health; + @BeforeEach - void PrepareHealth() { + void prepareHealth() { health = new Health("SUCCESS", "API is healthy"); } @Test void testGetMessage() { - - // AAA // Arrange - String expectedMessage = "API is healthy"; // Act String actualMessage = health.getMessage(); + // Assert assertEquals(expectedMessage, actualMessage); - } @Test void testGetStatus() { - // AAA // Arrange String expectedStatus = "SUCCESS"; + // Act String actualStatus = health.getStatus(); + // Assert assertEquals(expectedStatus, actualStatus); } @Test void testGetTimestamp() { - + // Act + String timestamp = health.getTimestamp(); + + // Assert + assertNotNull(timestamp, "Timestamp should not be null"); + assertFalse(timestamp.isEmpty(), "Timestamp should not be empty"); + assertTrue(timestamp.matches("\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}"), + "Timestamp should match ISO format pattern"); } @Test void testSetMessage() { - - // AAA + // Arrange String expectedMessage = "ERROR"; + // Act health.setMessage(expectedMessage); - //Assert - assertEquals(expectedMessage, health.getMessage()); - - + // Assert + assertEquals(expectedMessage, health.getMessage()); } @Test void testSetStatus() { + // Arrange + String expectedStatus = "FAILED"; + + // Act + health.setStatus(expectedStatus); + + // Assert + assertEquals(expectedStatus, health.getStatus()); + } + @Test + void testConstructorWithParameters() { + // Arrange & Act + Health newHealth = new Health("ERROR", "System is down"); + + // Assert + assertEquals("ERROR", newHealth.getStatus()); + assertEquals("System is down", newHealth.getMessage()); + assertNotNull(newHealth.getTimestamp()); + } + + @Test + void testTimestampIsGenerated() { + // Arrange & Act + Health health1 = new Health("SUCCESS", "Test 1"); + + // Small delay to ensure different timestamps + try { Thread.sleep(1); } catch (InterruptedException e) { /* ignore */ } + + Health health2 = new Health("SUCCESS", "Test 2"); + + // Assert + assertNotEquals(health1.getTimestamp(), health2.getTimestamp(), + "Different Health objects should have different timestamps"); } } diff --git a/src/test/java/es/fpalanturing/studentify/controller/HealthControllerTest.java b/src/test/java/es/fpalanturing/studentify/controller/HealthControllerTest.java new file mode 100644 index 0000000..af9c2d9 --- /dev/null +++ b/src/test/java/es/fpalanturing/studentify/controller/HealthControllerTest.java @@ -0,0 +1,51 @@ +package es.fpalanturing.studentify.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +/** + * Unit tests for HealthController. + * Tests health check endpoint functionality. + */ +@WebMvcTest(HealthController.class) +class HealthControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void getHealthStatus_ShouldReturnSuccessStatus() throws Exception { + // Act & Assert + mockMvc.perform(get("/api/health")) + .andExpect(status().isOk()) + .andExpect(content().contentType("application/json")) + .andExpect(jsonPath("$.status").value("success")) + .andExpect(jsonPath("$.message").value("API is healthy")) + .andExpect(jsonPath("$.timestamp").exists()); + } + + @Test + void getHealthStatus_ShouldReturnValidTimestamp() throws Exception { + // Act & Assert + mockMvc.perform(get("/api/health")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.timestamp").isString()) + .andExpect(jsonPath("$.timestamp").isNotEmpty()); + } + + @Test + void getHealthStatus_ShouldReturnConsistentResponse() throws Exception { + // Test multiple calls to ensure consistency + for (int i = 0; i < 3; i++) { + mockMvc.perform(get("/api/health")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("success")) + .andExpect(jsonPath("$.message").value("API is healthy")); + } + } +} \ No newline at end of file diff --git a/src/test/java/es/fpalanturing/studentify/controller/StudentControllerTest.java b/src/test/java/es/fpalanturing/studentify/controller/StudentControllerTest.java new file mode 100644 index 0000000..caab9f9 --- /dev/null +++ b/src/test/java/es/fpalanturing/studentify/controller/StudentControllerTest.java @@ -0,0 +1,208 @@ +package es.fpalanturing.studentify.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import es.fpalanturing.studentify.model.Student; +import es.fpalanturing.studentify.model.StudentDao; +import es.fpalanturing.studentify.service.StudentService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Arrays; +import java.util.Collections; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +/** + * Unit tests for StudentController. + * Tests REST endpoints for student operations. + */ +@WebMvcTest(StudentController.class) +class StudentControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private StudentService studentService; + + @Autowired + private ObjectMapper objectMapper; + + private Student testStudent; + private StudentDao testStudentDao; + + @BeforeEach + void setUp() { + testStudent = new Student("John Doe", "john.doe@example.com"); + testStudent.setId(1L); + testStudentDao = new StudentDao("John Doe", "john.doe@example.com"); + } + + @Test + void getAllStudents_ShouldReturnListOfStudents() throws Exception { + // Arrange + when(studentService.readAllStudents()).thenReturn(Arrays.asList(testStudent)); + + // Act & Assert + mockMvc.perform(get("/api/students")) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$[0].id").value(1)) + .andExpect(jsonPath("$[0].name").value("John Doe")) + .andExpect(jsonPath("$[0].email").value("john.doe@example.com")); + + verify(studentService).readAllStudents(); + } + + @Test + void getAllStudents_ShouldReturnEmptyList_WhenNoStudents() throws Exception { + // Arrange + when(studentService.readAllStudents()).thenReturn(Collections.emptyList()); + + // Act & Assert + mockMvc.perform(get("/api/students")) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$").isEmpty()); + + verify(studentService).readAllStudents(); + } + + @Test + void getAllStudents_ShouldReturnInternalServerError_WhenServiceThrowsException() throws Exception { + // Arrange + when(studentService.readAllStudents()).thenThrow(new RuntimeException("Database error")); + + // Act & Assert + mockMvc.perform(get("/api/students")) + .andExpect(status().isInternalServerError()); + + verify(studentService).readAllStudents(); + } + + @Test + void createStudent_ShouldReturnCreatedStudent() throws Exception { + // Arrange + when(studentService.createStudent(any(Student.class))).thenReturn(testStudent); + + // Act & Assert + mockMvc.perform(post("/api/students") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(testStudentDao))) + .andExpect(status().isCreated()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.id").value(1)) + .andExpect(jsonPath("$.name").value("John Doe")) + .andExpect(jsonPath("$.email").value("john.doe@example.com")); + + verify(studentService).createStudent(any(Student.class)); + } + + @Test + void createStudent_ShouldReturnBadRequest_WhenStudentDaoIsNull() throws Exception { + // Act & Assert + mockMvc.perform(post("/api/students") + .contentType(MediaType.APPLICATION_JSON) + .content("null")) + .andExpect(status().isBadRequest()); + + verify(studentService, never()).createStudent(any(Student.class)); + } + + @Test + void createStudent_ShouldReturnBadRequest_WhenNameIsNull() throws Exception { + // Arrange + StudentDao invalidDao = new StudentDao(null, "test@example.com"); + + // Act & Assert + mockMvc.perform(post("/api/students") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(invalidDao))) + .andExpect(status().isBadRequest()); + + verify(studentService, never()).createStudent(any(Student.class)); + } + + @Test + void createStudent_ShouldReturnBadRequest_WhenEmailIsNull() throws Exception { + // Arrange + StudentDao invalidDao = new StudentDao("John Doe", null); + + // Act & Assert + mockMvc.perform(post("/api/students") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(invalidDao))) + .andExpect(status().isBadRequest()); + + verify(studentService, never()).createStudent(any(Student.class)); + } + + @Test + void createStudent_ShouldReturnBadRequest_WhenNameIsEmpty() throws Exception { + // Arrange + StudentDao invalidDao = new StudentDao("", "test@example.com"); + + // Act & Assert + mockMvc.perform(post("/api/students") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(invalidDao))) + .andExpect(status().isBadRequest()); + + verify(studentService, never()).createStudent(any(Student.class)); + } + + @Test + void createStudent_ShouldReturnBadRequest_WhenEmailIsEmpty() throws Exception { + // Arrange + StudentDao invalidDao = new StudentDao("John Doe", ""); + + // Act & Assert + mockMvc.perform(post("/api/students") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(invalidDao))) + .andExpect(status().isBadRequest()); + + verify(studentService, never()).createStudent(any(Student.class)); + } + + @Test + void createStudent_ShouldTrimWhitespace() throws Exception { + // Arrange + StudentDao daoWithWhitespace = new StudentDao(" John Doe ", " john.doe@example.com "); + when(studentService.createStudent(any(Student.class))).thenReturn(testStudent); + + // Act & Assert + mockMvc.perform(post("/api/students") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(daoWithWhitespace))) + .andExpect(status().isCreated()); + + verify(studentService).createStudent(argThat(student -> + "John Doe".equals(student.getName()) && + "john.doe@example.com".equals(student.getEmail()) + )); + } + + @Test + void createStudent_ShouldReturnInternalServerError_WhenServiceThrowsException() throws Exception { + // Arrange + when(studentService.createStudent(any(Student.class))) + .thenThrow(new RuntimeException("Database error")); + + // Act & Assert + mockMvc.perform(post("/api/students") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(testStudentDao))) + .andExpect(status().isInternalServerError()); + + verify(studentService).createStudent(any(Student.class)); + } +} \ No newline at end of file diff --git a/src/test/java/es/fpalanturing/studentify/integration/StudentIntegrationTest.java b/src/test/java/es/fpalanturing/studentify/integration/StudentIntegrationTest.java new file mode 100644 index 0000000..9f944f1 --- /dev/null +++ b/src/test/java/es/fpalanturing/studentify/integration/StudentIntegrationTest.java @@ -0,0 +1,134 @@ +package es.fpalanturing.studentify.integration; + +import com.fasterxml.jackson.databind.ObjectMapper; +import es.fpalanturing.studentify.model.Student; +import es.fpalanturing.studentify.model.StudentDao; +import es.fpalanturing.studentify.repository.StudentRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureWebMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import static org.hamcrest.Matchers.hasSize; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +/** + * Integration tests for the Student API. + * Tests the complete flow from controller to database. + */ +@SpringBootTest +@AutoConfigureWebMvc +@ActiveProfiles("test") +@Transactional +class StudentIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private StudentRepository studentRepository; + + @Autowired + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + studentRepository.deleteAll(); + } + + @Test + void createAndRetrieveStudent_ShouldWorkEndToEnd() throws Exception { + // Arrange + StudentDao newStudent = new StudentDao("Alice Johnson", "alice.johnson@example.com"); + + // Act: Create student + mockMvc.perform(post("/api/students") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(newStudent))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.name").value("Alice Johnson")) + .andExpect(jsonPath("$.email").value("alice.johnson@example.com")) + .andExpect(jsonPath("$.id").exists()); + + // Act: Retrieve all students + mockMvc.perform(get("/api/students")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))) + .andExpect(jsonPath("$[0].name").value("Alice Johnson")) + .andExpect(jsonPath("$[0].email").value("alice.johnson@example.com")); + } + + @Test + void createMultipleStudents_ShouldReturnAllStudents() throws Exception { + // Arrange + StudentDao student1 = new StudentDao("John Doe", "john.doe@example.com"); + StudentDao student2 = new StudentDao("Jane Smith", "jane.smith@example.com"); + + // Act: Create first student + mockMvc.perform(post("/api/students") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(student1))) + .andExpect(status().isCreated()); + + // Act: Create second student + mockMvc.perform(post("/api/students") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(student2))) + .andExpect(status().isCreated()); + + // Act: Retrieve all students + mockMvc.perform(get("/api/students")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(2))); + } + + @Test + void createStudentWithDuplicateEmail_ShouldReturnError() throws Exception { + // Arrange + StudentDao student1 = new StudentDao("John Doe", "duplicate@example.com"); + StudentDao student2 = new StudentDao("Jane Doe", "duplicate@example.com"); + + // Act: Create first student + mockMvc.perform(post("/api/students") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(student1))) + .andExpect(status().isCreated()); + + // Act: Try to create student with duplicate email + mockMvc.perform(post("/api/students") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(student2))) + .andExpect(status().isInternalServerError()); + } + + @Test + void createStudentWithInvalidData_ShouldReturnBadRequest() throws Exception { + // Test empty name + StudentDao invalidStudent1 = new StudentDao("", "test@example.com"); + mockMvc.perform(post("/api/students") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(invalidStudent1))) + .andExpect(status().isBadRequest()); + + // Test empty email + StudentDao invalidStudent2 = new StudentDao("John Doe", ""); + mockMvc.perform(post("/api/students") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(invalidStudent2))) + .andExpect(status().isBadRequest()); + } + + @Test + void getStudents_WhenEmpty_ShouldReturnEmptyArray() throws Exception { + // Act & Assert + mockMvc.perform(get("/api/students")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(0))); + } +} \ No newline at end of file diff --git a/src/test/java/es/fpalanturing/studentify/service/StudentServiceDatabaseTest.java b/src/test/java/es/fpalanturing/studentify/service/StudentServiceDatabaseTest.java new file mode 100644 index 0000000..0c45cca --- /dev/null +++ b/src/test/java/es/fpalanturing/studentify/service/StudentServiceDatabaseTest.java @@ -0,0 +1,220 @@ +package es.fpalanturing.studentify.service; + +import es.fpalanturing.studentify.model.Student; +import es.fpalanturing.studentify.repository.StudentRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; + +import java.util.Arrays; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * Unit tests for StudentServiceDatabase. + * Tests business logic and database operations for student management. + */ +@ExtendWith(MockitoExtension.class) +class StudentServiceDatabaseTest { + + @Mock + private StudentRepository repository; + + @InjectMocks + private StudentServiceDatabase studentService; + + private Student testStudent; + + @BeforeEach + void setUp() { + testStudent = new Student("John Doe", "john.doe@example.com"); + testStudent.setId(1L); + } + + @Test + void readAllStudents_ShouldReturnAllStudents() { + // Arrange + when(repository.findAll()).thenReturn(Arrays.asList(testStudent)); + + // Act + Iterable result = studentService.readAllStudents(); + + // Assert + assertNotNull(result); + assertTrue(result.iterator().hasNext()); + assertEquals(testStudent, result.iterator().next()); + verify(repository).findAll(); + } + + @Test + void readAllStudents_ShouldReturnEmptyList_WhenNoStudents() { + // Arrange + when(repository.findAll()).thenReturn(Collections.emptyList()); + + // Act + Iterable result = studentService.readAllStudents(); + + // Assert + assertNotNull(result); + assertFalse(result.iterator().hasNext()); + verify(repository).findAll(); + } + + @Test + void readAllStudents_ShouldThrowRuntimeException_WhenRepositoryThrowsException() { + // Arrange + when(repository.findAll()).thenThrow(new RuntimeException("Database connection failed")); + + // Act & Assert + RuntimeException exception = assertThrows(RuntimeException.class, + () -> studentService.readAllStudents()); + + assertEquals("Failed to retrieve students from database", exception.getMessage()); + verify(repository).findAll(); + } + + @Test + void createStudent_ShouldReturnCreatedStudent() { + // Arrange + Student inputStudent = new Student("Jane Doe", "jane.doe@example.com"); + Student savedStudent = new Student("Jane Doe", "jane.doe@example.com"); + savedStudent.setId(2L); + + when(repository.save(inputStudent)).thenReturn(savedStudent); + + // Act + Student result = studentService.createStudent(inputStudent); + + // Assert + assertNotNull(result); + assertEquals(2L, result.getId()); + assertEquals("Jane Doe", result.getName()); + assertEquals("jane.doe@example.com", result.getEmail()); + verify(repository).save(inputStudent); + } + + @Test + void createStudent_ShouldThrowIllegalArgumentException_WhenStudentIsNull() { + // Act & Assert + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> studentService.createStudent(null)); + + assertEquals("Student cannot be null", exception.getMessage()); + verify(repository, never()).save(any()); + } + + @Test + void createStudent_ShouldThrowIllegalArgumentException_WhenNameIsNull() { + // Arrange + Student invalidStudent = new Student(null, "test@example.com"); + + // Act & Assert + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> studentService.createStudent(invalidStudent)); + + assertEquals("Student name cannot be null or empty", exception.getMessage()); + verify(repository, never()).save(any()); + } + + @Test + void createStudent_ShouldThrowIllegalArgumentException_WhenNameIsEmpty() { + // Arrange + Student invalidStudent = new Student("", "test@example.com"); + + // Act & Assert + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> studentService.createStudent(invalidStudent)); + + assertEquals("Student name cannot be null or empty", exception.getMessage()); + verify(repository, never()).save(any()); + } + + @Test + void createStudent_ShouldThrowIllegalArgumentException_WhenNameIsWhitespace() { + // Arrange + Student invalidStudent = new Student(" ", "test@example.com"); + + // Act & Assert + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> studentService.createStudent(invalidStudent)); + + assertEquals("Student name cannot be null or empty", exception.getMessage()); + verify(repository, never()).save(any()); + } + + @Test + void createStudent_ShouldThrowIllegalArgumentException_WhenEmailIsNull() { + // Arrange + Student invalidStudent = new Student("John Doe", null); + + // Act & Assert + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> studentService.createStudent(invalidStudent)); + + assertEquals("Student email cannot be null or empty", exception.getMessage()); + verify(repository, never()).save(any()); + } + + @Test + void createStudent_ShouldThrowIllegalArgumentException_WhenEmailIsEmpty() { + // Arrange + Student invalidStudent = new Student("John Doe", ""); + + // Act & Assert + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> studentService.createStudent(invalidStudent)); + + assertEquals("Student email cannot be null or empty", exception.getMessage()); + verify(repository, never()).save(any()); + } + + @Test + void createStudent_ShouldThrowIllegalArgumentException_WhenEmailIsWhitespace() { + // Arrange + Student invalidStudent = new Student("John Doe", " "); + + // Act & Assert + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> studentService.createStudent(invalidStudent)); + + assertEquals("Student email cannot be null or empty", exception.getMessage()); + verify(repository, never()).save(any()); + } + + @Test + void createStudent_ShouldThrowRuntimeException_WhenEmailAlreadyExists() { + // Arrange + Student duplicateStudent = new Student("John Doe", "existing@example.com"); + when(repository.save(duplicateStudent)) + .thenThrow(new DataIntegrityViolationException("Duplicate email")); + + // Act & Assert + RuntimeException exception = assertThrows(RuntimeException.class, + () -> studentService.createStudent(duplicateStudent)); + + assertTrue(exception.getMessage().contains("Student with email existing@example.com already exists")); + verify(repository).save(duplicateStudent); + } + + @Test + void createStudent_ShouldThrowRuntimeException_WhenRepositoryThrowsGeneralException() { + // Arrange + Student student = new Student("John Doe", "john@example.com"); + when(repository.save(student)) + .thenThrow(new RuntimeException("Database connection failed")); + + // Act & Assert + RuntimeException exception = assertThrows(RuntimeException.class, + () -> studentService.createStudent(student)); + + assertEquals("Failed to create student", exception.getMessage()); + verify(repository).save(student); + } +} \ No newline at end of file diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties new file mode 100644 index 0000000..1a71530 --- /dev/null +++ b/src/test/resources/application-test.properties @@ -0,0 +1,17 @@ +# Test profile configuration +spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE +spring.datasource.driverClassName=org.h2.Driver +spring.datasource.username=sa +spring.datasource.password= + +# JPA Configuration for tests +spring.jpa.database-platform=org.hibernate.dialect.H2Dialect +spring.jpa.hibernate.ddl-auto=create-drop +spring.jpa.show-sql=false + +# Disable H2 console in tests +spring.h2.console.enabled=false + +# Logging for tests +logging.level.es.fpalanturing.studentify=INFO +logging.level.org.springframework.web=INFO \ No newline at end of file