Aula 13 — Desenvolvimento do back-end em camadas
🎯 Objetivos de aprendizagem¶
Ao final desta aula você será capaz de:
- Organizar uma API Express em camadas (
routes → controllers → services → repositories → db), aplicando a regra de dependência entre elas. - Aplicar injeção de dependência sem framework, passando repositórios para services por parâmetro/factory, e explicar por que isso torna o código testável.
- Centralizar e validar a configuração da aplicação com
zodemsrc/config/index.js, falhando rápido quando falta uma variável de ambiente. - Construir uma hierarquia de erros de domínio e um tratador de erros central que mapeia cada erro para o status HTTP correto, com logs estruturados via
pino. - Aplicar proteções básicas de segurança (
helmet,express-rate-limit, CORS restritivo, limite de payload) e relacioná-las ao OWASP Top 10. - Escrever testes automatizados de integração (rota, com
supertest) e unitários (service, com repositório falso) usandovitest. - Criar e executar migrations de banco de dados com scripts numerados e uma tabela de controle, sem depender de
schema.sqlmanual.
📋 Pré-requisitos desta aula¶
- API
unieventos-apifuncionando com Express 5, persistência em MySQL (Aula 09) e autenticação Firebase (Aula 10), com CRUD completo (Aula 11). - Estrutura mínima de pastas
src/routes,src/controllersjá existente (mesmo que ainda sem separação completa em services/repositories). - Node.js 22 LTS e MySQL rodando localmente (ou acessível via
DATABASE_URL).
Checklist antes de começar:
- [ ]
npm run devsobe a API sem erro. - [ ] Existe pelo menos um endpoint de eventos funcionando (
GET /api/eventos). - [ ] Você sabe onde estão as credenciais do banco no seu
.env.
🗺️ Roteiro¶
| Bloco | Tempo | Atividade |
|---|---|---|
| 1 | 50 min | Arquitetura em camadas, injeção de dependência, configuração centralizada com zod |
| 2 | 50 min | Hierarquia de erros, segurança prática (helmet, rate limit, CORS), OWASP Top 10 |
| 3 | 50 min | Testes com vitest + supertest, migrations com scripts numerados |
Retomando a Aula 12¶
Na Aula 12 trocamos o MySQL por Supabase sem alterar uma linha do front-end, porque a camada services/ já escondia a origem dos dados atrás de uma interface única — o padrão Adapter em ação. Isso só foi possível porque o back-end já tinha, mesmo que informalmente, uma separação entre "o que a rota expõe" e "de onde o dado vem". Hoje formalizamos essa separação: paramos de escrever back-end que "funciona" e passamos a escrever back-end que se sustenta — testável, seguro, com erros previsíveis e configuração validada.
1. Arquitetura em camadas revisitada¶
Até aqui, o unieventos-api cresceu organicamente: rota chama controller, controller consulta o banco direto, às vezes até valida direto na rota. Isso funciona para um protótipo, mas não escala — cada mudança no banco obriga a mexer em código que deveria só cuidar de HTTP, e não dá para testar regra de negócio sem subir um banco de verdade.
A solução é formalizar cinco responsabilidades separadas:
routes/ → só sabe de HTTP: métodos, caminhos, parâmetros, chama o controller
controllers/ → traduz requisição/resposta HTTP para chamadas de service
services/ → regra de negócio pura, não sabe de Express nem de SQL
repositories/ → sabe conversar com a fonte de dados (MySQL, Supabase, memória...)
db/ → conexão de baixo nível (pool do mysql2, cliente do Supabase)
Mais três pastas de apoio, que qualquer camada pode usar:
middlewares/ → funções que interceptam a requisição (auth, validação, log, segurança)
validators/ → esquemas zod que descrevem o formato esperado de cada entrada
utils/ → funções puras reaproveitáveis (logger, formatação, helpers)
config/ → leitura e validação centralizada de variáveis de ambiente
1.1 O fluxo de uma requisição, camada por camada¶
Cliente HTTP (front-end / Postman)
│
│ POST /api/eventos { titulo, categoria, vagas, ... }
▼
┌─────────────────────┐
│ middlewares globais │ helmet, cors, express.json, rate-limit, log
└─────────┬────────────┘
▼
┌─────────────────────┐
│ routes/eventos.js │ define o path e delega ao controller
└─────────┬────────────┘
▼
┌─────────────────────┐
│ middlewares de rota │ autenticação (verificarToken), validação (validar(schema))
└─────────┬────────────┘
▼
┌──────────────────────────┐
│ controllers/eventosController │ lê req.body/req.params, chama o service,
│ │ monta a resposta HTTP (status + JSON)
└─────────┬──────────────────────┘
▼
┌──────────────────────────┐
│ services/eventosService │ regra de negócio: "vagas não pode ser negativo",
│ │ "só o dono pode editar", orquestra repositórios
└─────────┬──────────────────────┘
▼
┌──────────────────────────┐
│ repositories/eventosRepository │ monta e executa a query, mapeia linha → objeto
└─────────┬──────────────────────┘
▼
┌──────────────────────────┐
│ db/pool.js │ conexão física com o MySQL (ou outro SGBD)
└────────────────────────────┘
A resposta sobe pelo mesmo caminho, em ordem inversa: o repository devolve dados brutos, o service aplica regra de negócio e devolve um objeto de domínio, o controller decide o status HTTP e serializa em JSON, o Express entrega ao cliente.
1.2 A regra de dependência¶
⚠️ Atenção Camada de fora pode conhecer e importar a de dentro. Camada de dentro nunca pode importar a de fora.
Na prática:
routespode importarcontrollers.controllersnão pode importarroutes.controllerspode importarservices.servicesnão pode importarcontrollersnemreq/resdo Express.servicespode importarrepositories.repositoriesnão pode importarservices.repositoriespode importardb.dbnão sabe querepositoriesexiste.
O motivo: quanto mais "para dentro", mais a camada deveria ser reutilizável e testável sem HTTP nem banco real. Um service que importa req/res do Express está, na prática, acoplado ao protocolo HTTP — e não dá mais para chamá-lo a partir de um job agendado, de um teste unitário puro, ou de uma futura API GraphQL sem reescrever regra de negócio.
🔎 Por baixo do capô Essa regra é uma versão simplificada da Dependency Inversion Principle (o "D" do SOLID) e da Arquitetura Limpa (Clean Architecture, Robert C. Martin): as regras de negócio no centro, os detalhes de infraestrutura (HTTP, banco, filesystem) na borda, sempre apontando para dentro.
2. Injeção de dependência sem framework¶
O problema mais comum em back-ends que "crescem sem arquitetura" é o service importando o repository diretamente no topo do arquivo. Funciona, mas prende o service a uma implementação específica — impossível testar sem banco de verdade, impossível trocar de fonte de dados sem editar o service.
Antes — import direto, acoplado:
// src/services/eventosService.ANTES.js
// PROBLEMA: este arquivo só funciona se existir um MySQL de verdade rodando.
// Não dá para testar a regra "vagas não pode ser negativo" sem banco.
import { pool } from '../db/pool.js'
export async function listarEventos() {
const [linhas] = await pool.query('SELECT * FROM eventos ORDER BY data_hora')
return linhas
}
export async function criarEvento(dados) {
if (dados.vagas < 0) {
throw new Error('vagas não pode ser negativo')
}
const [resultado] = await pool.query(
'INSERT INTO eventos (titulo, categoria, vagas) VALUES (?, ?, ?)',
[dados.titulo, dados.categoria, dados.vagas],
)
return { id: resultado.insertId, ...dados }
}
Depois — o repositório é injetado (passado por parâmetro):
// src/services/eventosService.js
// O service NÃO SABE se o repositório fala com MySQL, Supabase ou memória.
// Ele só conhece a INTERFACE: listar(), buscarPorId(), criar(), atualizar(), remover().
import { ErroDeValidacao, ErroNaoEncontrado } from '../errors/index.js'
export function criarServicoDeEventos({ eventosRepository }) {
return {
async listarEventos(filtros) {
return eventosRepository.listar(filtros)
},
async buscarEventoPorId(id) {
const evento = await eventosRepository.buscarPorId(id)
if (!evento) {
throw new ErroNaoEncontrado(`Evento ${id} não encontrado`)
}
return evento
},
async criarEvento(dados) {
if (dados.vagas < 0) {
throw new ErroDeValidacao('vagas não pode ser negativo')
}
return eventosRepository.criar(dados)
},
async atualizarEvento(id, dados) {
await this.buscarEventoPorId(id) // reaproveita a validação de existência
return eventosRepository.atualizar(id, dados)
},
async removerEvento(id) {
await this.buscarEventoPorId(id)
return eventosRepository.remover(id)
},
}
}
A função criarServicoDeEventos é uma factory: recebe as dependências como argumento (aqui, um objeto com eventosRepository) e devolve o objeto pronto para uso. Quem monta a aplicação decide qual repositório injetar — em produção, o do MySQL; em teste, um repositório falso em memória, sem precisar de banco nenhum.
// src/app.js (montagem — quem decide as dependências concretas)
import { criarRepositorioDeEventosMySQL } from './repositories/eventosRepository.mysql.js'
import { criarServicoDeEventos } from './services/eventosService.js'
const eventosRepository = criarRepositorioDeEventosMySQL()
const eventosService = criarServicoDeEventos({ eventosRepository })
// eventosService agora pode ser passado ao controller, sem que o service
// jamais tenha importado o pool do MySQL diretamente.
💡 Dica Injeção de dependência não exige framework nenhum em JavaScript — não precisamos de
@Injectable()nem de container de DI. Uma função que recebe parâmetros já é injeção de dependência. O nome bonito não deve intimidar: é passar objetos como argumento, em vez de importar dentro do arquivo.
3. Configuração centralizada com zod¶
Espalhar process.env.ALGUMA_COISA pelo código inteiro é frágil: se a variável não existir, o erro só aparece no meio de uma requisição, em produção, na pior hora. A solução é validar todo o ambiente uma única vez, na inicialização, e falhar rápido se algo estiver faltando.
// src/config/index.js
import { z } from 'zod'
import 'dotenv/config'
const esquemaDeAmbiente = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().positive().default(3000),
DB_HOST: z.string().min(1, 'DB_HOST é obrigatória'),
DB_PORT: z.coerce.number().int().positive().default(3306),
DB_USER: z.string().min(1, 'DB_USER é obrigatória'),
DB_PASSWORD: z.string().min(1, 'DB_PASSWORD é obrigatória'),
DB_NAME: z.string().min(1, 'DB_NAME é obrigatória'),
FIREBASE_PROJECT_ID: z.string().min(1, 'FIREBASE_PROJECT_ID é obrigatória'),
CORS_ORIGEM_PERMITIDA: z.string().min(1, 'CORS_ORIGEM_PERMITIDA é obrigatória'),
})
// safeParse NÃO lança exceção — devolve um objeto { success, data } ou { success, error }.
// Isso permite montar uma mensagem de erro clara antes de encerrar o processo.
const resultado = esquemaDeAmbiente.safeParse(process.env)
if (!resultado.success) {
console.error('❌ Configuração de ambiente inválida:')
for (const problema of resultado.error.issues) {
console.error(` - ${problema.path.join('.')}: ${problema.message}`)
}
// Falha rápido: melhor a aplicação nem subir do que subir quebrada.
process.exit(1)
}
export const config = Object.freeze(resultado.data)
# .env.example — copie para .env e preencha com valores reais
NODE_ENV=development
PORT=3000
DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PASSWORD=troque-esta-senha
DB_NAME=uni_eventos
FIREBASE_PROJECT_ID=uni-eventos-12345
CORS_ORIGEM_PERMITIDA=http://localhost:5173
A partir de agora, nenhum outro arquivo lê process.env diretamente — todos importam config de src/config/index.js:
// src/db/pool.js — uso de config em vez de process.env espalhado
import { config } from '../config/index.js'
export const configuracaoDoPool = {
host: config.DB_HOST,
port: config.DB_PORT,
user: config.DB_USER,
password: config.DB_PASSWORD,
database: config.DB_NAME,
}
⚠️ Atenção Se você esquecer
DB_PASSWORDno.env, o processo não sobe — imprime exatamente qual variável falta e sai comprocess.exit(1). Isso é intencional: é infinitamente melhor descobrir isso nonpm run devdo que às 23h58 tentando fazer o deploy funcionar para a Avaliação 3.
4. Tratamento de erros maduro¶
4.1 Hierarquia de erros de domínio¶
// src/errors/index.js
// Erros de DOMÍNIO carregam significado de negócio, não de protocolo HTTP.
// Quem decide o status HTTP é o tratador central (Seção 4.2), não o service.
export class ErroDeAplicacao extends Error {
constructor(mensagem, status = 500) {
super(mensagem)
this.name = this.constructor.name
this.status = status
// Marca erros esperados/tratáveis, para diferenciar de bugs inesperados no log.
this.operacional = true
}
}
export class ErroDeValidacao extends ErroDeAplicacao {
constructor(mensagem, detalhes = []) {
super(mensagem, 400)
this.detalhes = detalhes
}
}
export class ErroNaoEncontrado extends ErroDeAplicacao {
constructor(mensagem = 'Recurso não encontrado') {
super(mensagem, 404)
}
}
export class ErroDeAutorizacao extends ErroDeAplicacao {
constructor(mensagem = 'Você não tem permissão para executar esta ação') {
super(mensagem, 403)
}
}
export class ErroDeConflito extends ErroDeAplicacao {
constructor(mensagem = 'Conflito com o estado atual do recurso') {
super(mensagem, 409)
}
}
Usar essa hierarquia no service fica direto:
// trecho de src/services/inscricoesService.js
import { ErroDeConflito, ErroDeAutorizacao } from '../errors/index.js'
async function inscrever({ eventoId, usuarioUid }) {
const jaInscrito = await inscricoesRepository.existeInscricao(eventoId, usuarioUid)
if (jaInscrito) {
throw new ErroDeConflito('Você já está inscrito neste evento')
}
// ...
}
async function cancelarInscricao({ inscricaoId, usuarioUidSolicitante }) {
const inscricao = await inscricoesRepository.buscarPorId(inscricaoId)
if (inscricao.usuario_uid !== usuarioUidSolicitante) {
throw new ErroDeAutorizacao('Só é possível cancelar a própria inscrição')
}
// ...
}
4.2 Logs estruturados com pino¶
npm install pino pino-http
// src/utils/logger.js
import pino from 'pino'
import { config } from '../config/index.js'
// Em desenvolvimento, log legível por humano (pino-pretty precisa ser instalado à parte
// como devDependency: npm install -D pino-pretty).
// Em produção, log em JSON puro — mais rápido e pronto para ferramentas de observabilidade.
export const logger = pino({
level: config.NODE_ENV === 'production' ? 'info' : 'debug',
transport:
config.NODE_ENV === 'production'
? undefined
: { target: 'pino-pretty', options: { colorize: true } },
})
4.3 O tratador de erros central¶
// src/middlewares/tratadorDeErros.js
import { logger } from '../utils/logger.js'
import { config } from '../config/index.js'
// Middleware de erro do Express: identificado pela ASSINATURA DE 4 PARÂMETROS.
// Em Express 5, erros lançados dentro de handlers async chegam aqui automaticamente,
// sem precisar de try/catch manual nem de .catch(next) em cada rota.
export function tratadorDeErros(erro, req, res, next) {
const status = erro.status ?? 500
const ehErroOperacional = erro.operacional === true
// Erros operacionais (esperados: validação, não encontrado...) viram log de aviso.
// Erros não-operacionais (bug inesperado) viram log de erro, com stack completo.
if (ehErroOperacional) {
logger.warn({ status, mensagem: erro.message, path: req.path }, 'erro operacional')
} else {
logger.error({ status, err: erro, path: req.path }, 'erro inesperado')
}
const corpoDaResposta = {
mensagem: ehErroOperacional ? erro.message : 'Erro interno do servidor',
}
if (erro.detalhes) {
corpoDaResposta.detalhes = erro.detalhes
}
// NUNCA vazar stack trace em produção — é informação valiosa para um atacante
// (caminhos de arquivo, versão de bibliotecas, estrutura interna).
if (config.NODE_ENV !== 'production') {
corpoDaResposta.stack = erro.stack
}
res.status(status).json(corpoDaResposta)
}
// src/server.js — captura de falhas que escapam do Express
import { app } from './app.js'
import { config } from './config/index.js'
import { logger } from './utils/logger.js'
const servidor = app.listen(config.PORT, () => {
logger.info(`API rodando na porta ${config.PORT} (${config.NODE_ENV})`)
})
// Promises rejeitadas sem .catch em NENHUM lugar do código (fora do ciclo de
// requisição do Express) caem aqui. Sem isso, o processo Node continua rodando
// em estado inconsistente, silenciosamente.
process.on('unhandledRejection', (motivo) => {
logger.error({ err: motivo }, 'unhandledRejection não tratada — encerrando processo')
servidor.close(() => process.exit(1))
})
process.on('uncaughtException', (erro) => {
logger.error({ err: erro }, 'uncaughtException — encerrando processo')
process.exit(1)
})
📌 Na prova
unhandledRejectioncaptura Promises rejeitadas que ninguém tratou;uncaughtExceptioncaptura exceções síncronas que escaparam de qualquertry/catch. Nenhum dos dois substitui tratamento de erro local — são uma rede de segurança final, não a primeira linha de defesa.
5. Segurança prática¶
Regra de ouro: nunca confie em nada que vem do cliente — nem no Content-Type declarado, nem no tamanho do payload, nem nos campos do corpo, nem no token de autenticação sem verificá-lo. Tudo que chega de fora é hostil até prova em contrário.
npm install helmet express-rate-limit cors
// src/middlewares/seguranca.js
import helmet from 'helmet'
import rateLimit from 'express-rate-limit'
import cors from 'cors'
import { config } from '../config/index.js'
// helmet(): define um conjunto de cabeçalhos HTTP de segurança com um só import
// (X-Content-Type-Options, X-Frame-Options, Strict-Transport-Security etc.).
export const cabecalhosDeSeguranca = helmet()
// Limita quantas requisições um mesmo IP pode fazer em uma janela de tempo —
// mitiga força bruta em login e ataques de negação de serviço simples.
export const limitadorDeTaxa = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutos
limit: 100, // 100 requisições por IP nessa janela
standardHeaders: true,
legacyHeaders: false,
message: { mensagem: 'Muitas requisições. Tente novamente mais tarde.' },
})
// CORS restritivo: só o domínio do front tem permissão — nunca use origin: '*'
// em uma API que aceita cookies ou token de autenticação.
export const corsConfigurado = cors({
origin: config.CORS_ORIGEM_PERMITIDA,
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
})
// trecho de src/app.js — ordem importa: segurança primeiro, depois parsing, depois rotas
import express from 'express'
import { cabecalhosDeSeguranca, limitadorDeTaxa, corsConfigurado } from './middlewares/seguranca.js'
export function criarApp({ eventosRepository } = {}) {
const app = express()
app.use(cabecalhosDeSeguranca)
app.use(corsConfigurado)
app.use(limitadorDeTaxa)
// Limite de tamanho do corpo: evita que alguém envie um payload de 500 MB
// para derrubar o processo por consumo de memória.
app.use(express.json({ limit: '10kb' }))
app.use(express.urlencoded({ extended: true, limit: '10kb' }))
// ... rotas registradas depois daqui (Seção "Mão na massa")
return app
}
⚠️ Atenção
express.json({ limit: '10kb' })rejeita automaticamente corpos maiores com413 Payload Too Large. Ajuste o limite ao seu domínio — 10kb é generoso para um formulário de evento, mas seria pouco se você aceitasse upload de imagem em base64 no corpo (nesse caso, prefira Storage, como no Supabase da Aula 12).
5.1 Checklist OWASP Top 10 aplicado a esta disciplina¶
| Categoria OWASP | O que fazemos no UniEventos |
|---|---|
| A01 — Quebra de controle de acesso | Middleware autenticacao.js (Aula 10) + verificação de dono do recurso nos services (ex.: ErroDeAutorizacao ao cancelar inscrição alheia) |
| A02 — Falhas criptográficas | Senha nunca é gerenciada por nós — delegada ao Firebase Auth; .env fora do controle de versão; HTTPS obrigatório em produção (Aula 15) |
| A03 — Injeção | Queries sempre parametrizadas com ? no mysql2 (nunca concatenação de string); validação de entrada com zod antes de tocar no banco |
| A04 — Design inseguro | Arquitetura em camadas desta aula; regra de negócio centralizada no service, não espalhada em cada rota |
| A05 — Configuração incorreta | helmet, CORS restritivo, .env validado por zod, stack trace escondida em produção |
| A07 — Falhas de identificação | Token do Firebase verificado no back a cada requisição (Aula 10), nunca confiar em usuario_uid vindo do corpo da requisição |
| A09 — Falhas de log e monitoramento | Logs estruturados com pino, diferenciando erro operacional de erro inesperado |
🔎 Por baixo do capô Note que "sanitizar entrada" aqui não significa escapar HTML manualmente — significa validar contra um schema (zod) antes de qualquer processamento, e nunca montar SQL por concatenação. Essas duas práticas já eliminam a maior parte da superfície de ataque de injeção em uma API JSON.
🧩 Padrão de projeto em uso¶
🧩 Padrões de projeto em uso — Builder, Dependency Injection, Singleton, Facade, Repository, Strategy¶
Esta aula é a mais densa em padrões GoF do semestre, porque a arquitetura em camadas é literalmente a aplicação simultânea de vários deles.
Dependency Injection — Seção 3:
criarServicoDeEventos({ eventosRepository })recebe a dependência em vez de importá-la. O service não conhece a implementação concreta, só a interface (listar,buscarPorId,criar...).Singleton — o pool de conexões do MySQL (criado na Aula 09 com
mysql2.createPool) é instanciado uma única vez por processo e reutilizado por todos os repositórios: ```js // src/db/pool.js import mysql from 'mysql2/promise' import { configuracaoDoPool } from './configuracaoDoPool.js'let instanciaDoPool // módulo ES: só existe uma vez por processo Node — Singleton natural
export function obterPool() { if (!instanciaDoPool) { instanciaDoPool = mysql.createPool(configuracaoDoPool) } return instanciaDoPool }
`` Qualquer repositório que chameobterPool()` recebe a mesma instância — é assim que o Singleton evita esgotar conexões do banco.Facade —
services/eventosService.jsé uma fachada simples sobre o repositório: o controller não precisa saber que, por trás decriarEvento, existem validação de negócio e uma chamada ao banco. Ele só vê uma operação de alto nível.Repository —
repositories/eventosRepository.mysql.jsencapsula toda a SQL; o resto da aplicação nunca escreveSELECT/INSERTfora dessa camada.Strategy — a escolha de qual repositório usar em tempo de execução (ver
src/repositories/index.jsna seção "Mão na massa") é o padrão Strategy: a mesma interface (listar,criar...), implementações intercambiáveis por ambiente (MySQL em produção, memória em teste).Builder — a montagem de uma query de listagem com filtros opcionais (categoria, texto, paginação) usa um builder que acumula condições passo a passo antes de gerar o SQL final — ver
QueryBuilderna Seção 7.2 abaixo.
6. Testes automatizados¶
6.1 A pirâmide de testes¶
▲
╱ ╲ poucos, lentos, caros de manter
╱ E2E╲ (Cypress/Playwright rodando a UI inteira)
╱───────╲
╱ API/ ╲ médios: sobem a aplicação, testam rotas HTTP reais
╱ integração ╲ (supertest — Seção 6.3)
╱───────────────╲
╱ unitários ╲ muitos, rápidos, baratos — testam uma função/service
╱ (service, utils) ╲ isolado, sem rede nem banco (Seção 6.4)
▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔
Testar o service unitariamente é mais barato que testar pela UI por três motivos: (1) roda em milissegundos, sem subir navegador nem servidor; (2) não depende de rede nem de banco de dados real, então não quebra por instabilidade externa; (3) aponta exatamente qual regra de negócio falhou, sem precisar navegar até a tela que dispara aquele fluxo.
6.2 Instalando e configurando¶
npm install -D vitest supertest
// package.json — trecho de "scripts"
{
"scripts": {
"dev": "node --watch src/server.js",
"start": "node src/server.js",
"test": "vitest run",
"test:watch": "vitest"
}
}
// vitest.config.js
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'node',
},
})
6.3 Teste de integração (rota, com supertest)¶
// test/eventos.rota.test.js
import { describe, it, expect } from 'vitest'
import request from 'supertest'
import { criarApp } from '../src/app.js'
import { criarRepositorioDeEventosEmMemoria } from '../src/repositories/eventosRepository.memoria.js'
// Sobe a aplicação Express de verdade, mas com o repositório de MEMÓRIA —
// nenhum MySQL precisa estar rodando para este teste passar.
function montarAppDeTeste() {
const eventosRepository = criarRepositorioDeEventosEmMemoria([
{ id: 1, titulo: 'Semana da Computação', categoria: 'palestra', vagas: 40 },
])
return criarApp({ eventosRepository })
}
describe('rotas de /api/eventos', () => {
it('GET /api/eventos retorna 200 e um array', async () => {
const app = montarAppDeTeste()
const resposta = await request(app).get('/api/eventos')
expect(resposta.status).toBe(200)
expect(Array.isArray(resposta.body)).toBe(true)
expect(resposta.body).toHaveLength(1)
})
it('GET /api/eventos/:id inexistente retorna 404 com mensagem', async () => {
const app = montarAppDeTeste()
const resposta = await request(app).get('/api/eventos/999')
expect(resposta.status).toBe(404)
expect(resposta.body.mensagem).toMatch(/não encontrado/i)
})
it('POST /api/eventos sem título retorna 400 (validação zod)', async () => {
const app = montarAppDeTeste()
const resposta = await request(app)
.post('/api/eventos')
.send({ categoria: 'palestra', vagas: 10 })
expect(resposta.status).toBe(400)
expect(resposta.body.detalhes).toBeDefined()
})
it('POST /api/eventos válido retorna 201 e o evento criado', async () => {
const app = montarAppDeTeste()
const resposta = await request(app)
.post('/api/eventos')
.send({ titulo: 'Hackathon FACET', categoria: 'workshop', vagas: 60 })
expect(resposta.status).toBe(201)
expect(resposta.body.titulo).toBe('Hackathon FACET')
expect(resposta.body.id).toBeDefined()
})
})
6.4 Teste unitário (service, com repositório falso)¶
// test/eventos.service.test.js
import { describe, it, expect } from 'vitest'
import { criarServicoDeEventos } from '../src/services/eventosService.js'
// Repositório FALSO (test double): implementa a mesma interface do repositório
// real, mas guarda tudo em um array na memória do próprio teste — zero I/O.
function criarRepositorioFalso(eventosIniciais = []) {
const eventos = [...eventosIniciais]
return {
async listar() {
return eventos
},
async buscarPorId(id) {
return eventos.find((evento) => evento.id === id) ?? null
},
async criar(dados) {
const novoEvento = { id: eventos.length + 1, ...dados }
eventos.push(novoEvento)
return novoEvento
},
}
}
describe('eventosService (unitário)', () => {
it('listarEventos delega ao repositório e devolve o array', async () => {
const service = criarServicoDeEventos({
eventosRepository: criarRepositorioFalso([{ id: 1, titulo: 'Evento A' }]),
})
const eventos = await service.listarEventos()
expect(eventos).toHaveLength(1)
expect(eventos[0].titulo).toBe('Evento A')
})
it('buscarEventoPorId lança ErroNaoEncontrado quando o id não existe', async () => {
const service = criarServicoDeEventos({ eventosRepository: criarRepositorioFalso([]) })
await expect(service.buscarEventoPorId(42)).rejects.toThrow('não encontrado')
})
it('criarEvento lança ErroDeValidacao quando vagas é negativo', async () => {
const service = criarServicoDeEventos({ eventosRepository: criarRepositorioFalso([]) })
await expect(
service.criarEvento({ titulo: 'Evento inválido', categoria: 'palestra', vagas: -5 }),
).rejects.toThrow('vagas não pode ser negativo')
})
})
Rodando os testes:
npm test
RUN v2.1.9 unieventos-api
✓ test/eventos.service.test.js (3 tests) 4ms
✓ test/eventos.rota.test.js (4 tests) 29ms
Test Files 2 passed (2)
Tests 7 passed (7)
Start at 20:14:02
Duration 612ms
💡 Dica Sete testes cobrindo as regras mais importantes (listar, 404, validação, criação) já dão confiança real para refatorar sem medo. A meta não é "100% de cobertura" — é cobrir os caminhos de negócio que importam.
7. Migrations de banco¶
7.1 Por que schema.sql manual não escala¶
Até a Aula 09, o banco foi criado rodando um schema.sql inteiro na mão. Isso funciona sozinho, mas quebra em equipe: cada pessoa pode ter uma versão diferente do schema local, não há histórico do que mudou e quando, e aplicar uma mudança em produção vira "abrir o MySQL Workbench e rezar". Migration resolve isso: cada mudança de schema vira um arquivo numerado, versionado no Git, aplicado em ordem, uma única vez, com registro em uma tabela de controle.
7.2 Implementação simples: scripts numerados + tabela de controle¶
-- migrations/0001_criar_tabela_eventos.sql
CREATE TABLE IF NOT EXISTS eventos (
id INT AUTO_INCREMENT PRIMARY KEY,
titulo VARCHAR(150) NOT NULL,
descricao TEXT,
categoria ENUM('palestra', 'minicurso', 'workshop') NOT NULL,
data_hora DATETIME NOT NULL,
local VARCHAR(150) NOT NULL,
vagas INT NOT NULL DEFAULT 0,
imagem_url VARCHAR(255),
criado_em TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- migrations/0002_criar_tabela_inscricoes.sql
CREATE TABLE IF NOT EXISTS inscricoes (
id INT AUTO_INCREMENT PRIMARY KEY,
evento_id INT NOT NULL,
usuario_uid VARCHAR(128) NOT NULL,
criado_em TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (evento_id) REFERENCES eventos(id) ON DELETE CASCADE,
UNIQUE KEY uk_evento_usuario (evento_id, usuario_uid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- migrations/0003_adicionar_indice_categoria.sql
ALTER TABLE eventos ADD INDEX idx_categoria (categoria);
// scripts/migrar.js
// Executor de migrations minimalista: lê migrations/*.sql em ordem numérica,
// aplica só as que ainda não constam na tabela de controle.
import { readdir, readFile } from 'node:fs/promises'
import path from 'node:path'
import mysql from 'mysql2/promise'
import { config } from '../src/config/index.js'
const PASTA_DE_MIGRATIONS = new URL('../migrations', import.meta.url)
async function garantirTabelaDeControle(conexao) {
await conexao.query(`
CREATE TABLE IF NOT EXISTS migrations_executadas (
nome_arquivo VARCHAR(255) PRIMARY KEY,
executado_em TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB
`)
}
async function listarMigrationsJaExecutadas(conexao) {
const [linhas] = await conexao.query('SELECT nome_arquivo FROM migrations_executadas')
return new Set(linhas.map((linha) => linha.nome_arquivo))
}
async function executarMigracoes() {
const conexao = await mysql.createConnection({
host: config.DB_HOST,
port: config.DB_PORT,
user: config.DB_USER,
password: config.DB_PASSWORD,
database: config.DB_NAME,
multipleStatements: true,
})
try {
await garantirTabelaDeControle(conexao)
const jaExecutadas = await listarMigrationsJaExecutadas(conexao)
const arquivos = (await readdir(PASTA_DE_MIGRATIONS))
.filter((arquivo) => arquivo.endsWith('.sql'))
.sort() // nomes numerados (0001_..., 0002_...) garantem ordem correta
let quantidadeAplicada = 0
for (const arquivo of arquivos) {
if (jaExecutadas.has(arquivo)) {
console.log(`↷ ${arquivo} já aplicada, pulando`)
continue
}
const caminhoCompleto = path.join(new URL(PASTA_DE_MIGRATIONS).pathname, arquivo)
const sql = await readFile(caminhoCompleto, 'utf-8')
console.log(`▶ aplicando ${arquivo}...`)
await conexao.query(sql)
await conexao.query('INSERT INTO migrations_executadas (nome_arquivo) VALUES (?)', [arquivo])
quantidadeAplicada += 1
console.log(`✔ ${arquivo} aplicada`)
}
console.log(`\nConcluído: ${quantidadeAplicada} migration(s) nova(s) aplicada(s).`)
} finally {
await conexao.end()
}
}
executarMigracoes().catch((erro) => {
console.error('❌ falha ao rodar migrations:', erro.message)
process.exit(1)
})
// package.json — trecho de "scripts"
{
"scripts": {
"migrar": "node scripts/migrar.js"
}
}
npm run migrar
# ▶ aplicando 0001_criar_tabela_eventos.sql...
# ✔ 0001_criar_tabela_eventos.sql aplicada
# ▶ aplicando 0002_criar_tabela_inscricoes.sql...
# ✔ 0002_criar_tabela_inscricoes.sql aplicada
# ▶ aplicando 0003_adicionar_indice_categoria.sql...
# ✔ 0003_adicionar_indice_categoria.sql aplicada
#
# Concluído: 3 migration(s) nova(s) aplicada(s).
npm run migrar
# ↷ 0001_criar_tabela_eventos.sql já aplicada, pulando
# ↷ 0002_criar_tabela_inscricoes.sql já aplicada, pulando
# ↷ 0003_adicionar_indice_categoria.sql já aplicada, pulando
#
# Concluído: 0 migration(s) nova(s) aplicada(s).
🔎 Por baixo do capô Ferramentas prontas como
node-pg-migrate(Postgres) ouumzug(multi-banco) fazem exatamente isso — tabela de controle + arquivos ordenados — só que com mais recursos (rollback automático, geração de esqueleto de arquivo, migrations em JS além de SQL). Entender o mecanismo manual antes de usar a ferramenta pronta evita tratá-la como caixa-preta.
💻 Mão na massa — refatorando o unieventos-api para arquitetura em camadas¶
Passo 1 — instale as dependências novas:
cd unieventos-api
npm install zod pino pino-http helmet express-rate-limit dotenv
npm install -D vitest supertest pino-pretty
Passo 2 — crie a estrutura de pastas:
mkdir -p src/config src/db src/errors src/middlewares src/repositories src/services src/controllers src/routes src/validators src/utils migrations scripts test
Passo 3 — configuração (já mostrada na Seção 3):
Copie o conteúdo de src/config/index.js e .env.example da Seção 3 para o projeto.
Passo 4 — o pool de conexão (Singleton):
// src/db/pool.js
import mysql from 'mysql2/promise'
import { config } from '../config/index.js'
let instanciaDoPool
export function obterPool() {
if (!instanciaDoPool) {
instanciaDoPool = mysql.createPool({
host: config.DB_HOST,
port: config.DB_PORT,
user: config.DB_USER,
password: config.DB_PASSWORD,
database: config.DB_NAME,
waitForConnections: true,
connectionLimit: 10,
})
}
return instanciaDoPool
}
Passo 5 — validador de entrada com zod:
// src/validators/eventoSchema.js
import { z } from 'zod'
export const eventoSchema = z.object({
titulo: z.string().min(3, 'titulo precisa ter ao menos 3 caracteres').max(150),
descricao: z.string().max(2000).optional(),
categoria: z.enum(['palestra', 'minicurso', 'workshop']),
dataHora: z.string().datetime({ message: 'dataHora precisa ser um ISO 8601 válido' }),
local: z.string().min(3).max(150),
vagas: z.coerce.number().int().nonnegative('vagas não pode ser negativo'),
imagemUrl: z.string().url().optional(),
})
export const eventoAtualizacaoSchema = eventoSchema.partial()
// src/middlewares/validar.js
import { ErroDeValidacao } from '../errors/index.js'
// Middleware genérico: recebe um schema zod e devolve um middleware Express
// que valida req.body antes de deixar a requisição seguir para o controller.
export function validar(schema) {
return (req, res, next) => {
const resultado = schema.safeParse(req.body)
if (!resultado.success) {
const detalhes = resultado.error.issues.map((problema) => ({
campo: problema.path.join('.'),
mensagem: problema.message,
}))
throw new ErroDeValidacao('Dados inválidos', detalhes)
}
req.body = resultado.data // body validado e com coerções aplicadas (ex.: vagas vira number)
next()
}
}
Passo 6 — os erros de domínio:
Use o conteúdo de src/errors/index.js da Seção 4.1.
Passo 7 — o repositório MySQL (com Builder de query):
// src/repositories/queryBuilderDeListagem.js
// Builder: monta incrementalmente a query SQL de listagem, adicionando cláusulas
// WHERE só para os filtros que realmente vieram preenchidos.
export class QueryBuilderDeListagem {
constructor(tabela) {
this.tabela = tabela
this.condicoes = []
this.parametros = []
this.limiteValor = 20
this.deslocamentoValor = 0
}
comCategoria(categoria) {
if (categoria) {
this.condicoes.push('categoria = ?')
this.parametros.push(categoria)
}
return this // encadeamento fluente — marca registrada do Builder
}
comBuscaDeTexto(termo) {
if (termo) {
this.condicoes.push('titulo LIKE ?')
this.parametros.push(`%${termo}%`)
}
return this
}
comPaginacao(pagina = 1, porPagina = 20) {
this.limiteValor = porPagina
this.deslocamentoValor = (pagina - 1) * porPagina
return this
}
construir() {
const clausulaWhere = this.condicoes.length > 0 ? `WHERE ${this.condicoes.join(' AND ')}` : ''
const sql = `
SELECT * FROM ${this.tabela}
${clausulaWhere}
ORDER BY data_hora ASC
LIMIT ? OFFSET ?
`
return {
sql,
parametros: [...this.parametros, this.limiteValor, this.deslocamentoValor],
}
}
}
// src/repositories/eventosRepository.mysql.js
import { obterPool } from '../db/pool.js'
import { QueryBuilderDeListagem } from './queryBuilderDeListagem.js'
function linhaParaEvento(linha) {
return {
id: linha.id,
titulo: linha.titulo,
descricao: linha.descricao,
categoria: linha.categoria,
dataHora: linha.data_hora,
local: linha.local,
vagas: linha.vagas,
imagemUrl: linha.imagem_url,
}
}
export function criarRepositorioDeEventosMySQL() {
const pool = obterPool()
return {
async listar({ categoria, busca, pagina, porPagina } = {}) {
const { sql, parametros } = new QueryBuilderDeListagem('eventos')
.comCategoria(categoria)
.comBuscaDeTexto(busca)
.comPaginacao(pagina, porPagina)
.construir()
const [linhas] = await pool.query(sql, parametros)
return linhas.map(linhaParaEvento)
},
async buscarPorId(id) {
const [linhas] = await pool.query('SELECT * FROM eventos WHERE id = ?', [id])
return linhas[0] ? linhaParaEvento(linhas[0]) : null
},
async criar(dados) {
const [resultado] = await pool.query(
`INSERT INTO eventos (titulo, descricao, categoria, data_hora, local, vagas, imagem_url)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[dados.titulo, dados.descricao ?? null, dados.categoria, dados.dataHora, dados.local, dados.vagas, dados.imagemUrl ?? null],
)
return { id: resultado.insertId, ...dados }
},
async atualizar(id, dados) {
await pool.query(
`UPDATE eventos SET titulo = ?, descricao = ?, categoria = ?, data_hora = ?, local = ?, vagas = ?, imagem_url = ?
WHERE id = ?`,
[dados.titulo, dados.descricao ?? null, dados.categoria, dados.dataHora, dados.local, dados.vagas, dados.imagemUrl ?? null, id],
)
return this.buscarPorId(id)
},
async remover(id) {
await pool.query('DELETE FROM eventos WHERE id = ?', [id])
},
}
}
Passo 8 — o repositório em memória (para testes):
// src/repositories/eventosRepository.memoria.js
// Implementa a MESMA interface do repositório MySQL, sem tocar em banco algum.
// Usado nos testes (Seção 6.3) e como referência didática de Strategy.
export function criarRepositorioDeEventosEmMemoria(eventosIniciais = []) {
let eventos = [...eventosIniciais]
let proximoId = eventos.length + 1
return {
async listar({ categoria } = {}) {
if (!categoria) return eventos
return eventos.filter((evento) => evento.categoria === categoria)
},
async buscarPorId(id) {
return eventos.find((evento) => evento.id === Number(id)) ?? null
},
async criar(dados) {
const novoEvento = { id: proximoId++, ...dados }
eventos.push(novoEvento)
return novoEvento
},
async atualizar(id, dados) {
eventos = eventos.map((evento) => (evento.id === Number(id) ? { ...evento, ...dados } : evento))
return this.buscarPorId(id)
},
async remover(id) {
eventos = eventos.filter((evento) => evento.id !== Number(id))
},
}
}
Passo 9 — Strategy: escolha do repositório por ambiente:
// src/repositories/index.js
// Strategy: a interface é sempre a mesma (listar/buscarPorId/criar/atualizar/remover);
// a implementação escolhida depende do ambiente de execução.
import { config } from '../config/index.js'
import { criarRepositorioDeEventosMySQL } from './eventosRepository.mysql.js'
import { criarRepositorioDeEventosEmMemoria } from './eventosRepository.memoria.js'
export function obterRepositorioDeEventos() {
if (config.NODE_ENV === 'test') {
return criarRepositorioDeEventosEmMemoria()
}
return criarRepositorioDeEventosMySQL()
}
Passo 10 — o service (mostrado completo na Seção 3):
Use src/services/eventosService.js da Seção 3, já com a hierarquia de erros da Seção 4.1.
Passo 11 — o controller:
// src/controllers/eventosController.js
export function criarControllerDeEventos({ eventosService }) {
return {
async listar(req, res) {
const { categoria, busca, pagina, porPagina } = req.query
const eventos = await eventosService.listarEventos({ categoria, busca, pagina, porPagina })
res.status(200).json(eventos)
},
async buscarPorId(req, res) {
const evento = await eventosService.buscarEventoPorId(Number(req.params.id))
res.status(200).json(evento)
},
async criar(req, res) {
const evento = await eventosService.criarEvento(req.body)
res.status(201).json(evento)
},
async atualizar(req, res) {
const evento = await eventosService.atualizarEvento(Number(req.params.id), req.body)
res.status(200).json(evento)
},
async remover(req, res) {
await eventosService.removerEvento(Number(req.params.id))
res.status(204).end()
},
}
}
⚠️ Atenção Repare que nenhum método do controller usa
try/catch. Em Express 5, um erro lançado dentro de um handlerasyncé capturado automaticamente e encaminhado ao middleware de erro — não precisamos mais de.catch(next)como no Express 4.
Passo 12 — as rotas:
// src/routes/eventos.routes.js
import { Router } from 'express'
import { validar } from '../middlewares/validar.js'
import { eventoSchema, eventoAtualizacaoSchema } from '../validators/eventoSchema.js'
import { verificarToken } from '../middlewares/autenticacao.js'
export function criarRotasDeEventos({ eventosController }) {
const router = Router()
router.get('/', eventosController.listar)
router.get('/:id', eventosController.buscarPorId)
// Rotas de escrita exigem autenticação (middleware da Aula 10) e corpo validado.
router.post('/', verificarToken, validar(eventoSchema), eventosController.criar)
router.put('/:id', verificarToken, validar(eventoAtualizacaoSchema), eventosController.atualizar)
router.delete('/:id', verificarToken, eventosController.remover)
return router
}
Passo 13 — montando a aplicação:
// src/app.js
import express from 'express'
import { cabecalhosDeSeguranca, limitadorDeTaxa, corsConfigurado } from './middlewares/seguranca.js'
import { tratadorDeErros } from './middlewares/tratadorDeErros.js'
import { criarRotasDeEventos } from './routes/eventos.routes.js'
import { criarControllerDeEventos } from './controllers/eventosController.js'
import { criarServicoDeEventos } from './services/eventosService.js'
import { obterRepositorioDeEventos } from './repositories/index.js'
export function criarApp({ eventosRepository = obterRepositorioDeEventos() } = {}) {
const app = express()
app.use(cabecalhosDeSeguranca)
app.use(corsConfigurado)
app.use(limitadorDeTaxa)
app.use(express.json({ limit: '10kb' }))
const eventosService = criarServicoDeEventos({ eventosRepository })
const eventosController = criarControllerDeEventos({ eventosService })
app.get('/health', (req, res) => {
res.status(200).json({ status: 'ok' })
})
app.use('/api/eventos', criarRotasDeEventos({ eventosController }))
// O tratador de erros é SEMPRE o último app.use — Express identifica middlewares
// de erro pela assinatura de 4 parâmetros, não pela posição, mas a convenção
// de deixá-lo por último evita que ele "capture" middlewares registrados depois.
app.use(tratadorDeErros)
return app
}
Passo 14 — o server.js (mostrado completo na Seção 4.3). Rode e confira:
npm run migrar
npm run dev
curl http://localhost:3000/health
# {"status":"ok"}
Passo 15 — rode os testes:
npm test
Confira que os 7 testes das Seções 6.3 e 6.4 passam.
🧪 Laboratório¶
1. Refatore seu projeto autoral para a arquitetura em camadas — crie as pastas config/, db/, errors/, middlewares/, repositories/, services/, controllers/, routes/, mova o código existente para os lugares certos.
Resultado esperado: npm run dev continua funcionando, e nenhuma rota importa o pool do banco diretamente.
Dica
Comece de dentro para fora: primeiro extraia o repositório (funções que tocam o banco), depois o service (regra de negócio), depois o controller (o que sobrar do handler antigo).
2. Centralize a configuração com zod — crie src/config/index.js validando pelo menos 4 variáveis do seu .env.
Resultado esperado: remover uma variável obrigatória do .env faz o processo falhar ao iniciar, com mensagem clara.
Dica
Use safeParse, não parse — assim você controla a mensagem de erro antes de chamar process.exit(1).
3. Implemente a hierarquia de erros e o tratador central no seu projeto, substituindo throw new Error(...) genérico por ErroDeValidacao, ErroNaoEncontrado etc.
Resultado esperado: uma requisição a um recurso inexistente devolve 404 com { mensagem: "..." }, sem stack trace em produção.
Dica
Simule produção localmente com NODE_ENV=production npm start e confira que a resposta de erro não tem o campo stack.
4. Escreva 3 testes automatizados — pelo menos um de rota (supertest) e um de service (unitário, repositório falso).
Resultado esperado: npm test mostra os 3 testes passando.
Dica
Copie a estrutura dos testes das Seções 6.3/6.4 e troque eventos pela entidade do seu domínio.
5. Adicione helmet, express-rate-limit e CORS restritivo ao seu app.js.
Resultado esperado: uma requisição de origem diferente da configurada em CORS_ORIGEM_PERMITIDA é bloqueada pelo navegador (verifique no console do DevTools).
Dica
Teste abrindo o front em uma porta e fazendo uma requisição para a API configurada com outra origem em CORS_ORIGEM_PERMITIDA — o erro de CORS aparece no console do navegador, não no Postman (Postman ignora CORS).
🐛 Erros comuns e como resolver¶
| Sintoma | Causa | Solução |
|---|---|---|
| Processo não inicia, imprime lista de variáveis faltando | .env incompleto em relação ao schema de config/index.js |
Copie .env.example para .env e preencha todos os campos |
TypeError: eventosRepository.listar is not a function |
Repositório injetado não implementa a interface esperada pelo service | Confira se toda implementação de repositório (MySQL, memória) expõe os mesmos métodos |
| Teste de rota falha com erro de conexão ao MySQL | Testes estão usando o repositório real em vez do de memória | Injete eventosRepository: criarRepositorioDeEventosEmMemoria() explicitamente no criarApp dos testes |
| Erro 500 aparece no navegador com stack trace completo | NODE_ENV não está definido como production no deploy |
Configure NODE_ENV=production nas variáveis de ambiente do serviço de deploy |
npm run migrar reaplica uma migration já aplicada |
Tabela migrations_executadas não foi criada ou o nome do arquivo mudou |
Confira SELECT * FROM migrations_executadas e não renomeie arquivos de migration já aplicados |
| CORS bloqueando o front mesmo em desenvolvimento | CORS_ORIGEM_PERMITIDA no .env não bate com a porta real do Vite (5173 por padrão) |
Ajuste a variável para a URL exata mostrada pelo npm run dev do front |
🏠 Atividade assíncrona (1 h)¶
- No projeto autoral, garanta que os 5 endpoints principais (listar, buscar por id, criar, atualizar, remover) passam pela arquitetura em camadas completa.
- Escreva testes cobrindo pelo menos 40% dos métodos do service principal (liste no README quais foram testados e por quê).
- Aplique o checklist de segurança da Seção 5:
helmet, rate limit, CORS restritivo, limite de payload — cole no README um trecho de log ou print mostrando oX-RateLimit-Limitno cabeçalho de resposta. - Rode
npm teste cole a saída completa no README, em uma seção "Testes".
Critério de pronto: npm test passa localmente, README atualizado com a seção de testes e o checklist de segurança marcado.
✅ Checkpoint do projeto autoral¶
Ao final desta aula, seu repositório <tema>-api deve ter:
- [ ] Estrutura
routes/ → controllers/ → services/ → repositories/ → db/completa, commiddlewares/,validators/,utils/,config/. - [ ]
src/config/index.jsvalidando o.envcom zod e falhando rápido se algo faltar. - [ ] Hierarquia de erros de domínio e tratador central mapeando para status HTTP.
- [ ]
helmet,express-rate-limite CORS restritivo configurados. - [ ] Pelo menos 3 testes automatizados passando (
npm test). - [ ] Pasta
migrations/com scripts numerados e scriptnpm run migrarfuncional.
📚 Para aprofundar¶
- Documentação oficial do Express 5 — tratamento de erros.
- Zod — documentação oficial
- Pino — documentação oficial
- Vitest — documentação oficial
- Supertest — repositório no GitHub
- OWASP Top 10 (2021, referência atual)
- helmet.js — documentação
- Martin, Robert C. — Clean Architecture (capítulos sobre a regra de dependência), referência complementar da bibliografia do plano de curso.
Próxima aula (14, 09/12/2026): documentamos a API inteira com OpenAPI 3 e Swagger UI — cada endpoint que construímos até aqui ganha um contrato formal, testável direto do navegador. Traga o unieventos-api (ou seu projeto autoral) já na arquitetura em camadas desta aula.
Prof. Ivan Luiz Pedroso Pires · Material didático de uso educacional; livre para consulta, estudo e reuso com atribuição.
Início · Banco de Desafios · Links úteis · Fontes no GitHub