diff --git a/.gitignore b/.gitignore index 524f096..da55453 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,14 @@ +# Maven build +target/ + +# IDE +.idea/ +*.iml +.vscode/ +.settings/ +.project +.classpath + # Compiled class file *.class diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..30cf57e --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/README.md b/README.md index dbe2b5d..ca38161 100644 --- a/README.md +++ b/README.md @@ -1,72 +1,92 @@ # Desafío Técnico: Servlets y AJAX -## Objetivo: -Demostrar el conocimiento sobre Java (mínimo versión 8), manejo de servlets y peticiones AJAX nativas. +## Descripción +Aplicación web de gestión de empleados desarrollada con Java 8, Spring Boot 2.7, Servlets y AJAX (Fetch API). +Utiliza H2 como base de datos en memoria y JDBC para la persistencia. -## Requisitos Técnicos: -### Java: -- Utiliza Java 8 o superior para la implementación. -- Utiliza las características de Java como lambdas y streams, cuando sea apropiado. -- Utilizar Maven como gestor de dependencias. -- Utilizar Spring Boot como Runtime para la ejecución del desafío en conjunto con Apache Tomcat como contenedor web. +## Arquitectura +``` +Controller (Servlet) → Service → Repository + ↕ ↕ + Model ← Mapper → Entity +``` +- **Entity**: Representación de la tabla en BD (nunca se expone al cliente). +- **Model**: DTO con `@SerializedName` para serialización JSON. +- **Mapper**: Conversión Entity ↔ Model. +- **Repository**: Acceso a datos con JDBC (`JdbcTemplate`). +- **Service**: Lógica de negocio y validaciones. +- **Servlet**: Controlador HTTP (`HttpServlet`) con endpoint RESTful. + +## Requisitos Previos +- Java 8 o superior +- Maven 3.6+ + +## Cómo Ejecutar + +```bash +# Clonar el repositorio +git clone +cd desafio-legacy -## Parte 1: Implementación de un Servicio Web con Servlets y AJAX +# Compilar y ejecutar +mvn spring-boot:run ``` - Crear una aplicación web en Java 8 con Servlets y manejo de AJAX, con las siguientes características: - Endpoint: /api/empleados - GET: Retorna una lista de empleados en formato JSON. - POST: Permite agregar un nuevo empleado enviando datos en formato JSON. - DELETE: Elimina un empleado por su ID. +La aplicación estará disponible en: **http://localhost:8080** - Datos esperados del empleado: +## Endpoints - ID (autogenerado), Nombre, Apellido, RUT/DNI, Cargo, Salario. +| Método | URL | Descripción | +|--------|------------------------|------------------------| +| GET | `/api/empleados` | Listar empleados | +| POST | `/api/empleados` | Crear empleado | +| DELETE | `/api/empleados/{id}` | Eliminar empleado | - Interfaz con AJAX: - Crear una página web simple en HTML + JavaScript (sin frameworks como React o Angular). - Usar AJAX (Fetch API o XMLHttpRequest) para: - - Cargar la lista de empleados sin recargar la página. - - Agregar nuevos empleados mediante un formulario sin recargar la página. - - Eliminar empleados con un botón sin recargar la página. +## Interfaces Disponibles - Requerimientos técnicos: - - No usar frameworks externos, solo Servlets y JDBC para conexión con una BD en memoria como H2. - - Manejo adecuado de excepciones y logging. - - Validación de datos en los endpoints. -``` +| URL | Descripción | +|----------------------------|--------------------------------| +| http://localhost:8080 | Interfaz web (HTML + AJAX) | +| http://localhost:8080/h2-console | Consola H2 (JDBC URL: `jdbc:h2:mem:empleadosdb`) | +| http://localhost:8080/swagger-ui.html | Swagger UI | +| http://localhost:8080/openapi.yaml | Especificación OpenAPI | -## Parte 2: Validaciones de Reglas de Negocio con AJAX +## Ejemplo de Uso (cURL) -``` - Implementar validaciones en la carga de empleados y nóminas: - - 1. En el backend (Java 8): - - Rechazar empleados con RUT/DNI duplicado. - - No permitir salarios base menores a $400,000. - - Bonos no pueden superar el 50% del salario base. - - El total de descuentos no puede ser mayor al salario base. - - Si alguna regla se incumple, se debe retornar una respuesta HTTP 400 con un JSON indicando los registros con error. - 2. En el frontend (JavaScript + AJAX): - - Implementar validaciones antes de enviar el formulario: - - Verificar que todos los campos estén completos. - - Validar formato del RUT/DNI. - - Validar que el salario base no sea menor a $400,000. - - Mostrar errores de validación de forma dinámica en la página (sin alertas de JavaScript). -``` +```bash +# Listar empleados +curl http://localhost:8080/api/empleados -## Entregables: -### Repositorio de GitHub: -- Realiza un Pull request a este repositorio indicando tu nombre, empresa reclutadora, correo y cargo al que postulas. -- Todos los PR serán rechazados, no es un indicador de la prueba. +# Crear empleado +curl -X POST http://localhost:8080/api/empleados \ + -H "Content-Type: application/json" \ + -d '{"nombre":"Juan","apellido":"Pérez","rut":"12345678-5","cargo":"Desarrollador","salario_base":500000,"bonos":100000,"descuentos":50000}' + +# Eliminar empleado +curl -X DELETE http://localhost:8080/api/empleados/1 +``` -### Documentación: -- Incluye instrucciones claras en un README en formato markdown, sobre cómo ejecutar y probar la aplicación. +## Validaciones de Negocio (Backend) +- RUT/DNI duplicado → HTTP 400 +- Salario base menor a $400,000 → HTTP 400 +- Bonos mayores al 50% del salario base → HTTP 400 +- Descuentos mayores al salario base → HTTP 400 +- Campos obligatorios vacíos → HTTP 400 +- Formato de RUT inválido → HTTP 400 -## Evaluación: -Se evaluará la solución en función de los siguientes criterios: +## Validaciones Frontend +- Campos obligatorios completos +- Formato de RUT/DNI (ej: `12345678-5`) +- Salario base mínimo $400,000 +- Errores mostrados dinámicamente (sin `alert()`) -- Correcta implementación de las funcionalidades solicitadas. -- Aplicación de buenas prácticas de desarrollo, patrones de diseño y principios SOLID. -- Uso adecuado de Java y Javascript. -- Claridad y completitud de la documentación. +## Tecnologías +- Java 8 +- Spring Boot 2.7.18 +- Apache Tomcat (embebido) +- H2 Database (en memoria) +- JDBC (`JdbcTemplate`) +- Servlets (`HttpServlet`) +- Gson (serialización JSON) +- HTML5 + CSS3 + JavaScript (Fetch API) +- SpringDoc OpenAPI (Swagger UI) diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..991938b --- /dev/null +++ b/pom.xml @@ -0,0 +1,66 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 2.7.18 + + + + com.previred + empleados + 1.0.0 + jar + Desafio Legacy - Empleados + + + 1.8 + 1.8 + 1.8 + UTF-8 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-jdbc + + + com.h2database + h2 + runtime + + + com.google.code.gson + gson + 2.10.1 + + + org.springdoc + springdoc-openapi-ui + 1.7.0 + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/src/main/java/com/previred/empleados/EmpleadosApplication.java b/src/main/java/com/previred/empleados/EmpleadosApplication.java new file mode 100644 index 0000000..74a6e88 --- /dev/null +++ b/src/main/java/com/previred/empleados/EmpleadosApplication.java @@ -0,0 +1,14 @@ +package com.previred.empleados; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.web.servlet.ServletComponentScan; + +@SpringBootApplication +@ServletComponentScan +public class EmpleadosApplication { + + public static void main(String[] args) { + SpringApplication.run(EmpleadosApplication.class, args); + } +} diff --git a/src/main/java/com/previred/empleados/entity/EmpleadoEntity.java b/src/main/java/com/previred/empleados/entity/EmpleadoEntity.java new file mode 100644 index 0000000..ce22376 --- /dev/null +++ b/src/main/java/com/previred/empleados/entity/EmpleadoEntity.java @@ -0,0 +1,80 @@ +package com.previred.empleados.entity; + +public class EmpleadoEntity { + + private Long id; + private String nombre; + private String apellido; + private String rut; + private String cargo; + private Double salarioBase; + private Double bonos; + private Double descuentos; + + public EmpleadoEntity() { + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getNombre() { + return nombre; + } + + public void setNombre(String nombre) { + this.nombre = nombre; + } + + public String getApellido() { + return apellido; + } + + public void setApellido(String apellido) { + this.apellido = apellido; + } + + public String getRut() { + return rut; + } + + public void setRut(String rut) { + this.rut = rut; + } + + public String getCargo() { + return cargo; + } + + public void setCargo(String cargo) { + this.cargo = cargo; + } + + public Double getSalarioBase() { + return salarioBase; + } + + public void setSalarioBase(Double salarioBase) { + this.salarioBase = salarioBase; + } + + public Double getBonos() { + return bonos; + } + + public void setBonos(Double bonos) { + this.bonos = bonos; + } + + public Double getDescuentos() { + return descuentos; + } + + public void setDescuentos(Double descuentos) { + this.descuentos = descuentos; + } +} diff --git a/src/main/java/com/previred/empleados/exception/BusinessValidationException.java b/src/main/java/com/previred/empleados/exception/BusinessValidationException.java new file mode 100644 index 0000000..ec091ac --- /dev/null +++ b/src/main/java/com/previred/empleados/exception/BusinessValidationException.java @@ -0,0 +1,17 @@ +package com.previred.empleados.exception; + +import java.util.List; + +public class BusinessValidationException extends RuntimeException { + + private final List errores; + + public BusinessValidationException(List errores) { + super("Error de validación de negocio"); + this.errores = errores; + } + + public List getErrores() { + return errores; + } +} diff --git a/src/main/java/com/previred/empleados/exception/EmpleadoNotFoundException.java b/src/main/java/com/previred/empleados/exception/EmpleadoNotFoundException.java new file mode 100644 index 0000000..9ab80da --- /dev/null +++ b/src/main/java/com/previred/empleados/exception/EmpleadoNotFoundException.java @@ -0,0 +1,8 @@ +package com.previred.empleados.exception; + +public class EmpleadoNotFoundException extends RuntimeException { + + public EmpleadoNotFoundException(Long id) { + super("Empleado no encontrado con ID: " + id); + } +} diff --git a/src/main/java/com/previred/empleados/mapper/EmpleadoMapper.java b/src/main/java/com/previred/empleados/mapper/EmpleadoMapper.java new file mode 100644 index 0000000..f94b890 --- /dev/null +++ b/src/main/java/com/previred/empleados/mapper/EmpleadoMapper.java @@ -0,0 +1,36 @@ +package com.previred.empleados.mapper; + +import com.previred.empleados.entity.EmpleadoEntity; +import com.previred.empleados.model.EmpleadoModel; + +public class EmpleadoMapper { + + private EmpleadoMapper() { + } + + public static EmpleadoModel toModel(EmpleadoEntity entity) { + EmpleadoModel model = new EmpleadoModel(); + model.setId(entity.getId()); + model.setNombre(entity.getNombre()); + model.setApellido(entity.getApellido()); + model.setRut(entity.getRut()); + model.setCargo(entity.getCargo()); + model.setSalarioBase(entity.getSalarioBase()); + model.setBonos(entity.getBonos()); + model.setDescuentos(entity.getDescuentos()); + return model; + } + + public static EmpleadoEntity toEntity(EmpleadoModel model) { + EmpleadoEntity entity = new EmpleadoEntity(); + entity.setId(model.getId()); + entity.setNombre(model.getNombre()); + entity.setApellido(model.getApellido()); + entity.setRut(model.getRut()); + entity.setCargo(model.getCargo()); + entity.setSalarioBase(model.getSalarioBase()); + entity.setBonos(model.getBonos()); + entity.setDescuentos(model.getDescuentos()); + return entity; + } +} diff --git a/src/main/java/com/previred/empleados/model/EmpleadoModel.java b/src/main/java/com/previred/empleados/model/EmpleadoModel.java new file mode 100644 index 0000000..8fe1c5e --- /dev/null +++ b/src/main/java/com/previred/empleados/model/EmpleadoModel.java @@ -0,0 +1,97 @@ +package com.previred.empleados.model; + +import com.google.gson.annotations.SerializedName; + +public class EmpleadoModel { + + @SerializedName("id") + private Long id; + + @SerializedName("nombre") + private String nombre; + + @SerializedName("apellido") + private String apellido; + + @SerializedName("rut") + private String rut; + + @SerializedName("cargo") + private String cargo; + + @SerializedName("salario_base") + private Double salarioBase; + + @SerializedName("bonos") + private Double bonos; + + @SerializedName("descuentos") + private Double descuentos; + + public EmpleadoModel() { + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getNombre() { + return nombre; + } + + public void setNombre(String nombre) { + this.nombre = nombre; + } + + public String getApellido() { + return apellido; + } + + public void setApellido(String apellido) { + this.apellido = apellido; + } + + public String getRut() { + return rut; + } + + public void setRut(String rut) { + this.rut = rut; + } + + public String getCargo() { + return cargo; + } + + public void setCargo(String cargo) { + this.cargo = cargo; + } + + public Double getSalarioBase() { + return salarioBase; + } + + public void setSalarioBase(Double salarioBase) { + this.salarioBase = salarioBase; + } + + public Double getBonos() { + return bonos; + } + + public void setBonos(Double bonos) { + this.bonos = bonos; + } + + public Double getDescuentos() { + return descuentos; + } + + public void setDescuentos(Double descuentos) { + this.descuentos = descuentos; + } +} diff --git a/src/main/java/com/previred/empleados/model/ErrorResponse.java b/src/main/java/com/previred/empleados/model/ErrorResponse.java new file mode 100644 index 0000000..6a423df --- /dev/null +++ b/src/main/java/com/previred/empleados/model/ErrorResponse.java @@ -0,0 +1,26 @@ +package com.previred.empleados.model; + +import com.google.gson.annotations.SerializedName; +import java.util.List; + +public class ErrorResponse { + + @SerializedName("mensaje") + private String mensaje; + + @SerializedName("errores") + private List errores; + + public ErrorResponse(String mensaje, List errores) { + this.mensaje = mensaje; + this.errores = errores; + } + + public String getMensaje() { + return mensaje; + } + + public List getErrores() { + return errores; + } +} diff --git a/src/main/java/com/previred/empleados/repository/EmpleadoRepository.java b/src/main/java/com/previred/empleados/repository/EmpleadoRepository.java new file mode 100644 index 0000000..716ca32 --- /dev/null +++ b/src/main/java/com/previred/empleados/repository/EmpleadoRepository.java @@ -0,0 +1,85 @@ +package com.previred.empleados.repository; + +import com.previred.empleados.entity.EmpleadoEntity; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Repository; + +import java.sql.PreparedStatement; +import java.sql.Statement; +import java.util.List; +import java.util.Optional; + +@Repository +public class EmpleadoRepository implements IEmpleadoRepository { + + private static final Logger log = LoggerFactory.getLogger(EmpleadoRepository.class); + + private final JdbcTemplate jdbcTemplate; + + private final RowMapper rowMapper = (rs, rowNum) -> { + EmpleadoEntity entity = new EmpleadoEntity(); + entity.setId(rs.getLong("id")); + entity.setNombre(rs.getString("nombre")); + entity.setApellido(rs.getString("apellido")); + entity.setRut(rs.getString("rut")); + entity.setCargo(rs.getString("cargo")); + entity.setSalarioBase(rs.getDouble("salario_base")); + entity.setBonos(rs.getDouble("bonos")); + entity.setDescuentos(rs.getDouble("descuentos")); + return entity; + }; + + public EmpleadoRepository(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public List findAll() { + log.debug("Consultando todos los empleados"); + return jdbcTemplate.query("SELECT * FROM empleados", rowMapper); + } + + public Optional findById(Long id) { + log.debug("Consultando empleado con ID: {}", id); + List results = jdbcTemplate.query( + "SELECT * FROM empleados WHERE id = ?", rowMapper, id); + return results.stream().findFirst(); + } + + public boolean existsByRut(String rut) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM empleados WHERE rut = ?", Integer.class, rut); + return count != null && count > 0; + } + + public EmpleadoEntity save(EmpleadoEntity entity) { + log.debug("Guardando empleado: {} {}", entity.getNombre(), entity.getApellido()); + KeyHolder keyHolder = new GeneratedKeyHolder(); + jdbcTemplate.update(connection -> { + PreparedStatement ps = connection.prepareStatement( + "INSERT INTO empleados (nombre, apellido, rut, cargo, salario_base, bonos, descuentos) " + + "VALUES (?, ?, ?, ?, ?, ?, ?)", + Statement.RETURN_GENERATED_KEYS); + ps.setString(1, entity.getNombre()); + ps.setString(2, entity.getApellido()); + ps.setString(3, entity.getRut()); + ps.setString(4, entity.getCargo()); + ps.setDouble(5, entity.getSalarioBase()); + ps.setDouble(6, entity.getBonos() != null ? entity.getBonos() : 0.0); + ps.setDouble(7, entity.getDescuentos() != null ? entity.getDescuentos() : 0.0); + return ps; + }, keyHolder); + entity.setId(keyHolder.getKey().longValue()); + return entity; + } + + public boolean deleteById(Long id) { + log.debug("Eliminando empleado con ID: {}", id); + int rows = jdbcTemplate.update("DELETE FROM empleados WHERE id = ?", id); + return rows > 0; + } +} diff --git a/src/main/java/com/previred/empleados/repository/IEmpleadoRepository.java b/src/main/java/com/previred/empleados/repository/IEmpleadoRepository.java new file mode 100644 index 0000000..874277c --- /dev/null +++ b/src/main/java/com/previred/empleados/repository/IEmpleadoRepository.java @@ -0,0 +1,19 @@ +package com.previred.empleados.repository; + +import com.previred.empleados.entity.EmpleadoEntity; + +import java.util.List; +import java.util.Optional; + +public interface IEmpleadoRepository { + + List findAll(); + + Optional findById(Long id); + + boolean existsByRut(String rut); + + EmpleadoEntity save(EmpleadoEntity entity); + + boolean deleteById(Long id); +} diff --git a/src/main/java/com/previred/empleados/service/EmpleadoService.java b/src/main/java/com/previred/empleados/service/EmpleadoService.java new file mode 100644 index 0000000..dc22c7f --- /dev/null +++ b/src/main/java/com/previred/empleados/service/EmpleadoService.java @@ -0,0 +1,98 @@ +package com.previred.empleados.service; + +import com.previred.empleados.entity.EmpleadoEntity; +import com.previred.empleados.exception.BusinessValidationException; +import com.previred.empleados.exception.EmpleadoNotFoundException; +import com.previred.empleados.mapper.EmpleadoMapper; +import com.previred.empleados.model.EmpleadoModel; +import com.previred.empleados.repository.IEmpleadoRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +@Service +public class EmpleadoService implements IEmpleadoService { + + private static final Logger log = LoggerFactory.getLogger(EmpleadoService.class); + private static final double SALARIO_MINIMO = 400000.0; + private static final double PORCENTAJE_MAXIMO_BONOS = 0.5; + + private final IEmpleadoRepository empleadoRepository; + + public EmpleadoService(IEmpleadoRepository empleadoRepository) { + this.empleadoRepository = empleadoRepository; + } + + public List listarEmpleados() { + return empleadoRepository.findAll().stream() + .map(EmpleadoMapper::toModel) + .collect(Collectors.toList()); + } + + public EmpleadoModel crearEmpleado(EmpleadoModel model) { + validar(model); + EmpleadoEntity entity = EmpleadoMapper.toEntity(model); + EmpleadoEntity saved = empleadoRepository.save(entity); + log.info("Empleado creado con ID: {}", saved.getId()); + return EmpleadoMapper.toModel(saved); + } + + public void eliminarEmpleado(Long id) { + if (!empleadoRepository.deleteById(id)) { + throw new EmpleadoNotFoundException(id); + } + log.info("Empleado eliminado con ID: {}", id); + } + + private void validar(EmpleadoModel model) { + List errores = new ArrayList<>(); + + if (isBlank(model.getNombre())) { + errores.add("El nombre es obligatorio"); + } + if (isBlank(model.getApellido())) { + errores.add("El apellido es obligatorio"); + } + if (isBlank(model.getRut())) { + errores.add("El RUT/DNI es obligatorio"); + } else { + if (!validarFormatoRut(model.getRut())) { + errores.add("El formato del RUT/DNI no es válido (ej: 12345678-5)"); + } + if (empleadoRepository.existsByRut(model.getRut())) { + errores.add("Ya existe un empleado con el RUT/DNI: " + model.getRut()); + } + } + if (isBlank(model.getCargo())) { + errores.add("El cargo es obligatorio"); + } + if (model.getSalarioBase() == null || model.getSalarioBase() < SALARIO_MINIMO) { + errores.add("El salario base no puede ser menor a $400,000"); + } + if (model.getBonos() != null && model.getSalarioBase() != null + && model.getBonos() > model.getSalarioBase() * PORCENTAJE_MAXIMO_BONOS) { + errores.add("Los bonos no pueden superar el 50% del salario base"); + } + if (model.getDescuentos() != null && model.getSalarioBase() != null + && model.getDescuentos() > model.getSalarioBase()) { + errores.add("El total de descuentos no puede ser mayor al salario base"); + } + + if (!errores.isEmpty()) { + log.warn("Validación fallida: {}", errores); + throw new BusinessValidationException(errores); + } + } + + private boolean validarFormatoRut(String rut) { + return rut.matches("^\\d{7,8}-[\\dkK]$"); + } + + private boolean isBlank(String value) { + return value == null || value.trim().isEmpty(); + } +} diff --git a/src/main/java/com/previred/empleados/service/IEmpleadoService.java b/src/main/java/com/previred/empleados/service/IEmpleadoService.java new file mode 100644 index 0000000..d13401d --- /dev/null +++ b/src/main/java/com/previred/empleados/service/IEmpleadoService.java @@ -0,0 +1,14 @@ +package com.previred.empleados.service; + +import com.previred.empleados.model.EmpleadoModel; + +import java.util.List; + +public interface IEmpleadoService { + + List listarEmpleados(); + + EmpleadoModel crearEmpleado(EmpleadoModel model); + + void eliminarEmpleado(Long id); +} diff --git a/src/main/java/com/previred/empleados/servlet/EmpleadoServlet.java b/src/main/java/com/previred/empleados/servlet/EmpleadoServlet.java new file mode 100644 index 0000000..d5c2730 --- /dev/null +++ b/src/main/java/com/previred/empleados/servlet/EmpleadoServlet.java @@ -0,0 +1,127 @@ +package com.previred.empleados.servlet; + +import com.google.gson.Gson; +import com.previred.empleados.exception.BusinessValidationException; +import com.previred.empleados.exception.EmpleadoNotFoundException; +import com.previred.empleados.model.EmpleadoModel; +import com.previred.empleados.model.ErrorResponse; +import com.previred.empleados.service.IEmpleadoService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.context.WebApplicationContext; +import org.springframework.web.context.support.WebApplicationContextUtils; + +import javax.servlet.ServletException; +import javax.servlet.annotation.WebServlet; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.BufferedReader; +import java.io.IOException; +import java.util.Collections; +import java.util.List; + +@WebServlet(urlPatterns = "/api/empleados/*") +public class EmpleadoServlet extends HttpServlet { + + private static final Logger log = LoggerFactory.getLogger(EmpleadoServlet.class); + private static final String CONTENT_TYPE_JSON = "application/json"; + private static final String ENCODING_UTF8 = "UTF-8"; + + private transient IEmpleadoService empleadoService; + private transient Gson gson; + + @Override + public void init() throws ServletException { + super.init(); + WebApplicationContext context = WebApplicationContextUtils + .getRequiredWebApplicationContext(getServletContext()); + this.empleadoService = context.getBean(IEmpleadoService.class); + this.gson = new Gson(); + log.info("EmpleadoServlet inicializado"); + } + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { + log.debug("GET /api/empleados"); + List empleados = empleadoService.listarEmpleados(); + writeJsonResponse(resp, HttpServletResponse.SC_OK, empleados); + } + + @Override + protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { + log.debug("POST /api/empleados"); + try { + String body = readBody(req); + EmpleadoModel model = gson.fromJson(body, EmpleadoModel.class); + if (model == null) { + writeErrorResponse(resp, HttpServletResponse.SC_BAD_REQUEST, + "El cuerpo de la solicitud no puede estar vacío"); + return; + } + EmpleadoModel created = empleadoService.crearEmpleado(model); + writeJsonResponse(resp, HttpServletResponse.SC_CREATED, created); + } catch (BusinessValidationException e) { + writeJsonResponse(resp, HttpServletResponse.SC_BAD_REQUEST, + new ErrorResponse("Error de validación", e.getErrores())); + } catch (Exception e) { + log.error("Error al crear empleado", e); + writeErrorResponse(resp, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, + "Error interno del servidor"); + } + } + + @Override + protected void doDelete(HttpServletRequest req, HttpServletResponse resp) throws IOException { + log.debug("DELETE /api/empleados"); + try { + Long id = extractIdFromPath(req); + if (id == null) { + writeErrorResponse(resp, HttpServletResponse.SC_BAD_REQUEST, + "Se requiere el ID del empleado en la URL (ej: /api/empleados/1)"); + return; + } + empleadoService.eliminarEmpleado(id); + resp.setStatus(HttpServletResponse.SC_NO_CONTENT); + } catch (EmpleadoNotFoundException e) { + writeErrorResponse(resp, HttpServletResponse.SC_NOT_FOUND, e.getMessage()); + } catch (NumberFormatException e) { + writeErrorResponse(resp, HttpServletResponse.SC_BAD_REQUEST, "El ID debe ser un número válido"); + } catch (Exception e) { + log.error("Error al eliminar empleado", e); + writeErrorResponse(resp, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, + "Error interno del servidor"); + } + } + + private Long extractIdFromPath(HttpServletRequest req) { + String pathInfo = req.getPathInfo(); + if (pathInfo == null || pathInfo.equals("/")) { + return null; + } + return Long.parseLong(pathInfo.substring(1)); + } + + private String readBody(HttpServletRequest req) throws IOException { + StringBuilder sb = new StringBuilder(); + try (BufferedReader reader = req.getReader()) { + String line; + while ((line = reader.readLine()) != null) { + sb.append(line); + } + } + return sb.toString(); + } + + private void writeJsonResponse(HttpServletResponse resp, int status, Object data) throws IOException { + resp.setContentType(CONTENT_TYPE_JSON); + resp.setCharacterEncoding(ENCODING_UTF8); + resp.setStatus(status); + resp.getWriter().write(gson.toJson(data)); + } + + private void writeErrorResponse(HttpServletResponse resp, int status, String message) throws IOException { + ErrorResponse error = new ErrorResponse(message, Collections.emptyList()); + writeJsonResponse(resp, status, error); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000..06a39a7 --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1,20 @@ +server.port=8080 + +# H2 en memoria +spring.datasource.url=jdbc:h2:mem:empleadosdb +spring.datasource.driver-class-name=org.h2.Driver +spring.datasource.username=sa +spring.datasource.password= +spring.datasource.initialization-mode=always + +# H2 Console +spring.h2.console.enabled=true +spring.h2.console.path=/h2-console + +# Logging +logging.level.com.previred=DEBUG +logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n + +# Swagger / OpenAPI +springdoc.swagger-ui.path=/swagger-ui.html +springdoc.swagger-ui.url=/openapi.yaml diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql new file mode 100644 index 0000000..40a22a7 --- /dev/null +++ b/src/main/resources/schema.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS empleados ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + nombre VARCHAR(100) NOT NULL, + apellido VARCHAR(100) NOT NULL, + rut VARCHAR(12) NOT NULL UNIQUE, + cargo VARCHAR(100) NOT NULL, + salario_base DOUBLE NOT NULL, + bonos DOUBLE DEFAULT 0, + descuentos DOUBLE DEFAULT 0 +); diff --git a/src/main/resources/static/css/styles.css b/src/main/resources/static/css/styles.css new file mode 100644 index 0000000..992d182 --- /dev/null +++ b/src/main/resources/static/css/styles.css @@ -0,0 +1,145 @@ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: Arial, sans-serif; + max-width: 960px; + margin: 0 auto; + padding: 20px; + background: #f5f5f5; +} + +h1 { + text-align: center; + margin-bottom: 20px; + color: #333; +} + +h2 { + margin-bottom: 15px; +} + +.card { + background: #fff; + border-radius: 8px; + padding: 20px; + margin-bottom: 20px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +label { + display: block; + font-weight: bold; + margin-bottom: 4px; + font-size: 14px; + color: #555; +} + +input { + width: 100%; + padding: 8px 10px; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 14px; +} + +input:focus { + outline: none; + border-color: #4a90d9; +} + +.btn { + padding: 10px 20px; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + color: #fff; +} + +.btn-primary { + background: #4a90d9; +} + +.btn-primary:hover { + background: #357abd; +} + +.btn-danger { + background: #d9534f; + padding: 6px 12px; + font-size: 12px; +} + +.btn-danger:hover { + background: #c9302c; +} + +.btn-container { + grid-column: 1 / -1; + text-align: right; + margin-top: 8px; +} + +.error-container { + grid-column: 1 / -1; +} + +.error-list { + background: #fce4e4; + border: 1px solid #d9534f; + border-radius: 4px; + padding: 10px 15px; + color: #a94442; + list-style: none; +} + +.error-list li { + padding: 2px 0; + font-size: 13px; +} + +.error-list li::before { + content: "⚠ "; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, +td { + padding: 10px 12px; + text-align: left; + border-bottom: 1px solid #eee; + font-size: 14px; +} + +th { + background: #4a90d9; + color: #fff; +} + +tr:hover { + background: #f0f7ff; +} + +.empty-msg { + text-align: center; + padding: 20px; + color: #999; +} + +.salary { + text-align: right; +} \ No newline at end of file diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html new file mode 100644 index 0000000..dbd69de --- /dev/null +++ b/src/main/resources/static/index.html @@ -0,0 +1,76 @@ + + + + + + Gestión de Empleados + + + +

Gestión de Empleados

+ +
+

Agregar Empleado

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+
+ +
+

Lista de Empleados

+ + + + + + + + + + + + + + + + + +
IDNombreApellidoRUT/DNICargoSalario BaseBonosDescuentosAcciones
Cargando...
+
+ + + + diff --git a/src/main/resources/static/js/app.js b/src/main/resources/static/js/app.js new file mode 100644 index 0000000..bb4e2f8 --- /dev/null +++ b/src/main/resources/static/js/app.js @@ -0,0 +1,128 @@ +var API_URL = '/api/empleados'; + +function formatMoney(value) { + return '$' + Number(value || 0).toLocaleString('es-CL'); +} + +function mostrarErrores(errores) { + var ul = document.getElementById('errores'); + ul.innerHTML = ''; + if (!errores || errores.length === 0) { + ul.style.display = 'none'; + return; + } + errores.forEach(function(err) { + var li = document.createElement('li'); + li.textContent = err; + ul.appendChild(li); + }); + ul.style.display = 'block'; +} + +function limpiarFormulario() { + document.getElementById('formEmpleado').reset(); + document.getElementById('bonos').value = '0'; + document.getElementById('descuentos').value = '0'; + mostrarErrores([]); +} + +function cargarEmpleados() { + fetch(API_URL) + .then(function(res) { return res.json(); }) + .then(function(empleados) { + var tbody = document.getElementById('tablaEmpleados'); + if (empleados.length === 0) { + tbody.innerHTML = 'No hay empleados registrados'; + return; + } + tbody.innerHTML = empleados.map(function(e) { + return '' + + '' + e.id + '' + + '' + e.nombre + '' + + '' + e.apellido + '' + + '' + e.rut + '' + + '' + e.cargo + '' + + '' + formatMoney(e.salario_base) + '' + + '' + formatMoney(e.bonos) + '' + + '' + formatMoney(e.descuentos) + '' + + '' + + ''; + }).join(''); + }) + .catch(function(err) { + console.error('Error al cargar empleados:', err); + }); +} + +function validarFrontend(data) { + var errores = []; + if (!data.nombre || !data.nombre.trim()) errores.push('El nombre es obligatorio'); + if (!data.apellido || !data.apellido.trim()) errores.push('El apellido es obligatorio'); + if (!data.rut || !data.rut.trim()) { + errores.push('El RUT/DNI es obligatorio'); + } else if (!/^\d{7,8}-[\dkK]$/.test(data.rut.trim())) { + errores.push('Formato de RUT/DNI inválido (ej: 12345678-5)'); + } + if (!data.cargo || !data.cargo.trim()) errores.push('El cargo es obligatorio'); + if (!data.salario_base || data.salario_base < 400000) { + errores.push('El salario base no puede ser menor a $400,000'); + } + return errores; +} + +function eliminarEmpleado(id) { + fetch(API_URL + '/' + id, { method: 'DELETE' }) + .then(function(res) { + if (!res.ok) { + return res.json().then(function(err) { throw err; }); + } + cargarEmpleados(); + }) + .catch(function(err) { + mostrarErrores([err.mensaje || 'Error al eliminar empleado']); + }); +} + +document.getElementById('formEmpleado').addEventListener('submit', function(e) { + e.preventDefault(); + var data = { + nombre: document.getElementById('nombre').value, + apellido: document.getElementById('apellido').value, + rut: document.getElementById('rut').value, + cargo: document.getElementById('cargo').value, + salario_base: parseFloat(document.getElementById('salarioBase').value) || 0, + bonos: parseFloat(document.getElementById('bonos').value) || 0, + descuentos: parseFloat(document.getElementById('descuentos').value) || 0 + }; + + var erroresCliente = validarFrontend(data); + if (erroresCliente.length > 0) { + mostrarErrores(erroresCliente); + return; + } + + fetch(API_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }) + .then(function(res) { + if (!res.ok) { + return res.json().then(function(err) { throw err; }); + } + return res.json(); + }) + .then(function() { + limpiarFormulario(); + cargarEmpleados(); + }) + .catch(function(err) { + if (err.errores) { + mostrarErrores(err.errores); + } else { + mostrarErrores([err.mensaje || 'Error al crear empleado']); + } + }); +}); + +cargarEmpleados(); diff --git a/src/main/resources/static/openapi.yaml b/src/main/resources/static/openapi.yaml new file mode 100644 index 0000000..3cffc26 --- /dev/null +++ b/src/main/resources/static/openapi.yaml @@ -0,0 +1,135 @@ +openapi: 3.0.3 +info: + title: API Empleados - Desafío Legacy + description: API REST para gestión de empleados usando Servlets y JDBC con H2 + version: 1.0.0 +servers: + - url: http://localhost:8080 + description: Servidor local + +paths: + /api/empleados: + get: + summary: Listar empleados + operationId: listarEmpleados + responses: + '200': + description: Lista de empleados + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Empleado' + post: + summary: Crear empleado + operationId: crearEmpleado + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/EmpleadoInput' + responses: + '201': + description: Empleado creado + content: + application/json: + schema: + $ref: '#/components/schemas/Empleado' + '400': + description: Error de validación + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /api/empleados/{id}: + delete: + summary: Eliminar empleado + operationId: eliminarEmpleado + parameters: + - name: id + in: path + required: true + schema: + type: integer + format: int64 + responses: + '204': + description: Empleado eliminado + '404': + description: Empleado no encontrado + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + +components: + schemas: + Empleado: + type: object + properties: + id: + type: integer + format: int64 + nombre: + type: string + apellido: + type: string + rut: + type: string + example: "12345678-5" + cargo: + type: string + salario_base: + type: number + format: double + minimum: 400000 + bonos: + type: number + format: double + descuentos: + type: number + format: double + + EmpleadoInput: + type: object + required: + - nombre + - apellido + - rut + - cargo + - salario_base + properties: + nombre: + type: string + apellido: + type: string + rut: + type: string + example: "12345678-5" + cargo: + type: string + salario_base: + type: number + format: double + minimum: 400000 + bonos: + type: number + format: double + default: 0 + descuentos: + type: number + format: double + default: 0 + + ErrorResponse: + type: object + properties: + mensaje: + type: string + errores: + type: array + items: + type: string diff --git a/src/test/java/com/previred/empleados/mapper/EmpleadoMapperTest.java b/src/test/java/com/previred/empleados/mapper/EmpleadoMapperTest.java new file mode 100644 index 0000000..8724a29 --- /dev/null +++ b/src/test/java/com/previred/empleados/mapper/EmpleadoMapperTest.java @@ -0,0 +1,72 @@ +package com.previred.empleados.mapper; + +import com.previred.empleados.entity.EmpleadoEntity; +import com.previred.empleados.model.EmpleadoModel; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class EmpleadoMapperTest { + + @Test + void toModel_deberiaMapearTodosLosCampos() { + EmpleadoEntity entity = new EmpleadoEntity(); + entity.setId(1L); + entity.setNombre("Juan"); + entity.setApellido("Pérez"); + entity.setRut("12345678-5"); + entity.setCargo("Desarrollador"); + entity.setSalarioBase(500000.0); + entity.setBonos(100000.0); + entity.setDescuentos(50000.0); + + EmpleadoModel model = EmpleadoMapper.toModel(entity); + + assertEquals(1L, model.getId()); + assertEquals("Juan", model.getNombre()); + assertEquals("Pérez", model.getApellido()); + assertEquals("12345678-5", model.getRut()); + assertEquals("Desarrollador", model.getCargo()); + assertEquals(500000.0, model.getSalarioBase()); + assertEquals(100000.0, model.getBonos()); + assertEquals(50000.0, model.getDescuentos()); + } + + @Test + void toEntity_deberiaMapearTodosLosCampos() { + EmpleadoModel model = new EmpleadoModel(); + model.setId(2L); + model.setNombre("María"); + model.setApellido("López"); + model.setRut("87654321-K"); + model.setCargo("Analista"); + model.setSalarioBase(600000.0); + model.setBonos(200000.0); + model.setDescuentos(100000.0); + + EmpleadoEntity entity = EmpleadoMapper.toEntity(model); + + assertEquals(2L, entity.getId()); + assertEquals("María", entity.getNombre()); + assertEquals("López", entity.getApellido()); + assertEquals("87654321-K", entity.getRut()); + assertEquals("Analista", entity.getCargo()); + assertEquals(600000.0, entity.getSalarioBase()); + assertEquals(200000.0, entity.getBonos()); + assertEquals(100000.0, entity.getDescuentos()); + } + + @Test + void toModel_conCamposNulos_deberiaMapearNulos() { + EmpleadoEntity entity = new EmpleadoEntity(); + entity.setId(1L); + entity.setNombre("Test"); + + EmpleadoModel model = EmpleadoMapper.toModel(entity); + + assertEquals(1L, model.getId()); + assertEquals("Test", model.getNombre()); + assertNull(model.getApellido()); + assertNull(model.getRut()); + } +} diff --git a/src/test/java/com/previred/empleados/repository/EmpleadoRepositoryTest.java b/src/test/java/com/previred/empleados/repository/EmpleadoRepositoryTest.java new file mode 100644 index 0000000..0de2b9e --- /dev/null +++ b/src/test/java/com/previred/empleados/repository/EmpleadoRepositoryTest.java @@ -0,0 +1,138 @@ +package com.previred.empleados.repository; + +import com.previred.empleados.entity.EmpleadoEntity; +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.jdbc.JdbcTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.jdbc.Sql; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +@JdbcTest +@Sql(statements = { + "CREATE TABLE IF NOT EXISTS empleados (" + + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + + "nombre VARCHAR(100) NOT NULL, " + + "apellido VARCHAR(100) NOT NULL, " + + "rut VARCHAR(12) NOT NULL UNIQUE, " + + "cargo VARCHAR(100) NOT NULL, " + + "salario_base DOUBLE NOT NULL, " + + "bonos DOUBLE DEFAULT 0, " + + "descuentos DOUBLE DEFAULT 0)" +}) +class EmpleadoRepositoryTest { + + @Autowired + private JdbcTemplate jdbcTemplate; + + private EmpleadoRepository repository; + + @BeforeEach + void setUp() { + repository = new EmpleadoRepository(jdbcTemplate); + jdbcTemplate.update("DELETE FROM empleados"); + } + + private EmpleadoEntity crearEntityBase() { + EmpleadoEntity entity = new EmpleadoEntity(); + entity.setNombre("Juan"); + entity.setApellido("Pérez"); + entity.setRut("12345678-5"); + entity.setCargo("Desarrollador"); + entity.setSalarioBase(500000.0); + entity.setBonos(100000.0); + entity.setDescuentos(50000.0); + return entity; + } + + @Test + void save_deberiaGuardarYRetornarConId() { + EmpleadoEntity entity = crearEntityBase(); + + EmpleadoEntity saved = repository.save(entity); + + assertNotNull(saved.getId()); + assertEquals("Juan", saved.getNombre()); + } + + @Test + void findAll_deberiaRetornarTodos() { + repository.save(crearEntityBase()); + EmpleadoEntity otro = crearEntityBase(); + otro.setRut("87654321-K"); + otro.setNombre("María"); + repository.save(otro); + + List resultado = repository.findAll(); + + assertEquals(2, resultado.size()); + } + + @Test + void findAll_sinDatos_deberiaRetornarVacio() { + List resultado = repository.findAll(); + + assertTrue(resultado.isEmpty()); + } + + @Test + void findById_existente_deberiaRetornar() { + EmpleadoEntity saved = repository.save(crearEntityBase()); + + Optional resultado = repository.findById(saved.getId()); + + assertTrue(resultado.isPresent()); + assertEquals("Juan", resultado.get().getNombre()); + } + + @Test + void findById_noExistente_deberiaRetornarVacio() { + Optional resultado = repository.findById(999L); + + assertFalse(resultado.isPresent()); + } + + @Test + void existsByRut_existente_deberiaRetornarTrue() { + repository.save(crearEntityBase()); + + assertTrue(repository.existsByRut("12345678-5")); + } + + @Test + void existsByRut_noExistente_deberiaRetornarFalse() { + assertFalse(repository.existsByRut("99999999-9")); + } + + @Test + void deleteById_existente_deberiaRetornarTrue() { + EmpleadoEntity saved = repository.save(crearEntityBase()); + + assertTrue(repository.deleteById(saved.getId())); + assertTrue(repository.findAll().isEmpty()); + } + + @Test + void deleteById_noExistente_deberiaRetornarFalse() { + assertFalse(repository.deleteById(999L)); + } + + @Test + void save_conBonosNulos_deberiaGuardarConCero() { + EmpleadoEntity entity = crearEntityBase(); + entity.setBonos(null); + entity.setDescuentos(null); + + EmpleadoEntity saved = repository.save(entity); + Optional found = repository.findById(saved.getId()); + + assertTrue(found.isPresent()); + assertEquals(0.0, found.get().getBonos()); + assertEquals(0.0, found.get().getDescuentos()); + } +} diff --git a/src/test/java/com/previred/empleados/service/EmpleadoServiceTest.java b/src/test/java/com/previred/empleados/service/EmpleadoServiceTest.java new file mode 100644 index 0000000..f048da9 --- /dev/null +++ b/src/test/java/com/previred/empleados/service/EmpleadoServiceTest.java @@ -0,0 +1,265 @@ +package com.previred.empleados.service; + +import com.previred.empleados.entity.EmpleadoEntity; +import com.previred.empleados.exception.BusinessValidationException; +import com.previred.empleados.exception.EmpleadoNotFoundException; +import com.previred.empleados.model.EmpleadoModel; +import com.previred.empleados.repository.IEmpleadoRepository; +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 java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class EmpleadoServiceTest { + + @Mock + private IEmpleadoRepository empleadoRepository; + + @InjectMocks + private EmpleadoService empleadoService; + + private EmpleadoModel modelValido; + + @BeforeEach + void setUp() { + modelValido = new EmpleadoModel(); + modelValido.setNombre("Juan"); + modelValido.setApellido("Pérez"); + modelValido.setRut("12345678-5"); + modelValido.setCargo("Desarrollador"); + modelValido.setSalarioBase(500000.0); + modelValido.setBonos(100000.0); + modelValido.setDescuentos(50000.0); + } + + // --- listarEmpleados --- + + @Test + void listarEmpleados_deberiaRetornarListaDeModels() { + EmpleadoEntity entity = new EmpleadoEntity(); + entity.setId(1L); + entity.setNombre("Juan"); + entity.setApellido("Pérez"); + entity.setRut("12345678-5"); + entity.setCargo("Dev"); + entity.setSalarioBase(500000.0); + entity.setBonos(0.0); + entity.setDescuentos(0.0); + when(empleadoRepository.findAll()).thenReturn(Arrays.asList(entity)); + + List resultado = empleadoService.listarEmpleados(); + + assertEquals(1, resultado.size()); + assertEquals("Juan", resultado.get(0).getNombre()); + verify(empleadoRepository).findAll(); + } + + @Test + void listarEmpleados_sinDatos_deberiaRetornarListaVacia() { + when(empleadoRepository.findAll()).thenReturn(Collections.emptyList()); + + List resultado = empleadoService.listarEmpleados(); + + assertTrue(resultado.isEmpty()); + } + + // --- crearEmpleado --- + + @Test + void crearEmpleado_conDatosValidos_deberiaCrear() { + when(empleadoRepository.existsByRut(anyString())).thenReturn(false); + EmpleadoEntity savedEntity = new EmpleadoEntity(); + savedEntity.setId(1L); + savedEntity.setNombre("Juan"); + savedEntity.setApellido("Pérez"); + savedEntity.setRut("12345678-5"); + savedEntity.setCargo("Desarrollador"); + savedEntity.setSalarioBase(500000.0); + savedEntity.setBonos(100000.0); + savedEntity.setDescuentos(50000.0); + when(empleadoRepository.save(any())).thenReturn(savedEntity); + + EmpleadoModel resultado = empleadoService.crearEmpleado(modelValido); + + assertNotNull(resultado); + assertEquals(1L, resultado.getId()); + verify(empleadoRepository).save(any()); + } + + // --- Validaciones de campos obligatorios --- + + @Test + void crearEmpleado_sinNombre_deberiaLanzarExcepcion() { + modelValido.setNombre(null); + + BusinessValidationException ex = assertThrows(BusinessValidationException.class, + () -> empleadoService.crearEmpleado(modelValido)); + + assertTrue(ex.getErrores().stream().anyMatch(e -> e.contains("nombre"))); + } + + @Test + void crearEmpleado_sinApellido_deberiaLanzarExcepcion() { + modelValido.setApellido(""); + + BusinessValidationException ex = assertThrows(BusinessValidationException.class, + () -> empleadoService.crearEmpleado(modelValido)); + + assertTrue(ex.getErrores().stream().anyMatch(e -> e.contains("apellido"))); + } + + @Test + void crearEmpleado_sinRut_deberiaLanzarExcepcion() { + modelValido.setRut(null); + + BusinessValidationException ex = assertThrows(BusinessValidationException.class, + () -> empleadoService.crearEmpleado(modelValido)); + + assertTrue(ex.getErrores().stream().anyMatch(e -> e.contains("RUT"))); + } + + @Test + void crearEmpleado_sinCargo_deberiaLanzarExcepcion() { + modelValido.setCargo(" "); + + BusinessValidationException ex = assertThrows(BusinessValidationException.class, + () -> empleadoService.crearEmpleado(modelValido)); + + assertTrue(ex.getErrores().stream().anyMatch(e -> e.contains("cargo"))); + } + + // --- Validación formato RUT --- + + @Test + void crearEmpleado_conRutFormatoInvalido_deberiaLanzarExcepcion() { + modelValido.setRut("123-456"); + + BusinessValidationException ex = assertThrows(BusinessValidationException.class, + () -> empleadoService.crearEmpleado(modelValido)); + + assertTrue(ex.getErrores().stream().anyMatch(e -> e.contains("formato"))); + } + + // --- Validación RUT duplicado --- + + @Test + void crearEmpleado_conRutDuplicado_deberiaLanzarExcepcion() { + when(empleadoRepository.existsByRut("12345678-5")).thenReturn(true); + + BusinessValidationException ex = assertThrows(BusinessValidationException.class, + () -> empleadoService.crearEmpleado(modelValido)); + + assertTrue(ex.getErrores().stream().anyMatch(e -> e.contains("Ya existe"))); + } + + // --- Validación salario mínimo --- + + @Test + void crearEmpleado_conSalarioMenorAlMinimo_deberiaLanzarExcepcion() { + modelValido.setSalarioBase(300000.0); + + BusinessValidationException ex = assertThrows(BusinessValidationException.class, + () -> empleadoService.crearEmpleado(modelValido)); + + assertTrue(ex.getErrores().stream().anyMatch(e -> e.contains("salario base"))); + } + + @Test + void crearEmpleado_conSalarioNulo_deberiaLanzarExcepcion() { + modelValido.setSalarioBase(null); + + BusinessValidationException ex = assertThrows(BusinessValidationException.class, + () -> empleadoService.crearEmpleado(modelValido)); + + assertTrue(ex.getErrores().stream().anyMatch(e -> e.contains("salario base"))); + } + + // --- Validación bonos > 50% salario --- + + @Test + void crearEmpleado_conBonosMayoresAl50Porciento_deberiaLanzarExcepcion() { + modelValido.setSalarioBase(500000.0); + modelValido.setBonos(300000.0); // 60% del salario + + BusinessValidationException ex = assertThrows(BusinessValidationException.class, + () -> empleadoService.crearEmpleado(modelValido)); + + assertTrue(ex.getErrores().stream().anyMatch(e -> e.contains("bonos"))); + } + + @Test + void crearEmpleado_conBonosExactamente50Porciento_deberiaCrear() { + modelValido.setBonos(250000.0); // exactamente 50% + when(empleadoRepository.existsByRut(anyString())).thenReturn(false); + EmpleadoEntity saved = new EmpleadoEntity(); + saved.setId(1L); + saved.setNombre("Juan"); + saved.setApellido("Pérez"); + saved.setRut("12345678-5"); + saved.setCargo("Desarrollador"); + saved.setSalarioBase(500000.0); + saved.setBonos(250000.0); + saved.setDescuentos(50000.0); + when(empleadoRepository.save(any())).thenReturn(saved); + + EmpleadoModel resultado = empleadoService.crearEmpleado(modelValido); + + assertNotNull(resultado); + } + + // --- Validación descuentos > salario --- + + @Test + void crearEmpleado_conDescuentosMayoresAlSalario_deberiaLanzarExcepcion() { + modelValido.setSalarioBase(500000.0); + modelValido.setDescuentos(600000.0); + + BusinessValidationException ex = assertThrows(BusinessValidationException.class, + () -> empleadoService.crearEmpleado(modelValido)); + + assertTrue(ex.getErrores().stream().anyMatch(e -> e.contains("descuentos"))); + } + + // --- eliminarEmpleado --- + + @Test + void eliminarEmpleado_existente_deberiaEliminar() { + when(empleadoRepository.deleteById(1L)).thenReturn(true); + + assertDoesNotThrow(() -> empleadoService.eliminarEmpleado(1L)); + + verify(empleadoRepository).deleteById(1L); + } + + @Test + void eliminarEmpleado_noExistente_deberiaLanzarExcepcion() { + when(empleadoRepository.deleteById(99L)).thenReturn(false); + + assertThrows(EmpleadoNotFoundException.class, + () -> empleadoService.eliminarEmpleado(99L)); + } + + // --- Múltiples errores --- + + @Test + void crearEmpleado_conMultiplesErrores_deberiaRetornarTodos() { + EmpleadoModel modelInvalido = new EmpleadoModel(); + + BusinessValidationException ex = assertThrows(BusinessValidationException.class, + () -> empleadoService.crearEmpleado(modelInvalido)); + + assertTrue(ex.getErrores().size() >= 4); + } +} diff --git a/src/test/java/com/previred/empleados/servlet/EmpleadoServletTest.java b/src/test/java/com/previred/empleados/servlet/EmpleadoServletTest.java new file mode 100644 index 0000000..12de3c4 --- /dev/null +++ b/src/test/java/com/previred/empleados/servlet/EmpleadoServletTest.java @@ -0,0 +1,169 @@ +package com.previred.empleados.servlet; + +import com.google.gson.Gson; +import com.previred.empleados.exception.BusinessValidationException; +import com.previred.empleados.exception.EmpleadoNotFoundException; +import com.previred.empleados.model.EmpleadoModel; +import com.previred.empleados.service.IEmpleadoService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.mock.web.MockServletContext; +import org.springframework.web.context.WebApplicationContext; + +import javax.servlet.ServletContext; +import javax.servlet.ServletException; +import java.io.IOException; +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.*; + +@ExtendWith(MockitoExtension.class) +class EmpleadoServletTest { + + @Mock + private IEmpleadoService empleadoService; + + private EmpleadoServlet servlet; + private Gson gson; + + @BeforeEach + void setUp() throws Exception { + gson = new Gson(); + servlet = new EmpleadoServlet(); + + // Inyectar dependencias via reflection (el servlet usa init() con WebApplicationContext) + java.lang.reflect.Field serviceField = EmpleadoServlet.class.getDeclaredField("empleadoService"); + serviceField.setAccessible(true); + serviceField.set(servlet, empleadoService); + + java.lang.reflect.Field gsonField = EmpleadoServlet.class.getDeclaredField("gson"); + gsonField.setAccessible(true); + gsonField.set(servlet, gson); + } + + // --- GET --- + + @Test + void doGet_deberiaRetornar200ConLista() throws IOException { + EmpleadoModel model = new EmpleadoModel(); + model.setId(1L); + model.setNombre("Juan"); + when(empleadoService.listarEmpleados()).thenReturn(Arrays.asList(model)); + + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/api/empleados"); + MockHttpServletResponse resp = new MockHttpServletResponse(); + + servlet.doGet(req, resp); + + assertEquals(200, resp.getStatus()); + assertTrue(resp.getContentType().startsWith("application/json")); + assertTrue(resp.getContentAsString().contains("Juan")); + } + + @Test + void doGet_sinEmpleados_deberiaRetornar200ConListaVacia() throws IOException { + when(empleadoService.listarEmpleados()).thenReturn(Collections.emptyList()); + + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/api/empleados"); + MockHttpServletResponse resp = new MockHttpServletResponse(); + + servlet.doGet(req, resp); + + assertEquals(200, resp.getStatus()); + assertEquals("[]", resp.getContentAsString()); + } + + // --- POST --- + + @Test + void doPost_conDatosValidos_deberiaRetornar201() throws IOException { + EmpleadoModel created = new EmpleadoModel(); + created.setId(1L); + created.setNombre("Juan"); + when(empleadoService.crearEmpleado(any())).thenReturn(created); + + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/empleados"); + req.setContentType("application/json"); + req.setContent(gson.toJson(created).getBytes()); + MockHttpServletResponse resp = new MockHttpServletResponse(); + + servlet.doPost(req, resp); + + assertEquals(201, resp.getStatus()); + assertTrue(resp.getContentAsString().contains("Juan")); + } + + @Test + void doPost_conValidacionFallida_deberiaRetornar400() throws IOException { + when(empleadoService.crearEmpleado(any())) + .thenThrow(new BusinessValidationException(Arrays.asList("Error de test"))); + + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/empleados"); + req.setContentType("application/json"); + req.setContent("{\"nombre\":\"Test\"}".getBytes()); + MockHttpServletResponse resp = new MockHttpServletResponse(); + + servlet.doPost(req, resp); + + assertEquals(400, resp.getStatus()); + assertTrue(resp.getContentAsString().contains("Error de test")); + } + + // --- DELETE --- + + @Test + void doDelete_conIdValido_deberiaRetornar204() throws IOException { + doNothing().when(empleadoService).eliminarEmpleado(1L); + + MockHttpServletRequest req = new MockHttpServletRequest("DELETE", "/api/empleados/1"); + req.setPathInfo("/1"); + MockHttpServletResponse resp = new MockHttpServletResponse(); + + servlet.doDelete(req, resp); + + assertEquals(204, resp.getStatus()); + } + + @Test + void doDelete_sinId_deberiaRetornar400() throws IOException { + MockHttpServletRequest req = new MockHttpServletRequest("DELETE", "/api/empleados"); + MockHttpServletResponse resp = new MockHttpServletResponse(); + + servlet.doDelete(req, resp); + + assertEquals(400, resp.getStatus()); + } + + @Test + void doDelete_noExistente_deberiaRetornar404() throws IOException { + doThrow(new EmpleadoNotFoundException(99L)).when(empleadoService).eliminarEmpleado(99L); + + MockHttpServletRequest req = new MockHttpServletRequest("DELETE", "/api/empleados/99"); + req.setPathInfo("/99"); + MockHttpServletResponse resp = new MockHttpServletResponse(); + + servlet.doDelete(req, resp); + + assertEquals(404, resp.getStatus()); + } + + @Test + void doDelete_conIdInvalido_deberiaRetornar400() throws IOException { + MockHttpServletRequest req = new MockHttpServletRequest("DELETE", "/api/empleados/abc"); + req.setPathInfo("/abc"); + MockHttpServletResponse resp = new MockHttpServletResponse(); + + servlet.doDelete(req, resp); + + assertEquals(400, resp.getStatus()); + assertTrue(resp.getContentAsString().contains("número válido")); + } +}