Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/application/create-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { assets, upload } from '../config/directory'
import legacyErrors from '../middlewares/erros-middleware'
import { generatePreview, reportPreview } from '../reports/controller'
import { routes as createEstadoRoutes } from './estado'
import { routes as createEventoRoutes } from './evento'
import { routes as createFaseSucessionalRoutes } from './fase-sucessional'
import { routes as createPaisRoutes } from './pais'
import { routes as createVegetacaoRoutes } from './vegetacao'
Expand Down Expand Up @@ -58,6 +59,7 @@ export function createApp({
const routes: Route[] = [
...createPaisRoutes(knex),
...createEstadoRoutes(knex),
...createEventoRoutes(knex),
...createFaseSucessionalRoutes(knex),
...createVegetacaoRoutes(knex)
]
Expand Down
51 changes: 51 additions & 0 deletions src/application/evento/BuscarEventoController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { BuscarEventoPorIdUseCase } from '@/domain/evento/BuscarEventoPorIdUseCase'
import {
HttpRequest, HttpResponse, StatusCode
} from '@/library/http/common'
import { BadRequestError } from '@/library/http/error/BadRequestError'
import { HttpError } from '@/library/http/error/HttpError'
import { InternalServerError } from '@/library/http/error/InternalServerError'
import { NotFoundError } from '@/library/http/error/NotFoundError'
import { NextHandler, RequestHandler } from '@/library/http/Server'

interface Dependencies {
buscarEventoPorIdUseCase: BuscarEventoPorIdUseCase
}

export class BuscarEventoController implements RequestHandler {
private readonly buscarEventoPorIdUseCase: BuscarEventoPorIdUseCase

constructor(dependencies: Dependencies) {
this.buscarEventoPorIdUseCase = dependencies.buscarEventoPorIdUseCase
}

async handle(request: HttpRequest, _next: NextHandler): Promise<HttpResponse | HttpError> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Standards] Duplicated Code (smell). Este bloco de validação de id (undefined/null/''/regex) é idêntico ao de ListaEventosController.ts:22-27, só muda o nome do campo e a mensagem. Com duas ocorrências ainda não acho que compense extrair, mas vale ficar de olho se aparecer um terceiro controller.

const { eventoId } = request.params as { eventoId?: string }

if (
eventoId === undefined
|| eventoId === null
|| eventoId === ''
|| !/^\d+$/.test(eventoId)
) {
return new BadRequestError({ message: 'eventoId inválido' })
}

const result = await this.buscarEventoPorIdUseCase.execute({
id: Number(eventoId)
})

if (result.left()) {
return new InternalServerError({ message: result.value.message })
}

if (!result.value) {
return new NotFoundError({ message: 'Evento não encontrado' })
}

return {
statusCode: StatusCode.Ok,
body: result.value
}
}
}
46 changes: 46 additions & 0 deletions src/application/evento/ListaEventosController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { ListaEventosUseCase } from '@/domain/evento/ListaEventosUseCase'
import {
HttpRequest, HttpResponse, StatusCode
} from '@/library/http/common'
import { BadRequestError } from '@/library/http/error/BadRequestError'
import { HttpError } from '@/library/http/error/HttpError'
import { InternalServerError } from '@/library/http/error/InternalServerError'
import { NextHandler, RequestHandler } from '@/library/http/Server'

interface Dependencies {
listaEventosUseCase: ListaEventosUseCase
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Standards] Sem cobertura de integração. Só há teste unitário com use case mockado (test/unit/application/evento/ListaEventosController.test.ts). Módulos irmãos que ganharam endpoints GET novos (fase-sucessional, vegetacao) vieram com test/integration/<modulo>/lista-*.test.ts batendo no app + banco real. test/integration/evento/evento-collection.test.ts já existe mas só testa o adapter, não a rota HTTP nova.

export class ListaEventosController implements RequestHandler {
private readonly listaEventosUseCase: ListaEventosUseCase

constructor(dependencies: Dependencies) {
this.listaEventosUseCase = dependencies.listaEventosUseCase
}

async handle(request: HttpRequest, _next: NextHandler): Promise<HttpResponse | HttpError> {
const { expedicaoId } = request.params as { expedicaoId?: string }

if (
expedicaoId === undefined
|| expedicaoId === null
|| expedicaoId === ''
|| !/^\d+$/.test(expedicaoId)
) {
return new BadRequestError({ message: 'expedicaoId inválido' })
}

const result = await this.listaEventosUseCase.execute({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Spec] Filtros, paginação e ordenação estável não chegam até aqui. EventoCollection.findAll já suporta tipo, capturado_de, capturado_ate e order, mas o handler só lê expedicaoId de request.params e monta { expedicao_id } — os filtros do spec ficam inalcançáveis via HTTP. Também falta ler ?limite/?pagina da query (combinado com o Jordan: "as duas listagens do módulo precisam paginar do mesmo jeito") e falta forçar order = { column: 'capturado_em', direction: 'desc' } com desempate por id, que é o ponto que o spec chama de "a parte difícil" — sem isso, duas linhas com o mesmo capturado_em podem sumir ou duplicar entre páginas assim que a paginação existir.

expedicao_id: Number(expedicaoId)
})

if (result.left()) {
return new InternalServerError({ message: result.value.message })
}

return {
statusCode: StatusCode.Ok,
body: result.value
}
}
}
37 changes: 37 additions & 0 deletions src/application/evento/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { type Knex } from 'knex'

import { BuscarEventoPorIdUseCase } from '@/domain/evento/BuscarEventoPorIdUseCase'
import { ListaEventosUseCase } from '@/domain/evento/ListaEventosUseCase'
import { EventoCollectionKnexAdapter } from '@/infrastructure/EventoCollectionKnexAdapter'
import { Method } from '@/library/http/common'
import { Route } from '@/library/http/Router'

import { BuscarEventoController } from './BuscarEventoController'
import { ListaEventosController } from './ListaEventosController'

export function routes(knex: Knex): Route[] {
const eventoCollection = new EventoCollectionKnexAdapter({ knex })

// TODO: adicionar autenticação quando a camada HTTP de autenticação estiver disponível.

return [
{
handlers: [
new ListaEventosController({
listaEventosUseCase: new ListaEventosUseCase({ eventoCollection })
})
],
method: Method.Get,
path: '/v2/expedicoes/:expedicaoId/eventos'
},
{
handlers: [
new BuscarEventoController({
buscarEventoPorIdUseCase: new BuscarEventoPorIdUseCase({ eventoCollection })
})
],
method: Method.Get,
path: '/v2/eventos/:eventoId'
}
]
}
20 changes: 20 additions & 0 deletions src/domain/evento/BuscarEventoPorIdUseCase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Either } from '@/library/either/Either'

import { Attributes } from './Evento'
import { EventoCollection } from './EventoCollection'

interface Dependencies {
eventoCollection: EventoCollection
}

export class BuscarEventoPorIdUseCase {
private readonly eventoCollection: EventoCollection

constructor(dependencies: Dependencies) {
this.eventoCollection = dependencies.eventoCollection
}

execute({ id }: { id: number }): Promise<Either<Error, Attributes | null>> {
return this.eventoCollection.findById(id)
}
}
20 changes: 20 additions & 0 deletions src/domain/evento/ListaEventosUseCase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Either } from '@/library/either/Either'

import { Attributes } from './Evento'
import { EventoCollection, EventoFilters } from './EventoCollection'

interface Dependencies {
eventoCollection: EventoCollection
}

export class ListaEventosUseCase {
private readonly eventoCollection: EventoCollection

constructor(dependencies: Dependencies) {
this.eventoCollection = dependencies.eventoCollection
}

execute(filters: EventoFilters): Promise<Either<Error, Attributes[]>> {
return this.eventoCollection.findAll(filters)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Standards] Speculative Generality (smell). execute aceita o EventoFilters inteiro, mas hoje o único chamador (ListaEventosController) nunca passa mais que expedicao_id — o restante do filtro é capacidade morta na camada HTTP até o controller ser corrigido para repassar os outros campos.

}
}
125 changes: 125 additions & 0 deletions test/unit/application/evento/BuscarEventoController.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import {
describe, expect, test, vi
} from 'vitest'

import { BuscarEventoController } from '@/application/evento/BuscarEventoController'
import { BuscarEventoPorIdUseCase } from '@/domain/evento/BuscarEventoPorIdUseCase'
import { Either } from '@/library/either/Either'
import {
Headers, HttpRequest, Method, StatusCode
} from '@/library/http/common'
import { BadRequestError } from '@/library/http/error/BadRequestError'
import { NotFoundError } from '@/library/http/error/NotFoundError'

describe('BuscarEventoController', () => {
const headers = {} as Headers

test('returns 200 with body when use case finds event', async () => {
const evento = {
id: 1,
expedicao_id: 10,
tipo: 'DIARIO' as const,
capturado_em: new Date('2026-09-15T10:00:00.000Z'),
latitude: null,
longitude: null,
altitude: null,
observacoes: 'Observação',
coleta: null,
created_at: new Date('2026-09-15T10:00:00.000Z'),
updated_at: new Date('2026-09-15T10:00:00.000Z'),
created_by: null,
updated_by: null
}

const buscarEventoPorIdUseCase = {
execute: vi.fn().mockResolvedValue(Either.right(evento))
} as unknown as BuscarEventoPorIdUseCase

const controller = new BuscarEventoController({
buscarEventoPorIdUseCase
})

const request = {
body: {},
headers,
method: Method.Get,
params: { eventoId: '1' },
path: '/eventos/1'
} satisfies HttpRequest

const response = await controller.handle(request, vi.fn())

expect(buscarEventoPorIdUseCase.execute).toHaveBeenCalledWith({
id: 1
})
expect('statusCode' in response ? response.statusCode : undefined).toBe(StatusCode.Ok)
expect('body' in response ? response.body : undefined).toEqual(evento)
})

test('returns BadRequest when eventoId is invalid', async () => {
const buscarEventoPorIdUseCase = {
execute: vi.fn()
} as unknown as BuscarEventoPorIdUseCase

const controller = new BuscarEventoController({
buscarEventoPorIdUseCase
})

const request = {
body: {},
headers,
method: Method.Get,
params: { eventoId: 'abc' },
path: '/eventos/abc'
} satisfies HttpRequest

const response = await controller.handle(request, vi.fn())

expect(buscarEventoPorIdUseCase.execute).not.toHaveBeenCalled()
expect(response).toBeInstanceOf(BadRequestError)
})

test('returns NotFound when event does not exist', async () => {
const buscarEventoPorIdUseCase = {
execute: vi.fn().mockResolvedValue(Either.right(null))
} as unknown as BuscarEventoPorIdUseCase

const controller = new BuscarEventoController({
buscarEventoPorIdUseCase
})

const request = {
body: {},
headers,
method: Method.Get,
params: { eventoId: '1' },
path: '/eventos/1'
} satisfies HttpRequest

const response = await controller.handle(request, vi.fn())

expect(response).toBeInstanceOf(NotFoundError)
})

test('returns InternalServerError when use case fails', async () => {
const buscarEventoPorIdUseCase = {
execute: vi.fn().mockResolvedValue(Either.left(new Error('boom')))
} as unknown as BuscarEventoPorIdUseCase

const controller = new BuscarEventoController({
buscarEventoPorIdUseCase
})

const request = {
body: {},
headers,
method: Method.Get,
params: { eventoId: '1' },
path: '/eventos/1'
} satisfies HttpRequest

const response = await controller.handle(request, vi.fn())

expect((response as Error).name).toBe('InternalServerError')
})
})
Loading
Loading