-
Notifications
You must be signed in to change notification settings - Fork 12
Pruebas Unitarias #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,360 @@ | ||
| // Mocks del cliente Prisma: todas las instancias de PrismaClient (una por modelo) | ||
| // deben resolver al mismo objeto simulado para poder aserir sobre las llamadas. | ||
| const mockPrisma = { | ||
| candidate: { | ||
| create: jest.fn(), | ||
| update: jest.fn(), | ||
| }, | ||
| education: { | ||
| create: jest.fn(), | ||
| update: jest.fn(), | ||
| }, | ||
| workExperience: { | ||
| create: jest.fn(), | ||
| update: jest.fn(), | ||
| }, | ||
| resume: { | ||
| create: jest.fn(), | ||
| }, | ||
| }; | ||
|
|
||
| class MockPrismaClientInitializationError extends Error {} | ||
|
|
||
| jest.mock('@prisma/client', () => { | ||
| return { | ||
| PrismaClient: jest.fn(() => mockPrisma), | ||
| Prisma: { | ||
| PrismaClientInitializationError: MockPrismaClientInitializationError, | ||
| }, | ||
| }; | ||
| }); | ||
|
|
||
| // Mock de multer: controlamos manualmente el middleware devuelto por upload.single() | ||
| // para simular éxito, rechazo de tipo de archivo y errores de Multer. | ||
| const mockSingle = jest.fn(); | ||
|
|
||
| jest.mock('multer', () => { | ||
| class MulterError extends Error { | ||
| code: string; | ||
| constructor(code: string) { | ||
| super(code); | ||
| this.code = code; | ||
| } | ||
| } | ||
|
|
||
| const multerMock: any = jest.fn(() => ({ | ||
| single: mockSingle, | ||
| })); | ||
| multerMock.diskStorage = jest.fn(() => ({})); | ||
| multerMock.MulterError = MulterError; | ||
|
|
||
| return multerMock; | ||
| }); | ||
|
|
||
| import { addCandidate } from '../application/services/candidateService'; | ||
| import { uploadFile } from '../application/services/fileUploadService'; | ||
| // eslint-disable-next-line @typescript-eslint/no-var-requires | ||
| const multer = require('multer'); | ||
|
|
||
| // Capturamos la config real que fileUploadService.ts pasa a multer() y multer.diskStorage() | ||
| // al cargar el módulo, ANTES de que ningún beforeEach limpie los mocks. | ||
| const diskStorageConfig = multer.diskStorage.mock.calls[0][0]; | ||
| const multerConfig = multer.mock.calls[0][0]; | ||
|
|
||
| describe('candidateService.addCandidate', () => { | ||
| const validCandidateData = { | ||
| firstName: 'Juan', | ||
| lastName: 'Perez', | ||
| email: 'juan.perez@example.com', | ||
| phone: '612345678', | ||
| address: 'Calle Falsa 123', | ||
| }; | ||
|
|
||
| beforeEach(() => { | ||
| jest.clearAllMocks(); | ||
| }); | ||
|
|
||
| describe('Recepción de los datos del formulario', () => { | ||
| it('rechaza el candidato si falta el nombre', async () => { | ||
| const invalidData = { ...validCandidateData, firstName: '' }; | ||
|
|
||
| await expect(addCandidate(invalidData)).rejects.toThrow('Invalid name'); | ||
| expect(mockPrisma.candidate.create).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('rechaza el candidato si el email tiene un formato inválido', async () => { | ||
| const invalidData = { ...validCandidateData, email: 'not-an-email' }; | ||
|
|
||
| await expect(addCandidate(invalidData)).rejects.toThrow('Invalid email'); | ||
| expect(mockPrisma.candidate.create).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('rechaza el candidato si el teléfono no es válido', async () => { | ||
| const invalidData = { ...validCandidateData, phone: '123' }; | ||
|
|
||
| await expect(addCandidate(invalidData)).rejects.toThrow('Invalid phone'); | ||
| expect(mockPrisma.candidate.create).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('acepta datos sin id como alta de un candidato nuevo', async () => { | ||
| mockPrisma.candidate.create.mockResolvedValue({ id: 1, ...validCandidateData }); | ||
|
|
||
| await addCandidate(validCandidateData); | ||
|
|
||
| expect(mockPrisma.candidate.create).toHaveBeenCalledWith({ | ||
| data: expect.objectContaining({ | ||
| firstName: 'Juan', | ||
| lastName: 'Perez', | ||
| email: 'juan.perez@example.com', | ||
| }), | ||
| }); | ||
| }); | ||
|
|
||
| it('omite las validaciones obligatorias cuando se envía un id (edición)', async () => { | ||
| mockPrisma.candidate.update.mockResolvedValue({ id: 5 }); | ||
|
|
||
| await addCandidate({ id: 5, firstName: '' }); | ||
|
|
||
| expect(mockPrisma.candidate.update).toHaveBeenCalledWith({ | ||
| where: { id: 5 }, | ||
| data: expect.any(Object), | ||
| }); | ||
| }); | ||
| }); | ||
|
|
||
| describe('Guardado en la base de datos', () => { | ||
| it('guarda el candidato y devuelve el registro creado', async () => { | ||
| const savedCandidate = { id: 10, ...validCandidateData }; | ||
| mockPrisma.candidate.create.mockResolvedValue(savedCandidate); | ||
|
|
||
| const result = await addCandidate(validCandidateData); | ||
|
|
||
| expect(result).toEqual(savedCandidate); | ||
| }); | ||
|
|
||
| it('guarda las educaciones asociadas al candidato', async () => { | ||
| mockPrisma.candidate.create.mockResolvedValue({ id: 2 }); | ||
| mockPrisma.education.create.mockResolvedValue({ id: 20 }); | ||
|
|
||
| const dataWithEducation = { | ||
| ...validCandidateData, | ||
| educations: [ | ||
| { institution: 'UNI', title: 'Ingeniería', startDate: '2020-01-01' }, | ||
| ], | ||
| }; | ||
|
|
||
| await addCandidate(dataWithEducation); | ||
|
|
||
| expect(mockPrisma.education.create).toHaveBeenCalledWith({ | ||
| data: expect.objectContaining({ | ||
| institution: 'UNI', | ||
| title: 'Ingeniería', | ||
| candidateId: 2, | ||
| }), | ||
| }); | ||
| }); | ||
|
|
||
| it('guarda las experiencias laborales asociadas al candidato', async () => { | ||
| mockPrisma.candidate.create.mockResolvedValue({ id: 3 }); | ||
| mockPrisma.workExperience.create.mockResolvedValue({ id: 30 }); | ||
|
|
||
| const dataWithExperience = { | ||
| ...validCandidateData, | ||
| workExperiences: [ | ||
| { company: 'ACME', position: 'Dev', startDate: '2021-01-01' }, | ||
| ], | ||
| }; | ||
|
|
||
| await addCandidate(dataWithExperience); | ||
|
|
||
| expect(mockPrisma.workExperience.create).toHaveBeenCalledWith({ | ||
| data: expect.objectContaining({ | ||
| company: 'ACME', | ||
| position: 'Dev', | ||
| candidateId: 3, | ||
| }), | ||
| }); | ||
| }); | ||
|
|
||
| it('guarda el CV asociado al candidato', async () => { | ||
| mockPrisma.candidate.create.mockResolvedValue({ id: 4 }); | ||
| mockPrisma.resume.create.mockResolvedValue({ id: 40 }); | ||
|
|
||
| const dataWithCv = { | ||
| ...validCandidateData, | ||
| cv: { filePath: '/uploads/cv.pdf', fileType: 'application/pdf' }, | ||
| }; | ||
|
|
||
| await addCandidate(dataWithCv); | ||
|
|
||
| expect(mockPrisma.resume.create).toHaveBeenCalledWith({ | ||
| data: expect.objectContaining({ | ||
| candidateId: 4, | ||
| filePath: '/uploads/cv.pdf', | ||
| fileType: 'application/pdf', | ||
| }), | ||
| }); | ||
| }); | ||
|
|
||
| it('lanza un error legible cuando el email ya existe (constraint P2002)', async () => { | ||
| mockPrisma.candidate.create.mockRejectedValue({ code: 'P2002' }); | ||
|
|
||
| await expect(addCandidate(validCandidateData)).rejects.toThrow( | ||
| 'The email already exists in the database' | ||
| ); | ||
| }); | ||
|
|
||
| it('propaga otros errores de base de datos sin modificarlos', async () => { | ||
| const dbError = new Error('Connection lost'); | ||
| mockPrisma.candidate.create.mockRejectedValue(dbError); | ||
|
|
||
| await expect(addCandidate(validCandidateData)).rejects.toThrow('Connection lost'); | ||
| }); | ||
|
|
||
| it('[BUG] no revierte al candidato ni a las educaciones ya guardadas si falla un guardado posterior (no hay transacción)', async () => { | ||
| mockPrisma.candidate.create.mockResolvedValue({ id: 7 }); | ||
| mockPrisma.education.create | ||
| .mockResolvedValueOnce({ id: 100 }) // primera educación: se guarda con éxito | ||
| .mockRejectedValueOnce(new Error('DB connection lost')); // segunda educación: falla | ||
|
|
||
| const dataWithTwoEducations = { | ||
| ...validCandidateData, | ||
| educations: [ | ||
| { institution: 'UNI A', title: 'Grado', startDate: '2018-01-01' }, | ||
| { institution: 'UNI B', title: 'Master', startDate: '2020-01-01' }, | ||
| ], | ||
| }; | ||
|
|
||
| await expect(addCandidate(dataWithTwoEducations)).rejects.toThrow('DB connection lost'); | ||
|
|
||
| // El candidato ya quedó insertado en la base de datos... | ||
| expect(mockPrisma.candidate.create).toHaveBeenCalledTimes(1); | ||
| // ...y la primera educación también, a pesar de que addCandidate() "falló" globalmente. | ||
| // Al no usar una transacción, quedan datos parciales/huérfanos en la base de datos. | ||
| expect(mockPrisma.education.create).toHaveBeenCalledTimes(2); | ||
| expect(mockPrisma.education.create).toHaveBeenNthCalledWith( | ||
| 1, | ||
| expect.objectContaining({ | ||
| data: expect.objectContaining({ institution: 'UNI A', candidateId: 7 }), | ||
| }) | ||
| ); | ||
| }); | ||
|
Comment on lines
+214
to
+241
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift Convierta este caso en una prueba de atomicidad. La prueba pasa cuando 🤖 Prompt for AI Agents |
||
| }); | ||
| }); | ||
|
|
||
| describe('fileUploadService.uploadFile', () => { | ||
| const buildRes = () => { | ||
| const res: any = {}; | ||
| res.status = jest.fn().mockReturnValue(res); | ||
| res.json = jest.fn().mockReturnValue(res); | ||
| return res; | ||
| }; | ||
|
|
||
| beforeEach(() => { | ||
| jest.clearAllMocks(); | ||
| }); | ||
|
|
||
| describe('Recepción de los datos del formulario', () => { | ||
| it('rechaza el archivo cuando el filtro de multer lo descarta por tipo inválido', () => { | ||
| mockSingle.mockReturnValue((req: any, res: any, cb: any) => { | ||
| req.file = undefined; | ||
| cb(null); | ||
| }); | ||
|
|
||
| const req: any = {}; | ||
| const res = buildRes(); | ||
|
|
||
| uploadFile(req, res); | ||
|
|
||
| expect(res.status).toHaveBeenCalledWith(400); | ||
| expect(res.json).toHaveBeenCalledWith({ | ||
| error: 'Invalid file type, only PDF and DOCX are allowed!', | ||
| }); | ||
| }); | ||
|
|
||
| it('responde 500 cuando ocurre un MulterError (por ejemplo, tamaño excedido)', () => { | ||
| const multerError = new (multer as any).MulterError('LIMIT_FILE_SIZE'); | ||
| mockSingle.mockReturnValue((req: any, res: any, cb: any) => { | ||
| cb(multerError); | ||
| }); | ||
|
|
||
| const req: any = {}; | ||
| const res = buildRes(); | ||
|
|
||
| uploadFile(req, res); | ||
|
|
||
| expect(res.status).toHaveBeenCalledWith(500); | ||
| expect(res.json).toHaveBeenCalledWith({ error: multerError.message }); | ||
| }); | ||
|
|
||
| it('responde 500 cuando ocurre un error inesperado durante la subida', () => { | ||
| const unexpectedError = new Error('disk full'); | ||
| mockSingle.mockReturnValue((req: any, res: any, cb: any) => { | ||
| cb(unexpectedError); | ||
| }); | ||
|
|
||
| const req: any = {}; | ||
| const res = buildRes(); | ||
|
|
||
| uploadFile(req, res); | ||
|
|
||
| expect(res.status).toHaveBeenCalledWith(500); | ||
| expect(res.json).toHaveBeenCalledWith({ error: 'disk full' }); | ||
| }); | ||
| }); | ||
|
|
||
| describe('Guardado en la base de datos', () => { | ||
| it('devuelve la ruta y el tipo del archivo cuando la subida es correcta', () => { | ||
| mockSingle.mockReturnValue((req: any, res: any, cb: any) => { | ||
| req.file = { | ||
| path: '../uploads/123-cv.pdf', | ||
| mimetype: 'application/pdf', | ||
| }; | ||
| cb(null); | ||
| }); | ||
|
|
||
| const req: any = {}; | ||
| const res = buildRes(); | ||
|
|
||
| uploadFile(req, res); | ||
|
|
||
| expect(res.status).toHaveBeenCalledWith(200); | ||
| expect(res.json).toHaveBeenCalledWith({ | ||
| filePath: '../uploads/123-cv.pdf', | ||
| fileType: 'application/pdf', | ||
| }); | ||
| }); | ||
| }); | ||
|
|
||
| describe('Seguridad (vulnerabilidades conocidas)', () => { | ||
| it('[VULNERABILIDAD] no sanea el nombre original del archivo: permite path traversal', () => { | ||
| const cb = jest.fn(); | ||
|
|
||
| diskStorageConfig.filename( | ||
| {} as any, | ||
| { originalname: '../../../../etc/passwd' } as any, | ||
| cb | ||
| ); | ||
|
|
||
| const [error, generatedFilename] = cb.mock.calls[0]; | ||
| expect(error).toBeNull(); | ||
| // Documenta el comportamiento actual: el "../" del nombre original no se elimina, | ||
| // por lo que un cliente malicioso podría intentar escribir fuera de uploads/. | ||
| expect(generatedFilename).toEqual(expect.stringContaining('../../../../etc/passwd')); | ||
| }); | ||
|
Comment on lines
+330
to
+344
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- candidate references ---'
rg -n -C 8 'diskStorageConfig|filename\s*:|destination\s*:|originalname|path\.join|path\.basename|sanitize|safe' backend/src backend/package.json package.json 2>/dev/null || true
printf '%s\n' '--- test context ---'
sed -n '280,375p' backend/src/tests/tests-iniciales.test.ts
printf '%s\n' '--- candidate file map ---'
fd -t f . backend/src | rg 'candidate|upload|multer|storage|config|test'Repository: LIDR-academy/AI4Devs-tdd-202607-seniors Length of output: 9628 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- upload service ---'
sed -n '1,140p' backend/src/application/services/fileUploadService.ts
printf '%s\n' '--- package versions and lock entries ---'
rg -n -C 3 '"multer"|"`@types/multer`"|multer@' backend/package.json package.json package-lock.json backend/package-lock.json yarn.lock pnpm-lock.yaml 2>/dev/null || true
printf '%s\n' '--- path construction probe ---'
node - <<'JS'
const path = require('node:path');
const destination = '../uploads/';
const originalname = '../../../../etc/passwd';
const generatedFilename = `1700000000000-${originalname}`;
const joined = path.join(destination, generatedFilename);
const resolved = path.resolve(destination, generatedFilename);
console.log(JSON.stringify({
destination,
originalname,
generatedFilename,
joined,
resolved,
escapesDestination: !resolved.startsWith(path.resolve(destination) + path.sep),
}, null, 2));
JSRepository: LIDR-academy/AI4Devs-tdd-202607-seniors Length of output: 3033 🌐 Web query:
💡 Result: In Multer 1.4.5-lts.1, the diskStorage engine uses the Citations:
Rechace nombres de archivo con path traversal.
🤖 Prompt for AI Agents |
||
|
|
||
| it('[VULNERABILIDAD] el filtro de archivos confía ciegamente en el mimetype declarado por el cliente', () => { | ||
| const cb = jest.fn(); | ||
|
|
||
| // Un ejecutable disfrazado de PDF con solo cambiar el mimetype declarado en la petición | ||
| multerConfig.fileFilter( | ||
| {} as any, | ||
| { mimetype: 'application/pdf', originalname: 'malware.exe' } as any, | ||
| cb | ||
| ); | ||
|
|
||
| // El filtro lo acepta: no inspecciona el contenido real del archivo, solo el header. | ||
| expect(cb).toHaveBeenCalledWith(null, true); | ||
| }); | ||
|
Comment on lines
+346
to
+358
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift 🧩 Analysis chain🏁 Script executed: sed -n '320,370p' backend/src/tests/tests-iniciales.test.ts
printf '\n--- multer references ---\n'
rg -n "multerConfig|fileFilter|mimetype|originalname" backend/src backend -g '!node_modules' | head -200Repository: LIDR-academy/AI4Devs-tdd-202607-seniors Length of output: 4858 🏁 Script executed: cat -n backend/src/application/services/fileUploadService.ts
printf '\n--- test setup ---\n'
sed -n '1,80p' backend/src/tests/tests-iniciales.test.ts
printf '\n--- upload call sites and routes ---\n'
rg -n "upload|fileUploadService|single\\(|array\\(|fields\\(|req\\.file|fileType" backend/src -g '*.ts'Repository: LIDR-academy/AI4Devs-tdd-202607-seniors Length of output: 8064 No trate el MIME declarado por el cliente como validación de tipo.
🤖 Prompt for AI Agents |
||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| # Crear tests unitarios | ||
|
|
||
| ## Objetivo | ||
|
|
||
| Crear una suite de tests unitarios para el backend | ||
|
|
||
| ## Contexto | ||
|
|
||
| - README.md | ||
|
|
||
| ## Instrucciones | ||
|
|
||
| - Escribe tests jest en backend/src/tests/tests-iniciales.test.ts para: | ||
| - backend\src\application\services\candidateService.ts | ||
| - backend\src\application\services\fileUploadService.ts | ||
|
|
||
| - Ejecútalos con npm test y corrige cualquier fallo. | ||
| - Hay 2 familias principales de tests: | ||
| 1. Recepción de los datos del formulario | ||
| 2. Guardado en la base de datos | ||
| Se deben cubrir ambas. | ||
|
|
||
| ## Preguntas posteriores | ||
|
|
||
| - ¿Qué otros casos límite se te ocurren que puedan fallar para candidateService.addCandidate y para fileUploadService.uploadFile ? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Corrija el caso de edición.
addCandidateejecutavalidateCandidateData(candidateData)antes de leerid. La llamada confirstName: ''debe rechazar conInvalid name, por lo que la aserción sobrecandidate.updateno se ejecuta. Si la edición debe permitir campos obligatorios vacíos, cambie el servicio y cubra ese contrato. Si no, elimine este caso o use datos válidos.🤖 Prompt for AI Agents