Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions backend/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
};
267 changes: 267 additions & 0 deletions backend/src/tests/tests-iniciales.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
/**
* Suite de tests para la inserción de candidatos (LTI).
*
* Dos familias, según la pista del enunciado:
* 1. Recepción de los datos del formulario -> `validateCandidateData`, unitario puro.
* 2. Guardado en la base de datos -> `addCandidate` + `Candidate.save`, con Prisma mockeado.
*
* Nota de método: el código de producción ya existía cuando se escribieron estos tests, así que el
* ciclo rojo natural no estaba disponible. Cada aserción se validó rompiendo a propósito la línea
* que la sostiene y comprobando que el test se pone en rojo (mutación manual). El detalle de cada
* mutación está en `prompts/prompts-iniciales.md`.
*/

import { validateCandidateData } from '../application/validator';
import { addCandidate } from '../application/services/candidateService';

// La frontera arquitectónica es la base de datos: el mock va acá y no más adentro.
// `prisma` se instancia a nivel de módulo en cada modelo, sin inyección, así que la única vía
// de sustitución es interceptar el propio paquete.
jest.mock('@prisma/client', () => {
const candidate = { create: jest.fn(), update: jest.fn() };
const education = { create: jest.fn(), update: jest.fn() };
const workExperience = { create: jest.fn(), update: jest.fn() };
const resume = { create: jest.fn(), update: jest.fn() };
return {
__client: { candidate, education, workExperience, resume },
PrismaClient: jest.fn(() => ({ candidate, education, workExperience, resume })),
Prisma: {
PrismaClientInitializationError: class PrismaClientInitializationError extends Error {},
},
};
});

const prisma = (jest.requireMock('@prisma/client') as any).__client;

/** Candidato válido mínimo, con los opcionales presentes. Cada test parte de acá y muta un campo. */
const candidatoValido = () => ({
firstName: 'Ada',
lastName: 'Lovelace',
email: 'ada@example.com',
phone: '612345678',
address: 'Calle Mayor 1',
});

const textoDe = (largo: number) => 'a'.repeat(largo);

beforeEach(() => {
jest.clearAllMocks();
prisma.candidate.create.mockResolvedValue({ id: 1, ...candidatoValido() });
prisma.candidate.update.mockResolvedValue({ id: 1, ...candidatoValido() });
prisma.education.create.mockResolvedValue({ id: 10 });
prisma.workExperience.create.mockResolvedValue({ id: 20 });
prisma.resume.create.mockResolvedValue({ id: 30 });
});

// ---------------------------------------------------------------------------
// Familia 1 — Recepción de los datos del formulario
// ---------------------------------------------------------------------------

describe('Recepción de datos del formulario', () => {
it('acepta un candidato con solo los campos obligatorios', () => {
expect(() =>
validateCandidateData({
firstName: 'Ada',
lastName: 'Lovelace',
email: 'ada@example.com',
}),
).not.toThrow();
});

describe('rechaza el candidato cuando un campo es inválido', () => {
it.each`
campo | valor | mensaje
${'firstName'} | ${''} | ${'Invalid name'}
${'firstName'} | ${'A'} | ${'Invalid name'}
${'firstName'} | ${textoDe(101)} | ${'Invalid name'}
${'firstName'} | ${'Ada2'} | ${'Invalid name'}
${'lastName'} | ${undefined} | ${'Invalid name'}
${'lastName'} | ${'Lovelace!'} | ${'Invalid name'}
${'email'} | ${undefined} | ${'Invalid email'}
${'email'} | ${'ada.example.com'} | ${'Invalid email'}
${'email'} | ${'ada@example'} | ${'Invalid email'}
${'phone'} | ${'512345678'} | ${'Invalid phone'}
${'phone'} | ${'61234567'} | ${'Invalid phone'}
${'address'} | ${textoDe(101)} | ${'Invalid address'}
`('$campo = "$valor" -> $mensaje', ({ campo, valor, mensaje }) => {
const candidato = { ...candidatoValido(), [campo]: valor };

expect(() => validateCandidateData(candidato)).toThrow(mensaje);
});
});

it.each(['phone', 'address'])('acepta el candidato sin %s, porque es opcional', (campo) => {
const candidato: any = candidatoValido();
delete candidato[campo];

expect(() => validateCandidateData(candidato)).not.toThrow();
});

it('rechaza una formación sin institución', () => {
const candidato = {
...candidatoValido(),
educations: [{ title: 'Matemáticas', startDate: '1842-01-01' }],
};

expect(() => validateCandidateData(candidato)).toThrow('Invalid institution');
});

it('rechaza una formación cuya fecha de inicio no tiene formato ISO', () => {
const candidato = {
...candidatoValido(),
educations: [{ institution: 'Cambridge', title: 'Matemáticas', startDate: '01-01-1842' }],
};

expect(() => validateCandidateData(candidato)).toThrow('Invalid date');
});

it('rechaza una experiencia laboral cuya descripción supera los 200 caracteres', () => {
const candidato = {
...candidatoValido(),
workExperiences: [
{
company: 'Analytical Engine',
position: 'Programadora',
description: textoDe(201),
startDate: '1843-01-01',
},
],
};

expect(() => validateCandidateData(candidato)).toThrow('Invalid description');
});

it('rechaza un CV sin tipo de archivo', () => {
const candidato = { ...candidatoValido(), cv: { filePath: '/tmp/cv.pdf' } };

expect(() => validateCandidateData(candidato)).toThrow('Invalid CV data');
});

/**
* Test de caracterización, no de aprobación: fija el comportamiento ACTUAL para que un cambio
* futuro sea visible. `validateCandidateData` devuelve en la primera línea si el payload trae
* `id`, así que un alta con un id ajeno entra sin validar ninguno de sus campos. El endpoint es
* de inserción y el enunciado declara la API abierta a fuentes externas.
* Ver el análisis en el PR.
*/
it('COMPORTAMIENTO ACTUAL: un id en el payload omite toda la validación', () => {
const candidatoIntegramenteInvalido = {
id: 999,
firstName: '',
lastName: '',
email: 'esto-no-es-un-email',
phone: 'tampoco',
};

expect(() => validateCandidateData(candidatoIntegramenteInvalido)).not.toThrow();
});
});

// ---------------------------------------------------------------------------
// Familia 2 — Guardado en la base de datos
// ---------------------------------------------------------------------------

describe('Guardado en la base de datos', () => {
it('crea el candidato con los campos recibidos y devuelve el registro guardado', async () => {
const guardado = await addCandidate(candidatoValido());

expect(prisma.candidate.create).toHaveBeenCalledWith({ data: candidatoValido() });
expect(prisma.candidate.update).not.toHaveBeenCalled();
expect(guardado).toEqual({ id: 1, ...candidatoValido() });
});

it('omite del alta los campos opcionales que no vinieron, en vez de mandarlos vacíos', async () => {
await addCandidate({
firstName: 'Ada',
lastName: 'Lovelace',
email: 'ada@example.com',
});

// `toHaveBeenCalledWith` y `toEqual` ignoran las claves cuyo valor es `undefined`, así que
// con ellos este test pasa igual aunque el candidato viaje con `phone: undefined` dentro.
// `toStrictEqual` es el único que distingue la clave ausente de la clave en undefined, que
// es exactamente lo que este test afirma. Lo detectó la mutación del guard de `Candidate.save`.
const datosEnviados = prisma.candidate.create.mock.calls[0][0].data;

expect(datosEnviados).toStrictEqual({
firstName: 'Ada',
lastName: 'Lovelace',
email: 'ada@example.com',
});
});

it('actualiza en vez de crear cuando el payload trae un id', async () => {
await addCandidate({ ...candidatoValido(), id: 7 });

expect(prisma.candidate.update).toHaveBeenCalledWith({
where: { id: 7 },
data: candidatoValido(),
});
expect(prisma.candidate.create).not.toHaveBeenCalled();
});

it('traduce la violación de email único (P2002) a un mensaje de dominio', async () => {
prisma.candidate.create.mockRejectedValue({ code: 'P2002' });

await expect(addCandidate(candidatoValido())).rejects.toThrow(
'The email already exists in the database',
);
});

it('guarda la formación con el id del candidato recién creado', async () => {
prisma.candidate.create.mockResolvedValue({ id: 42 });

await addCandidate({
...candidatoValido(),
educations: [{ institution: 'Cambridge', title: 'Matemáticas', startDate: '1842-01-01' }],
});

expect(prisma.education.create).toHaveBeenCalledWith({
data: expect.objectContaining({
institution: 'Cambridge',
title: 'Matemáticas',
candidateId: 42,
}),
});
});

/**
* Test de caracterización. `Candidate` arma un nested create de Prisma a partir de
* `this.education`, pero su constructor lee `data.education` (singular) mientras el servicio
* pasa `educations` (plural): por esta vía el nested create nunca se dispara y la formación se
* guarda después, en su propio insert. Fijarlo evita que el día que alguien corrija el nombre
* del campo la formación se inserte dos veces sin que nadie lo note.
*/
it('COMPORTAMIENTO ACTUAL: el alta del candidato no incluye la formación anidada', async () => {
await addCandidate({
...candidatoValido(),
educations: [{ institution: 'Cambridge', title: 'Matemáticas', startDate: '1842-01-01' }],
});

expect(prisma.candidate.create).toHaveBeenCalledWith({ data: candidatoValido() });
});
});

// ---------------------------------------------------------------------------
// El error que un `toThrow` descuidado deja pasar
// ---------------------------------------------------------------------------

describe('Propagación del error de validación', () => {
/**
* `addCandidate` reenvía el fallo con `throw new Error(error)`, que estringifica el Error
* original: el mensaje que llega al cliente es "Error: Invalid email" y no "Invalid email".
* Con `toThrow('Invalid email')` el test pasaría igual —hace match parcial— y ocultaría el
* defecto; por eso la aserción va anclada con una expresión regular.
*/
it('el mensaje que sale del servicio llega con el prefijo "Error: " pegado', async () => {
await expect(addCandidate({ ...candidatoValido(), email: 'no-es-un-email' })).rejects.toThrow(
/^Error: Invalid email$/,
);
});

it('no llega a tocar la base cuando la validación falla', async () => {
await expect(addCandidate({ ...candidatoValido(), email: 'no-es-un-email' })).rejects.toThrow();

expect(prisma.candidate.create).not.toHaveBeenCalled();
});
});
Loading