Nível 3Unidade 3 · Integração front-end/back-end3 aulas de 50 min + 1 h EAD

Aula 13 — Desenvolvimento do back-end em camadas

Nível 3 — Frameworks Modernos · FACET-SNP-310 · WebLab · Prof. Ivan Luiz Pedroso Pires

🎯 Objetivos de aprendizagem

Ao final desta aula você será capaz de:

📋 Pré-requisitos desta aula

Checklist antes de começar:

🗺️ 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:

Texto
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:

Texto
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

Texto
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:

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:

JavaScript
// 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):

JavaScript
// 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.

JavaScript
// 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.

JavaScript
// 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)
Terminal
# .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 arquivoprocess.env diretamente — todos importam config de src/config/index.js:

JavaScript
// 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_PASSWORD no .env, o processo não sobe — imprime exatamente qual variável falta e sai com process.exit(1). Isso é intencional: é infinitamente melhor descobrir isso no npm run dev do 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

JavaScript
// 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:

JavaScript
// 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

Terminal
npm install pino pino-http
JavaScript
// 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

JavaScript
// 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)
}
JavaScript
// 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 unhandledRejection captura Promises rejeitadas que ninguém tratou; uncaughtException captura exceções síncronas que escaparam de qualquer try/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.

Terminal
npm install helmet express-rate-limit cors
JavaScript
// 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'],
})
JavaScript
// 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 com 413 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.

Facadeservices/eventosService.js é uma fachada simples sobre o repositório: o controller não precisa saber que, por trás de criarEvento, existem validação de negócio e uma chamada ao banco. Ele só vê uma operação de alto nível.

Repositoryrepositories/eventosRepository.mysql.js encapsula toda a SQL; o resto da aplicação nunca escreve SELECT/INSERT fora dessa camada.

Strategy — a escolha de qual repositório usar em tempo de execução (ver src/repositories/index.js na 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 QueryBuilder na Seção 7.2 abaixo.

6. Testes automatizados

6.1 A pirâmide de testes

Texto
        ▲
       ╱ ╲        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

Terminal
npm install -D vitest supertest
JSON
// package.json — trecho de "scripts"
{
  "scripts": {
    "dev": "node --watch src/server.js",
    "start": "node src/server.js",
    "test": "vitest run",
    "test:watch": "vitest"
  }
}
JavaScript
// vitest.config.js
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    environment: 'node',
  },
})

6.3 Teste de integração (rota, com supertest)

JavaScript
// 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)

JavaScript
// 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:

Terminal
npm test
Texto
 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

SQL
-- 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;
SQL
-- 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;
SQL
-- migrations/0003_adicionar_indice_categoria.sql
ALTER TABLE eventos ADD INDEX idx_categoria (categoria);
JavaScript
// 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)
})
JSON
// package.json — trecho de "scripts"
{
  "scripts": {
    "migrar": "node scripts/migrar.js"
  }
}
Terminal
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) ou umzug (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:

Terminal
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:

Terminal
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):

JavaScript
// 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:

JavaScript
// 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()
JavaScript
// 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):

JavaScript
// 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],
    }
  }
}
JavaScript
// 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):

JavaScript
// 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:

JavaScript
// 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:

JavaScript
// 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 handler async é capturado automaticamente e encaminhado ao middleware de erro — não precisamos mais de .catch(next) como no Express 4.

Passo 12 — as rotas:

JavaScript
// 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:

JavaScript
// 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:

Terminal
npm run migrar
npm run dev
curl http://localhost:3000/health
# {"status":"ok"}

Passo 15 — rode os testes:

Terminal
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)

  1. No projeto autoral, garanta que os 5 endpoints principais (listar, buscar por id, criar, atualizar, remover) passam pela arquitetura em camadas completa.
  2. Escreva testes cobrindo pelo menos 40% dos métodos do service principal (liste no README quais foram testados e por quê).
  3. 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 o X-RateLimit-Limit no cabeçalho de resposta.
  4. Rode npm test e 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:

📚 Para aprofundar


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.

WebLab — Laboratório de Desenvolvimento Web · UNEMAT — Universidade do Estado de Mato Grosso · Campus Sinop · FACET
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