From 6f4966962ca2a24cdf73d6a8b3906d9136e9c010 Mon Sep 17 00:00:00 2001 From: Arturo Gomez Date: Tue, 21 Apr 2026 13:41:56 -0600 Subject: [PATCH 01/24] =?UTF-8?q?fix(bot):=20agregar=20par=C3=A1metro=20ho?= =?UTF-8?q?urs=20en=20/help=20y=20actualizar=20estado=20de=20handlers=20en?= =?UTF-8?q?=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../springboot/MyTodoList/telegram/handler/HelpHandler.java | 1 + documentation/telegram.md | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/telegram/handler/HelpHandler.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/telegram/handler/HelpHandler.java index 870faedb2..a9d1a3128 100644 --- a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/telegram/handler/HelpHandler.java +++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/telegram/handler/HelpHandler.java @@ -19,6 +19,7 @@ public class HelpHandler { " /task {id} — View task details\n" + " /task_status {id} {status} — Change task status\n" + " Statuses: TODO · IN_PROGRESS · BLOCKED · DONE\n" + + " When marking DONE: /task_status {id} DONE {hours}\n" + " /comment {id} {text} — Add a comment to a task"; public void handle(Update update, TelegramClient client) { diff --git a/documentation/telegram.md b/documentation/telegram.md index 51a012883..e9fa9fc81 100644 --- a/documentation/telegram.md +++ b/documentation/telegram.md @@ -26,8 +26,8 @@ Comparte el mismo proceso, el mismo service layer, y el mismo connection pool qu | Dependencias Maven (telegrambots 9.1.0) | ✅ Funcional | | Activación condicional (@ConditionalOnExpression) | ✅ Funcional | | ChuvaBot.java — long polling + TelegramClient | ✅ Funcional | -| Enrutamiento de comandos en ChuvaBot.consume() | 🔲 TODO | -| 8 handlers creados como stubs | 🔲 TODO (lógica de negocio) | +| Enrutamiento de comandos en ChuvaBot.consume() | ✅ Funcional | +| 8 handlers implementados con lógica de negocio | ✅ Funcional | | NotificationService — envío real de mensajes | 🔲 TODO | --- From 3a753c98ad5494ee90691d1ce3cd7045e7445a86 Mon Sep 17 00:00:00 2001 From: Arturo Gomez Date: Tue, 21 Apr 2026 14:03:23 -0600 Subject: [PATCH 02/24] =?UTF-8?q?feat(bot):=20capa=20NLU=20con=20clasifica?= =?UTF-8?q?ci=C3=B3n=20de=20lenguaje=20natural=20via=20Anthropic=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MyTodoList/telegram/ChuvaBot.java | 64 ++++++++- .../telegram/SyntheticUpdateFactory.java | 55 ++++++++ .../telegram/nlu/NaturalLanguageRouter.java | 121 ++++++++++++++++++ .../telegram/nlu/NluErrorMessages.java | 46 +++++++ .../MyTodoList/telegram/nlu/NluResult.java | 32 +++++ .../MyTodoList/telegram/nlu/NluStatus.java | 7 + 6 files changed, 322 insertions(+), 3 deletions(-) create mode 100644 MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/telegram/SyntheticUpdateFactory.java create mode 100644 MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/telegram/nlu/NaturalLanguageRouter.java create mode 100644 MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/telegram/nlu/NluErrorMessages.java create mode 100644 MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/telegram/nlu/NluResult.java create mode 100644 MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/telegram/nlu/NluStatus.java diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/telegram/ChuvaBot.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/telegram/ChuvaBot.java index e83693edf..e154fcb2c 100644 --- a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/telegram/ChuvaBot.java +++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/telegram/ChuvaBot.java @@ -1,6 +1,11 @@ package com.springboot.MyTodoList.telegram; import com.springboot.MyTodoList.telegram.handler.*; +import com.springboot.MyTodoList.telegram.SyntheticUpdateFactory; +import com.springboot.MyTodoList.telegram.nlu.NluErrorMessages; +import com.springboot.MyTodoList.telegram.nlu.NluResult; +import com.springboot.MyTodoList.telegram.nlu.NluStatus; +import com.springboot.MyTodoList.telegram.nlu.NaturalLanguageRouter; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; @@ -28,6 +33,7 @@ public class ChuvaBot implements SpringLongPollingBot, LongPollingSingleThreadUp private final TaskHandler taskHandler; private final TaskStatusHandler taskStatusHandler; private final CommentHandler commentHandler; + private final NaturalLanguageRouter nluRouter; public ChuvaBot( @Value("${telegram.bot.token}") String botToken, @@ -39,7 +45,8 @@ public ChuvaBot( MyTasksHandler myTasksHandler, TaskHandler taskHandler, TaskStatusHandler taskStatusHandler, - CommentHandler commentHandler) { + CommentHandler commentHandler, + NaturalLanguageRouter nluRouter) { this.botToken = botToken; this.telegramClient = telegramClient; this.startHandler = startHandler; @@ -50,6 +57,7 @@ public ChuvaBot( this.taskHandler = taskHandler; this.taskStatusHandler = taskStatusHandler; this.commentHandler = commentHandler; + this.nluRouter = nluRouter; } @Override @@ -67,10 +75,19 @@ public void consume(Update update) { if (!update.hasMessage() || !update.getMessage().hasText()) return; String text = update.getMessage().getText().trim(); - String command = text.split("\\s+")[0].toLowerCase(); + Long chatId = update.getMessage().getChatId(); - log.debug("Received command '{}' from chatId {}", command, update.getMessage().getChatId()); + if (text.startsWith("/")) { + String command = text.split("\\s+")[0].toLowerCase(); + log.debug("Received command '{}' from chatId {}", command, chatId); + routeCommand(command, update); + } else { + log.debug("Received NL message from chatId {}, classifying...", chatId); + handleNaturalLanguage(text, update, chatId); + } + } + private void routeCommand(String command, Update update) { switch (command) { case "/start" -> startHandler.handle(update, telegramClient); case "/login" -> loginHandler.handle(update, telegramClient); @@ -84,6 +101,47 @@ public void consume(Update update) { } } + private void handleNaturalLanguage(String text, Update update, Long chatId) { + NluResult result = nluRouter.classify(text); + + if (result.getStatus() == NluStatus.OK) { + String syntheticText = buildSyntheticCommand(result); + log.debug("NLU mapped to command '{}' for chatId {}", result.getCommand(), chatId); + Update syntheticUpdate = SyntheticUpdateFactory.withText(update, syntheticText); + routeCommand("/" + result.getCommand(), syntheticUpdate); + } else if (result.getStatus() == NluStatus.MISSING_PARAMS) { + String message = NluErrorMessages.getErrorMessage(result.getCommand(), result.getMissing()); + TelegramHelper.send(telegramClient, chatId, message); + } else { + TelegramHelper.send(telegramClient, chatId, + "No entendí tu mensaje. Escribe /help para ver los comandos disponibles."); + } + } + + private String buildSyntheticCommand(NluResult result) { + StringBuilder sb = new StringBuilder("/").append(result.getCommand()); + if (result.getParams() != null) { + switch (result.getCommand()) { + case "task" -> appendParam(sb, result, "id"); + case "task_status" -> { + appendParam(sb, result, "id"); + appendParam(sb, result, "status"); + appendParam(sb, result, "hours"); + } + case "comment" -> { + appendParam(sb, result, "id"); + appendParam(sb, result, "text"); + } + } + } + return sb.toString(); + } + + private void appendParam(StringBuilder sb, NluResult result, String key) { + String val = result.getParams().get(key); + if (val != null && !val.isBlank()) sb.append(" ").append(val); + } + private void sendUnknownCommand(Long chatId) { try { telegramClient.execute(SendMessage.builder() diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/telegram/SyntheticUpdateFactory.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/telegram/SyntheticUpdateFactory.java new file mode 100644 index 000000000..20239c887 --- /dev/null +++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/telegram/SyntheticUpdateFactory.java @@ -0,0 +1,55 @@ +package com.springboot.MyTodoList.telegram; + +import org.telegram.telegrambots.meta.api.objects.Update; +import org.telegram.telegrambots.meta.api.objects.message.Message; + +import java.lang.reflect.Field; + +public class SyntheticUpdateFactory { + + private SyntheticUpdateFactory() {} + + /** + * Returns a new Update that is identical to the original except that + * message.text is replaced with syntheticText. Uses reflection because + * the Telegram library does not expose setters for message fields. + */ + public static Update withText(Update original, String syntheticText) { + try { + Message originalMsg = original.getMessage(); + + Message syntheticMsg = new Message(); + copyFields(originalMsg, syntheticMsg); + setField(syntheticMsg, "text", syntheticText); + + Update syntheticUpdate = new Update(); + copyFields(original, syntheticUpdate); + setField(syntheticUpdate, "message", syntheticMsg); + + return syntheticUpdate; + } catch (Exception e) { + return original; + } + } + + private static void copyFields(Object src, Object dst) throws Exception { + for (Field f : src.getClass().getDeclaredFields()) { + f.setAccessible(true); + f.set(dst, f.get(src)); + } + } + + private static void setField(Object obj, String fieldName, Object value) throws Exception { + Class clazz = obj.getClass(); + while (clazz != null) { + try { + Field f = clazz.getDeclaredField(fieldName); + f.setAccessible(true); + f.set(obj, value); + return; + } catch (NoSuchFieldException e) { + clazz = clazz.getSuperclass(); + } + } + } +} diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/telegram/nlu/NaturalLanguageRouter.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/telegram/nlu/NaturalLanguageRouter.java new file mode 100644 index 000000000..15f3820be --- /dev/null +++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/telegram/nlu/NaturalLanguageRouter.java @@ -0,0 +1,121 @@ +package com.springboot.MyTodoList.telegram.nlu; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +@Slf4j +public class NaturalLanguageRouter { + + private static final String ANTHROPIC_URL = "https://api.anthropic.com/v1/messages"; + private static final String ANTHROPIC_VERSION = "2023-06-01"; + private static final String MODEL = "claude-haiku-4-5-20251001"; + + private static final String SYSTEM_PROMPT = + "You are a command classifier for a task management Telegram bot." + + "Your only job is to map the user's message to one of the available commands and extract parameters." + + "You must ALWAYS respond with valid JSON only. No explanations, no markdown, no extra text.\n" + + "Available commands:\n" + + "- start: greet the user or show the main menu. No params.\n" + + "- help: show available commands. No params.\n" + + "- my_projects: list the user's projects. No params.\n" + + "- my_tasks: list the user's assigned tasks. No params.\n" + + "- task: show task detail. Params: id (integer).\n" + + "- task_status: update task status. Params: id (integer), status (one of: TODO, IN_PROGRESS, BLOCKED, DONE), hours (decimal, required only if status is DONE).\n" + + "- comment: add a comment to a task. Params: id (integer), text (string).\n" + + "Response format:\n" + + "- If all required params are present: {\"status\": \"ok\", \"command\": \"\", \"params\": { ... }}\n" + + "- If the command is identified but params are missing: {\"status\": \"missing_params\", \"command\": \"\", \"missing\": [\"\", ...]}\n" + + "- If the message cannot be mapped to any command: {\"status\": \"unknown\"}"; + + @Value("${anthropic.api.key:}") + private String anthropicApiKey; + + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + + public NaturalLanguageRouter(ObjectMapper objectMapper) { + this.restTemplate = new RestTemplate(); + this.objectMapper = objectMapper; + } + + public NluResult classify(String userMessage) { + if (anthropicApiKey == null || anthropicApiKey.isBlank()) { + log.warn("ANTHROPIC_API_KEY not configured, NLU disabled"); + return NluResult.unknown(); + } + + try { + String responseJson = callAnthropicApi(userMessage); + return parseResponse(responseJson); + } catch (Exception e) { + log.warn("NLU classification failed: {}", e.getMessage()); + return NluResult.unknown(); + } + } + + private String callAnthropicApi(String userMessage) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("x-api-key", anthropicApiKey); + headers.set("anthropic-version", ANTHROPIC_VERSION); + + Map body = new HashMap<>(); + body.put("model", MODEL); + body.put("max_tokens", 150); + body.put("system", SYSTEM_PROMPT); + body.put("messages", List.of(Map.of("role", "user", "content", userMessage))); + + HttpEntity> request = new HttpEntity<>(body, headers); + String raw = restTemplate.postForObject(ANTHROPIC_URL, request, String.class); + + JsonNode root = objectMapper.readValue(raw, JsonNode.class); + return root.path("content").get(0).path("text").asText(); + } + + @SuppressWarnings("unchecked") + private NluResult parseResponse(String json) throws Exception { + JsonNode node = objectMapper.readTree(json); + String statusStr = node.path("status").asText("unknown").toLowerCase(); + + NluResult result = new NluResult(); + + switch (statusStr) { + case "ok" -> { + String command = node.path("command").asText(); + if ("login".equals(command)) { + return NluResult.unknown(); + } + result.setStatus(NluStatus.OK); + result.setCommand(command); + Map params = new HashMap<>(); + node.path("params").fields().forEachRemaining(e -> params.put(e.getKey(), e.getValue().asText())); + result.setParams(params); + } + case "missing_params" -> { + String command = node.path("command").asText(); + if ("login".equals(command)) { + return NluResult.unknown(); + } + result.setStatus(NluStatus.MISSING_PARAMS); + result.setCommand(command); + List missing = objectMapper.convertValue(node.path("missing"), List.class); + result.setMissing(missing); + } + default -> result.setStatus(NluStatus.UNKNOWN); + } + + return result; + } +} diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/telegram/nlu/NluErrorMessages.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/telegram/nlu/NluErrorMessages.java new file mode 100644 index 000000000..67fc03f66 --- /dev/null +++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/telegram/nlu/NluErrorMessages.java @@ -0,0 +1,46 @@ +package com.springboot.MyTodoList.telegram.nlu; + +import java.util.List; + +public class NluErrorMessages { + + private NluErrorMessages() {} + + public static String getErrorMessage(String command, List missing) { + if (missing == null || missing.isEmpty()) { + return fallback(); + } + + if ("task_status".equals(command)) { + if (missing.contains("hours")) { + return "Para marcar una tarea como DONE debes especificar tus horas reales trabajadas.\n" + + "Ejemplo: marca la tarea 5 como done con 3.5 horas"; + } + if (missing.contains("id")) { + return "No identifiqué qué tarea quieres actualizar. Dime el ID de la tarea."; + } + if (missing.contains("status")) { + return "No identifiqué el nuevo estado. Los estados válidos son: TODO, IN_PROGRESS, BLOCKED, DONE."; + } + } + + if ("task".equals(command) && missing.contains("id")) { + return "No identifiqué qué tarea quieres ver. Dime el ID de la tarea."; + } + + if ("comment".equals(command)) { + if (missing.contains("id")) { + return "No identifiqué en qué tarea quieres comentar. Dime el ID de la tarea."; + } + if (missing.contains("text")) { + return "No identifiqué el texto del comentario. ¿Qué quieres agregar?"; + } + } + + return fallback(); + } + + private static String fallback() { + return "No entendí tu mensaje. Escribe /help para ver los comandos disponibles."; + } +} diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/telegram/nlu/NluResult.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/telegram/nlu/NluResult.java new file mode 100644 index 000000000..981e20f07 --- /dev/null +++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/telegram/nlu/NluResult.java @@ -0,0 +1,32 @@ +package com.springboot.MyTodoList.telegram.nlu; + +import java.util.List; +import java.util.Map; + +public class NluResult { + + private NluStatus status; + private String command; + private Map params; + private List missing; + + public NluResult() {} + + public static NluResult unknown() { + NluResult r = new NluResult(); + r.status = NluStatus.UNKNOWN; + return r; + } + + public NluStatus getStatus() { return status; } + public void setStatus(NluStatus status) { this.status = status; } + + public String getCommand() { return command; } + public void setCommand(String command) { this.command = command; } + + public Map getParams() { return params; } + public void setParams(Map params) { this.params = params; } + + public List getMissing() { return missing; } + public void setMissing(List missing) { this.missing = missing; } +} diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/telegram/nlu/NluStatus.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/telegram/nlu/NluStatus.java new file mode 100644 index 000000000..8ebfa873b --- /dev/null +++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/telegram/nlu/NluStatus.java @@ -0,0 +1,7 @@ +package com.springboot.MyTodoList.telegram.nlu; + +public enum NluStatus { + OK, + MISSING_PARAMS, + UNKNOWN +} From 1bec14aadcc9e57fc8aed93c4b46d503e8ac9172 Mon Sep 17 00:00:00 2001 From: Arturo Gomez Date: Tue, 21 Apr 2026 14:03:28 -0600 Subject: [PATCH 03/24] chore(config): agregar variable ANTHROPIC_API_KEY en application.yml y docker-compose --- MtdrSpring/backend/src/main/resources/application.yml | 4 ++++ MtdrSpring/docker-compose.yml | 1 + 2 files changed, 5 insertions(+) diff --git a/MtdrSpring/backend/src/main/resources/application.yml b/MtdrSpring/backend/src/main/resources/application.yml index 1e42e123b..9a44e5a08 100644 --- a/MtdrSpring/backend/src/main/resources/application.yml +++ b/MtdrSpring/backend/src/main/resources/application.yml @@ -22,6 +22,10 @@ telegram: token: ${TELEGRAM_BOT_TOKEN:} username: ${TELEGRAM_BOT_USERNAME:} +anthropic: + api: + key: ${ANTHROPIC_API_KEY:} + springdoc: swagger-ui: path: /swagger-ui.html diff --git a/MtdrSpring/docker-compose.yml b/MtdrSpring/docker-compose.yml index 1dc215cb9..bb7ba80d0 100644 --- a/MtdrSpring/docker-compose.yml +++ b/MtdrSpring/docker-compose.yml @@ -34,6 +34,7 @@ services: JWT_SECRET: ${JWT_SECRET:-chuvabot_local_dev_secret_key_minimo32chars!!} JWT_EXPIRATION_MS: ${JWT_EXPIRATION_MS:-86400000} TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-} + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} # Activar para ver queries SQL en docker logs: # SPRING_PROFILES_ACTIVE: local From 1394fa51a3e7571649becbe190e2ccc5d62773be Mon Sep 17 00:00:00 2001 From: Arturo Gomez Date: Tue, 21 Apr 2026 14:05:24 -0600 Subject: [PATCH 04/24] =?UTF-8?q?docs(docs):=20documentaci=C3=B3n=20de=20l?= =?UTF-8?q?a=20capa=20NLU=20del=20Telegram=20Bot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- documentation/NLU.md | 173 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 documentation/NLU.md diff --git a/documentation/NLU.md b/documentation/NLU.md new file mode 100644 index 000000000..ccb0744e3 --- /dev/null +++ b/documentation/NLU.md @@ -0,0 +1,173 @@ +### Feature: Natural Language Interface para Telegram Bot + +## Objetivo + +Agregar una capa de comprensión de lenguaje natural al Telegram Bot existente. El usuario podrá escribir mensajes en lenguaje natural en lugar de comandos exactos. Los comandos siguen funcionando — el NLU es una capa adicional, no un reemplazo. + +--- + +## Scope + +Comandos incluidos en el NLU (7 de 8): + +| Comando | Ejemplo en lenguaje natural | +| --- | --- | +| `/start` | "hola", "comenzar", "menú" | +| `/help` | "ayuda", "qué puedo hacer", "lista de comandos" | +| `/my_projects` | "dame mis proyectos", "en qué proyectos estoy" | +| `/my_tasks` | "dame mis tareas", "qué tengo pendiente" | +| `/task {id}` | "muéstrame la tarea 5", "detalle de tarea 3" | +| `/task_status {id} {status} [hours]` | "pon la tarea 5 en progreso", "marca la 3 como done con 2 horas" | +| `/comment {id} {text}` | "comenta en la tarea 5: revisé el código" | + +**Excluido del NLU:** `/login` — maneja credenciales y no debe ser inferido desde lenguaje natural. + +--- + +## Flujo de procesamiento + +``` +Mensaje entrante + │ + ▼ +¿Empieza con "/"? + │ │ + SÍ NO + │ │ + ▼ ▼ +Ejecutar NaturalLanguageRouter +comando llama a Claude API +directo │ + ┌────┴────────────┐ + │ │ + {status: ok} {status: missing_params} + comando + comando identificado + parámetros pero parámetros incompletos + completos │ + │ Mensaje predeterminado + ▼ específico al error + Ejecutar │ + handler {status: unknown} + normal │ + Mensaje genérico + de "no entendí" +``` + +--- + +## Componente nuevo: NaturalLanguageRouter + +**Tipo:** `@Service` dentro del módulo `telegram/` + +**Responsabilidad:** Recibir el texto del mensaje, llamar a la API de Claude, interpretar la respuesta JSON, y retornar un resultado tipado al `ChuvaBot`. + +**Ubicación sugerida:** `telegram/nlu/NaturalLanguageRouter.java` + +**Integración en ChuvaBot:** `ChuvaBot.consume()` invoca `NaturalLanguageRouter` únicamente cuando el mensaje no empieza con `/`. + +--- + +## Contrato JSON con el LLM + +El LLM responde exclusivamente con uno de estos tres formatos JSON: + +**Caso 1 — Éxito, parámetros completos:** + +```json +{ "status": "ok", "command": "task_status", "params": { "id": "5", "status": "IN_PROGRESS" } } +``` + +**Caso 2 — Comando identificado, parámetros incompletos:** + +```json +{ "status": "missing_params", "command": "task_status", "missing": ["hours"] } +``` + +**Caso 3 — No se entendió el mensaje:** + +```json +{ "status": "unknown" } +``` + +--- + +## Mensajes predeterminados de error + +El `NaturalLanguageRouter` (o una clase auxiliar `NluErrorMessages`) contiene todos los mensajes de error. No se genera texto con IA — son strings fijos mapeados por `(command, missing[])`. Ejemplos: + +| Comando | Parámetros faltantes | Mensaje | +| --- | --- | --- | +| `task_status` | `["hours"]` | "Para marcar una tarea como DONE debes especificar tus horas reales trabajadas. Ejemplo: marca la tarea 5 como done con 3.5 horas" | +| `task_status` | `["id"]` | "No identifiqué qué tarea quieres actualizar. Dime el ID de la tarea." | +| `task_status` | `["status"]` | "No identifiqué el nuevo estado. Los estados válidos son: TODO, IN_PROGRESS, BLOCKED, DONE." | +| `task` | `["id"]` | "No identifiqué qué tarea quieres ver. Dime el ID de la tarea." | +| `comment` | `["id"]` | "No identifiqué en qué tarea quieres comentar. Dime el ID de la tarea." | +| `comment` | `["text"]` | "No identifiqué el texto del comentario. ¿Qué quieres agregar?" | +| cualquiera | — (unknown) | "No entendí tu mensaje. Escribe /help para ver los comandos disponibles." | + +--- + +## System prompt para Claude API + +El prompt del sistema define el comportamiento del LLM. Se envía en cada llamada como `system` message. + +``` +You are a command classifier for a task management Telegram bot. +Your only job is to map the user's message to one of the available commands and extract parameters. +You must ALWAYS respond with valid JSON only. No explanations, no markdown, no extra text. + +Available commands: +- start: greet the user or show the main menu. No params. +- help: show available commands. No params. +- my_projects: list the user's projects. No params. +- my_tasks: list the user's assigned tasks. No params. +- task: show task detail. Params: id (integer). +- task_status: update task status. Params: id (integer), status (one of: TODO, IN_PROGRESS, BLOCKED, DONE), hours (decimal, required only if status is DONE). +- comment: add a comment to a task. Params: id (integer), text (string). + +Response format: +- If all required params are present: {"status": "ok", "command": "", "params": { ... }} +- If the command is identified but params are missing: {"status": "missing_params", "command": "", "missing": ["", ...]} +- If the message cannot be mapped to any command: {"status": "unknown"} +``` + +--- + +## Modelo y configuración de la API + +| Parámetro | Valor | +| --- | --- | +| Modelo | `claude-haiku-4-5-20251001` | +| `max_tokens` | `150` | +| Variable de entorno | `ANTHROPIC_API_KEY` | +| Endpoint | `https://api.anthropic.com/v1/messages` | + +El modelo Haiku es suficiente para clasificación de intenciones. El límite de 150 tokens es adecuado dado que la respuesta siempre es un JSON pequeño. + +--- + +## Variables de entorno nuevas + +| Variable | Descripción | Requerida | +| --- | --- | --- | +| `ANTHROPIC_API_KEY` | API key de la Console de Anthropic | Solo si NLU está activo | + +El NLU debe activarse condicionalmente: si `ANTHROPIC_API_KEY` no está presente, `ChuvaBot` omite el paso de NLU y responde con el mensaje genérico de "no entendí" para mensajes sin `/`. + +--- + +## Lo que NO cambia + +- Las respuestas de Telegram son idénticas a las de los comandos existentes. +- Los handlers no se modifican. +- El flujo de autenticación por `telegram_chat_id` no cambia. +- `NotificationService` no se modifica. +- No se usa streaming ni herramientas (tool use) de la API — solo completion simple. + +--- + +## Lo que queda fuera de esta fase (mejoras futuras) + +- Respuestas generadas con IA (actualmente son los mismos strings de los handlers). +- Conversación multi-turno (el NLU es stateless, cada mensaje se clasifica de forma independiente). +- Soporte para `/login` en lenguaje natural. \ No newline at end of file From 61a07ba22e46da454c1e5d7a0e94fbfbd064523f Mon Sep 17 00:00:00 2001 From: Arturo Gomez Date: Tue, 21 Apr 2026 14:08:09 -0600 Subject: [PATCH 05/24] chore(config): agregar ANTHROPIC_API_KEY en .env.example --- MtdrSpring/.env.example | 3 +++ 1 file changed, 3 insertions(+) diff --git a/MtdrSpring/.env.example b/MtdrSpring/.env.example index b6a2f80e0..5cd25182c 100644 --- a/MtdrSpring/.env.example +++ b/MtdrSpring/.env.example @@ -12,3 +12,6 @@ JWT_EXPIRATION_MS=86400000 # Telegram Bot — leave empty to disable the bot TELEGRAM_BOT_TOKEN= + +# Anthropic API — leave empty to disable NLU +ANTHROPIC_API_KEY= From c855c1776e8a7addcd78691b86ea83e45f22db83 Mon Sep 17 00:00:00 2001 From: Arturo Gomez Date: Tue, 21 Apr 2026 14:29:27 -0600 Subject: [PATCH 06/24] fix(bot): strip markdown code fences en respuesta NLU para evitar error de parseo JSON --- .../telegram/nlu/NaturalLanguageRouter.java | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/telegram/nlu/NaturalLanguageRouter.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/telegram/nlu/NaturalLanguageRouter.java index 15f3820be..15bbbfc44 100644 --- a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/telegram/nlu/NaturalLanguageRouter.java +++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/telegram/nlu/NaturalLanguageRouter.java @@ -65,7 +65,7 @@ public NluResult classify(String userMessage) { } } - private String callAnthropicApi(String userMessage) { + private String callAnthropicApi(String userMessage) throws Exception { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); headers.set("x-api-key", anthropicApiKey); @@ -84,9 +84,20 @@ private String callAnthropicApi(String userMessage) { return root.path("content").get(0).path("text").asText(); } + private String stripMarkdown(String text) { + String s = text.trim(); + if (s.startsWith("```")) { + int firstNewline = s.indexOf('\n'); + if (firstNewline != -1) s = s.substring(firstNewline + 1); + int lastFence = s.lastIndexOf("```"); + if (lastFence != -1) s = s.substring(0, lastFence); + } + return s.trim(); + } + @SuppressWarnings("unchecked") private NluResult parseResponse(String json) throws Exception { - JsonNode node = objectMapper.readTree(json); + JsonNode node = objectMapper.readTree(stripMarkdown(json)); String statusStr = node.path("status").asText("unknown").toLowerCase(); NluResult result = new NluResult(); From 3776ab28e1c38ea47d31f25951e951a8b2892764 Mon Sep 17 00:00:00 2001 From: Arturo Gomez Date: Tue, 21 Apr 2026 14:29:42 -0600 Subject: [PATCH 07/24] chore(chore): nueva skill /deploy para despliegue Docker de la app --- .claude/skills/deploy/SKILL.md | 40 ++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 .claude/skills/deploy/SKILL.md diff --git a/.claude/skills/deploy/SKILL.md b/.claude/skills/deploy/SKILL.md new file mode 100644 index 000000000..bef17d728 --- /dev/null +++ b/.claude/skills/deploy/SKILL.md @@ -0,0 +1,40 @@ +--- +name: deploy +description: Despliega la aplicación Chuva Bot con Docker. Baja el stack, reconstruye la imagen y lo levanta en background. Si hay error de build o arranque, revisa logs, diagnostica y espera confirmación antes de aplicar cualquier fix. +--- + +## Comando de despliegue + +Siempre usar exactamente este comando (desde la raíz del repo): + +```bash +docker-compose -f MtdrSpring/docker-compose.yml down && docker-compose -f MtdrSpring/docker-compose.yml up --build -d +``` + +No usar rutas absolutas ni flags adicionales salvo que el usuario lo indique explícitamente. + +## Flujo de ejecución + +1. Ejecutar el comando de despliegue completo. +2. Si el build y el arranque son exitosos → informar al usuario que el stack está corriendo. +3. Si hay error en cualquier etapa → ir al flujo de diagnóstico. + +## Flujo de diagnóstico ante errores + +1. Revisar los logs del contenedor: + ```bash + docker logs chuvabot-app --tail 80 + ``` +2. Identificar la causa raíz del error (compilación, arranque de Spring, conexión a DB, etc.). +3. Explicar al usuario: + - Qué falló + - Por qué falló + - Qué cambio propones para resolverlo +4. **Esperar confirmación explícita del usuario antes de modificar cualquier archivo o ejecutar cualquier comando adicional.** + +## Restricciones + +- Nunca aplicar un fix automáticamente sin confirmación del usuario. +- Nunca omitir el `down` antes del `up` — siempre bajar el stack primero. +- Nunca usar `docker compose` (plugin) en lugar de `docker-compose` (CLI) salvo que el usuario lo indique. +- El `--env-file` no es necesario — Docker Compose carga `.env` automáticamente desde `MtdrSpring/`. From 260156e227113c2d423d82e63d3add2a69324cfe Mon Sep 17 00:00:00 2001 From: Arturo Gomez Date: Tue, 21 Apr 2026 16:23:38 -0600 Subject: [PATCH 08/24] =?UTF-8?q?fix(bot):=20reemplazar=20reflexi=C3=B3n?= =?UTF-8?q?=20por=20Jackson=20en=20SyntheticUpdateFactory,=20subir=20a=20S?= =?UTF-8?q?onnet=204.6=20y=20agregar=20validaci=C3=B3n=20defensiva=20de=20?= =?UTF-8?q?params=20NLU?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MyTodoList/telegram/ChuvaBot.java | 28 +++++++++- .../telegram/SyntheticUpdateFactory.java | 54 +++++-------------- .../telegram/nlu/NaturalLanguageRouter.java | 6 ++- 3 files changed, 44 insertions(+), 44 deletions(-) diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/telegram/ChuvaBot.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/telegram/ChuvaBot.java index e154fcb2c..3f155ab69 100644 --- a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/telegram/ChuvaBot.java +++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/telegram/ChuvaBot.java @@ -2,10 +2,14 @@ import com.springboot.MyTodoList.telegram.handler.*; import com.springboot.MyTodoList.telegram.SyntheticUpdateFactory; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; import com.springboot.MyTodoList.telegram.nlu.NluErrorMessages; import com.springboot.MyTodoList.telegram.nlu.NluResult; import com.springboot.MyTodoList.telegram.nlu.NluStatus; import com.springboot.MyTodoList.telegram.nlu.NaturalLanguageRouter; +import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; @@ -34,6 +38,7 @@ public class ChuvaBot implements SpringLongPollingBot, LongPollingSingleThreadUp private final TaskStatusHandler taskStatusHandler; private final CommentHandler commentHandler; private final NaturalLanguageRouter nluRouter; + private final ObjectMapper objectMapper; public ChuvaBot( @Value("${telegram.bot.token}") String botToken, @@ -46,7 +51,8 @@ public ChuvaBot( TaskHandler taskHandler, TaskStatusHandler taskStatusHandler, CommentHandler commentHandler, - NaturalLanguageRouter nluRouter) { + NaturalLanguageRouter nluRouter, + ObjectMapper objectMapper) { this.botToken = botToken; this.telegramClient = telegramClient; this.startHandler = startHandler; @@ -58,6 +64,7 @@ public ChuvaBot( this.taskStatusHandler = taskStatusHandler; this.commentHandler = commentHandler; this.nluRouter = nluRouter; + this.objectMapper = objectMapper; } @Override @@ -105,9 +112,15 @@ private void handleNaturalLanguage(String text, Update update, Long chatId) { NluResult result = nluRouter.classify(text); if (result.getStatus() == NluStatus.OK) { + List invalidParams = validateNluParams(result); + if (!invalidParams.isEmpty()) { + String message = NluErrorMessages.getErrorMessage(result.getCommand(), invalidParams); + TelegramHelper.send(telegramClient, chatId, message); + return; + } String syntheticText = buildSyntheticCommand(result); log.debug("NLU mapped to command '{}' for chatId {}", result.getCommand(), chatId); - Update syntheticUpdate = SyntheticUpdateFactory.withText(update, syntheticText); + Update syntheticUpdate = SyntheticUpdateFactory.withText(update, syntheticText, objectMapper); routeCommand("/" + result.getCommand(), syntheticUpdate); } else if (result.getStatus() == NluStatus.MISSING_PARAMS) { String message = NluErrorMessages.getErrorMessage(result.getCommand(), result.getMissing()); @@ -137,6 +150,17 @@ private String buildSyntheticCommand(NluResult result) { return sb.toString(); } + private List validateNluParams(NluResult result) { + List invalid = new ArrayList<>(); + Map params = result.getParams(); + if (params == null) return invalid; + if (List.of("task", "task_status", "comment").contains(result.getCommand())) { + String id = params.get("id"); + if (id != null && !id.matches("\\d+")) invalid.add("id"); + } + return invalid; + } + private void appendParam(StringBuilder sb, NluResult result, String key) { String val = result.getParams().get(key); if (val != null && !val.isBlank()) sb.append(" ").append(val); diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/telegram/SyntheticUpdateFactory.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/telegram/SyntheticUpdateFactory.java index 20239c887..b62b129ed 100644 --- a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/telegram/SyntheticUpdateFactory.java +++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/telegram/SyntheticUpdateFactory.java @@ -1,55 +1,27 @@ package com.springboot.MyTodoList.telegram; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.extern.slf4j.Slf4j; import org.telegram.telegrambots.meta.api.objects.Update; -import org.telegram.telegrambots.meta.api.objects.message.Message; - -import java.lang.reflect.Field; +@Slf4j public class SyntheticUpdateFactory { private SyntheticUpdateFactory() {} - /** - * Returns a new Update that is identical to the original except that - * message.text is replaced with syntheticText. Uses reflection because - * the Telegram library does not expose setters for message fields. - */ - public static Update withText(Update original, String syntheticText) { + public static Update withText(Update original, String syntheticText, ObjectMapper objectMapper) { try { - Message originalMsg = original.getMessage(); - - Message syntheticMsg = new Message(); - copyFields(originalMsg, syntheticMsg); - setField(syntheticMsg, "text", syntheticText); - - Update syntheticUpdate = new Update(); - copyFields(original, syntheticUpdate); - setField(syntheticUpdate, "message", syntheticMsg); - - return syntheticUpdate; + String json = objectMapper.writeValueAsString(original); + ObjectNode root = (ObjectNode) objectMapper.readTree(json); + ObjectNode message = (ObjectNode) root.get("message"); + if (message != null) { + message.put("text", syntheticText); + } + return objectMapper.treeToValue(root, Update.class); } catch (Exception e) { + log.warn("Failed to create synthetic update: {}", e.getMessage()); return original; } } - - private static void copyFields(Object src, Object dst) throws Exception { - for (Field f : src.getClass().getDeclaredFields()) { - f.setAccessible(true); - f.set(dst, f.get(src)); - } - } - - private static void setField(Object obj, String fieldName, Object value) throws Exception { - Class clazz = obj.getClass(); - while (clazz != null) { - try { - Field f = clazz.getDeclaredField(fieldName); - f.setAccessible(true); - f.set(obj, value); - return; - } catch (NoSuchFieldException e) { - clazz = clazz.getSuperclass(); - } - } - } } diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/telegram/nlu/NaturalLanguageRouter.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/telegram/nlu/NaturalLanguageRouter.java index 15bbbfc44..40c099ac0 100644 --- a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/telegram/nlu/NaturalLanguageRouter.java +++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/telegram/nlu/NaturalLanguageRouter.java @@ -20,12 +20,16 @@ public class NaturalLanguageRouter { private static final String ANTHROPIC_URL = "https://api.anthropic.com/v1/messages"; private static final String ANTHROPIC_VERSION = "2023-06-01"; - private static final String MODEL = "claude-haiku-4-5-20251001"; + private static final String MODEL = "claude-sonnet-4-6"; private static final String SYSTEM_PROMPT = "You are a command classifier for a task management Telegram bot." + "Your only job is to map the user's message to one of the available commands and extract parameters." + "You must ALWAYS respond with valid JSON only. No explanations, no markdown, no extra text.\n" + + "IMPORTANT: When extracting parameters, ignore Spanish and English articles and prepositions " + + "(el, la, los, las, un, una, de, a, the). " + + "The 'id' param must ALWAYS be a plain integer (digits only, e.g. 18). " + + "Never include words like 'el', 'la', 'tarea' as param values.\n" + "Available commands:\n" + "- start: greet the user or show the main menu. No params.\n" + "- help: show available commands. No params.\n" + From e3d13217befd30890ffec095619e42dd26c65046 Mon Sep 17 00:00:00 2001 From: Arturo Gomez Date: Thu, 23 Apr 2026 14:05:49 -0600 Subject: [PATCH 09/24] feat(api): eliminar proyecto con todo su contenido en cascada --- .../com/springboot/MyTodoList/project/ProjectService.java | 6 ++++++ .../MyTodoList/project/member/ProjectMemberRepository.java | 1 + .../com/springboot/MyTodoList/sprint/SprintRepository.java | 1 + .../java/com/springboot/MyTodoList/task/TaskRepository.java | 2 ++ .../MyTodoList/task/activity/TaskActivityRepository.java | 1 + 5 files changed, 11 insertions(+) diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/project/ProjectService.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/project/ProjectService.java index 9060c8d3b..53a8c1e49 100644 --- a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/project/ProjectService.java +++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/project/ProjectService.java @@ -15,6 +15,7 @@ import com.springboot.MyTodoList.sprint.SprintRepository; import com.springboot.MyTodoList.task.Task; import com.springboot.MyTodoList.task.TaskRepository; +import com.springboot.MyTodoList.task.activity.TaskActivityRepository; import com.springboot.MyTodoList.user.User; import com.springboot.MyTodoList.user.UserService; import lombok.RequiredArgsConstructor; @@ -32,6 +33,7 @@ public class ProjectService { private final ProjectMemberRepository memberRepository; private final SprintRepository sprintRepository; private final TaskRepository taskRepository; + private final TaskActivityRepository activityRepository; private final UserService userService; private final AuditLogService auditLogService; private final ProjectMapper projectMapper; @@ -85,6 +87,10 @@ public void deleteProject(Long userId, Long projectId) { Project project = findProject(projectId); requireManager(userId, project); auditLogService.log(actor, EntityType.PROJECT, project.getId(), AuditAction.DELETE, project, null); + activityRepository.deleteByTask_Project(project); + taskRepository.deleteByProject(project); + sprintRepository.deleteByProject(project); + memberRepository.deleteByProject(project); projectRepository.delete(project); } diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/project/member/ProjectMemberRepository.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/project/member/ProjectMemberRepository.java index a4f547396..87adab397 100644 --- a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/project/member/ProjectMemberRepository.java +++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/project/member/ProjectMemberRepository.java @@ -15,4 +15,5 @@ public interface ProjectMemberRepository extends JpaRepository findAllByProject(Project project, Pageable pageable); List findAllByProject(Project project); void deleteByProjectAndEmployee(Project project, User employee); + void deleteByProject(Project project); } diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/sprint/SprintRepository.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/sprint/SprintRepository.java index 4dbc44f22..3809088fd 100644 --- a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/sprint/SprintRepository.java +++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/sprint/SprintRepository.java @@ -12,4 +12,5 @@ public interface SprintRepository extends JpaRepository { Page findByProject(Project project, Pageable pageable); List findByProjectAndStatus(Project project, SprintStatus status); boolean existsByProjectAndStatus(Project project, SprintStatus status); + void deleteByProject(Project project); } diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/task/TaskRepository.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/task/TaskRepository.java index b3207daa7..eba2da636 100644 --- a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/task/TaskRepository.java +++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/task/TaskRepository.java @@ -76,6 +76,8 @@ SELECT t.assignedTo.id, t.assignedTo.fullName, SUM(t.storyPoints), SUM(t.actualH """) List findEfficiencyBySprint(@Param("sprint") Sprint sprint, @Param("status") TaskStatus status); + void deleteByProject(Project project); + @Query("SELECT COUNT(t) FROM Task t WHERE t.project = :project AND t.sprint IS NULL") long countBacklogByProject(@Param("project") Project project); diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/task/activity/TaskActivityRepository.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/task/activity/TaskActivityRepository.java index 5b9edff91..976d421b2 100644 --- a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/task/activity/TaskActivityRepository.java +++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/task/activity/TaskActivityRepository.java @@ -8,4 +8,5 @@ public interface TaskActivityRepository extends JpaRepository { Page findByTaskOrderByCreatedAtDesc(Task task, Pageable pageable); void deleteByTask(Task task); + void deleteByTask_Project(com.springboot.MyTodoList.project.Project project); } From 71c649daf202fc42b4e1c039d829490b990d9126 Mon Sep 17 00:00:00 2001 From: Arturo Gomez Date: Thu, 23 Apr 2026 14:05:53 -0600 Subject: [PATCH 10/24] =?UTF-8?q?feat(frontend):=20bot=C3=B3n=20de=20elimi?= =?UTF-8?q?nar=20proyecto=20con=20confirmaci=C3=B3n=20para=20el=20manager?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/projects/ProjectCard.jsx | 19 +++++++++-- .../frontend/src/pages/MyProjectsPage.jsx | 34 ++++++++++++++++++- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/MtdrSpring/backend/src/main/frontend/src/components/projects/ProjectCard.jsx b/MtdrSpring/backend/src/main/frontend/src/components/projects/ProjectCard.jsx index 2c9f773f1..97be167f9 100644 --- a/MtdrSpring/backend/src/main/frontend/src/components/projects/ProjectCard.jsx +++ b/MtdrSpring/backend/src/main/frontend/src/components/projects/ProjectCard.jsx @@ -2,7 +2,7 @@ import React from 'react'; import { useNavigate } from 'react-router-dom'; import Badge from '../common/Badge'; -export default function ProjectCard({ project, currentUserId }) { +export default function ProjectCard({ project, currentUserId, onDelete }) { const navigate = useNavigate(); const isManager = project.manager?.id === currentUserId; @@ -13,7 +13,20 @@ export default function ProjectCard({ project, currentUserId }) { >

{project.projectName}

- +
+ + {isManager && onDelete && ( + + )} +
{project.description && (

{project.description}

@@ -22,7 +35,7 @@ export default function ProjectCard({ project, currentUserId }) { {isManager && ( - + )} {isManager ? 'Manager' : 'Member'} diff --git a/MtdrSpring/backend/src/main/frontend/src/pages/MyProjectsPage.jsx b/MtdrSpring/backend/src/main/frontend/src/pages/MyProjectsPage.jsx index 8f268a19a..5774e59d4 100644 --- a/MtdrSpring/backend/src/main/frontend/src/pages/MyProjectsPage.jsx +++ b/MtdrSpring/backend/src/main/frontend/src/pages/MyProjectsPage.jsx @@ -7,6 +7,7 @@ import ProjectForm from '../components/projects/ProjectForm'; import Modal from '../components/common/Modal'; import Button from '../components/common/Button'; import LoadingSpinner from '../components/common/LoadingSpinner'; +import ConfirmDialog from '../components/common/ConfirmDialog'; export default function MyProjectsPage() { const { user } = useAuth(); @@ -16,6 +17,8 @@ export default function MyProjectsPage() { const [error, setError] = useState(''); const [showCreate, setShowCreate] = useState(false); const [saving, setSaving] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleting, setDeleting] = useState(false); const load = () => { setLoading(true); @@ -40,6 +43,21 @@ export default function MyProjectsPage() { } }; + const handleDeleteProject = async () => { + if (!deleteTarget) return; + setDeleting(true); + try { + await projectService.delete(deleteTarget.id); + setProjects((prev) => prev.filter((p) => p.id !== deleteTarget.id)); + setDeleteTarget(null); + toast.success('Proyecto eliminado'); + } catch (err) { + toast.error(err.message); + } finally { + setDeleting(false); + } + }; + return (
@@ -54,13 +72,27 @@ export default function MyProjectsPage() { )}
{projects.map((p) => ( - + ))}
setShowCreate(false)} title="New project"> setShowCreate(false)} loading={saving} /> + + setDeleteTarget(null)} + loading={deleting} + />
); } From 36f90b5ff6d9c65a8434d99717fe22704127f4b1 Mon Sep 17 00:00:00 2001 From: Arturo Gomez Date: Thu, 23 Apr 2026 14:14:13 -0600 Subject: [PATCH 11/24] =?UTF-8?q?feat(frontend):=20bot=C3=B3n=20de=20edita?= =?UTF-8?q?r=20proyecto=20con=20modal=20para=20el=20manager?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/projects/ProjectCard.jsx | 13 ++++++++- .../frontend/src/pages/MyProjectsPage.jsx | 27 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/MtdrSpring/backend/src/main/frontend/src/components/projects/ProjectCard.jsx b/MtdrSpring/backend/src/main/frontend/src/components/projects/ProjectCard.jsx index 97be167f9..3ae31a817 100644 --- a/MtdrSpring/backend/src/main/frontend/src/components/projects/ProjectCard.jsx +++ b/MtdrSpring/backend/src/main/frontend/src/components/projects/ProjectCard.jsx @@ -2,7 +2,7 @@ import React from 'react'; import { useNavigate } from 'react-router-dom'; import Badge from '../common/Badge'; -export default function ProjectCard({ project, currentUserId, onDelete }) { +export default function ProjectCard({ project, currentUserId, onDelete, onEdit }) { const navigate = useNavigate(); const isManager = project.manager?.id === currentUserId; @@ -15,6 +15,17 @@ export default function ProjectCard({ project, currentUserId, onDelete }) {

{project.projectName}

+ {isManager && onEdit && ( + + )} {isManager && onDelete && (
- {initial.id && ( -
- - -
- )} +
From 13ad77f425bf7b9513adbce8b66154fdf28c3fd1 Mon Sep 17 00:00:00 2001 From: Arturo Gomez Date: Thu, 23 Apr 2026 21:56:29 -0600 Subject: [PATCH 13/24] =?UTF-8?q?feat(api):=20endpoint=20para=20reabrir=20?= =?UTF-8?q?un=20sprint=20cerrado=20(CLOSED=20=E2=86=92=20PLANNING)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MyTodoList/sprint/SprintController.java | 8 ++++++++ .../MyTodoList/sprint/SprintService.java | 19 ++++++++++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/sprint/SprintController.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/sprint/SprintController.java index 866adc4b4..8441aac1d 100644 --- a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/sprint/SprintController.java +++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/sprint/SprintController.java @@ -71,6 +71,14 @@ public ResponseEntity close(@PathVariable Long projectId, return ResponseEntity.ok(sprintService.closeSprint(uid(auth), projectId, sprintId)); } + @PostMapping("/{sprintId}/reopen") + @Operation(summary = "Reopen a closed sprint (back to PLANNING)") + public ResponseEntity reopen(@PathVariable Long projectId, + @PathVariable Long sprintId, + Authentication auth) { + return ResponseEntity.ok(sprintService.reopenSprint(uid(auth), projectId, sprintId)); + } + private Long uid(Authentication auth) { return Long.parseLong(auth.getName()); } diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/sprint/SprintService.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/sprint/SprintService.java index 3db7386e1..5693ee372 100644 --- a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/sprint/SprintService.java +++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/sprint/SprintService.java @@ -109,9 +109,26 @@ public SprintResponse closeSprint(Long userId, Long projectId, Long sprintId) { if (sprint.getStatus() == SprintStatus.CLOSED) { throw new ConflictException("Sprint is already closed"); } + Sprint old = cloneForAudit(sprint); sprint.setStatus(SprintStatus.CLOSED); sprintRepository.save(sprint); - auditLogService.log(actor, EntityType.SPRINT, sprint.getId(), AuditAction.UPDATE, null, sprint); + auditLogService.log(actor, EntityType.SPRINT, sprint.getId(), AuditAction.UPDATE, old, sprint); + return sprintMapper.toResponse(sprint); + } + + @Transactional + public SprintResponse reopenSprint(Long userId, Long projectId, Long sprintId) { + User actor = userService.findActiveUserById(userId); + Project project = projectService.findProject(projectId); + projectService.requireManager(userId, project); + Sprint sprint = findSprint(sprintId, project); + if (sprint.getStatus() != SprintStatus.CLOSED) { + throw new ConflictException("Only a CLOSED sprint can be reopened"); + } + Sprint old = cloneForAudit(sprint); + sprint.setStatus(SprintStatus.PLANNING); + sprintRepository.save(sprint); + auditLogService.log(actor, EntityType.SPRINT, sprint.getId(), AuditAction.UPDATE, old, sprint); return sprintMapper.toResponse(sprint); } From 5bfc16c47cf5dd7035550435acf025f4376cf962 Mon Sep 17 00:00:00 2001 From: Arturo Gomez Date: Thu, 23 Apr 2026 21:56:32 -0600 Subject: [PATCH 14/24] =?UTF-8?q?feat(frontend):=20bot=C3=B3n=20reopen=20e?= =?UTF-8?q?n=20sprints=20cerrados=20y=20bot=C3=B3n=20close=20en=20sprints?= =?UTF-8?q?=20en=20planning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/sprints/SprintCard.jsx | 12 ++++++-- .../src/components/sprints/SprintList.jsx | 4 +-- .../main/frontend/src/pages/SprintsPage.jsx | 30 +++++++++++++++++-- .../frontend/src/services/sprintService.js | 3 ++ 4 files changed, 43 insertions(+), 6 deletions(-) diff --git a/MtdrSpring/backend/src/main/frontend/src/components/sprints/SprintCard.jsx b/MtdrSpring/backend/src/main/frontend/src/components/sprints/SprintCard.jsx index 0fc23d1f9..a07e8741a 100644 --- a/MtdrSpring/backend/src/main/frontend/src/components/sprints/SprintCard.jsx +++ b/MtdrSpring/backend/src/main/frontend/src/components/sprints/SprintCard.jsx @@ -8,7 +8,7 @@ const fmt = (iso) => { return `${d}-${m}-${y}`; }; -export default function SprintCard({ sprint, isManager, onActivate, onClose, onSelect }) { +export default function SprintCard({ sprint, isManager, onActivate, onClose, onReopen, onSelect }) { return (
onSelect(sprint)} @@ -27,13 +27,21 @@ export default function SprintCard({ sprint, isManager, onActivate, onClose, onS {isManager && sprint.status !== 'CLOSED' && (
e.stopPropagation()}> {sprint.status === 'PLANNING' && ( - + <> + + + )} {sprint.status === 'ACTIVE' && ( )}
)} + {isManager && sprint.status === 'CLOSED' && ( +
e.stopPropagation()}> + +
+ )}
); } diff --git a/MtdrSpring/backend/src/main/frontend/src/components/sprints/SprintList.jsx b/MtdrSpring/backend/src/main/frontend/src/components/sprints/SprintList.jsx index 77a108c64..66fad50f4 100644 --- a/MtdrSpring/backend/src/main/frontend/src/components/sprints/SprintList.jsx +++ b/MtdrSpring/backend/src/main/frontend/src/components/sprints/SprintList.jsx @@ -1,7 +1,7 @@ import React, { useRef, useState } from 'react'; import SprintCard from './SprintCard'; -export default function SprintList({ sprints, isManager, onActivate, onClose, onSelect }) { +export default function SprintList({ sprints, isManager, onActivate, onClose, onReopen, onSelect }) { const ref = useRef(null); const [dragging, setDragging] = useState(false); const [startX, setStartX] = useState(0); @@ -43,7 +43,7 @@ export default function SprintList({ sprints, isManager, onActivate, onClose, on > {sprints.map((s) => (
- +
))}
diff --git a/MtdrSpring/backend/src/main/frontend/src/pages/SprintsPage.jsx b/MtdrSpring/backend/src/main/frontend/src/pages/SprintsPage.jsx index 7a2021f5c..50c68f171 100644 --- a/MtdrSpring/backend/src/main/frontend/src/pages/SprintsPage.jsx +++ b/MtdrSpring/backend/src/main/frontend/src/pages/SprintsPage.jsx @@ -32,6 +32,7 @@ export default function SprintsPage() { const [taskSaving, setTaskSaving] = useState(false); const [confirmCloseSprint, setConfirmCloseSprint] = useState(null); + const [confirmReopenSprint, setConfirmReopenSprint] = useState(null); const [confirmDeleteTask, setConfirmDeleteTask] = useState(null); const [actionLoading, setActionLoading] = useState(false); @@ -95,6 +96,20 @@ export default function SprintsPage() { finally { setActionLoading(false); } }; + const handleReopen = (sprint) => { + setConfirmReopenSprint(sprint); + }; + + const doReopenSprint = async () => { + setActionLoading(true); + try { + await sprintService.reopen(project.id, confirmReopenSprint.id); + setConfirmReopenSprint(null); + load(); + } catch (err) { toast.error(err.message); } + finally { setActionLoading(false); } + }; + const handleStatusChange = async (task, status) => { try { const updated = await taskService.changeStatus(project.id, task.id, status); @@ -142,7 +157,7 @@ export default function SprintsPage() { const closedSprints = sprints.filter((s) => s.status === 'CLOSED').sort(byDate); - const listProps = { isManager, onActivate: handleActivate, onClose: handleClose, onSelect: loadSprintTasks }; + const listProps = { isManager, onActivate: handleActivate, onClose: handleClose, onReopen: handleReopen, onSelect: loadSprintTasks }; return (
@@ -243,12 +258,23 @@ export default function SprintsPage() { onClose={() => setConfirmCloseSprint(null)} onConfirm={doCloseSprint} title="Close sprint" - message={`Close "${confirmCloseSprint?.sprintName}"? All tasks will be frozen and this cannot be undone.`} + message={`Close "${confirmCloseSprint?.sprintName}"? All tasks will be frozen. You can reopen it later if needed.`} confirmLabel="Close sprint" variant="warning" loading={actionLoading} /> + setConfirmReopenSprint(null)} + onConfirm={doReopenSprint} + title="Reopen sprint" + message={`"${confirmReopenSprint?.sprintName}" will return to PLANNING. Its tasks will become editable again.`} + confirmLabel="Reopen" + variant="warning" + loading={actionLoading} + /> + setConfirmDeleteTask(null)} diff --git a/MtdrSpring/backend/src/main/frontend/src/services/sprintService.js b/MtdrSpring/backend/src/main/frontend/src/services/sprintService.js index a3d2db1d0..3ad89a88f 100644 --- a/MtdrSpring/backend/src/main/frontend/src/services/sprintService.js +++ b/MtdrSpring/backend/src/main/frontend/src/services/sprintService.js @@ -19,4 +19,7 @@ export const sprintService = { close: (projectId, sprintId) => apiFetch(`/projects/${projectId}/sprints/${sprintId}/close`, { method: 'POST' }), + + reopen: (projectId, sprintId) => + apiFetch(`/projects/${projectId}/sprints/${sprintId}/reopen`, { method: 'POST' }), }; From 2eec1ac68265c506a5133de4df6eb44367c48cfc Mon Sep 17 00:00:00 2001 From: Arturo Gomez Date: Wed, 29 Apr 2026 14:56:52 -0600 Subject: [PATCH 15/24] chore(chore): agregar flag skipFrontend en Maven y script test en npm --- MtdrSpring/backend/pom.xml | 2 ++ MtdrSpring/backend/src/main/frontend/package.json | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/MtdrSpring/backend/pom.xml b/MtdrSpring/backend/pom.xml index 985c9152b..43feb34e9 100644 --- a/MtdrSpring/backend/pom.xml +++ b/MtdrSpring/backend/pom.xml @@ -21,6 +21,7 @@ ${project.basedir}/src/main/frontend v23.9.0 8.1.2 + false @@ -133,6 +134,7 @@ ${npm.version} ${frontend-src-dir} ${project.build.directory} + ${skipFrontend} diff --git a/MtdrSpring/backend/src/main/frontend/package.json b/MtdrSpring/backend/src/main/frontend/package.json index 2eecdb461..521f3283c 100644 --- a/MtdrSpring/backend/src/main/frontend/package.json +++ b/MtdrSpring/backend/src/main/frontend/package.json @@ -16,7 +16,9 @@ }, "scripts": { "start": "react-scripts start", - "build": "react-scripts build" + "build": "react-scripts build", + "test": "react-scripts test --watchAll=false", + "test:watch": "react-scripts test" }, "eslintConfig": { "extends": "react-app" From 1154bcdb247f5044e921a4cd3e7a478935b2bc3a Mon Sep 17 00:00:00 2001 From: Arturo Gomez Date: Wed, 29 Apr 2026 14:56:55 -0600 Subject: [PATCH 16/24] test(api): infraestructura de tests unitarios con Mockito para service layer --- .../springboot/MyTodoList/TestFixtures.java | 57 +++++ .../MyTodoList/auth/AuthServiceTest.java | 98 ++++++++ .../MyTodoList/config/JwtUtilTest.java | 41 +++ .../project/ProjectServiceTest.java | 132 ++++++++++ .../MyTodoList/sprint/SprintServiceTest.java | 146 +++++++++++ .../MyTodoList/task/TaskServiceTest.java | 235 ++++++++++++++++++ 6 files changed, 709 insertions(+) create mode 100644 MtdrSpring/backend/src/test/java/com/springboot/MyTodoList/TestFixtures.java create mode 100644 MtdrSpring/backend/src/test/java/com/springboot/MyTodoList/auth/AuthServiceTest.java create mode 100644 MtdrSpring/backend/src/test/java/com/springboot/MyTodoList/config/JwtUtilTest.java create mode 100644 MtdrSpring/backend/src/test/java/com/springboot/MyTodoList/project/ProjectServiceTest.java create mode 100644 MtdrSpring/backend/src/test/java/com/springboot/MyTodoList/sprint/SprintServiceTest.java create mode 100644 MtdrSpring/backend/src/test/java/com/springboot/MyTodoList/task/TaskServiceTest.java diff --git a/MtdrSpring/backend/src/test/java/com/springboot/MyTodoList/TestFixtures.java b/MtdrSpring/backend/src/test/java/com/springboot/MyTodoList/TestFixtures.java new file mode 100644 index 000000000..5a852247b --- /dev/null +++ b/MtdrSpring/backend/src/test/java/com/springboot/MyTodoList/TestFixtures.java @@ -0,0 +1,57 @@ +package com.springboot.MyTodoList; + +import com.springboot.MyTodoList.common.enums.ProjectStatus; +import com.springboot.MyTodoList.common.enums.SprintStatus; +import com.springboot.MyTodoList.common.enums.TaskStatus; +import com.springboot.MyTodoList.project.Project; +import com.springboot.MyTodoList.sprint.Sprint; +import com.springboot.MyTodoList.task.Task; +import com.springboot.MyTodoList.user.User; + +import java.time.LocalDate; + +public class TestFixtures { + + public static User user(Long id) { + return User.builder() + .id(id) + .fullName("User " + id) + .email("user" + id + "@test.com") + .passwordHash("$2a$10$hashedpassword") + .isActive(true) + .build(); + } + + public static Project project(Long id, User manager) { + return Project.builder() + .id(id) + .projectName("Project " + id) + .description("Test project") + .status(ProjectStatus.ACTIVE) + .manager(manager) + .build(); + } + + public static Sprint sprint(Long id, Project project, SprintStatus status) { + return Sprint.builder() + .id(id) + .project(project) + .sprintName("Sprint " + id) + .startDate(LocalDate.now()) + .endDate(LocalDate.now().plusDays(14)) + .status(status) + .build(); + } + + public static Task task(Long id, Project project, Sprint sprint) { + return Task.builder() + .id(id) + .project(project) + .sprint(sprint) + .taskName("Task " + id) + .status(TaskStatus.TODO) + .storyPoints(3) + .createdBy(project.getManager()) + .build(); + } +} diff --git a/MtdrSpring/backend/src/test/java/com/springboot/MyTodoList/auth/AuthServiceTest.java b/MtdrSpring/backend/src/test/java/com/springboot/MyTodoList/auth/AuthServiceTest.java new file mode 100644 index 000000000..935ddf247 --- /dev/null +++ b/MtdrSpring/backend/src/test/java/com/springboot/MyTodoList/auth/AuthServiceTest.java @@ -0,0 +1,98 @@ +package com.springboot.MyTodoList.auth; + +import com.springboot.MyTodoList.TestFixtures; +import com.springboot.MyTodoList.common.exception.ConflictException; +import com.springboot.MyTodoList.config.JwtUtil; +import com.springboot.MyTodoList.user.User; +import com.springboot.MyTodoList.user.UserMapper; +import com.springboot.MyTodoList.user.UserRepository; +import com.springboot.MyTodoList.user.UserResponse; +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.security.authentication.BadCredentialsException; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +class AuthServiceTest { + + @Mock UserRepository userRepository; + @Mock PasswordEncoder passwordEncoder; + @Mock JwtUtil jwtUtil; + @Mock UserMapper userMapper; + @InjectMocks AuthService authService; + + @Test + void register_withDuplicateEmail_throwsConflict() { + given(userRepository.existsByEmail("dup@test.com")).willReturn(true); + + RegisterRequest req = new RegisterRequest(); + req.setFullName("Test"); + req.setEmail("dup@test.com"); + req.setPassword("pass"); + + assertThatThrownBy(() -> authService.register(req)) + .isInstanceOf(ConflictException.class) + .hasMessageContaining("Email already in use"); + + verify(userRepository, never()).save(any()); + } + + @Test + void login_withWrongPassword_throwsBadCredentials() { + User user = TestFixtures.user(1L); + given(userRepository.findByEmail("user@test.com")).willReturn(Optional.of(user)); + given(passwordEncoder.matches("wrong", user.getPasswordHash())).willReturn(false); + + LoginRequest req = new LoginRequest(); + req.setEmail("user@test.com"); + req.setPassword("wrong"); + + assertThatThrownBy(() -> authService.login(req)) + .isInstanceOf(BadCredentialsException.class); + } + + @Test + void login_withInactiveUser_throwsBadCredentials() { + User inactive = TestFixtures.user(1L); + inactive.setActive(false); + given(userRepository.findByEmail("user@test.com")).willReturn(Optional.of(inactive)); + + LoginRequest req = new LoginRequest(); + req.setEmail("user@test.com"); + req.setPassword("pass"); + + assertThatThrownBy(() -> authService.login(req)) + .isInstanceOf(BadCredentialsException.class) + .hasMessageContaining("inactive"); + } + + @Test + void login_success_returnsToken() { + User user = TestFixtures.user(1L); + given(userRepository.findByEmail("user1@test.com")).willReturn(Optional.of(user)); + given(passwordEncoder.matches(any(), any())).willReturn(true); + given(jwtUtil.generateToken(1L)).willReturn("jwt-token"); + given(userMapper.toResponse(user)).willReturn(new UserResponse()); + + LoginRequest req = new LoginRequest(); + req.setEmail("user1@test.com"); + req.setPassword("correctpass"); + + AuthResponse response = authService.login(req); + + assertThat(response.getToken()).isEqualTo("jwt-token"); + verify(jwtUtil).generateToken(1L); + } +} diff --git a/MtdrSpring/backend/src/test/java/com/springboot/MyTodoList/config/JwtUtilTest.java b/MtdrSpring/backend/src/test/java/com/springboot/MyTodoList/config/JwtUtilTest.java new file mode 100644 index 000000000..b4df86acc --- /dev/null +++ b/MtdrSpring/backend/src/test/java/com/springboot/MyTodoList/config/JwtUtilTest.java @@ -0,0 +1,41 @@ +package com.springboot.MyTodoList.config; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class JwtUtilTest { + + private static final String SECRET = "test_secret_key_minimum_32_characters_long_hmac"; + private JwtUtil jwtUtil; + + @BeforeEach + void setUp() { + jwtUtil = new JwtUtil(SECRET, 86400000L); + } + + @Test + void generateAndValidate_roundtrip() { + String token = jwtUtil.generateToken(42L); + + assertThat(jwtUtil.validateToken(token)).isTrue(); + assertThat(jwtUtil.extractUserId(token)).isEqualTo("42"); + } + + @Test + void validateToken_withTamperedToken_returnsFalse() { + String token = jwtUtil.generateToken(1L); + String tampered = token.substring(0, token.length() - 4) + "XXXX"; + + assertThat(jwtUtil.validateToken(tampered)).isFalse(); + } + + @Test + void validateToken_withExpiredToken_returnsFalse() { + JwtUtil expiringJwt = new JwtUtil(SECRET, -1L); + String token = expiringJwt.generateToken(1L); + + assertThat(jwtUtil.validateToken(token)).isFalse(); + } +} diff --git a/MtdrSpring/backend/src/test/java/com/springboot/MyTodoList/project/ProjectServiceTest.java b/MtdrSpring/backend/src/test/java/com/springboot/MyTodoList/project/ProjectServiceTest.java new file mode 100644 index 000000000..e04e55004 --- /dev/null +++ b/MtdrSpring/backend/src/test/java/com/springboot/MyTodoList/project/ProjectServiceTest.java @@ -0,0 +1,132 @@ +package com.springboot.MyTodoList.project; + +import com.springboot.MyTodoList.TestFixtures; +import com.springboot.MyTodoList.audit.AuditLogService; +import com.springboot.MyTodoList.common.enums.SprintStatus; +import com.springboot.MyTodoList.common.enums.TaskStatus; +import com.springboot.MyTodoList.common.exception.ForbiddenException; +import com.springboot.MyTodoList.common.exception.NotProjectParticipantException; +import com.springboot.MyTodoList.project.member.ProjectMemberRepository; +import com.springboot.MyTodoList.sprint.Sprint; +import com.springboot.MyTodoList.sprint.SprintRepository; +import com.springboot.MyTodoList.task.Task; +import com.springboot.MyTodoList.task.TaskRepository; +import com.springboot.MyTodoList.task.activity.TaskActivityRepository; +import com.springboot.MyTodoList.user.User; +import com.springboot.MyTodoList.user.UserService; +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.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class ProjectServiceTest { + + @Mock ProjectRepository projectRepository; + @Mock ProjectMemberRepository memberRepository; + @Mock SprintRepository sprintRepository; + @Mock TaskRepository taskRepository; + @Mock TaskActivityRepository activityRepository; + @Mock UserService userService; + @Mock AuditLogService auditLogService; + @Mock ProjectMapper projectMapper; + @InjectMocks ProjectService projectService; + + User manager; + User stranger; + Project project; + + @BeforeEach + void setUp() { + manager = TestFixtures.user(1L); + stranger = TestFixtures.user(99L); + project = TestFixtures.project(10L, manager); + + given(projectRepository.findById(10L)).willReturn(Optional.of(project)); + given(projectMapper.toResponse(any())).willReturn(new ProjectResponse()); + } + + // --- requireManager --- + + @Test + void requireManager_withManager_passes() { + assertThatCode(() -> projectService.requireManager(1L, project)) + .doesNotThrowAnyException(); + } + + @Test + void requireManager_withNonManager_throwsForbidden() { + assertThatThrownBy(() -> projectService.requireManager(99L, project)) + .isInstanceOf(ForbiddenException.class) + .hasMessageContaining("Only the project manager"); + } + + // --- requireParticipant --- + + @Test + void requireParticipant_withManager_passes() { + assertThatCode(() -> projectService.requireParticipant(1L, project)) + .doesNotThrowAnyException(); + } + + @Test + void requireParticipant_withStranger_throws() { + given(projectRepository.findAllByParticipant(eq(99L), any(Pageable.class))) + .willReturn(Page.empty()); + + assertThatThrownBy(() -> projectService.requireParticipant(99L, project)) + .isInstanceOf(NotProjectParticipantException.class); + } + + // --- closeProject --- + + @Test + void closeProject_autoclosesPlanningAndActiveSprints() { + given(userService.findActiveUserById(1L)).willReturn(manager); + + Sprint planning = TestFixtures.sprint(1L, project, SprintStatus.PLANNING); + Sprint active = TestFixtures.sprint(2L, project, SprintStatus.ACTIVE); + + given(sprintRepository.findByProjectAndStatus(project, SprintStatus.PLANNING)).willReturn(List.of(planning)); + given(sprintRepository.findByProjectAndStatus(project, SprintStatus.ACTIVE)).willReturn(List.of(active)); + given(taskRepository.findByProject(project)).willReturn(List.of()); + + projectService.closeProject(1L, 10L); + + assertThat(planning.getStatus()).isEqualTo(SprintStatus.CLOSED); + assertThat(active.getStatus()).isEqualTo(SprintStatus.CLOSED); + verify(sprintRepository).saveAll(any()); + } + + @Test + void closeProject_returnsCorrectPendingTaskCount() { + given(userService.findActiveUserById(1L)).willReturn(manager); + given(sprintRepository.findByProjectAndStatus(any(), any())).willReturn(List.of()); + + Task doneTask = TestFixtures.task(1L, project, null); + doneTask.setStatus(TaskStatus.DONE); + Task pendingTask = TestFixtures.task(2L, project, null); + pendingTask.setStatus(TaskStatus.IN_PROGRESS); + + given(taskRepository.findByProject(project)).willReturn(List.of(doneTask, pendingTask)); + + CloseProjectResponse response = projectService.closeProject(1L, 10L); + + assertThat(response.getPendingTasks()).isEqualTo(1); + } +} diff --git a/MtdrSpring/backend/src/test/java/com/springboot/MyTodoList/sprint/SprintServiceTest.java b/MtdrSpring/backend/src/test/java/com/springboot/MyTodoList/sprint/SprintServiceTest.java new file mode 100644 index 000000000..165a5a447 --- /dev/null +++ b/MtdrSpring/backend/src/test/java/com/springboot/MyTodoList/sprint/SprintServiceTest.java @@ -0,0 +1,146 @@ +package com.springboot.MyTodoList.sprint; + +import com.springboot.MyTodoList.TestFixtures; +import com.springboot.MyTodoList.audit.AuditLogService; +import com.springboot.MyTodoList.common.enums.SprintStatus; +import com.springboot.MyTodoList.common.exception.ConflictException; +import com.springboot.MyTodoList.common.exception.ForbiddenException; +import com.springboot.MyTodoList.project.Project; +import com.springboot.MyTodoList.project.ProjectService; +import com.springboot.MyTodoList.user.User; +import com.springboot.MyTodoList.user.UserService; +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.time.LocalDate; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class SprintServiceTest { + + @Mock SprintRepository sprintRepository; + @Mock ProjectService projectService; + @Mock UserService userService; + @Mock AuditLogService auditLogService; + @Mock SprintMapper sprintMapper; + @InjectMocks SprintService sprintService; + + User manager; + Project project; + Sprint sprint; + + @BeforeEach + void setUp() { + manager = TestFixtures.user(1L); + project = TestFixtures.project(10L, manager); + sprint = TestFixtures.sprint(100L, project, SprintStatus.PLANNING); + + given(userService.findActiveUserById(1L)).willReturn(manager); + given(projectService.findProject(10L)).willReturn(project); + given(sprintRepository.findById(100L)).willReturn(Optional.of(sprint)); + given(sprintMapper.toResponse(any())).willReturn(new SprintResponse()); + } + + @Test + void createSprint_whenEndDateBeforeStartDate_throwsConflict() { + SprintRequest request = new SprintRequest(); + request.setSprintName("Bad Sprint"); + request.setStartDate(LocalDate.now().plusDays(5)); + request.setEndDate(LocalDate.now()); + + assertThatThrownBy(() -> sprintService.createSprint(1L, 10L, request)) + .isInstanceOf(ConflictException.class) + .hasMessageContaining("end_date must be after start_date"); + } + + @Test + void createSprint_success_savesCalled() { + SprintRequest request = new SprintRequest(); + request.setSprintName("Sprint 1"); + request.setStartDate(LocalDate.now()); + request.setEndDate(LocalDate.now().plusDays(14)); + + sprintService.createSprint(1L, 10L, request); + + verify(sprintRepository).save(any(Sprint.class)); + } + + @Test + void activateSprint_whenNoneActive_transitionsToActive() { + given(sprintRepository.existsByProjectAndStatus(project, SprintStatus.ACTIVE)).willReturn(false); + + sprintService.activateSprint(1L, 10L, 100L); + + assertThat(sprint.getStatus()).isEqualTo(SprintStatus.ACTIVE); + verify(sprintRepository).save(sprint); + } + + @Test + void activateSprint_whenAnotherAlreadyActive_throwsConflict() { + given(sprintRepository.existsByProjectAndStatus(project, SprintStatus.ACTIVE)).willReturn(true); + + assertThatThrownBy(() -> sprintService.activateSprint(1L, 10L, 100L)) + .isInstanceOf(ConflictException.class) + .hasMessageContaining("SPRINT_ALREADY_ACTIVE"); + } + + @Test + void activateSprint_whenNotPlanning_throwsConflict() { + sprint.setStatus(SprintStatus.ACTIVE); + + assertThatThrownBy(() -> sprintService.activateSprint(1L, 10L, 100L)) + .isInstanceOf(ConflictException.class) + .hasMessageContaining("Only a PLANNING sprint can be activated"); + } + + @Test + void updateSprint_whenClosed_throwsForbidden() { + sprint.setStatus(SprintStatus.CLOSED); + SprintRequest request = new SprintRequest(); + request.setSprintName("Updated"); + request.setStartDate(LocalDate.now()); + request.setEndDate(LocalDate.now().plusDays(14)); + + assertThatThrownBy(() -> sprintService.updateSprint(1L, 10L, 100L, request)) + .isInstanceOf(ForbiddenException.class) + .hasMessageContaining("Cannot edit a closed sprint"); + } + + @Test + void closeSprint_success_setsStatusToClosed() { + sprint.setStatus(SprintStatus.ACTIVE); + + sprintService.closeSprint(1L, 10L, 100L); + + assertThat(sprint.getStatus()).isEqualTo(SprintStatus.CLOSED); + verify(sprintRepository).save(sprint); + } + + @Test + void closeSprint_whenAlreadyClosed_throwsConflict() { + sprint.setStatus(SprintStatus.CLOSED); + + assertThatThrownBy(() -> sprintService.closeSprint(1L, 10L, 100L)) + .isInstanceOf(ConflictException.class) + .hasMessageContaining("already closed"); + } + + @Test + void reopenSprint_whenNotClosed_throwsConflict() { + sprint.setStatus(SprintStatus.PLANNING); + + assertThatThrownBy(() -> sprintService.reopenSprint(1L, 10L, 100L)) + .isInstanceOf(ConflictException.class) + .hasMessageContaining("Only a CLOSED sprint can be reopened"); + } +} diff --git a/MtdrSpring/backend/src/test/java/com/springboot/MyTodoList/task/TaskServiceTest.java b/MtdrSpring/backend/src/test/java/com/springboot/MyTodoList/task/TaskServiceTest.java new file mode 100644 index 000000000..17021af63 --- /dev/null +++ b/MtdrSpring/backend/src/test/java/com/springboot/MyTodoList/task/TaskServiceTest.java @@ -0,0 +1,235 @@ +package com.springboot.MyTodoList.task; + +import com.springboot.MyTodoList.TestFixtures; +import com.springboot.MyTodoList.audit.AuditLogService; +import com.springboot.MyTodoList.common.enums.ActivityType; +import com.springboot.MyTodoList.common.enums.SprintStatus; +import com.springboot.MyTodoList.common.enums.TaskStatus; +import com.springboot.MyTodoList.common.exception.ClosedSprintException; +import com.springboot.MyTodoList.common.exception.ForbiddenException; +import com.springboot.MyTodoList.common.exception.ValidationException; +import com.springboot.MyTodoList.notification.NotificationService; +import com.springboot.MyTodoList.project.Project; +import com.springboot.MyTodoList.project.ProjectService; +import com.springboot.MyTodoList.sprint.Sprint; +import com.springboot.MyTodoList.sprint.SprintRepository; +import com.springboot.MyTodoList.task.activity.TaskActivity; +import com.springboot.MyTodoList.task.activity.TaskActivityRepository; +import com.springboot.MyTodoList.user.User; +import com.springboot.MyTodoList.user.UserService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class TaskServiceTest { + + @Mock TaskRepository taskRepository; + @Mock TaskActivityRepository activityRepository; + @Mock SprintRepository sprintRepository; + @Mock ProjectService projectService; + @Mock UserService userService; + @Mock AuditLogService auditLogService; + @Mock NotificationService notificationService; + @Mock TaskMapper taskMapper; + @InjectMocks TaskService taskService; + + User manager; + User member; + Project project; + Sprint activeSprint; + Task task; + + @BeforeEach + void setUp() { + manager = TestFixtures.user(1L); + member = TestFixtures.user(2L); + project = TestFixtures.project(10L, manager); + activeSprint = TestFixtures.sprint(100L, project, SprintStatus.ACTIVE); + task = TestFixtures.task(200L, project, activeSprint); + task.setAssignedTo(member); + + given(taskRepository.findById(200L)).willReturn(Optional.of(task)); + given(taskMapper.toResponse(any())).willReturn(new TaskResponse()); + } + + // --- changeStatus: DONE validation --- + + @Test + void changeStatus_toDONE_withoutActualHours_throws() { + given(userService.findActiveUserById(1L)).willReturn(manager); + given(projectService.findProject(10L)).willReturn(project); + + StatusChangeRequest req = doneRequest(null); + + assertThatThrownBy(() -> taskService.changeStatus(1L, 10L, 200L, req)) + .isInstanceOf(ValidationException.class) + .hasMessageContaining("actual_hours"); + } + + @Test + void changeStatus_toDONE_withZeroHours_throws() { + given(userService.findActiveUserById(1L)).willReturn(manager); + given(projectService.findProject(10L)).willReturn(project); + + StatusChangeRequest req = doneRequest(BigDecimal.ZERO); + + assertThatThrownBy(() -> taskService.changeStatus(1L, 10L, 200L, req)) + .isInstanceOf(ValidationException.class) + .hasMessageContaining("actual_hours"); + } + + @Test + void changeStatus_toDONE_withValidHours_setsActualHours() { + given(userService.findActiveUserById(1L)).willReturn(manager); + given(projectService.findProject(10L)).willReturn(project); + + taskService.changeStatus(1L, 10L, 200L, doneRequest(new BigDecimal("3.5"))); + + assertThat(task.getActualHours()).isEqualByComparingTo("3.5"); + assertThat(task.getStatus()).isEqualTo(TaskStatus.DONE); + } + + // --- changeStatus: permission checks --- + + @Test + void changeStatus_byManager_succeeds() { + given(userService.findActiveUserById(1L)).willReturn(manager); + given(projectService.findProject(10L)).willReturn(project); + + StatusChangeRequest req = statusRequest(TaskStatus.IN_PROGRESS); + taskService.changeStatus(1L, 10L, 200L, req); + + assertThat(task.getStatus()).isEqualTo(TaskStatus.IN_PROGRESS); + } + + @Test + void changeStatus_byAssignee_succeeds() { + given(userService.findActiveUserById(2L)).willReturn(member); + given(projectService.findProject(10L)).willReturn(project); + + StatusChangeRequest req = statusRequest(TaskStatus.IN_PROGRESS); + taskService.changeStatus(2L, 10L, 200L, req); + + assertThat(task.getStatus()).isEqualTo(TaskStatus.IN_PROGRESS); + } + + @Test + void changeStatus_byUnrelatedUser_throws() { + User stranger = TestFixtures.user(99L); + given(userService.findActiveUserById(99L)).willReturn(stranger); + given(projectService.findProject(10L)).willReturn(project); + + assertThatThrownBy(() -> taskService.changeStatus(99L, 10L, 200L, statusRequest(TaskStatus.IN_PROGRESS))) + .isInstanceOf(ForbiddenException.class) + .hasMessageContaining("manager or assigned user"); + } + + // --- changeStatus: closed sprint --- + + @Test + void changeStatus_inClosedSprint_throws() { + activeSprint.setStatus(SprintStatus.CLOSED); + given(userService.findActiveUserById(1L)).willReturn(manager); + given(projectService.findProject(10L)).willReturn(project); + + assertThatThrownBy(() -> taskService.changeStatus(1L, 10L, 200L, statusRequest(TaskStatus.IN_PROGRESS))) + .isInstanceOf(ClosedSprintException.class); + } + + // --- changeStatus: BLOCKED notification --- + + @Test + void changeStatus_toBlocked_notifiesManager() { + given(userService.findActiveUserById(1L)).willReturn(manager); + given(projectService.findProject(10L)).willReturn(project); + + taskService.changeStatus(1L, 10L, 200L, statusRequest(TaskStatus.BLOCKED)); + + verify(notificationService).send(eq(manager), eq("TASK_BLOCKED"), anyString()); + } + + @Test + void changeStatus_toBlocked_notificationFailure_doesNotThrow() { + given(userService.findActiveUserById(1L)).willReturn(manager); + given(projectService.findProject(10L)).willReturn(project); + willThrow(new RuntimeException("Telegram down")).given(notificationService).send(any(), any(), any()); + + // should complete without throwing + taskService.changeStatus(1L, 10L, 200L, statusRequest(TaskStatus.BLOCKED)); + + assertThat(task.getStatus()).isEqualTo(TaskStatus.BLOCKED); + } + + // --- changeSprint --- + + @Test + void changeSprint_generatesSPRINT_CHANGE_activity() { + given(userService.findActiveUserById(1L)).willReturn(manager); + given(projectService.findProject(10L)).willReturn(project); + + Sprint newSprint = TestFixtures.sprint(101L, project, SprintStatus.PLANNING); + given(sprintRepository.findById(101L)).willReturn(Optional.of(newSprint)); + + SprintChangeRequest req = new SprintChangeRequest(); + req.setSprintId(101L); + taskService.changeSprint(1L, 10L, 200L, req); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TaskActivity.class); + verify(activityRepository).save(captor.capture()); + assertThat(captor.getValue().getActivityType()).isEqualTo(ActivityType.SPRINT_CHANGE); + } + + // --- resolveSprint: different project --- + + @Test + void createTask_inSprintOfDifferentProject_throws() { + given(userService.findActiveUserById(1L)).willReturn(manager); + given(projectService.findProject(10L)).willReturn(project); + + Project otherProject = TestFixtures.project(99L, manager); + Sprint foreignSprint = TestFixtures.sprint(500L, otherProject, SprintStatus.PLANNING); + given(sprintRepository.findById(500L)).willReturn(Optional.of(foreignSprint)); + + TaskRequest req = new TaskRequest(); + req.setTaskName("Test Task"); + req.setStoryPoints(3); + req.setSprintId(500L); + + assertThatThrownBy(() -> taskService.createTask(1L, 10L, req)) + .isInstanceOf(ForbiddenException.class) + .hasMessageContaining("Sprint does not belong to this project"); + } + + // --- helpers --- + + private StatusChangeRequest doneRequest(BigDecimal hours) { + StatusChangeRequest r = new StatusChangeRequest(); + r.setStatus(TaskStatus.DONE); + r.setActualHours(hours); + return r; + } + + private StatusChangeRequest statusRequest(TaskStatus status) { + StatusChangeRequest r = new StatusChangeRequest(); + r.setStatus(status); + return r; + } +} From aefe8fe2c937fb8c45cdca05ddf9287fc92a5fa6 Mon Sep 17 00:00:00 2001 From: Arturo Gomez Date: Wed, 29 Apr 2026 14:56:58 -0600 Subject: [PATCH 17/24] test(frontend): tests de apiFetch y AuthContext con Jest --- .../src/__tests__/AuthContext.test.jsx | 70 ++++++++++++++++++ .../main/frontend/src/__tests__/api.test.js | 71 +++++++++++++++++++ .../src/main/frontend/src/setupTests.js | 1 + 3 files changed, 142 insertions(+) create mode 100644 MtdrSpring/backend/src/main/frontend/src/__tests__/AuthContext.test.jsx create mode 100644 MtdrSpring/backend/src/main/frontend/src/__tests__/api.test.js create mode 100644 MtdrSpring/backend/src/main/frontend/src/setupTests.js diff --git a/MtdrSpring/backend/src/main/frontend/src/__tests__/AuthContext.test.jsx b/MtdrSpring/backend/src/main/frontend/src/__tests__/AuthContext.test.jsx new file mode 100644 index 000000000..04153c28e --- /dev/null +++ b/MtdrSpring/backend/src/main/frontend/src/__tests__/AuthContext.test.jsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { render, screen, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { AuthProvider, useAuth } from '../context/AuthContext'; + +jest.mock('../services/authService', () => ({ + authService: { + login: jest.fn(), + register: jest.fn(), + }, +})); + +import { authService } from '../services/authService'; + +function TestConsumer() { + const { user, token, login, logout } = useAuth(); + return ( +
+ {user ? user.email : 'none'} + {token || 'none'} + + +
+ ); +} + +beforeEach(() => { + localStorage.clear(); + jest.clearAllMocks(); +}); + +test('login saves token and user to localStorage and updates state', async () => { + authService.login.mockResolvedValue({ + token: 'jwt-abc', + user: { id: 1, email: 'u@test.com' }, + }); + + render(); + await act(() => userEvent.click(screen.getByText('Login'))); + + expect(screen.getByTestId('token').textContent).toBe('jwt-abc'); + expect(screen.getByTestId('user').textContent).toBe('u@test.com'); + expect(localStorage.getItem('jwt')).toBe('jwt-abc'); + expect(JSON.parse(localStorage.getItem('user')).email).toBe('u@test.com'); +}); + +test('logout clears localStorage and resets state', async () => { + localStorage.setItem('jwt', 'existing-token'); + localStorage.setItem('user', JSON.stringify({ id: 1, email: 'u@test.com' })); + + render(); + expect(screen.getByTestId('token').textContent).toBe('existing-token'); + + await act(() => userEvent.click(screen.getByText('Logout'))); + + expect(screen.getByTestId('token').textContent).toBe('none'); + expect(screen.getByTestId('user').textContent).toBe('none'); + expect(localStorage.getItem('jwt')).toBeNull(); + expect(localStorage.getItem('user')).toBeNull(); +}); + +test('initial state is loaded from localStorage on mount', () => { + localStorage.setItem('jwt', 'persisted-token'); + localStorage.setItem('user', JSON.stringify({ id: 2, email: 'persisted@test.com' })); + + render(); + + expect(screen.getByTestId('token').textContent).toBe('persisted-token'); + expect(screen.getByTestId('user').textContent).toBe('persisted@test.com'); +}); diff --git a/MtdrSpring/backend/src/main/frontend/src/__tests__/api.test.js b/MtdrSpring/backend/src/main/frontend/src/__tests__/api.test.js new file mode 100644 index 000000000..60d67a75c --- /dev/null +++ b/MtdrSpring/backend/src/main/frontend/src/__tests__/api.test.js @@ -0,0 +1,71 @@ +import { apiFetch, ApiError } from '../services/api'; + +beforeEach(() => { + localStorage.clear(); + delete window.location; + window.location = { href: '' }; +}); + +function mockFetch(status, body, headers = {}) { + global.fetch = jest.fn().mockResolvedValue({ + status, + ok: status >= 200 && status < 300, + headers: { + get: (key) => headers[key] || null, + }, + json: () => Promise.resolve(body), + }); +} + +test('401 response clears localStorage and redirects to /login', async () => { + localStorage.setItem('jwt', 'old-token'); + localStorage.setItem('user', '{"id":1}'); + mockFetch(401, {}); + + await apiFetch('/projects'); + + expect(localStorage.getItem('jwt')).toBeNull(); + expect(localStorage.getItem('user')).toBeNull(); + expect(window.location.href).toBe('/login'); +}); + +test('non-OK response throws ApiError with correct status', async () => { + mockFetch(404, { message: 'Not found' }); + + await expect(apiFetch('/projects/999')).rejects.toMatchObject({ + name: 'ApiError', + status: 404, + message: 'Not found', + }); +}); + +test('204 response returns null', async () => { + global.fetch = jest.fn().mockResolvedValue({ + status: 204, + ok: true, + headers: { get: () => null }, + json: () => Promise.reject(new Error('no body')), + }); + + const result = await apiFetch('/tasks/1', { method: 'DELETE' }); + + expect(result).toBeNull(); +}); + +test('ok response returns parsed JSON', async () => { + mockFetch(200, { id: 1, projectName: 'Alpha' }); + + const result = await apiFetch('/projects/1'); + + expect(result).toEqual({ id: 1, projectName: 'Alpha' }); +}); + +test('JWT in localStorage is included as Authorization header', async () => { + localStorage.setItem('jwt', 'my-token'); + mockFetch(200, {}); + + await apiFetch('/projects'); + + const calledHeaders = global.fetch.mock.calls[0][1].headers; + expect(calledHeaders['Authorization']).toBe('Bearer my-token'); +}); diff --git a/MtdrSpring/backend/src/main/frontend/src/setupTests.js b/MtdrSpring/backend/src/main/frontend/src/setupTests.js new file mode 100644 index 000000000..7b0828bfa --- /dev/null +++ b/MtdrSpring/backend/src/main/frontend/src/setupTests.js @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; From a1dfc0b811eede63d66e60ce9fe176043467bd55 Mon Sep 17 00:00:00 2001 From: Arturo Gomez Date: Wed, 29 Apr 2026 14:57:01 -0600 Subject: [PATCH 18/24] chore(chore): workflow de GitHub Actions CI con jobs backend, frontend y docker --- .github/workflows/ci.yml | 70 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..d26946e6c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,70 @@ +name: CI + +on: + push: + branches: [main, master, alternative] + pull_request: + branches: [main, master] + +jobs: + + backend-test: + name: Backend Tests + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: maven + + - name: Run backend unit tests + working-directory: MtdrSpring/backend + env: + JWT_SECRET: test_secret_key_minimum_32_characters_long_hmac + run: mvn test -DskipFrontend=true --no-transfer-progress + + - name: Upload Surefire reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: surefire-reports + path: MtdrSpring/backend/target/surefire-reports/ + retention-days: 7 + + frontend-test: + name: Frontend Tests + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + cache-dependency-path: MtdrSpring/backend/src/main/frontend/package-lock.json + + - name: Install dependencies + working-directory: MtdrSpring/backend/src/main/frontend + run: npm ci + + - name: Run frontend tests + working-directory: MtdrSpring/backend/src/main/frontend + run: npm test + + docker-build: + name: Docker Build Check + runs-on: ubuntu-latest + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') + needs: [backend-test, frontend-test] + + steps: + - uses: actions/checkout@v4 + + - name: Build Docker image + working-directory: MtdrSpring + run: docker build -f backend/Dockerfile backend/ -t chuvabot:ci-check From 34a6e365319ac1fa092ce53feca1802988448cae Mon Sep 17 00:00:00 2001 From: Arturo Gomez Date: Wed, 29 Apr 2026 14:57:04 -0600 Subject: [PATCH 19/24] docs(docs): plan de infraestructura de testing automatizado --- documentation/plans/automated_testing_plan.md | 359 ++++++++++++++++++ 1 file changed, 359 insertions(+) create mode 100644 documentation/plans/automated_testing_plan.md diff --git a/documentation/plans/automated_testing_plan.md b/documentation/plans/automated_testing_plan.md new file mode 100644 index 000000000..67c71e34b --- /dev/null +++ b/documentation/plans/automated_testing_plan.md @@ -0,0 +1,359 @@ +# Plan: Testing Infrastructure — Chuva Bot + +## Context + +The project has zero tests and no CI. The Spring Boot monolith's layered architecture (Controller → Service → Repository) makes the service layer fully testable with pure Mockito — no database, no Spring context, no slow startup. All business rules (status transitions, permission checks, sprint lifecycle) live in the services, so that's where the ROI is highest. + +Oracle XE takes 60–90s to start. CI **must never depend on Oracle**. Every test here uses `@ExtendWith(MockitoExtension.class)` — no `@SpringBootTest`, no H2, no datasource configuration needed. + +`spring-boot-starter-test` (JUnit 5, Mockito, AssertJ) is already in `pom.xml`. Nothing else needs to be added for the backend. + +--- + +## Dependency Graph + +``` +pom.xml (add skipFrontend flag) + └── enables CI backend job to run mvn test without Node.js + +TestFixtures.java + ├── SprintServiceTest.java + ├── TaskServiceTest.java + ├── ProjectServiceTest.java + └── AuthServiceTest.java (doesn't use fixtures) + +JwtUtilTest.java ← standalone, no fixtures needed + +package.json (add test script) + └── setupTests.js + ├── api.test.js + └── AuthContext.test.jsx + +ci.yml + ├── job: backend-test → mvn test -DskipFrontend=true + ├── job: frontend-test → npm ci && npm test + └── job: docker-build → only on push to main, needs both above +``` + +--- + +## Files to Create / Modify + +### 1. `MtdrSpring/backend/pom.xml` — add frontend skip flag + +Add `${skipFrontend}` to the `frontend-maven-plugin` global ``. This lets CI skip the Node.js build during backend-only test runs. + +```xml + + com.github.eirslett + frontend-maven-plugin + 1.9.1 + + ${node.version} + ${npm.version} + ${frontend-src-dir} + ${project.build.directory} + ${skipFrontend} + + ... + +``` + +### 2. `MtdrSpring/backend/src/test/java/com/springboot/MyTodoList/TestFixtures.java` + +Static factory methods for entity builders. Keeps test setup DRY across four test classes. + +```java +package com.springboot.MyTodoList; + +public class TestFixtures { + public static User user(Long id) { + return User.builder().id(id).fullName("User " + id) + .email("user" + id + "@test.com").passwordHash("hash") + .isActive(true).build(); + } + public static Project project(Long id, User manager) { + return Project.builder().id(id).projectName("Project " + id) + .status(ProjectStatus.ACTIVE).manager(manager).build(); + } + public static Sprint sprint(Long id, Project project, SprintStatus status) { + return Sprint.builder().id(id).project(project).sprintName("Sprint " + id) + .startDate(LocalDate.now()).endDate(LocalDate.now().plusDays(14)) + .status(status).build(); + } + public static Task task(Long id, Project project, Sprint sprint) { + return Task.builder().id(id).project(project).sprint(sprint) + .taskName("Task " + id).status(TaskStatus.TODO).storyPoints(3) + .createdBy(project.getManager()).build(); + } +} +``` + +### 3. `src/test/java/com/springboot/MyTodoList/config/JwtUtilTest.java` + +Instantiate `JwtUtil` directly — no Spring, no mocks needed. + +Tests (3): +- `generateAndValidate_roundtrip` — token is valid, `extractUserId` returns the userId +- `validateToken_withTamperedToken_returnsFalse` — swap last char → false +- `validateToken_withExpiredToken_returnsFalse` — `new JwtUtil(secret, -1L)` → expired immediately + +```java +@ExtendWith(MockitoExtension.class) +class JwtUtilTest { + private static final String SECRET = "test_secret_key_minimum_32_characters_long"; + private JwtUtil jwtUtil; + + @BeforeEach void setUp() { jwtUtil = new JwtUtil(SECRET, 86400000L); } + ... +} +``` + +### 4. `src/test/java/com/springboot/MyTodoList/sprint/SprintServiceTest.java` + +Mocks: `SprintRepository`, `ProjectService`, `UserService`, `AuditLogService`, `SprintMapper`. + +Tests (9): +| Test | Asserts | +|------|---------| +| `createSprint_whenEndDateBeforeStartDate_throwsConflict` | `ConflictException` from `validateDates` | +| `createSprint_success` | `sprintRepository.save()` called, mapper called | +| `activateSprint_whenNoneActive_transitionsToActive` | `sprint.status = ACTIVE`, save called | +| `activateSprint_whenAnotherAlreadyActive_throwsConflict` | `existsByProjectAndStatus` returns true → `ConflictException` | +| `activateSprint_whenNotPlanning_throwsConflict` | Sprint is ACTIVE already → `ConflictException` | +| `updateSprint_whenClosed_throwsForbidden` | `ForbiddenException` | +| `closeSprint_success` | status becomes CLOSED, save called | +| `closeSprint_whenAlreadyClosed_throwsConflict` | `ConflictException` | +| `reopenSprint_whenNotClosed_throwsConflict` | Sprint is PLANNING → `ConflictException` | + +Setup pattern: +```java +@ExtendWith(MockitoExtension.class) +class SprintServiceTest { + @Mock SprintRepository sprintRepository; + @Mock ProjectService projectService; + @Mock UserService userService; + @Mock AuditLogService auditLogService; + @Mock SprintMapper sprintMapper; + @InjectMocks SprintService sprintService; + + User manager; Project project; Sprint sprint; + + @BeforeEach void setUp() { + manager = TestFixtures.user(1L); + project = TestFixtures.project(10L, manager); + sprint = TestFixtures.sprint(100L, project, SprintStatus.PLANNING); + when(userService.findActiveUserById(1L)).thenReturn(manager); + when(projectService.findProject(10L)).thenReturn(project); + when(sprintRepository.findById(100L)).thenReturn(Optional.of(sprint)); + when(sprintMapper.toResponse(any())).thenReturn(new SprintResponse()); + } +} +``` + +### 5. `src/test/java/com/springboot/MyTodoList/task/TaskServiceTest.java` + +Mocks: `TaskRepository`, `TaskActivityRepository`, `SprintRepository`, `ProjectService`, `UserService`, `AuditLogService`, `NotificationService`, `TaskMapper`. + +Tests (11): +| Test | Key assertion | +|------|--------------| +| `changeStatus_toDONE_withoutActualHours_throws` | `ValidationException` | +| `changeStatus_toDONE_withZeroHours_throws` | `ValidationException` (zero is not > 0) | +| `changeStatus_toDONE_withValidHours_succeeds` | `task.actualHours` set, STATUS_CHANGE activity saved | +| `changeStatus_byManager_succeeds` | manager != assignee, still allowed | +| `changeStatus_byAssignee_succeeds` | non-manager assignee allowed | +| `changeStatus_byUnrelated_throws` | neither manager nor assignee → `ForbiddenException` | +| `changeStatus_inClosedSprint_throws` | sprint status CLOSED → `ClosedSprintException` | +| `changeStatus_toBlocked_notifiesManager` | `notificationService.send()` called for manager | +| `changeStatus_toBlocked_notificationFailure_doesNotThrow` | `notificationService.send()` throws, but method completes | +| `createTask_inSprintOfDifferentProject_throws` | sprint.project.id ≠ project.id → `ForbiddenException` | +| `changeSprint_generatesSPRINT_CHANGE_activity` | `activityRepository.save()` called with `SPRINT_CHANGE` type | + +### 6. `src/test/java/com/springboot/MyTodoList/project/ProjectServiceTest.java` + +Mocks: `ProjectRepository`, `ProjectMemberRepository`, `SprintRepository`, `TaskRepository`, `TaskActivityRepository`, `UserService`, `AuditLogService`, `ProjectMapper`. + +Tests (6): +| Test | Key assertion | +|------|--------------| +| `requireManager_withNonManager_throwsForbidden` | `ForbiddenException` | +| `requireManager_withManager_passes` | no exception | +| `requireParticipant_withManager_passes` | no exception (manager path, no repo call) | +| `requireParticipant_withStranger_throws` | `findAllByParticipant` returns empty → `NotProjectParticipantException` | +| `closeProject_autoclosesPlanningAndActiveSprints` | `sprintRepository.saveAll()` called with sprints set to CLOSED | +| `closeProject_returnsCorrectCounts` | response.closedSprints and pendingTasks counts match stubs | + +Note: `requireParticipant` for non-manager calls `projectRepository.findAllByParticipant(userId, Pageable.unpaged())`. Stub it to return `Page.empty()` for stranger path. + +### 7. `src/test/java/com/springboot/MyTodoList/auth/AuthServiceTest.java` + +Mocks: `UserRepository`, `PasswordEncoder`, `JwtUtil`, `UserMapper`. + +Tests (4): +| Test | Key assertion | +|------|--------------| +| `register_withDuplicateEmail_throws` | `existsByEmail` returns true → `ConflictException` | +| `login_withWrongPassword_throws` | `passwordEncoder.matches` returns false → `BadCredentialsException` | +| `login_withInactiveUser_throws` | `user.isActive()` = false → `BadCredentialsException` | +| `login_success_returnsJwt` | `jwtUtil.generateToken()` called, token in response | + +### 8. `MtdrSpring/backend/src/main/frontend/package.json` — add test script + +```json +"scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test --watchAll=false", + "test:watch": "react-scripts test" +}, +``` + +`react-scripts` 5.0.1 bundles `@testing-library/react@13`, `@testing-library/jest-dom@5`, and `@testing-library/user-event@13`. No extra packages needed. + +### 9. `MtdrSpring/backend/src/main/frontend/src/setupTests.js` + +```js +import '@testing-library/jest-dom'; +``` + +CRA auto-loads this file before each test suite. + +### 10. `MtdrSpring/backend/src/main/frontend/src/__tests__/api.test.js` + +Mock `global.fetch` directly — no MSW needed for unit-level tests. + +Tests (5): +- `401 response → clears localStorage jwt+user and redirects to /login` +- `non-OK response → throws ApiError with correct status` +- `204 response → returns null` +- `ok response → returns parsed JSON` +- `JWT in localStorage → included as Authorization: Bearer header` + +```js +import { apiFetch, ApiError } from '../services/api'; + +beforeEach(() => { + localStorage.clear(); + delete window.location; + window.location = { href: '' }; +}); +``` + +### 11. `MtdrSpring/backend/src/main/frontend/src/__tests__/AuthContext.test.jsx` + +Mock `authService` module. Tests (3): +- `login() → saves token+user to localStorage and updates context state` +- `logout() → clears localStorage and nulls state` +- `initial state → loads token+user from localStorage on mount` + +### 12. `.github/workflows/ci.yml` + +```yaml +name: CI +on: + push: { branches: [master] } + pull_request: { branches: [master] } + +jobs: + backend-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: { java-version: '17', distribution: 'temurin', cache: maven } + - name: Run backend unit tests + working-directory: MtdrSpring/backend + env: + JWT_SECRET: test_secret_key_minimum_32_characters_long_hmac + run: mvn test -DskipFrontend=true --no-transfer-progress + - uses: actions/upload-artifact@v4 + if: always() + with: + name: surefire-reports + path: MtdrSpring/backend/target/surefire-reports/ + retention-days: 7 + + frontend-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + cache-dependency-path: MtdrSpring/backend/src/main/frontend/package-lock.json + - run: npm ci + working-directory: MtdrSpring/backend/src/main/frontend + - run: npm test + working-directory: MtdrSpring/backend/src/main/frontend + + docker-build: + runs-on: ubuntu-latest + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + needs: [backend-test, frontend-test] + steps: + - uses: actions/checkout@v4 + - name: Build Docker image (smoke check) + working-directory: MtdrSpring + run: docker build -f backend/Dockerfile backend/ -t chuvabot:ci-check +``` + +Key design choices: +- `JWT_SECRET` env var is set in CI (main `application.yml` reads `${JWT_SECRET}`). No H2, no Spring context loaded for any test. +- `-DskipFrontend=true` routes through the `${skipFrontend}` we add to the plugin — Node.js is never downloaded. +- `docker-build` is a smoke check only; it runs after both test jobs and only on pushes to main. + +--- + +## Branch + +All work goes on a dedicated branch: **`feature/testing-infrastructure`** + +```bash +git checkout -b feature/testing-infrastructure +``` + +Open a PR against `master` when done. The CI workflow will trigger automatically on the PR. + +--- + +## Implementation Order + +1. `pom.xml` + `TestFixtures.java` + `JwtUtilTest.java` → verify `mvn test -DskipFrontend=true` passes locally +2. `SprintServiceTest.java` → confirms the most complex lifecycle logic +3. `TaskServiceTest.java` → highest business-rule density +4. `ProjectServiceTest.java` + `AuthServiceTest.java` +5. `.github/workflows/ci.yml` → CI green from first push +6. Frontend: `package.json` test script + `setupTests.js` + `api.test.js` + `AuthContext.test.jsx` + +--- + +## What Is NOT Tested (by design) + +- MapStruct mappers — compile-time generated, never fail at runtime +- Repository JPQL queries — require Oracle, validate locally only +- Telegram bot command handlers — depend on TelegramClient API, low ROI +- Dashboard aggregate queries — Oracle-specific SQL +- React chart components (Recharts SVG) — complex render, low ROI + +--- + +## Verification + +Backend: +```bash +cd MtdrSpring/backend +JWT_SECRET=test_secret_key_minimum_32_characters_long_hmac \ + mvn test -DskipFrontend=true --no-transfer-progress +# Expect: BUILD SUCCESS, ~34 tests, 0 failures, 0 errors +``` + +Frontend: +```bash +cd MtdrSpring/backend/src/main/frontend +npm test +# Expect: all suites pass, no --watchAll prompt +``` + +CI: push any commit to main or open a PR against main. GitHub Actions shows `backend-test` and `frontend-test` jobs green. From 656cdf130f81451d3aebef675c2b37bb7255724f Mon Sep 17 00:00:00 2001 From: Arturo Gomez Date: Wed, 29 Apr 2026 15:05:00 -0600 Subject: [PATCH 20/24] =?UTF-8?q?docs(docs):=20documentaci=C3=B3n=20de=20t?= =?UTF-8?q?esting,=20eliminar=20plan=20temporal=20de=20plans/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- documentation/plans/automated_testing_plan.md | 359 ------------------ documentation/testing.md | 323 ++++++++++++++++ 2 files changed, 323 insertions(+), 359 deletions(-) delete mode 100644 documentation/plans/automated_testing_plan.md create mode 100644 documentation/testing.md diff --git a/documentation/plans/automated_testing_plan.md b/documentation/plans/automated_testing_plan.md deleted file mode 100644 index 67c71e34b..000000000 --- a/documentation/plans/automated_testing_plan.md +++ /dev/null @@ -1,359 +0,0 @@ -# Plan: Testing Infrastructure — Chuva Bot - -## Context - -The project has zero tests and no CI. The Spring Boot monolith's layered architecture (Controller → Service → Repository) makes the service layer fully testable with pure Mockito — no database, no Spring context, no slow startup. All business rules (status transitions, permission checks, sprint lifecycle) live in the services, so that's where the ROI is highest. - -Oracle XE takes 60–90s to start. CI **must never depend on Oracle**. Every test here uses `@ExtendWith(MockitoExtension.class)` — no `@SpringBootTest`, no H2, no datasource configuration needed. - -`spring-boot-starter-test` (JUnit 5, Mockito, AssertJ) is already in `pom.xml`. Nothing else needs to be added for the backend. - ---- - -## Dependency Graph - -``` -pom.xml (add skipFrontend flag) - └── enables CI backend job to run mvn test without Node.js - -TestFixtures.java - ├── SprintServiceTest.java - ├── TaskServiceTest.java - ├── ProjectServiceTest.java - └── AuthServiceTest.java (doesn't use fixtures) - -JwtUtilTest.java ← standalone, no fixtures needed - -package.json (add test script) - └── setupTests.js - ├── api.test.js - └── AuthContext.test.jsx - -ci.yml - ├── job: backend-test → mvn test -DskipFrontend=true - ├── job: frontend-test → npm ci && npm test - └── job: docker-build → only on push to main, needs both above -``` - ---- - -## Files to Create / Modify - -### 1. `MtdrSpring/backend/pom.xml` — add frontend skip flag - -Add `${skipFrontend}` to the `frontend-maven-plugin` global ``. This lets CI skip the Node.js build during backend-only test runs. - -```xml - - com.github.eirslett - frontend-maven-plugin - 1.9.1 - - ${node.version} - ${npm.version} - ${frontend-src-dir} - ${project.build.directory} - ${skipFrontend} - - ... - -``` - -### 2. `MtdrSpring/backend/src/test/java/com/springboot/MyTodoList/TestFixtures.java` - -Static factory methods for entity builders. Keeps test setup DRY across four test classes. - -```java -package com.springboot.MyTodoList; - -public class TestFixtures { - public static User user(Long id) { - return User.builder().id(id).fullName("User " + id) - .email("user" + id + "@test.com").passwordHash("hash") - .isActive(true).build(); - } - public static Project project(Long id, User manager) { - return Project.builder().id(id).projectName("Project " + id) - .status(ProjectStatus.ACTIVE).manager(manager).build(); - } - public static Sprint sprint(Long id, Project project, SprintStatus status) { - return Sprint.builder().id(id).project(project).sprintName("Sprint " + id) - .startDate(LocalDate.now()).endDate(LocalDate.now().plusDays(14)) - .status(status).build(); - } - public static Task task(Long id, Project project, Sprint sprint) { - return Task.builder().id(id).project(project).sprint(sprint) - .taskName("Task " + id).status(TaskStatus.TODO).storyPoints(3) - .createdBy(project.getManager()).build(); - } -} -``` - -### 3. `src/test/java/com/springboot/MyTodoList/config/JwtUtilTest.java` - -Instantiate `JwtUtil` directly — no Spring, no mocks needed. - -Tests (3): -- `generateAndValidate_roundtrip` — token is valid, `extractUserId` returns the userId -- `validateToken_withTamperedToken_returnsFalse` — swap last char → false -- `validateToken_withExpiredToken_returnsFalse` — `new JwtUtil(secret, -1L)` → expired immediately - -```java -@ExtendWith(MockitoExtension.class) -class JwtUtilTest { - private static final String SECRET = "test_secret_key_minimum_32_characters_long"; - private JwtUtil jwtUtil; - - @BeforeEach void setUp() { jwtUtil = new JwtUtil(SECRET, 86400000L); } - ... -} -``` - -### 4. `src/test/java/com/springboot/MyTodoList/sprint/SprintServiceTest.java` - -Mocks: `SprintRepository`, `ProjectService`, `UserService`, `AuditLogService`, `SprintMapper`. - -Tests (9): -| Test | Asserts | -|------|---------| -| `createSprint_whenEndDateBeforeStartDate_throwsConflict` | `ConflictException` from `validateDates` | -| `createSprint_success` | `sprintRepository.save()` called, mapper called | -| `activateSprint_whenNoneActive_transitionsToActive` | `sprint.status = ACTIVE`, save called | -| `activateSprint_whenAnotherAlreadyActive_throwsConflict` | `existsByProjectAndStatus` returns true → `ConflictException` | -| `activateSprint_whenNotPlanning_throwsConflict` | Sprint is ACTIVE already → `ConflictException` | -| `updateSprint_whenClosed_throwsForbidden` | `ForbiddenException` | -| `closeSprint_success` | status becomes CLOSED, save called | -| `closeSprint_whenAlreadyClosed_throwsConflict` | `ConflictException` | -| `reopenSprint_whenNotClosed_throwsConflict` | Sprint is PLANNING → `ConflictException` | - -Setup pattern: -```java -@ExtendWith(MockitoExtension.class) -class SprintServiceTest { - @Mock SprintRepository sprintRepository; - @Mock ProjectService projectService; - @Mock UserService userService; - @Mock AuditLogService auditLogService; - @Mock SprintMapper sprintMapper; - @InjectMocks SprintService sprintService; - - User manager; Project project; Sprint sprint; - - @BeforeEach void setUp() { - manager = TestFixtures.user(1L); - project = TestFixtures.project(10L, manager); - sprint = TestFixtures.sprint(100L, project, SprintStatus.PLANNING); - when(userService.findActiveUserById(1L)).thenReturn(manager); - when(projectService.findProject(10L)).thenReturn(project); - when(sprintRepository.findById(100L)).thenReturn(Optional.of(sprint)); - when(sprintMapper.toResponse(any())).thenReturn(new SprintResponse()); - } -} -``` - -### 5. `src/test/java/com/springboot/MyTodoList/task/TaskServiceTest.java` - -Mocks: `TaskRepository`, `TaskActivityRepository`, `SprintRepository`, `ProjectService`, `UserService`, `AuditLogService`, `NotificationService`, `TaskMapper`. - -Tests (11): -| Test | Key assertion | -|------|--------------| -| `changeStatus_toDONE_withoutActualHours_throws` | `ValidationException` | -| `changeStatus_toDONE_withZeroHours_throws` | `ValidationException` (zero is not > 0) | -| `changeStatus_toDONE_withValidHours_succeeds` | `task.actualHours` set, STATUS_CHANGE activity saved | -| `changeStatus_byManager_succeeds` | manager != assignee, still allowed | -| `changeStatus_byAssignee_succeeds` | non-manager assignee allowed | -| `changeStatus_byUnrelated_throws` | neither manager nor assignee → `ForbiddenException` | -| `changeStatus_inClosedSprint_throws` | sprint status CLOSED → `ClosedSprintException` | -| `changeStatus_toBlocked_notifiesManager` | `notificationService.send()` called for manager | -| `changeStatus_toBlocked_notificationFailure_doesNotThrow` | `notificationService.send()` throws, but method completes | -| `createTask_inSprintOfDifferentProject_throws` | sprint.project.id ≠ project.id → `ForbiddenException` | -| `changeSprint_generatesSPRINT_CHANGE_activity` | `activityRepository.save()` called with `SPRINT_CHANGE` type | - -### 6. `src/test/java/com/springboot/MyTodoList/project/ProjectServiceTest.java` - -Mocks: `ProjectRepository`, `ProjectMemberRepository`, `SprintRepository`, `TaskRepository`, `TaskActivityRepository`, `UserService`, `AuditLogService`, `ProjectMapper`. - -Tests (6): -| Test | Key assertion | -|------|--------------| -| `requireManager_withNonManager_throwsForbidden` | `ForbiddenException` | -| `requireManager_withManager_passes` | no exception | -| `requireParticipant_withManager_passes` | no exception (manager path, no repo call) | -| `requireParticipant_withStranger_throws` | `findAllByParticipant` returns empty → `NotProjectParticipantException` | -| `closeProject_autoclosesPlanningAndActiveSprints` | `sprintRepository.saveAll()` called with sprints set to CLOSED | -| `closeProject_returnsCorrectCounts` | response.closedSprints and pendingTasks counts match stubs | - -Note: `requireParticipant` for non-manager calls `projectRepository.findAllByParticipant(userId, Pageable.unpaged())`. Stub it to return `Page.empty()` for stranger path. - -### 7. `src/test/java/com/springboot/MyTodoList/auth/AuthServiceTest.java` - -Mocks: `UserRepository`, `PasswordEncoder`, `JwtUtil`, `UserMapper`. - -Tests (4): -| Test | Key assertion | -|------|--------------| -| `register_withDuplicateEmail_throws` | `existsByEmail` returns true → `ConflictException` | -| `login_withWrongPassword_throws` | `passwordEncoder.matches` returns false → `BadCredentialsException` | -| `login_withInactiveUser_throws` | `user.isActive()` = false → `BadCredentialsException` | -| `login_success_returnsJwt` | `jwtUtil.generateToken()` called, token in response | - -### 8. `MtdrSpring/backend/src/main/frontend/package.json` — add test script - -```json -"scripts": { - "start": "react-scripts start", - "build": "react-scripts build", - "test": "react-scripts test --watchAll=false", - "test:watch": "react-scripts test" -}, -``` - -`react-scripts` 5.0.1 bundles `@testing-library/react@13`, `@testing-library/jest-dom@5`, and `@testing-library/user-event@13`. No extra packages needed. - -### 9. `MtdrSpring/backend/src/main/frontend/src/setupTests.js` - -```js -import '@testing-library/jest-dom'; -``` - -CRA auto-loads this file before each test suite. - -### 10. `MtdrSpring/backend/src/main/frontend/src/__tests__/api.test.js` - -Mock `global.fetch` directly — no MSW needed for unit-level tests. - -Tests (5): -- `401 response → clears localStorage jwt+user and redirects to /login` -- `non-OK response → throws ApiError with correct status` -- `204 response → returns null` -- `ok response → returns parsed JSON` -- `JWT in localStorage → included as Authorization: Bearer header` - -```js -import { apiFetch, ApiError } from '../services/api'; - -beforeEach(() => { - localStorage.clear(); - delete window.location; - window.location = { href: '' }; -}); -``` - -### 11. `MtdrSpring/backend/src/main/frontend/src/__tests__/AuthContext.test.jsx` - -Mock `authService` module. Tests (3): -- `login() → saves token+user to localStorage and updates context state` -- `logout() → clears localStorage and nulls state` -- `initial state → loads token+user from localStorage on mount` - -### 12. `.github/workflows/ci.yml` - -```yaml -name: CI -on: - push: { branches: [master] } - pull_request: { branches: [master] } - -jobs: - backend-test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-java@v4 - with: { java-version: '17', distribution: 'temurin', cache: maven } - - name: Run backend unit tests - working-directory: MtdrSpring/backend - env: - JWT_SECRET: test_secret_key_minimum_32_characters_long_hmac - run: mvn test -DskipFrontend=true --no-transfer-progress - - uses: actions/upload-artifact@v4 - if: always() - with: - name: surefire-reports - path: MtdrSpring/backend/target/surefire-reports/ - retention-days: 7 - - frontend-test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: npm - cache-dependency-path: MtdrSpring/backend/src/main/frontend/package-lock.json - - run: npm ci - working-directory: MtdrSpring/backend/src/main/frontend - - run: npm test - working-directory: MtdrSpring/backend/src/main/frontend - - docker-build: - runs-on: ubuntu-latest - if: github.event_name == 'push' && github.ref == 'refs/heads/master' - needs: [backend-test, frontend-test] - steps: - - uses: actions/checkout@v4 - - name: Build Docker image (smoke check) - working-directory: MtdrSpring - run: docker build -f backend/Dockerfile backend/ -t chuvabot:ci-check -``` - -Key design choices: -- `JWT_SECRET` env var is set in CI (main `application.yml` reads `${JWT_SECRET}`). No H2, no Spring context loaded for any test. -- `-DskipFrontend=true` routes through the `${skipFrontend}` we add to the plugin — Node.js is never downloaded. -- `docker-build` is a smoke check only; it runs after both test jobs and only on pushes to main. - ---- - -## Branch - -All work goes on a dedicated branch: **`feature/testing-infrastructure`** - -```bash -git checkout -b feature/testing-infrastructure -``` - -Open a PR against `master` when done. The CI workflow will trigger automatically on the PR. - ---- - -## Implementation Order - -1. `pom.xml` + `TestFixtures.java` + `JwtUtilTest.java` → verify `mvn test -DskipFrontend=true` passes locally -2. `SprintServiceTest.java` → confirms the most complex lifecycle logic -3. `TaskServiceTest.java` → highest business-rule density -4. `ProjectServiceTest.java` + `AuthServiceTest.java` -5. `.github/workflows/ci.yml` → CI green from first push -6. Frontend: `package.json` test script + `setupTests.js` + `api.test.js` + `AuthContext.test.jsx` - ---- - -## What Is NOT Tested (by design) - -- MapStruct mappers — compile-time generated, never fail at runtime -- Repository JPQL queries — require Oracle, validate locally only -- Telegram bot command handlers — depend on TelegramClient API, low ROI -- Dashboard aggregate queries — Oracle-specific SQL -- React chart components (Recharts SVG) — complex render, low ROI - ---- - -## Verification - -Backend: -```bash -cd MtdrSpring/backend -JWT_SECRET=test_secret_key_minimum_32_characters_long_hmac \ - mvn test -DskipFrontend=true --no-transfer-progress -# Expect: BUILD SUCCESS, ~34 tests, 0 failures, 0 errors -``` - -Frontend: -```bash -cd MtdrSpring/backend/src/main/frontend -npm test -# Expect: all suites pass, no --watchAll prompt -``` - -CI: push any commit to main or open a PR against main. GitHub Actions shows `backend-test` and `frontend-test` jobs green. diff --git a/documentation/testing.md b/documentation/testing.md new file mode 100644 index 000000000..1fd05f891 --- /dev/null +++ b/documentation/testing.md @@ -0,0 +1,323 @@ +# Testing + +Documentación de la infraestructura de pruebas del proyecto: qué se prueba, cómo está organizado, y cómo ejecutarlo localmente y en CI. + +--- + +## Filosofía general + +El proyecto usa **pruebas unitarias puras con Mockito** para el backend. No hay Spring context, no hay H2, no hay Oracle en CI. Cada test instancia el servicio directamente con dependencias mockeadas. + +Esto es posible porque toda la lógica de negocio vive en la capa de Service, que usa inyección por constructor. Mockito reemplaza los repositorios y servicios dependientes con objetos que devuelven lo que el test necesita. + +Oracle XE tarda 60–90 segundos en arrancar. **CI nunca depende de Oracle.** Las queries JPQL complejas se validan localmente contra el Docker de desarrollo. + +--- + +## Estructura de archivos + +``` +src/test/java/com/springboot/MyTodoList/ +├── TestFixtures.java ← builders compartidos entre todos los tests +├── config/ +│ └── JwtUtilTest.java +├── sprint/ +│ └── SprintServiceTest.java +├── task/ +│ └── TaskServiceTest.java +├── project/ +│ └── ProjectServiceTest.java +└── auth/ + └── AuthServiceTest.java + +src/main/frontend/src/ +├── setupTests.js ← configuración global de Jest +└── __tests__/ + ├── api.test.js + └── AuthContext.test.jsx +``` + +--- + +## TestFixtures + +**Archivo:** `src/test/java/com/springboot/MyTodoList/TestFixtures.java` + +Clase utilitaria con builders estáticos. Todos los tests del service layer la usan para construir entidades sin repetir código. + +| Método | Devuelve | +|--------|----------| +| `user(id)` | `User` activo con email `userN@test.com` | +| `project(id, manager)` | `Project` ACTIVE con el manager dado | +| `sprint(id, project, status)` | `Sprint` con fechas de hoy a +14 días | +| `task(id, project, sprint)` | `Task` en TODO con 3 story points | + +--- + +## JwtUtilTest + +**Archivo:** `src/test/java/com/springboot/MyTodoList/config/JwtUtilTest.java` + +Prueba la clase `JwtUtil` directamente — sin Spring, sin mocks. Se instancia con `new JwtUtil(secret, expirationMs)`. + +### Tests + +**`generateAndValidate_roundtrip`** +Genera un token para el userId 42, luego verifica que `validateToken()` retorna `true` y que `extractUserId()` devuelve `"42"`. Confirma que el ciclo completo de creación y lectura funciona. + +**`validateToken_withTamperedToken_returnsFalse`** +Genera un token válido y le modifica los últimos caracteres. Verifica que `validateToken()` retorna `false`. Garantiza que la firma HMAC detecta alteraciones. + +**`validateToken_withExpiredToken_returnsFalse`** +Crea una instancia de `JwtUtil` con `expirationMs = -1` (expira inmediatamente). Verifica que el token generado ya es inválido. Cubre el caso de sesiones expiradas. + +--- + +## SprintServiceTest + +**Archivo:** `src/test/java/com/springboot/MyTodoList/sprint/SprintServiceTest.java` + +Mocks: `SprintRepository`, `ProjectService`, `UserService`, `AuditLogService`, `SprintMapper`. + +El `@BeforeEach` prepara un manager, proyecto y sprint en PLANNING, y los stub en los mocks correspondientes. + +### Tests + +**`createSprint_whenEndDateBeforeStartDate_throwsConflict`** +Intenta crear un sprint donde `endDate` es anterior a `startDate`. Verifica que se lanza `ConflictException` con mensaje `"end_date must be after start_date"`. Cubre la validación de fechas del método `validateDates()`. + +**`createSprint_success_savesCalled`** +Crea un sprint con fechas válidas. Verifica que `sprintRepository.save()` fue llamado. Confirma que el flujo feliz persiste el sprint. + +**`activateSprint_whenNoneActive_transitionsToActive`** +Activa un sprint que está en PLANNING, con `existsByProjectAndStatus(ACTIVE) = false`. Verifica que el sprint cambia su status a ACTIVE y que se llama `save()`. Cubre la transición PLANNING → ACTIVE. + +**`activateSprint_whenAnotherAlreadyActive_throwsConflict`** +Intenta activar un sprint cuando ya existe uno activo en el proyecto. Verifica que se lanza `ConflictException` con `"SPRINT_ALREADY_ACTIVE"`. Cubre la regla: solo un sprint activo por proyecto a la vez. + +**`activateSprint_whenNotPlanning_throwsConflict`** +Intenta activar un sprint que ya está en ACTIVE. Verifica que se lanza `ConflictException` con `"Only a PLANNING sprint can be activated"`. Cubre que no se puede activar un sprint que no está en estado PLANNING. + +**`updateSprint_whenClosed_throwsForbidden`** +Intenta editar un sprint que está en CLOSED. Verifica que se lanza `ForbiddenException` con `"Cannot edit a closed sprint"`. Cubre la regla: los sprints cerrados son de solo lectura. + +**`closeSprint_success_setsStatusToClosed`** +Cierra un sprint en ACTIVE. Verifica que el status cambia a CLOSED y que se llama `save()`. Cubre la transición → CLOSED. + +**`closeSprint_whenAlreadyClosed_throwsConflict`** +Intenta cerrar un sprint que ya está CLOSED. Verifica que se lanza `ConflictException`. Cubre el guard contra doble cierre. + +**`reopenSprint_whenNotClosed_throwsConflict`** +Intenta reabrir un sprint que está en PLANNING. Verifica que se lanza `ConflictException` con `"Only a CLOSED sprint can be reopened"`. Cubre que solo los sprints CLOSED se pueden reabrir. + +--- + +## TaskServiceTest + +**Archivo:** `src/test/java/com/springboot/MyTodoList/task/TaskServiceTest.java` + +Mocks: `TaskRepository`, `TaskActivityRepository`, `SprintRepository`, `ProjectService`, `UserService`, `AuditLogService`, `NotificationService`, `TaskMapper`. + +El `@BeforeEach` prepara un manager (id=1), un member (id=2), un proyecto con el manager, un sprint ACTIVE, y una tarea asignada al member. + +### Tests — validación de DONE + +**`changeStatus_toDONE_withoutActualHours_throws`** +Intenta marcar una tarea como DONE sin proporcionar `actualHours`. Verifica que se lanza `ValidationException` con mensaje sobre `"actual_hours"`. Cubre la regla: las horas reales son obligatorias al completar una tarea. + +**`changeStatus_toDONE_withZeroHours_throws`** +Intenta marcar DONE con `actualHours = 0`. Verifica que se lanza `ValidationException`. Cubre que cero horas no es un valor válido (debe ser mayor a cero). + +**`changeStatus_toDONE_withValidHours_setsActualHours`** +Marca DONE con `actualHours = 3.5`. Verifica que `task.getActualHours()` es 3.5 y que el status cambió a DONE. Cubre el flujo feliz del cierre de tarea. + +### Tests — permisos de cambio de status + +**`changeStatus_byManager_succeeds`** +El manager del proyecto (que NO es el asignado) cambia el status a IN_PROGRESS. Verifica que la operación completa sin excepción. Cubre la regla: el manager siempre puede cambiar el status, aunque no esté asignado. + +**`changeStatus_byAssignee_succeeds`** +El asignado (que NO es manager) cambia el status a IN_PROGRESS. Verifica que la operación completa sin excepción. Cubre la regla: el usuario asignado puede cambiar el status de su propia tarea. + +**`changeStatus_byUnrelatedUser_throws`** +Un usuario que no es manager ni asignado intenta cambiar el status. Verifica que se lanza `ForbiddenException`. Cubre que ningún otro usuario del sistema puede cambiar status de tareas ajenas. + +### Tests — sprint cerrado + +**`changeStatus_inClosedSprint_throws`** +Pone el sprint de la tarea en CLOSED e intenta cambiar el status. Verifica que se lanza `ClosedSprintException`. Cubre la regla: las tareas en sprints cerrados son de solo lectura. + +### Tests — notificaciones + +**`changeStatus_toBlocked_notifiesManager`** +Cambia el status de una tarea a BLOCKED. Verifica que `notificationService.send()` fue llamado con el manager como destinatario y el event type `"TASK_BLOCKED"`. Cubre el disparo de notificación cuando una tarea se bloquea. + +**`changeStatus_toBlocked_notificationFailure_doesNotThrow`** +Configura `notificationService.send()` para que lance una excepción. Cambia el status a BLOCKED. Verifica que la operación de negocio completa sin propagar el error. Cubre la entrega best-effort: una notificación caída no debe rollbackear el cambio de status. + +### Tests — sprint y actividad + +**`changeSprint_generatesSPRINT_CHANGE_activity`** +Mueve una tarea a otro sprint. Captura el argumento pasado a `activityRepository.save()` y verifica que el tipo de actividad es `SPRINT_CHANGE`. Cubre que los movimientos entre sprints quedan registrados en el historial de actividad. + +**`createTask_inSprintOfDifferentProject_throws`** +Intenta crear una tarea asignándola a un sprint que pertenece a otro proyecto. Verifica que se lanza `ForbiddenException` con `"Sprint does not belong to this project"`. Cubre la validación de integridad entre tarea y sprint. + +--- + +## ProjectServiceTest + +**Archivo:** `src/test/java/com/springboot/MyTodoList/project/ProjectServiceTest.java` + +Mocks: `ProjectRepository`, `ProjectMemberRepository`, `SprintRepository`, `TaskRepository`, `TaskActivityRepository`, `UserService`, `AuditLogService`, `ProjectMapper`. + +### Tests + +**`requireManager_withManager_passes`** +Llama a `requireManager()` con el id del manager del proyecto. Verifica que no se lanza ninguna excepción. Confirma el camino feliz del check de permisos. + +**`requireManager_withNonManager_throwsForbidden`** +Llama a `requireManager()` con un usuario extraño. Verifica que se lanza `ForbiddenException`. Cubre que solo el manager puede realizar acciones de manager. + +**`requireParticipant_withManager_passes`** +El manager del proyecto llama a `requireParticipant()`. Verifica que no se lanza excepción (el manager no necesita estar en PROJECT_MEMBER). Cubre el camino rápido del check de participante. + +**`requireParticipant_withStranger_throws`** +Un usuario que no es manager y no aparece en PROJECT_MEMBER llama a `requireParticipant()`. Verifica que se lanza `NotProjectParticipantException`. Cubre que los usuarios ajenos al proyecto no pueden ver su contenido. + +**`closeProject_autoclosesPlanningAndActiveSprints`** +Cierra un proyecto que tiene un sprint PLANNING y uno ACTIVE. Verifica que ambos sprints terminan en estado CLOSED y que se llama `sprintRepository.saveAll()`. Cubre el cierre en cascada de sprints al cerrar un proyecto. + +**`closeProject_returnsCorrectPendingTaskCount`** +Cierra un proyecto con dos tareas: una DONE y una IN_PROGRESS. Verifica que el `CloseProjectResponse` reporta 1 tarea pendiente. Cubre el conteo correcto de trabajo incompleto al cerrar. + +--- + +## AuthServiceTest + +**Archivo:** `src/test/java/com/springboot/MyTodoList/auth/AuthServiceTest.java` + +Mocks: `UserRepository`, `PasswordEncoder`, `JwtUtil`, `UserMapper`. + +### Tests + +**`register_withDuplicateEmail_throws`** +Intenta registrar un usuario con un email que ya existe en la base de datos. Verifica que se lanza `ConflictException` y que `userRepository.save()` **nunca** se llama. Cubre que no se crean duplicados. + +**`login_withWrongPassword_throws`** +El usuario existe pero `passwordEncoder.matches()` retorna `false`. Verifica que se lanza `BadCredentialsException`. Cubre la autenticación con contraseña incorrecta. + +**`login_withInactiveUser_throws`** +El usuario existe pero tiene `isActive = false`. Verifica que se lanza `BadCredentialsException` con `"inactive"`. Cubre el soft-delete: los usuarios desactivados no pueden iniciar sesión. + +**`login_success_returnsToken`** +Credenciales correctas, usuario activo. Verifica que `jwtUtil.generateToken()` fue llamado con el id del usuario y que el token está en el `AuthResponse`. Cubre el flujo feliz del login. + +--- + +## Frontend — api.test.js + +**Archivo:** `src/main/frontend/src/__tests__/api.test.js` + +Mockea `global.fetch` directamente. Cada test configura la respuesta que simulará el servidor. + +### Tests + +**`401 response clears localStorage and redirects to /login`** +Guarda un token en localStorage, luego hace una petición que devuelve 401. Verifica que el token y el user se eliminaron de localStorage y que `window.location.href` es `'/login'`. Cubre el flujo de sesión expirada. + +**`non-OK response throws ApiError with correct status`** +Devuelve una respuesta 404 con `{ message: 'Not found' }`. Verifica que `apiFetch` lanza un `ApiError` con `status = 404` y el mensaje correcto. Cubre el manejo genérico de errores HTTP. + +**`204 response returns null`** +Devuelve una respuesta 204 (sin cuerpo). Verifica que `apiFetch` retorna `null`. Cubre el DELETE y otros endpoints que no retornan cuerpo. + +**`ok response returns parsed JSON`** +Devuelve una respuesta 200 con un objeto JSON. Verifica que `apiFetch` retorna el objeto parseado. Cubre el camino feliz de toda petición GET/PUT/POST. + +**`JWT in localStorage is included as Authorization header`** +Guarda un token en localStorage antes de la petición. Verifica que el header `Authorization: Bearer ` fue incluido en el fetch. Cubre la autenticación automática de todas las peticiones. + +--- + +## Frontend — AuthContext.test.jsx + +**Archivo:** `src/main/frontend/src/__tests__/AuthContext.test.jsx` + +Mockea el módulo `authService`. Renderiza un componente consumidor que expone `token`, `user`, y botones para `login` y `logout`. + +### Tests + +**`login saves token and user to localStorage and updates state`** +Simula un click en "Login" con `authService.login` mockeado para retornar `{ token, user }`. Verifica que el token aparece en el DOM, en localStorage, y que el email del usuario es correcto. Cubre el flujo completo de inicio de sesión. + +**`logout clears localStorage and resets state`** +Precarga localStorage con un token y usuario. Simula un click en "Logout". Verifica que el estado del contexto queda vacío y localStorage está limpio. Cubre el flujo de cierre de sesión. + +**`initial state is loaded from localStorage on mount`** +Precarga localStorage con datos de sesión. Monta el componente. Verifica que el token y el email están presentes en el DOM desde el primer render. Cubre la persistencia de sesión al recargar la página. + +--- + +## GitHub Actions CI + +**Archivo:** `.github/workflows/ci.yml` + +El workflow corre en cada push y PR a `main`, `master`, y `alternative`. + +### Jobs + +**`backend-test`** +- Descarga Java 17 (Temurin) con caché de Maven automático. +- Corre `mvn test -DskipFrontend=true` — no descarga Node, no toca Oracle. +- Pasa `JWT_SECRET` como variable de entorno (requerida por `application.yml`). +- Sube los reportes Surefire como artifact, siempre (incluso si falla). + +**`frontend-test`** +- Descarga Node 20 con caché de `node_modules`. +- Corre `npm ci` y luego `npm test` (`--watchAll=false` está en el script). +- Corre en paralelo con `backend-test`. + +**`docker-build`** +- Solo corre en push a `main`/`master` (no en PRs). +- Solo corre si `backend-test` y `frontend-test` pasan. +- Hace `docker build` completo para verificar que la imagen compila. No hace push al registry. + +### Cómo funciona `skipFrontend` + +El `pom.xml` tiene una propiedad `false` y el `frontend-maven-plugin` tiene `${skipFrontend}`. Al pasar `-DskipFrontend=true` en CI, Maven omite la descarga de Node y el build de React. El job de frontend los maneja por separado. + +--- + +## Cómo ejecutar localmente + +### Backend + +```bash +cd MtdrSpring/backend +JWT_SECRET=test_secret_key_minimum_32_characters_long_hmac \ + mvn test -DskipFrontend=true --no-transfer-progress +``` + +Resultado esperado: `BUILD SUCCESS`, ~33 tests, 0 failures. + +### Frontend + +```bash +cd MtdrSpring/backend/src/main/frontend +npm test +``` + +Resultado esperado: 3 suites, 8 tests, todos en verde. + +--- + +## Qué NO se prueba (por diseño) + +| Qué | Por qué | +|-----|---------| +| MapStruct mappers | Se generan en compile-time. Si no compilan, el build falla antes de los tests. | +| Queries JPQL de repositorios | Requieren Oracle real. Se validan localmente con Docker. | +| Comandos del bot de Telegram | Dependen de `TelegramClient`. Bajo ROI en tests unitarios. | +| Queries de Dashboard | SQL específico de Oracle con agregaciones. Validar solo local. | +| Componentes de charts (Recharts) | Renderizan SVG complejo. Bajo ROI y alto costo de mantenimiento. | From 22ae4f3633a23f3152c868247028239c7bb6dd92 Mon Sep 17 00:00:00 2001 From: Arturo Gomez Date: Wed, 29 Apr 2026 15:35:40 -0600 Subject: [PATCH 21/24] fix(api): agregar LENIENT strictness en TaskServiceTest para stubs del BeforeEach --- .../java/com/springboot/MyTodoList/task/TaskServiceTest.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/MtdrSpring/backend/src/test/java/com/springboot/MyTodoList/task/TaskServiceTest.java b/MtdrSpring/backend/src/test/java/com/springboot/MyTodoList/task/TaskServiceTest.java index 17021af63..b4ce25e21 100644 --- a/MtdrSpring/backend/src/test/java/com/springboot/MyTodoList/task/TaskServiceTest.java +++ b/MtdrSpring/backend/src/test/java/com/springboot/MyTodoList/task/TaskServiceTest.java @@ -24,6 +24,8 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; import java.math.BigDecimal; import java.util.Optional; @@ -39,6 +41,7 @@ import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) class TaskServiceTest { @Mock TaskRepository taskRepository; From e26e6a5eab73ba5b2d1bb127a4095e601c79bed6 Mon Sep 17 00:00:00 2001 From: Arturo Gomez Date: Wed, 29 Apr 2026 15:35:44 -0600 Subject: [PATCH 22/24] fix(frontend): agregar testing-library como deps directas y usar npm install en CI --- .github/workflows/ci.yml | 4 +--- MtdrSpring/backend/src/main/frontend/package.json | 5 ++++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d26946e6c..3b8ca5e97 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,12 +45,10 @@ jobs: - uses: actions/setup-node@v4 with: node-version: '20' - cache: npm - cache-dependency-path: MtdrSpring/backend/src/main/frontend/package-lock.json - name: Install dependencies working-directory: MtdrSpring/backend/src/main/frontend - run: npm ci + run: npm install - name: Run frontend tests working-directory: MtdrSpring/backend/src/main/frontend diff --git a/MtdrSpring/backend/src/main/frontend/package.json b/MtdrSpring/backend/src/main/frontend/package.json index 521f3283c..ddc3521e2 100644 --- a/MtdrSpring/backend/src/main/frontend/package.json +++ b/MtdrSpring/backend/src/main/frontend/package.json @@ -12,7 +12,10 @@ "devDependencies": { "tailwindcss": "^3.4.1", "postcss": "^8.4.35", - "autoprefixer": "^10.4.17" + "autoprefixer": "^10.4.17", + "@testing-library/jest-dom": "^5.17.0", + "@testing-library/react": "^13.4.0", + "@testing-library/user-event": "^13.5.0" }, "scripts": { "start": "react-scripts start", From 4b69d95831f2db050a9d2a088546c26c3254888b Mon Sep 17 00:00:00 2001 From: Arturo Gomez Date: Wed, 29 Apr 2026 15:43:20 -0600 Subject: [PATCH 23/24] fix(api): agregar LENIENT strictness en ProjectServiceTest y SprintServiceTest --- .../com/springboot/MyTodoList/project/ProjectServiceTest.java | 3 +++ .../com/springboot/MyTodoList/sprint/SprintServiceTest.java | 3 +++ 2 files changed, 6 insertions(+) diff --git a/MtdrSpring/backend/src/test/java/com/springboot/MyTodoList/project/ProjectServiceTest.java b/MtdrSpring/backend/src/test/java/com/springboot/MyTodoList/project/ProjectServiceTest.java index e04e55004..94cfdf6e6 100644 --- a/MtdrSpring/backend/src/test/java/com/springboot/MyTodoList/project/ProjectServiceTest.java +++ b/MtdrSpring/backend/src/test/java/com/springboot/MyTodoList/project/ProjectServiceTest.java @@ -20,6 +20,8 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -35,6 +37,7 @@ import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) class ProjectServiceTest { @Mock ProjectRepository projectRepository; diff --git a/MtdrSpring/backend/src/test/java/com/springboot/MyTodoList/sprint/SprintServiceTest.java b/MtdrSpring/backend/src/test/java/com/springboot/MyTodoList/sprint/SprintServiceTest.java index 165a5a447..46a3cf6fc 100644 --- a/MtdrSpring/backend/src/test/java/com/springboot/MyTodoList/sprint/SprintServiceTest.java +++ b/MtdrSpring/backend/src/test/java/com/springboot/MyTodoList/sprint/SprintServiceTest.java @@ -15,6 +15,8 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; import java.time.LocalDate; import java.util.Optional; @@ -26,6 +28,7 @@ import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) class SprintServiceTest { @Mock SprintRepository sprintRepository; From edb5f4ac9d3e249f2ed33e7306fedc281dd74295 Mon Sep 17 00:00:00 2001 From: Arturo Gomez Date: Wed, 29 Apr 2026 15:46:53 -0600 Subject: [PATCH 24/24] fix(api): retornar ArrayList mutable en stubs de closeProject para evitar UnsupportedOperationException --- .../MyTodoList/project/ProjectServiceTest.java | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/MtdrSpring/backend/src/test/java/com/springboot/MyTodoList/project/ProjectServiceTest.java b/MtdrSpring/backend/src/test/java/com/springboot/MyTodoList/project/ProjectServiceTest.java index 94cfdf6e6..0cce53b83 100644 --- a/MtdrSpring/backend/src/test/java/com/springboot/MyTodoList/project/ProjectServiceTest.java +++ b/MtdrSpring/backend/src/test/java/com/springboot/MyTodoList/project/ProjectServiceTest.java @@ -25,6 +25,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import java.util.ArrayList; import java.util.List; import java.util.Optional; @@ -105,9 +106,9 @@ void closeProject_autoclosesPlanningAndActiveSprints() { Sprint planning = TestFixtures.sprint(1L, project, SprintStatus.PLANNING); Sprint active = TestFixtures.sprint(2L, project, SprintStatus.ACTIVE); - given(sprintRepository.findByProjectAndStatus(project, SprintStatus.PLANNING)).willReturn(List.of(planning)); - given(sprintRepository.findByProjectAndStatus(project, SprintStatus.ACTIVE)).willReturn(List.of(active)); - given(taskRepository.findByProject(project)).willReturn(List.of()); + given(sprintRepository.findByProjectAndStatus(project, SprintStatus.PLANNING)).willReturn(new ArrayList<>(List.of(planning))); + given(sprintRepository.findByProjectAndStatus(project, SprintStatus.ACTIVE)).willReturn(new ArrayList<>(List.of(active))); + given(taskRepository.findByProject(project)).willReturn(new ArrayList<>()); projectService.closeProject(1L, 10L); @@ -119,14 +120,14 @@ void closeProject_autoclosesPlanningAndActiveSprints() { @Test void closeProject_returnsCorrectPendingTaskCount() { given(userService.findActiveUserById(1L)).willReturn(manager); - given(sprintRepository.findByProjectAndStatus(any(), any())).willReturn(List.of()); + given(sprintRepository.findByProjectAndStatus(any(), any())).willReturn(new ArrayList<>()); Task doneTask = TestFixtures.task(1L, project, null); doneTask.setStatus(TaskStatus.DONE); Task pendingTask = TestFixtures.task(2L, project, null); pendingTask.setStatus(TaskStatus.IN_PROGRESS); - given(taskRepository.findByProject(project)).willReturn(List.of(doneTask, pendingTask)); + given(taskRepository.findByProject(project)).willReturn(new ArrayList<>(List.of(doneTask, pendingTask))); CloseProjectResponse response = projectService.closeProject(1L, 10L);