diff --git a/src/application/create-app.ts b/src/application/create-app.ts index a3b3901c..5af907ad 100644 --- a/src/application/create-app.ts +++ b/src/application/create-app.ts @@ -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' @@ -58,6 +59,7 @@ export function createApp({ const routes: Route[] = [ ...createPaisRoutes(knex), ...createEstadoRoutes(knex), + ...createEventoRoutes(knex), ...createFaseSucessionalRoutes(knex), ...createVegetacaoRoutes(knex) ] diff --git a/src/application/evento/BuscarEventoController.ts b/src/application/evento/BuscarEventoController.ts new file mode 100644 index 00000000..5fdc83d4 --- /dev/null +++ b/src/application/evento/BuscarEventoController.ts @@ -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 { + 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 + } + } +} diff --git a/src/application/evento/ListaEventosController.ts b/src/application/evento/ListaEventosController.ts new file mode 100644 index 00000000..b5655257 --- /dev/null +++ b/src/application/evento/ListaEventosController.ts @@ -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 +} + +export class ListaEventosController implements RequestHandler { + private readonly listaEventosUseCase: ListaEventosUseCase + + constructor(dependencies: Dependencies) { + this.listaEventosUseCase = dependencies.listaEventosUseCase + } + + async handle(request: HttpRequest, _next: NextHandler): Promise { + 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({ + expedicao_id: Number(expedicaoId) + }) + + if (result.left()) { + return new InternalServerError({ message: result.value.message }) + } + + return { + statusCode: StatusCode.Ok, + body: result.value + } + } +} diff --git a/src/application/evento/index.ts b/src/application/evento/index.ts new file mode 100644 index 00000000..268853bb --- /dev/null +++ b/src/application/evento/index.ts @@ -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' + } + ] +} diff --git a/src/domain/evento/BuscarEventoPorIdUseCase.ts b/src/domain/evento/BuscarEventoPorIdUseCase.ts new file mode 100644 index 00000000..76e08343 --- /dev/null +++ b/src/domain/evento/BuscarEventoPorIdUseCase.ts @@ -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> { + return this.eventoCollection.findById(id) + } +} diff --git a/src/domain/evento/ListaEventosUseCase.ts b/src/domain/evento/ListaEventosUseCase.ts new file mode 100644 index 00000000..8ee0c6ac --- /dev/null +++ b/src/domain/evento/ListaEventosUseCase.ts @@ -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> { + return this.eventoCollection.findAll(filters) + } +} diff --git a/test/unit/application/evento/BuscarEventoController.test.ts b/test/unit/application/evento/BuscarEventoController.test.ts new file mode 100644 index 00000000..c080e773 --- /dev/null +++ b/test/unit/application/evento/BuscarEventoController.test.ts @@ -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') + }) +}) diff --git a/test/unit/application/evento/ListaEventosController.test.ts b/test/unit/application/evento/ListaEventosController.test.ts new file mode 100644 index 00000000..e87411b6 --- /dev/null +++ b/test/unit/application/evento/ListaEventosController.test.ts @@ -0,0 +1,98 @@ +import { + describe, expect, test, vi +} from 'vitest' + +import { ListaEventosController } from '@/application/evento/ListaEventosController' +import { ListaEventosUseCase } from '@/domain/evento/ListaEventosUseCase' +import { Either } from '@/library/either/Either' +import { + Headers, HttpRequest, Method, StatusCode +} from '@/library/http/common' +import { BadRequestError } from '@/library/http/error/BadRequestError' + +describe('ListaEventosController', () => { + const headers = {} as Headers + + test('returns 200 with body when use case succeeds', async () => { + const lista = [ + { + 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 listaEventosUseCase = { + execute: vi.fn().mockResolvedValue(Either.right(lista)) + } as unknown as ListaEventosUseCase + + const controller = new ListaEventosController({ listaEventosUseCase }) + + const request = { + body: {}, + headers, + method: Method.Get, + params: { expedicaoId: '10' }, + path: '/expedicoes/10/eventos' + } satisfies HttpRequest + + const response = await controller.handle(request, vi.fn()) + + expect(listaEventosUseCase.execute).toHaveBeenCalledWith({ + expedicao_id: 10 + }) + expect('statusCode' in response ? response.statusCode : undefined).toBe(StatusCode.Ok) + expect('body' in response ? response.body : undefined).toEqual(lista) + }) + + test('returns BadRequest when expedicaoId is invalid', async () => { + const listaEventosUseCase = { + execute: vi.fn() + } as unknown as ListaEventosUseCase + + const controller = new ListaEventosController({ listaEventosUseCase }) + + const request = { + body: {}, + headers, + method: Method.Get, + params: { expedicaoId: 'abc' }, + path: '/expedicoes/abc/eventos' + } satisfies HttpRequest + + const response = await controller.handle(request, vi.fn()) + + expect(listaEventosUseCase.execute).not.toHaveBeenCalled() + expect(response).toBeInstanceOf(BadRequestError) + }) + + test('returns InternalServerError when use case fails', async () => { + const listaEventosUseCase = { + execute: vi.fn().mockResolvedValue(Either.left(new Error('boom'))) + } as unknown as ListaEventosUseCase + + const controller = new ListaEventosController({ listaEventosUseCase }) + + const request = { + body: {}, + headers, + method: Method.Get, + params: { expedicaoId: '10' }, + path: '/expedicoes/10/eventos' + } satisfies HttpRequest + + const response = await controller.handle(request, vi.fn()) + + expect((response as Error).name).toBe('InternalServerError') + }) +})