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