Sesión 6 · chuleta
En el orden de las slides. Copia con el botón de cada bloque; si la slide dice «teclea», esas líneas van a mano.
📋 S6·01 · receta «Cambiar de rama» a s6-start
Sección titulada «📋 S6·01 · receta «Cambiar de rama» a s6-start»git add -A && git commit -m "wip: mi trabajo" # guarda lo tuyo (si hay algo)git fetch origingit switch -c mi-s6 origin/s6-start # checkpoint: mi-s6-cp origin/s6-checkpointnpm installnpx prisma generatenpx prisma migrate dev # desde tu rama pedirá reset (esperado): npx prisma migrate reset y npm run seed📋 S6·02 · receta al checkpoint + DB de test
Sección titulada «📋 S6·02 · receta al checkpoint + DB de test»git add -A && git commit -m "wip: mi trabajo" # guarda lo tuyo (si hay algo)git fetch origingit switch -c mi-s6-cp origin/s6-checkpointnpm installnpx prisma generatenpx prisma migrate dev # desde tu rama pedirá reset (esperado): npx prisma migrate reset y npm run seedcp .env.test.example .env.test # DB de test separada: academia_testnpm test # 3 ficheros, 14 tests en verde📋 S6·03 · instalar Vitest y Supertest (terminal)
Sección titulada «📋 S6·03 · instalar Vitest y Supertest (terminal)»npm install -D vitest@5 @vitest/coverage-v8@5 \ supertest@7 @types/supertest📋 S6·04 · scripts test, test:watch y test:coverage (terminal)
Sección titulada «📋 S6·04 · scripts test, test:watch y test:coverage (terminal)»npm pkg set scripts.test="vitest run" scripts.test:watch="vitest" scripts.test:coverage="vitest run --coverage"📋 S6·05 · .env.test (en la raíz)
Sección titulada «📋 S6·05 · .env.test (en la raíz)»# .env.test — en la raíz del proyectoDATABASE_URL="postgresql://academia:academia@localhost:5432/academia_test"JWT_SECRET="secreto-solo-para-tests-de-al-menos-32-caracteres"NODE_ENV="test"📋 S6·06 · comprobar que git ignora .env.test (terminal)
Sección titulada «📋 S6·06 · comprobar que git ignora .env.test (terminal)»git check-ignore -v .env.test # .gitignore:3:.env.test .env.test📋 S6·07 · test/load-test-env.ts completo
Sección titulada «📋 S6·07 · test/load-test-env.ts completo»import { config } from 'dotenv';
// Carga .env.test por encima de cualquier variable ya definida y se niega a// seguir si la URL no apunta a una base de datos *_test: los tests hacen// deleteMany() y no queremos vaciar la base de datos de desarrollo.export function loadTestEnv() { config({ path: '.env.test', override: true, quiet: true }); const url = process.env.DATABASE_URL ?? ''; if (!/\/\w+_test(\?|$)/.test(url)) { throw new Error( `DATABASE_URL de test no termina en _test: "${url}". ` + 'Revisa .env.test', ); } return url;}📋 S6·08 · test/setup-env.ts completo
Sección titulada «📋 S6·08 · test/setup-env.ts completo»// test/setup-env.ts — se ejecuta antes de cada fichero de testimport { loadTestEnv } from './load-test-env.js';
loadTestEnv();📋 S6·09 · test/global-setup.ts completo
Sección titulada «📋 S6·09 · test/global-setup.ts completo»import { execSync } from 'node:child_process';import pg from 'pg';import { loadTestEnv } from './load-test-env.js';
// Se ejecuta UNA vez antes de toda la suite: crea la DB de test si no existe// y le aplica las migraciones (migrate deploy: sin preguntas, como en CI).export default async function setup() { const url = loadTestEnv(); const dbName = new URL(url).pathname.slice(1); const admin = new pg.Client({ // a la base "postgres", que siempre existe connectionString: url.replace(`/${dbName}`, '/postgres') }); await admin.connect(); const { rowCount } = await admin.query( 'SELECT 1 FROM pg_database WHERE datname = $1', [dbName]); // dbName ya lo validó loadTestEnv: es seguro interpolarlo if (!rowCount) await admin.query(`CREATE DATABASE "${dbName}"`); await admin.end(); execSync('npx prisma migrate deploy', { stdio: 'inherit', env: process.env });}📋 S6·10 · vitest.config.ts completo (en la raíz)
Sección titulada «📋 S6·10 · vitest.config.ts completo (en la raíz)»// vitest.config.ts (en la raíz)import { defineConfig } from 'vitest/config';export default defineConfig({ test: { environment: 'node', globals: false, // imports explícitos // crea y migra la DB de test, una vez globalSetup: './test/global-setup.ts', // carga .env.test antes de cada fichero setupFiles: ['./test/setup-env.ts'], fileParallelism: false, // una sola DB de test → en serie coverage: { provider: 'v8', include: ['src/**'], exclude: ['src/playground/**', 'src/seed-manual.ts', 'src/server.ts', 'src/crud.ts'] }, },});📋 S6·11 · línea include de tsconfig.json
Sección titulada «📋 S6·11 · línea include de tsconfig.json»"include": ["src", "checks", "test", "prisma7.config.ts", "vitest.config.ts"]📋 S6·12 · test/helpers.ts, primera parte (resetDb)
Sección titulada «📋 S6·12 · test/helpers.ts, primera parte (resetDb)»// test/helpers.ts — primera parteimport { prisma } from '../src/db.js';
// Borra en orden inverso a las FKs: primero lo que apunta// a otras tablas.export async function resetDb() { await prisma.enrollment.deleteMany(); await prisma.course.deleteMany(); await prisma.category.deleteMany(); await prisma.user.deleteMany();}📋 S6·13 · src/services/courses.service.test.ts, esqueleto
Sección titulada «📋 S6·13 · src/services/courses.service.test.ts, esqueleto»import { beforeEach, describe, expect, it, vi } from 'vitest';import { coursesRepository } from '../repositories/courses.repository.js';import { coursesService } from './courses.service.js';
// Sustituye el módulo entero del repositorio por funciones mock: el servicio// se prueba sin base de datos ni Express.vi.mock('../repositories/courses.repository.js');
const course = (instructorId: number) => ({ id: 1, instructorId }) as Awaited< ReturnType<typeof coursesRepository.findById> >;
describe('coursesService.updateCourse', () => { beforeEach(() => { vi.resetAllMocks(); }); // aquí dentro, los it de las slides siguientes});📋 S6·14 · test del 404 (dentro del describe)
Sección titulada «📋 S6·14 · test del 404 (dentro del describe)»it('lanza 404 si el curso no existe', async () => { vi.mocked(coursesRepository.findById).mockResolvedValue(null);
await expect( coursesService.updateCourse( 1, { id: 42, role: 'INSTRUCTOR' }, { title: 'x' }), ).rejects.toMatchObject({ statusCode: 404 });});📋 S6·15 · test del ADMIN (dentro del describe)
Sección titulada «📋 S6·15 · test del ADMIN (dentro del describe)»it('permite actualizar a un ADMIN aunque no sea el dueño', async () => { vi.mocked(coursesRepository.findById) .mockResolvedValue(course(99)); vi.mocked(coursesRepository.update) .mockResolvedValue({ id: 1 } as never);
await coursesService.updateCourse( 1, { id: 42, role: 'ADMIN' }, { title: 'x' });
expect(coursesRepository.update) .toHaveBeenCalledWith(1, { title: 'x' }); });📋 S6·16 · test/helpers.ts completo (sustituye al anterior)
Sección titulada «📋 S6·16 · test/helpers.ts completo (sustituye al anterior)»// test/helpers.ts — completo: sustituye al anteriorimport jwt from 'jsonwebtoken';import { prisma } from '../src/db.js';import type { AuthPayload } from '../src/middlewares/require-auth.js';import { hashPassword } from '../src/services/password.js';
// Borra en orden inverso a las FKs: primero lo que apunta// a otras tablas.export async function resetDb() { await prisma.enrollment.deleteMany(); await prisma.course.deleteMany(); await prisma.category.deleteMany(); await prisma.user.deleteMany();}
export function generateTestToken(payload: AuthPayload) { return jwt.sign(payload, process.env.JWT_SECRET!, { algorithm: 'HS256', expiresIn: '1h', });}
let seq = 0;export async function createUser(role: AuthPayload['role'], password = 'Passw0rd!') { seq++; const user = await prisma.user.create({ data: { email: `${role.toLowerCase()}${seq}@test.dev`, name: `${role} ${seq}`, role, password: await hashPassword(password) }, }); return { user, token: generateTestToken({ sub: String(user.id), role }), password };}
export const bearer = (token: string) => ({ Authorization: `Bearer ${token}` });📋 S6·17 · test/courses.routes.test.ts, cabecera
Sección titulada «📋 S6·17 · test/courses.routes.test.ts, cabecera»import request from 'supertest';import { beforeEach, describe, expect, it } from 'vitest';import { app } from '../src/app.js';import { prisma } from '../src/db.js';import { bearer, createUser, resetDb } from './helpers.js';
// Supertest arranca la app en un puerto efímero:// vosotros no llamáis a app.listen() ni elegís puerto.beforeEach(resetDb);📋 S6·18 · GET no expone datos + 404, y cierre del describe
Sección titulada «📋 S6·18 · GET no expone datos + 404, y cierre del describe» // sigue dentro del describe GET it('no expone el email ni el hash del instructor', async () => { const { user } = await createUser('INSTRUCTOR'); await prisma.course.create({ data: { title: 'Curso', price: 10, published: true, instructorId: user.id } }); const res = await request(app).get('/api/v1/courses'); expect(res.body[0].instructor) .toEqual({ id: user.id, name: user.name }); });
it('responde 404 (no 500) a un curso inexistente', async () => { const res = await request(app).get('/api/v1/courses/99999'); expect(res.status).toBe(404); });}); // cierra el describe GET📋 S6·19 · describe POST /api/v1/courses completo (al final de courses.routes.test.ts)
Sección titulada «📋 S6·19 · describe POST /api/v1/courses completo (al final de courses.routes.test.ts)»// test/courses.routes.test.ts — añade al finaldescribe('POST /api/v1/courses', () => { const body = { title: 'Nuevo curso', price: 49.99 };
it('401 sin token', async () => { const res = await request(app) .post('/api/v1/courses').send(body); expect(res.status).toBe(401); }); it('403 si el token es de un STUDENT', async () => { const { token } = await createUser('STUDENT'); const res = await request(app).post('/api/v1/courses') .set(bearer(token)).send(body); expect(res.status).toBe(403); });
it('201 como INSTRUCTOR, y el instructor es quien hace la petición', async () => { const { user, token } = await createUser('INSTRUCTOR'); const res = await request(app).post('/api/v1/courses') .set(bearer(token)).send(body); expect(res.status).toBe(201); expect(res.body.instructorId).toBe(user.id); });
it('400 si el body trae campos no permitidos (mass assignment)', async () => { const { token } = await createUser('INSTRUCTOR'); const res = await request(app).post('/api/v1/courses') .set(bearer(token)).send({ ...body, instructorId: 1 }); expect(res.status).toBe(400); });}); // cierra el describe POST📋 S6·20 · test/auth.routes.test.ts completo (fichero nuevo)
Sección titulada «📋 S6·20 · test/auth.routes.test.ts completo (fichero nuevo)»// test/auth.routes.test.ts — fichero nuevoimport request from 'supertest';import { beforeEach, describe, expect, it } from 'vitest';import { app } from '../src/app.js';import { createUser, resetDb } from './helpers.js';
beforeEach(resetDb);
describe('POST /api/v1/auth/login', () => { it('devuelve un token con credenciales correctas', async () => { const { user, password } = await createUser('STUDENT'); const res = await request(app).post('/api/v1/auth/login') .send({ email: user.email, password }); expect(res.status).toBe(200); expect(res.body.token).toEqual(expect.any(String)); });
it('mismo 401 y mismo mensaje si falla el email o la contraseña', async () => { const { user } = await createUser('STUDENT'); const wrongPassword = await request(app) .post('/api/v1/auth/login') .send({ email: user.email, password: 'mala' }); const wrongEmail = await request(app) .post('/api/v1/auth/login') .send({ email: 'nadie@test.dev', password: 'mala' }); expect(wrongPassword.status).toBe(401); expect(wrongEmail.status).toBe(401); expect(wrongEmail.body).toEqual(wrongPassword.body); });}); // cierra el describe📋 S6·21 · npm test dos veces seguidas (terminal)
Sección titulada «📋 S6·21 · npm test dos veces seguidas (terminal)»npm test # 3 ficheros, 14 tests en verde, ~2-3 snpm test # otra vez: mismo resultado (nada residual entre ejecuciones)📋 S6·22 · cobertura y coverage/ fuera de git (terminal)
Sección titulada «📋 S6·22 · cobertura y coverage/ fuera de git (terminal)»npm run test:coverage # informe en coverage/echo "coverage" >> .gitignore # que no acabe en git📋 S6·23 · línea del checkpoint para la receta
Sección titulada «📋 S6·23 · línea del checkpoint para la receta»git switch -c mi-s6-cp origin/s6-checkpoint📋 S6·24 · src/services/s6-tests.ia.test.ts (la suite de la IA)
Sección titulada «📋 S6·24 · src/services/s6-tests.ia.test.ts (la suite de la IA)»// ─────────────────────────────────────────────────────────────────────────// Generado por un asistente de IA (Copilot, modo agente) · Sesión 6// Prompt: "Escribe tests para coursesService.updateCourse"// La suite pasa en verde. Tu tarea: ¿protege de algo?// 1. Invierte la condición de ownership en courses.service.ts// (if (!isOwner && !isAdmin) → if (isOwner && !isAdmin)) y vuelve a correrla.// 2. ¿Qué tests siguen en verde con el código roto? Esos no valen.// ─────────────────────────────────────────────────────────────────────────import { describe, expect, it, vi } from 'vitest';import { AppError } from '../errors/app-error.js';import { coursesRepository } from '../repositories/courses.repository.js';import { coursesService } from './courses.service.js';
vi.mock('../repositories/courses.repository.js');
describe('coursesService.updateCourse', () => { it('llama al repositorio', async () => { vi.mocked(coursesRepository.findById).mockResolvedValue({ id: 1, instructorId: 1 } as never); vi.mocked(coursesRepository.update).mockResolvedValue({ id: 1, title: 'Nuevo' } as never);
await coursesService.updateCourse(1, { id: 1, role: 'ADMIN' }, { title: 'Nuevo' }).catch(() => {});
expect(coursesRepository.findById).toHaveBeenCalledWith(1); });
it('devuelve el curso actualizado', async () => { vi.mocked(coursesRepository.update).mockResolvedValue({ id: 1, title: 'Nuevo' } as never);
const updated = await coursesRepository.update(1, { title: 'Nuevo' });
expect(updated.title).toBe('Nuevo'); });
it('lanza un error si algo va mal', async () => { vi.mocked(coursesRepository.findById).mockResolvedValue(null);
await expect( coursesService.updateCourse(1, { id: 1, role: 'INSTRUCTOR' }, { title: 'x' }), ).rejects.toThrow(AppError); });});