0Qué vas a construir y por dónde empezar

Ya tienes los tipos de A.03. Ahora vas a darles comportamiento: guardar un expediente, recuperarlo y calcular cuánto se paga con bonificación. Al terminar entregarás código y pruebas con ./mvnw test en verde.

Una imagen sencillaEl modelo es la ficha del expediente. El repositorio es el archivador que guarda fichas. El servicio decide si se puede registrar una ficha y cuánto corresponde pagar. En las pruebas, Mockito hace de archivador de mentira: tú decides qué responde.
  1. Prepara el proyecto y revisa tu ExpedienteComprueba los datos de A.03 que necesitará el servicio.
  2. Define el contrato y el archivador en memoriaQué operaciones se pueden pedir y cómo se guardan los datos.
  3. Pon nombre a los erroresCrea las excepciones antes de usarlas.
  4. Construye el servicio y decide el plazoRecibe el repositorio por constructor y aplica las reglas.
  5. Prueba cada caso con MockitoPrepara una situación, llama al método y comprueba el resultado.
  6. Ejecuta, corrige y revisa la entregaComprueba los cinco criterios del enunciado.
Fuente y propuestaLos requisitos proceden de A0.4, con el apoyo de los apartados f (inyección) y b (R1). Los archivos desplegables son una propuesta didáctica que completa esos requisitos, no una solución oficial. La inclusión del día 20, el tratamiento de fechas inválidas y el nombre ExpedienteDuplicadoException son decisiones de esta propuesta.

1Antes de escribir: localiza tus archivos

Abre el proyecto MULTAGAL de A.02, con los modelos de A.03. Trabaja desde la carpeta que contiene pom.xml y el Maven Wrapper. Los ejemplos usan el paquete base es.edu.multagal; si el tuyo cambia, ajusta los package y los import.

CarpetaQué debe haber al terminar
src/main/java/es/edu/multagal/domain/model/Tu Expediente y EstadoExpediente de A.03.
src/main/java/es/edu/multagal/repository/RepositorioExpedientes.java y RepositorioExpedientesMemoria.java.
src/main/java/es/edu/multagal/domain/exception/Las tres excepciones de esta propuesta, cada una en su archivo.
src/main/java/es/edu/multagal/service/ServicioExpedientes.java.
src/test/java/es/edu/multagal/service/ServicioExpedientesTest.java.

El puente con A.03

El servicio necesita leer numero(), estado(), importe() y fechaNotificacion(). El importe es BigDecimal y la fecha es LocalDate. Si elegiste una clase con getters, sustituye esas llamadas por tus getNumero(), getEstado(), etc.

Los tests usan una ficha mínima de cuatro campos. Conserva tu modelo de A.03: si añadiste Denuncia u otros campos, adapta la función auxiliar expediente(...) de los tests para construir una ficha válida. No borres relaciones para que encaje el ejemplo.

Ver la forma mínima que asumen los ejemplos (referencia, no reemplazo de A.03)
Expediente.java
package es.edu.multagal.domain.model;

import java.math.BigDecimal;
import java.time.LocalDate;

public record Expediente(String numero, EstadoExpediente estado,
                         BigDecimal importe, LocalDate fechaNotificacion) { }

Comprueba: tu pom.xml conserva la dependencia de pruebas generada en A.02 (spring-boot-starter-test, ámbito test). Necesitaremos JUnit, Mockito y AssertJ. Recarga Maven en el IDE si no reconoce sus imports.

Primera paradaEjecuta .\mvnw.cmd compile en PowerShell o ./mvnw compile en Linux/macOS. Debe aparecer BUILD SUCCESS. Aquí solo compruebas que el modelo y los paquetes compilan; todavía no necesitas arrancar la aplicación.

2Primero el contrato; después, el archivador

2.1 · Escribe qué podrás pedir al repositorio

Una interface enumera operaciones sin decidir cómo se hacen. Optional<Expediente> expresa «puede haber una ficha o puede no haberla»; las búsquedas múltiples devuelven una lista, vacía si no hay coincidencias.

Hazlo: crea la interfaz en repository con los cuatro métodos del enunciado. En esta propuesta, guardar devuelve la ficha guardada.

Comparar con la interfaz completa
RepositorioExpedientes.java
package es.edu.multagal.repository;

import es.edu.multagal.domain.model.EstadoExpediente;
import es.edu.multagal.domain.model.Expediente;
import java.util.List;
import java.util.Optional;

public interface RepositorioExpedientes {
    Expediente guardar(Expediente expediente);
    Optional<Expediente> buscarPorNumero(String numero);
    List<Expediente> buscarTodos();
    List<Expediente> buscarPorEstado(EstadoExpediente estado);
}

2.2 · Implementa las operaciones en memoria

implements obliga a cumplir el contrato. El mapa relaciona un número con una ficha: "X-1" → expediente. put guarda, get consulta y values permite listar. Los datos desaparecen al reiniciar: este repositorio es provisional.

Hazlo: crea RepositorioExpedientesMemoria, usa ConcurrentHashMap y anótalo con @Repository para que Spring lo encuentre.

Ver el repositorio completo con imports
RepositorioExpedientesMemoria.java
package es.edu.multagal.repository;

import es.edu.multagal.domain.model.EstadoExpediente;
import es.edu.multagal.domain.model.Expediente;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.stereotype.Repository;

@Repository
public class RepositorioExpedientesMemoria implements RepositorioExpedientes {
    private final Map<String, Expediente> datos = new ConcurrentHashMap<>();

    @Override
    public Expediente guardar(Expediente expediente) {
        datos.put(expediente.numero(), expediente);
        return expediente;
    }

    @Override
    public Optional<Expediente> buscarPorNumero(String numero) {
        return Optional.ofNullable(datos.get(numero));
    }

    @Override
    public List<Expediente> buscarTodos() {
        return List.copyOf(datos.values());
    }

    @Override
    public List<Expediente> buscarPorEstado(EstadoExpediente estado) {
        return datos.values().stream()
                .filter(e -> e.estado() == estado)
                .toList();
    }
}

Lee dos líneas con calma: Optional.ofNullable(...) convierte la ausencia de ficha en Optional.empty(); filter(e -> e.estado() == estado) conserva solo las fichas del estado solicitado.

¿Por qué final si el mapa cambia?final impide sustituir la referencia datos por otro mapa. No impide añadir fichas al mapa. El repositorio guarda datos; el servicio solo conservará una referencia a su dependencia.

Comprueba: vuelve a ejecutar compile. La implementación debe cumplir los cuatro métodos. En la UD3 se sustituirá por JDBC sin reescribir la lógica del servicio.

3Pon nombre a lo que puede salir mal

Consultar una ficha inexistente no devuelve una ficha vacía: el servicio lanza ExpedienteNoEncontradoException. Todas las excepciones propias comparten una raíz, MultagalException. extends expresa esa herencia; super(...) entrega el mensaje al constructor padre.

Hazlo: crea un archivo por clase pública en domain/exception. Añadimos ExpedienteDuplicadoException para rechazar duplicados de forma reconocible en los tests. El enunciado exige rechazarlos, pero no fija el nombre de esa excepción.

Ver los tres archivos completos
MultagalException.java
package es.edu.multagal.domain.exception;

public class MultagalException extends RuntimeException {
    public MultagalException(String mensaje) {
        super(mensaje);
    }
}
ExpedienteNoEncontradoException.java
package es.edu.multagal.domain.exception;

public class ExpedienteNoEncontradoException extends MultagalException {
    public ExpedienteNoEncontradoException(String numero) {
        super("No existe el expediente " + numero);
    }
}
ExpedienteDuplicadoException.java
package es.edu.multagal.domain.exception;

public class ExpedienteDuplicadoException extends MultagalException {
    public ExpedienteDuplicadoException(String numero) {
        super("Ya existe el expediente " + numero);
    }
}

4El servicio decide; el repositorio guarda

4.1 · Entiende el constructor antes de copiar

El servicio recibe un RepositorioExpedientes, no crea su propia implementación. Durante el arranque, Spring le entrega el bean de memoria. En el test, tú le entregarás un mock. La clase de servicio será la misma en los dos casos.

ServicioExpedientes→ depende de →RepositorioExpedientes (interfaz)

@Service identifica la clase como lógica de negocio. Un único constructor no necesita @Autowired. El campo es final: la dependencia queda asignada al construir el servicio.

Sin estado mutable en el servicioNo guardes «el expediente actual» en un campo: el servicio se comparte entre peticiones. Usa parámetros, variables locales y retornos. Su único campo aquí es la dependencia del repositorio.

4.2 · Traduce cada método a una decisión

MétodoPreguntaResultado
obtener¿Existe ese número?Devuelve la ficha; si no existe, lanza la excepción de no encontrado.
registrar¿Está libre ese número?Guarda y devuelve la ficha; si ya existe, rechaza sin guardar.
calcularImporteConBonificacion¿El pago cae en el plazo elegido?Devuelve la mitad o el importe completo. No modifica la ficha.

4.3 · Decide los límites antes de programarlos

R1 establece un 50 % de bonificación en los 20 días naturales siguientes a la notificación, con renuncia a alegaciones. La actividad te pide resolver, documentar y probar el límite. Aquí elegimos los días 1 a 20, ambos incluidos; «naturales» incluye fines de semana. No es una interpretación única impuesta por el enunciado.

Ejemplo didáctico: notificación el 1 de enero de 2026 e importe de 100 €
Fecha de pagoDías transcurridosResultado de esta propuesta
31 de diciembre de 2025−1Rechazar: el pago es anterior.
1 de enero de 20260100 €: elegimos contar desde el día siguiente.
2 de enero de 2026150 €: primer día incluido.
21 de enero de 20262050 €: último día incluido.
22 de enero de 202621100 €: fuera de plazo.

También elegimos rechazar el cálculo si falta alguna fecha. Un expediente aún no notificado puede existir en A.03; eso no significa que podamos calcular su plazo. Estas guardas son decisiones adicionales del ejemplo, no nuevos requisitos oficiales.

Escribe tu decisión en la documentación del proyecto (por ejemplo, en docs/adr/0002-plazo-bonificacion.md; este nombre es una sugerencia): qué días incluyes, cómo tratas el día 0 y las fechas ausentes o anteriores, y qué tests lo demuestran.

Calcular no es pagarR1 incluye la renuncia a alegaciones. Este método devuelve un importe: no registra un pago ni cambia el estado del expediente. No confundas esta operación de cálculo de A.04 con implementar todo el procedimiento de pago.
Ver el servicio completo, con imports y las decisiones anteriores
ServicioExpedientes.java
package es.edu.multagal.service;

import es.edu.multagal.domain.exception.ExpedienteDuplicadoException;
import es.edu.multagal.domain.exception.ExpedienteNoEncontradoException;
import es.edu.multagal.domain.model.Expediente;
import es.edu.multagal.repository.RepositorioExpedientes;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import org.springframework.stereotype.Service;

@Service
public class ServicioExpedientes {
    private final RepositorioExpedientes repositorio;

    public ServicioExpedientes(RepositorioExpedientes repositorio) {
        this.repositorio = repositorio;
    }

    public Expediente obtener(String numero) {
        return repositorio.buscarPorNumero(numero)
                .orElseThrow(() -> new ExpedienteNoEncontradoException(numero));
    }

    public Expediente registrar(Expediente expediente) {
        if (repositorio.buscarPorNumero(expediente.numero()).isPresent()) {
            throw new ExpedienteDuplicadoException(expediente.numero());
        }
        return repositorio.guardar(expediente);
    }

    public BigDecimal calcularImporteConBonificacion(Expediente expediente,
                                                     LocalDate fechaPago) {
        if (expediente.fechaNotificacion() == null || fechaPago == null) {
            throw new IllegalArgumentException("Se necesitan ambas fechas");
        }
        long dias = ChronoUnit.DAYS.between(expediente.fechaNotificacion(), fechaPago);
        if (dias < 0) {
            throw new IllegalArgumentException("El pago no puede preceder a la notificación");
        }
        // Decisión de esta propuesta: días 1 a 20 incluidos; día 0 sin descuento.
        if (dias >= 1 && dias <= 20) {
            return expediente.importe().multiply(new BigDecimal("0.50"));
        }
        return expediente.importe();
    }
}

Lee el código: orElseThrow devuelve el contenido del Optional o lanza la excepción. isPresent() detecta el duplicado. ChronoUnit.DAYS.between calcula los días entre las fechas. new BigDecimal("0.50") representa exactamente la mitad.

Alcance del ejemplo: el mapa permite operaciones concurrentes, pero «consultar y después guardar» son dos operaciones separadas. Esta vertical didáctica comprueba duplicados en ejecución secuencial; no demuestra unicidad ante dos altas simultáneas.

5Prueba el servicio sin arrancar Spring

Una prueba unitaria comprueba una pieza aislada. Aquí la pieza real es ServicioExpedientes. Mockito crea un repositorio simulado: no usa el mapa de memoria ni una base de datos. Así sabes que estás probando las decisiones del servicio.

5.1 · Entiende las tres fases de un test

  1. Preparar: crea la ficha e indica qué responderá el mock.
  2. Actuar: llama al método real del servicio.
  3. Comprobar: verifica el resultado, la excepción o la llamada a guardar.
InstrucciónEn palabras sencillas
mock(RepositorioExpedientes.class)Crea un doble que cumple la interfaz.
when(...).thenReturn(...)«Cuando te pidan esto, responde aquello». No inserta fichas reales.
new ServicioExpedientes(repo)Inyecta tú mismo el doble por el constructor.
assertThat(...)Comprueba el resultado; si no coincide, el test falla.
assertThatThrownBy(...)Comprueba que la operación lanza la excepción esperada.
verify(repo).guardar(nuevo)Comprueba que el servicio pidió guardar esa ficha.
@BeforeEachPrepara un mock y un servicio nuevos antes de cada test.

5.2 · Escribe los casos antes de desplegar la solución

CasoPreparaciónQué debes comprobar
Obtener existenteLa búsqueda devuelve una ficha.Recibes esa ficha.
Registrar nuevoNo existe; guardar devuelve la ficha.Se guarda y se devuelve. Es el caso favorable de registrar.
Obtener inexistenteLa búsqueda devuelve Optional.empty().Se lanza la excepción de no encontrado.
Registrar duplicadoLa búsqueda ya encuentra el número.Se rechaza y no se llama a guardar.
Cálculo dentro del plazo100 €, notificación y pago 10 días después.50 €.
Días 20 y 21Misma ficha, dos fechas de pago.50 € y 100 €, según la decisión documentada.

Hazlo: crea la clase en src/test/java/es/edu/multagal/service/. Empieza por obtener una ficha. Cuando pase, añade el registro correcto y los errores. Termina con las fechas.

Ver la clase de pruebas completa (12 tests, incluidos los casos adicionales)
ServicioExpedientesTest.java
package es.edu.multagal.service;

import es.edu.multagal.domain.exception.ExpedienteDuplicadoException;
import es.edu.multagal.domain.exception.ExpedienteNoEncontradoException;
import es.edu.multagal.domain.model.EstadoExpediente;
import es.edu.multagal.domain.model.Expediente;
import es.edu.multagal.repository.RepositorioExpedientes;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.*;

class ServicioExpedientesTest {
    private RepositorioExpedientes repo;
    private ServicioExpedientes servicio;
    private final LocalDate notificacion = LocalDate.of(2026, 1, 1);

    @BeforeEach
    void preparar() {
        repo = mock(RepositorioExpedientes.class);
        servicio = new ServicioExpedientes(repo);
    }

    // Adapta SOLO esta construcción a los campos de tu modelo de A.03.
    private Expediente expediente(LocalDate fecha) {
        return new Expediente("X-1", EstadoExpediente.ABIERTO,
                new BigDecimal("100.00"), fecha);
    }

    @Test
    void devuelveElExpedienteCuandoExiste() {
        Expediente esperado = expediente(notificacion);
        when(repo.buscarPorNumero("X-1")).thenReturn(Optional.of(esperado));

        Expediente resultado = servicio.obtener("X-1");

        assertThat(resultado).isEqualTo(esperado);
    }

    @Test
    void registraUnNumeroNuevo() {
        Expediente nuevo = expediente(notificacion);
        when(repo.buscarPorNumero("X-1")).thenReturn(Optional.empty());
        when(repo.guardar(nuevo)).thenReturn(nuevo);

        Expediente resultado = servicio.registrar(nuevo);

        assertThat(resultado).isEqualTo(nuevo);
        verify(repo).guardar(nuevo);
    }

    @Test
    void lanzaExcepcionSiNoExiste() {
        when(repo.buscarPorNumero("X-1")).thenReturn(Optional.empty());

        assertThatThrownBy(() -> servicio.obtener("X-1"))
                .isInstanceOf(ExpedienteNoEncontradoException.class);
    }

    @Test
    void rechazaDuplicadoSinGuardar() {
        Expediente existente = expediente(notificacion);
        when(repo.buscarPorNumero("X-1")).thenReturn(Optional.of(existente));

        assertThatThrownBy(() -> servicio.registrar(existente))
                .isInstanceOf(ExpedienteDuplicadoException.class);
        verify(repo, never()).guardar(any(Expediente.class));
    }

    private void comprobarImporte(int dias, String esperado) {
        BigDecimal importe = servicio.calcularImporteConBonificacion(
                expediente(notificacion), notificacion.plusDays(dias));
        assertThat(importe).isEqualByComparingTo(esperado);
        verifyNoInteractions(repo);
    }

    @Test
    void bonificaUnDiaInteriorDelPlazo() {
        comprobarImporte(10, "50.00");
    }

    @Test
    void bonificaElDia20() {
        comprobarImporte(20, "50.00");
    }

    @Test
    void cobraCompletoElDia21() {
        comprobarImporte(21, "100.00");
    }

    // Casos adicionales para las decisiones de esta propuesta.
    @Test
    void bonificaElPrimerDiaSiguiente() {
        comprobarImporte(1, "50.00");
    }

    @Test
    void noBonificaElMismoDia() {
        comprobarImporte(0, "100.00");
    }

    @Test
    void rechazaPagoAnterior() {
        assertThatThrownBy(() -> servicio.calcularImporteConBonificacion(
                expediente(notificacion), notificacion.minusDays(1)))
                .isInstanceOf(IllegalArgumentException.class);
    }

    @Test
    void rechazaCalculoSinNotificacion() {
        assertThatThrownBy(() -> servicio.calcularImporteConBonificacion(
                expediente(null), notificacion.plusDays(1)))
                .isInstanceOf(IllegalArgumentException.class);
    }

    @Test
    void rechazaCalculoSinFechaDePago() {
        assertThatThrownBy(() -> servicio.calcularImporteConBonificacion(
                expediente(notificacion), null))
                .isInstanceOf(IllegalArgumentException.class);
    }
}
Dos detalles que evitan confusionesplusDays(20) evita confundir «día 20 del plazo» con «20 de enero». Para dinero, isEqualByComparingTo compara el valor: 50.0 y 50.00 representan el mismo importe.

El helper expediente(...) concentra la adaptación a A.03. Si tu constructor exige una denuncia, constrúyela con datos ficticios válidos y pásala allí; si tu modelo prohíbe una fecha nula, prueba esa restricción en el modelo y adapta el caso adicional correspondiente.

6Ejecuta y comprueba qué estás entregando

Primero la clase de pruebas; después, toda la suite

Ejecuta desde la carpeta de pom.xml. El primer comando aísla esta actividad; el segundo comprueba el entregable completo.

PowerShell · Windows
.\mvnw.cmd "-Dtest=ServicioExpedientesTest" test
.\mvnw.cmd test
Terminal · Linux / macOS
./mvnw -Dtest=ServicioExpedientesTest test
./mvnw test

Resultado esperado con la propuesta sin adaptar: 12 tests de ServicioExpedientesTest, cero fallos y cero errores, y BUILD SUCCESS. La suite completa puede tener más tests. No des por terminada la entrega si solo pasa el comando filtrado.

Si algo falla, busca el síntoma

SíntomaQué revisar
No encuentra una clase o un métodoPaquete, imports, nombre de archivo y accesores del modelo. Las tres excepciones deben existir.
El constructor de Expediente no encajaAdapta el helper de pruebas a los campos que elegiste en A.03.
registrar devuelve null en el testConfigura también when(repo.guardar(nuevo)).thenReturn(nuevo): el mock no guarda de verdad.
El día 20 fallaCompara la fecha de notificación, plusDays(20) y el operador <=. Revisa que test y decisión coincidan.
Intenta conectar a una base de datosEsta clase debe usar Mockito y new, sin @SpringBootTest. Si falla otro test generado en A.02 que carga el contexto, revisa su configuración por separado; no ocultes el fallo de la suite ni borres pruebas para conseguir verde.
Maven no encuentra pruebasUsa src/test/java, nombre terminado en Test, imports de JUnit y métodos con @Test.

Los cinco criterios oficiales, con evidencia

Criterio del enunciadoDónde lo demuestras
Inyección por constructor y campos finalConstructor de ServicioExpedientes y su campo repositorio.
El servicio depende de la interfazSu import y el tipo del parámetro son RepositorioExpedientes.
Pruebas sin Docker ni @SpringBootTestLa clase de tests instancia el servicio con un mock.
Casos límite del plazo cubiertosPruebas de los días 20 y 21 y decisión documentada.
Servicio sin estado mutableNo guarda fichas ni resultados en campos; trabaja con parámetros y retornos.

Entregable: interfaz, repositorio en memoria, servicio, excepciones y pruebas pasando con ./mvnw test, junto con la explicación de tu decisión sobre el plazo. Todo forma parte de la rama hito/ud0-arranque de la ENTREGA 1.

Comprueba que puedes explicarlo sin mirar el código

¿Qué cambia al sustituir memoria por JDBC? La implementación que cumple el contrato. El servicio sigue recibiendo la misma interfaz.

¿Quién inyecta en el test? Tú, al llamar al constructor con el mock. Spring no participa.

¿Por qué el duplicado se comprueba en el servicio? Es una decisión de negocio que necesita consultar si el número ya existe.

¿Por qué dos tests para 20 y 21? Para demostrar exactamente dónde termina el descuento y detectar errores de un día.

Fuente única: A0.4 — actividad y criterios y sus enlaces a f — Inyección de dependencias y b — Regla R1. Consultados el 26/09/2026. Código y decisiones adicionales señalados como propuesta didáctica.