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/`. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..3b8ca5e97 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,68 @@ +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' + + - name: Install dependencies + working-directory: MtdrSpring/backend/src/main/frontend + run: npm install + + - 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 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= 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..ddc3521e2 100644 --- a/MtdrSpring/backend/src/main/frontend/package.json +++ b/MtdrSpring/backend/src/main/frontend/package.json @@ -12,11 +12,16 @@ "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", - "build": "react-scripts build" + "build": "react-scripts build", + "test": "react-scripts test --watchAll=false", + "test:watch": "react-scripts test" }, "eslintConfig": { "extends": "react-app" 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/components/projects/ProjectCard.jsx b/MtdrSpring/backend/src/main/frontend/src/components/projects/ProjectCard.jsx index 2c9f773f1..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 }) { +export default function ProjectCard({ project, currentUserId, onDelete, onEdit }) { const navigate = useNavigate(); const isManager = project.manager?.id === currentUserId; @@ -13,7 +13,31 @@ export default function ProjectCard({ project, currentUserId }) { >

{project.projectName}

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

{project.description}

@@ -22,7 +46,7 @@ export default function ProjectCard({ project, currentUserId }) { {isManager && ( - + )} {isManager ? 'Manager' : 'Member'} diff --git a/MtdrSpring/backend/src/main/frontend/src/components/projects/ProjectForm.jsx b/MtdrSpring/backend/src/main/frontend/src/components/projects/ProjectForm.jsx index 42e7417b6..7bf52bb1e 100644 --- a/MtdrSpring/backend/src/main/frontend/src/components/projects/ProjectForm.jsx +++ b/MtdrSpring/backend/src/main/frontend/src/components/projects/ProjectForm.jsx @@ -5,7 +5,6 @@ export default function ProjectForm({ initial = {}, onSubmit, onCancel, loading const [form, setForm] = useState({ projectName: initial.projectName || '', description: initial.description || '', - status: initial.status || 'ACTIVE', }); const set = (field) => (e) => setForm((f) => ({ ...f, [field]: e.target.value })); @@ -28,14 +27,7 @@ export default function ProjectForm({ initial = {}, onSubmit, onCancel, loading className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-gray-300 resize-none" /> - {initial.id && ( -
- - -
- )} +
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/MyProjectsPage.jsx b/MtdrSpring/backend/src/main/frontend/src/pages/MyProjectsPage.jsx index 8f268a19a..f139b86b5 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,10 @@ 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 [editTarget, setEditTarget] = useState(null); + const [editSaving, setEditSaving] = useState(false); const load = () => { setLoading(true); @@ -40,6 +45,36 @@ export default function MyProjectsPage() { } }; + const handleEditProject = async (form) => { + if (!editTarget) return; + setEditSaving(true); + try { + const updated = await projectService.update(editTarget.id, form); + setProjects((prev) => prev.map((p) => (p.id === editTarget.id ? updated : p))); + setEditTarget(null); + toast.success('Proyecto actualizado'); + } catch (err) { + toast.error(err.message); + } finally { + setEditSaving(false); + } + }; + + 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 +89,37 @@ export default function MyProjectsPage() { )}
{projects.map((p) => ( - + ))}
setShowCreate(false)} title="New project"> setShowCreate(false)} loading={saving} /> + + setEditTarget(null)} title="Editar proyecto"> + setEditTarget(null)} + loading={editSaving} + /> + + + setDeleteTarget(null)} + loading={deleting} + />
); } 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' }), }; 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'; 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/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/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/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); } 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); } 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..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 @@ -1,6 +1,15 @@ package com.springboot.MyTodoList.telegram; 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; @@ -28,6 +37,8 @@ public class ChuvaBot implements SpringLongPollingBot, LongPollingSingleThreadUp private final TaskHandler taskHandler; private final TaskStatusHandler taskStatusHandler; private final CommentHandler commentHandler; + private final NaturalLanguageRouter nluRouter; + private final ObjectMapper objectMapper; public ChuvaBot( @Value("${telegram.bot.token}") String botToken, @@ -39,7 +50,9 @@ public ChuvaBot( MyTasksHandler myTasksHandler, TaskHandler taskHandler, TaskStatusHandler taskStatusHandler, - CommentHandler commentHandler) { + CommentHandler commentHandler, + NaturalLanguageRouter nluRouter, + ObjectMapper objectMapper) { this.botToken = botToken; this.telegramClient = telegramClient; this.startHandler = startHandler; @@ -50,6 +63,8 @@ public ChuvaBot( this.taskHandler = taskHandler; this.taskStatusHandler = taskStatusHandler; this.commentHandler = commentHandler; + this.nluRouter = nluRouter; + this.objectMapper = objectMapper; } @Override @@ -67,10 +82,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 +108,64 @@ public void consume(Update update) { } } + 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, objectMapper); + 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 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); + } + 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..b62b129ed --- /dev/null +++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/telegram/SyntheticUpdateFactory.java @@ -0,0 +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; + +@Slf4j +public class SyntheticUpdateFactory { + + private SyntheticUpdateFactory() {} + + public static Update withText(Update original, String syntheticText, ObjectMapper objectMapper) { + try { + 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; + } + } +} 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/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..40c099ac0 --- /dev/null +++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/telegram/nlu/NaturalLanguageRouter.java @@ -0,0 +1,136 @@ +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-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" + + "- 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) throws Exception { + 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(); + } + + 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(stripMarkdown(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 +} 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/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..0cce53b83 --- /dev/null +++ b/MtdrSpring/backend/src/test/java/com/springboot/MyTodoList/project/ProjectServiceTest.java @@ -0,0 +1,136 @@ +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.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.ArrayList; +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) +@MockitoSettings(strictness = Strictness.LENIENT) +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(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); + + 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(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(new ArrayList<>(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..46a3cf6fc --- /dev/null +++ b/MtdrSpring/backend/src/test/java/com/springboot/MyTodoList/sprint/SprintServiceTest.java @@ -0,0 +1,149 @@ +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 org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +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) +@MockitoSettings(strictness = Strictness.LENIENT) +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..b4ce25e21 --- /dev/null +++ b/MtdrSpring/backend/src/test/java/com/springboot/MyTodoList/task/TaskServiceTest.java @@ -0,0 +1,238 @@ +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 org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +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) +@MockitoSettings(strictness = Strictness.LENIENT) +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; + } +} 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 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 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 | --- 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. |