Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
6f49669
fix(bot): agregar parámetro hours en /help y actualizar estado de han…
ArturoGomezGz Apr 21, 2026
3a753c9
feat(bot): capa NLU con clasificación de lenguaje natural via Anthrop…
ArturoGomezGz Apr 21, 2026
1bec14a
chore(config): agregar variable ANTHROPIC_API_KEY en application.yml …
ArturoGomezGz Apr 21, 2026
1394fa5
docs(docs): documentación de la capa NLU del Telegram Bot
ArturoGomezGz Apr 21, 2026
61a07ba
chore(config): agregar ANTHROPIC_API_KEY en .env.example
ArturoGomezGz Apr 21, 2026
c855c17
fix(bot): strip markdown code fences en respuesta NLU para evitar err…
ArturoGomezGz Apr 21, 2026
3776ab2
chore(chore): nueva skill /deploy para despliegue Docker de la app
ArturoGomezGz Apr 21, 2026
260156e
fix(bot): reemplazar reflexión por Jackson en SyntheticUpdateFactory,…
ArturoGomezGz Apr 21, 2026
e3d1321
feat(api): eliminar proyecto con todo su contenido en cascada
ArturoGomezGz Apr 23, 2026
71c649d
feat(frontend): botón de eliminar proyecto con confirmación para el m…
ArturoGomezGz Apr 23, 2026
36f90b5
feat(frontend): botón de editar proyecto con modal para el manager
ArturoGomezGz Apr 23, 2026
8f34759
fix(frontend): eliminar selector de status del form de edición, el ba…
ArturoGomezGz Apr 23, 2026
13ad77f
feat(api): endpoint para reabrir un sprint cerrado (CLOSED → PLANNING)
ArturoGomezGz Apr 24, 2026
5bfc16c
feat(frontend): botón reopen en sprints cerrados y botón close en spr…
ArturoGomezGz Apr 24, 2026
2eec1ac
chore(chore): agregar flag skipFrontend en Maven y script test en npm
ArturoGomezGz Apr 29, 2026
1154bcd
test(api): infraestructura de tests unitarios con Mockito para servic…
ArturoGomezGz Apr 29, 2026
aefe8fe
test(frontend): tests de apiFetch y AuthContext con Jest
ArturoGomezGz Apr 29, 2026
a1dfc0b
chore(chore): workflow de GitHub Actions CI con jobs backend, fronten…
ArturoGomezGz Apr 29, 2026
34a6e36
docs(docs): plan de infraestructura de testing automatizado
ArturoGomezGz Apr 29, 2026
656cdf1
docs(docs): documentación de testing, eliminar plan temporal de plans/
ArturoGomezGz Apr 29, 2026
22ae4f3
fix(api): agregar LENIENT strictness en TaskServiceTest para stubs de…
ArturoGomezGz Apr 29, 2026
e26e6a5
fix(frontend): agregar testing-library como deps directas y usar npm …
ArturoGomezGz Apr 29, 2026
0a98938
Merge branch 'alternative' into feat-automated-testing
ArturoGomezGz Apr 29, 2026
4b69d95
fix(api): agregar LENIENT strictness en ProjectServiceTest y SprintSe…
ArturoGomezGz Apr 29, 2026
edb5f4a
fix(api): retornar ArrayList mutable en stubs de closeProject para ev…
ArturoGomezGz Apr 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .claude/skills/deploy/SKILL.md
Original file line number Diff line number Diff line change
@@ -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/`.
68 changes: 68 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions MtdrSpring/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
2 changes: 2 additions & 0 deletions MtdrSpring/backend/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
<frontend-src-dir>${project.basedir}/src/main/frontend</frontend-src-dir>
<node.version>v23.9.0</node.version>
<npm.version>8.1.2</npm.version>
<skipFrontend>false</skipFrontend>
</properties>

<dependencies>
Expand Down Expand Up @@ -133,6 +134,7 @@
<npmVersion>${npm.version}</npmVersion>
<workingDirectory>${frontend-src-dir}</workingDirectory>
<installDirectory>${project.build.directory}</installDirectory>
<skip>${skipFrontend}</skip>
</configuration>
<executions>
<execution>
Expand Down
9 changes: 7 additions & 2 deletions MtdrSpring/backend/src/main/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<span data-testid="user">{user ? user.email : 'none'}</span>
<span data-testid="token">{token || 'none'}</span>
<button onClick={() => login('u@test.com', 'pass')}>Login</button>
<button onClick={logout}>Logout</button>
</div>
);
}

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(<AuthProvider><TestConsumer /></AuthProvider>);
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(<AuthProvider><TestConsumer /></AuthProvider>);
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(<AuthProvider><TestConsumer /></AuthProvider>);

expect(screen.getByTestId('token').textContent).toBe('persisted-token');
expect(screen.getByTestId('user').textContent).toBe('persisted@test.com');
});
71 changes: 71 additions & 0 deletions MtdrSpring/backend/src/main/frontend/src/__tests__/api.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -13,7 +13,31 @@ export default function ProjectCard({ project, currentUserId }) {
>
<div className="flex items-start justify-between gap-2">
<h3 className="font-semibold text-gray-900 text-sm">{project.projectName}</h3>
<Badge value={project.status} />
<div className="flex items-center gap-2">
<Badge value={project.status} />
{isManager && onEdit && (
<button
onClick={(e) => { e.stopPropagation(); onEdit(project); }}
className="text-gray-300 hover:text-blue-500 transition-colors"
title="Editar proyecto"
>
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1 1 0 000-1.41l-2.34-2.34a1 1 0 00-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/>
</svg>
</button>
)}
{isManager && onDelete && (
<button
onClick={(e) => { e.stopPropagation(); onDelete(project); }}
className="text-gray-300 hover:text-red-500 transition-colors"
title="Eliminar proyecto"
>
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M9 3h6l1 1h4v2H4V4h4L9 3zm-4 5h14l-1 13H6L5 8zm5 2v9h1v-9h-1zm4 0v9h1v-9h-1z"/>
</svg>
</button>
)}
</div>
</div>
{project.description && (
<p className="text-xs text-gray-500 line-clamp-2">{project.description}</p>
Expand All @@ -22,7 +46,7 @@ export default function ProjectCard({ project, currentUserId }) {
<span className={`flex items-center gap-1 font-medium ${isManager ? 'text-amber-600' : 'text-gray-400'}`}>
{isManager && (
<svg xmlns="http://www.w3.org/2000/svg" className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="currentColor">
<path d="M2 19h20v2H2v-2zM2 6l5 7 5-7 5 7 5-7v11H2V6z"/>
<path d="M2 19h20v2H2v-2zM2 6l5 7 5-7 5-7 5 7v11H2V6z"/>
</svg>
)}
{isManager ? 'Manager' : 'Member'}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));
Expand All @@ -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"
/>
</div>
{initial.id && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
<select value={form.status} onChange={set('status')} 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">
{['ACTIVE', 'PAUSED', 'CLOSED'].map((s) => <option key={s}>{s}</option>)}
</select>
</div>
)}

<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="secondary" onClick={onCancel}>Cancel</Button>
<Button type="submit" disabled={loading || !isValid}>{loading ? 'Saving...' : 'Save'}</Button>
Expand Down
Loading
Loading