Ir al contenido

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»
Ventana de terminal
git add -A && git commit -m "wip: mi trabajo" # guarda lo tuyo (si hay algo)
git fetch origin
git switch -c mi-s6 origin/s6-start # checkpoint: mi-s6-cp origin/s6-checkpoint
npm install
npx prisma generate
npx 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»
Ventana de terminal
git add -A && git commit -m "wip: mi trabajo" # guarda lo tuyo (si hay algo)
git fetch origin
git switch -c mi-s6-cp origin/s6-checkpoint
npm install
npx prisma generate
npx prisma migrate dev # desde tu rama pedirá reset (esperado): npx prisma migrate reset y npm run seed
cp .env.test.example .env.test # DB de test separada: academia_test
npm test # 3 ficheros, 14 tests en verde

📋 S6·03 · instalar Vitest y Supertest (terminal)

Sección titulada «📋 S6·03 · instalar Vitest y Supertest (terminal)»
Ventana de 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)»
Ventana de terminal
npm pkg set scripts.test="vitest run" scripts.test:watch="vitest" scripts.test:coverage="vitest run --coverage"
Ventana de terminal
# .env.test — en la raíz del proyecto
DATABASE_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)»
Ventana de 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»
test/load-test-env.ts
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;
}
// test/setup-env.ts — se ejecuta antes de cada fichero de test
import { loadTestEnv } from './load-test-env.js';
loadTestEnv();

📋 S6·09 · test/global-setup.ts completo

Sección titulada «📋 S6·09 · test/global-setup.ts completo»
test/global-setup.ts
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 parte
import { 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»
src/services/courses.service.test.ts
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 anterior
import 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»
test/courses.routes.test.ts
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 final
describe('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 nuevo
import 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)»
Ventana de terminal
npm test # 3 ficheros, 14 tests en verde, ~2-3 s
npm 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)»
Ventana de 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»
Ventana de terminal
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)»
src/services/s6-tests.ia.test.ts
// ─────────────────────────────────────────────────────────────────────────
// 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);
});
});