From 9e9979b3dd1fcc3488ee037e898903cf8e0aa632 Mon Sep 17 00:00:00 2001 From: maria-gabriela Date: Mon, 21 Sep 2026 11:07:42 -0300 Subject: [PATCH 1/2] feat: implementa leitura de eventos --- src/application/create-app.ts | 2 + .../evento/BuscarEventoController.ts | 51 +++++++ .../evento/ListaEventosController.ts | 46 +++++++ src/application/evento/index.ts | 37 ++++++ src/domain/evento/BuscarEventoPorIdUseCase.ts | 20 +++ src/domain/evento/ListaEventosUseCase.ts | 20 +++ .../evento/BuscarEventoController.test.ts | 125 ++++++++++++++++++ .../evento/ListaEventosController.test.ts | 98 ++++++++++++++ 8 files changed, 399 insertions(+) create mode 100644 src/application/evento/BuscarEventoController.ts create mode 100644 src/application/evento/ListaEventosController.ts create mode 100644 src/application/evento/index.ts create mode 100644 src/domain/evento/BuscarEventoPorIdUseCase.ts create mode 100644 src/domain/evento/ListaEventosUseCase.ts create mode 100644 test/unit/application/evento/BuscarEventoController.test.ts create mode 100644 test/unit/application/evento/ListaEventosController.test.ts 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') + }) +}) From 027f1186ba75f5d1cd1b084b6cda64055016e0f1 Mon Sep 17 00:00:00 2001 From: maria-gabriela Date: Thu, 24 Sep 2026 17:32:17 -0300 Subject: [PATCH 2/2] fix: ajusta leitura de eventos conforme review --- .../evento/ListaEventosController.ts | 34 +++++- src/domain/evento/EventoCollection.ts | 11 +- src/domain/evento/ListaEventosUseCase.ts | 6 +- .../EventoCollectionKnexAdapter.ts | 29 ++++- .../evento/evento-collection.test.ts | 51 ++++++++- .../integration/evento/eventos-routes.test.ts | 106 ++++++++++++++++++ .../evento/ListaEventosController.test.ts | 106 +++++++++++++----- 7 files changed, 303 insertions(+), 40 deletions(-) create mode 100644 test/integration/evento/eventos-routes.test.ts diff --git a/src/application/evento/ListaEventosController.ts b/src/application/evento/ListaEventosController.ts index b5655257..af1f8535 100644 --- a/src/application/evento/ListaEventosController.ts +++ b/src/application/evento/ListaEventosController.ts @@ -1,3 +1,4 @@ +import { EventoTipo } from '@/domain/evento/Evento' import { ListaEventosUseCase } from '@/domain/evento/ListaEventosUseCase' import { HttpRequest, HttpResponse, StatusCode @@ -19,7 +20,21 @@ export class ListaEventosController implements RequestHandler { } async handle(request: HttpRequest, _next: NextHandler): Promise { - const { expedicaoId } = request.params as { expedicaoId?: string } + const { + expedicaoId, + tipo, + capturado_de, + capturado_ate, + limite, + pagina + } = request.params as { + expedicaoId?: string + tipo?: string + capturado_de?: string + capturado_ate?: string + limite?: string + pagina?: string + } if ( expedicaoId === undefined @@ -30,9 +45,20 @@ export class ListaEventosController implements RequestHandler { return new BadRequestError({ message: 'expedicaoId inválido' }) } - const result = await this.listaEventosUseCase.execute({ - expedicao_id: Number(expedicaoId) - }) + const filters = { + expedicao_id: Number(expedicaoId), + ...(limite && !Number.isNaN(Number(limite)) + ? { limite: Number(limite) } + : {}), + ...(pagina && !Number.isNaN(Number(pagina)) + ? { pagina: Number(pagina) } + : {}), + ...(tipo ? { tipo: tipo as EventoTipo } : {}), + ...(capturado_de ? { capturado_de: new Date(capturado_de) } : {}), + ...(capturado_ate ? { capturado_ate: new Date(capturado_ate) } : {}) + } + + const result = await this.listaEventosUseCase.execute(filters) if (result.left()) { return new InternalServerError({ message: result.value.message }) diff --git a/src/domain/evento/EventoCollection.ts b/src/domain/evento/EventoCollection.ts index f329460d..b040e53d 100644 --- a/src/domain/evento/EventoCollection.ts +++ b/src/domain/evento/EventoCollection.ts @@ -15,10 +15,19 @@ export interface EventoFilters { capturado_de?: Date capturado_ate?: Date order?: EventoOrder + limite?: number + pagina?: number +} + +export interface Paginated { + itens: T[] + total: number + limite: number + pagina: number } export interface EventoCollection { - findAll(filters: EventoFilters): Promise> + findAll(filters: EventoFilters): Promise>> findById(id: number): Promise> create(attributes: CreateAttributes): Promise> } diff --git a/src/domain/evento/ListaEventosUseCase.ts b/src/domain/evento/ListaEventosUseCase.ts index 8ee0c6ac..ac2b3909 100644 --- a/src/domain/evento/ListaEventosUseCase.ts +++ b/src/domain/evento/ListaEventosUseCase.ts @@ -1,7 +1,9 @@ import { Either } from '@/library/either/Either' import { Attributes } from './Evento' -import { EventoCollection, EventoFilters } from './EventoCollection' +import { + EventoCollection, EventoFilters, Paginated +} from './EventoCollection' interface Dependencies { eventoCollection: EventoCollection @@ -14,7 +16,7 @@ export class ListaEventosUseCase { this.eventoCollection = dependencies.eventoCollection } - execute(filters: EventoFilters): Promise> { + execute(filters: EventoFilters): Promise>> { return this.eventoCollection.findAll(filters) } } diff --git a/src/infrastructure/EventoCollectionKnexAdapter.ts b/src/infrastructure/EventoCollectionKnexAdapter.ts index bda60696..8ce84b59 100644 --- a/src/infrastructure/EventoCollectionKnexAdapter.ts +++ b/src/infrastructure/EventoCollectionKnexAdapter.ts @@ -3,7 +3,9 @@ import { Knex } from 'knex' import { Attributes, ColetaAttributes, CreateAttributes, EventoTipo } from '@/domain/evento/Evento' -import { EventoCollection, EventoFilters } from '@/domain/evento/EventoCollection' +import { + EventoCollection, EventoFilters, Paginated +} from '@/domain/evento/EventoCollection' import { Either } from '@/library/either/Either' import { CollectionError } from './error/CollectionError' @@ -105,7 +107,7 @@ export class EventoCollectionKnexAdapter implements EventoCollection { ]) } - async findAll(filters: EventoFilters): Promise> { + async findAll(filters: EventoFilters): Promise>> { try { const query = this.select() @@ -125,11 +127,28 @@ export class EventoCollectionKnexAdapter implements EventoCollection { query.where('eventos.capturado_em', '<=', filters.capturado_ate) } - const order = filters.order ?? { column: 'capturado_em' as const, direction: 'desc' as const } - query.orderBy(`eventos.${order.column}`, order.direction) + const countQuery = query.clone() + + const countRows = await countQuery + .clearSelect() + .count>('* as count') + const [{ count }] = countRows + + const limite = filters.limite && filters.limite > 0 ? filters.limite : 20 + const pagina = filters.pagina && filters.pagina > 0 ? filters.pagina : 1 + const offset = (pagina - 1) * limite + + query.orderBy('eventos.capturado_em', 'desc') + query.orderBy('eventos.id', 'desc') + query.limit(limite).offset(offset) const rows = await query as Array> - return Either.right(rows.map(toAttributes)) + return Either.right({ + itens: rows.map(toAttributes), + total: Number(count), + limite, + pagina + }) } catch (error) { return Either.left(new CollectionError({ message: 'Failed to list eventos', cause: error })) } diff --git a/test/integration/evento/evento-collection.test.ts b/test/integration/evento/evento-collection.test.ts index 3a515dfb..b0d7c778 100644 --- a/test/integration/evento/evento-collection.test.ts +++ b/test/integration/evento/evento-collection.test.ts @@ -168,7 +168,7 @@ describe('EventoCollectionKnexAdapter', () => { expect(found.right()).toBe(true) if (!found.right()) return - expect(found.value.map(evento => evento.id)).toEqual([tarde.value.id, cedo.value.id]) + expect(found.value.itens.map(evento => evento.id)).toEqual([tarde.value.id, cedo.value.id]) } finally { await knex('eventos').whereIn('id', [cedo.value.id, tarde.value.id]).delete() } @@ -186,13 +186,58 @@ describe('EventoCollectionKnexAdapter', () => { expect(found.right()).toBe(true) if (!found.right()) return - expect(found.value.map(evento => evento.id)).toEqual([coleta.value.id]) - expect(found.value[0].coleta).toEqual(ficha) + expect(found.value.itens.map(evento => evento.id)).toEqual([coleta.value.id]) + expect(found.value.itens[0].coleta).toEqual(ficha) } finally { await knex('eventos').whereIn('id', [diario.value.id, coleta.value.id]).delete() } }) + test('filtra por intervalo de captura e pagina os resultados', async () => { + const antes = await collection.create({ + ...novoDiario(), capturado_em: new Date('2026-02-14T10:00:00.000Z') + }) + const dentro1 = await collection.create({ + ...novoDiario(), capturado_em: new Date('2026-02-15T10:00:00.000Z') + }) + const dentro2 = await collection.create({ + ...novaColeta(), capturado_em: new Date('2026-02-15T14:00:00.000Z') + }) + const depois = await collection.create({ + ...novoDiario(), capturado_em: new Date('2026-02-16T10:00:00.000Z') + }) + + expect(antes.right() && dentro1.right() && dentro2.right() && depois.right()).toBe(true) + if (!antes.right() || !dentro1.right() || !dentro2.right() || !depois.right()) return + + try { + const found = await collection.findAll({ + expedicao_id: expedicaoId, + capturado_de: new Date('2026-02-15T00:00:00.000Z'), + capturado_ate: new Date('2026-02-15T23:59:59.999Z'), + limite: 1, + pagina: 2 + }) + + expect(found.right()).toBe(true) + if (!found.right()) return + + expect(found.value.total).toBe(2) + expect(found.value.limite).toBe(1) + expect(found.value.pagina).toBe(2) + expect(found.value.itens.map(evento => evento.id)).toEqual([dentro1.value.id]) + } finally { + await knex('eventos') + .whereIn('id', [ + antes.value.id, + dentro1.value.id, + dentro2.value.id, + depois.value.id + ]) + .delete() + } + }) + test('apaga eventos e fichas junto com a expedição', async () => { const [expedicao] = await knex('expedicoes') .insert({ diff --git a/test/integration/evento/eventos-routes.test.ts b/test/integration/evento/eventos-routes.test.ts new file mode 100644 index 00000000..8f726b70 --- /dev/null +++ b/test/integration/evento/eventos-routes.test.ts @@ -0,0 +1,106 @@ +import { + afterAll, beforeAll, describe, expect, test +} from 'vitest' + +import { createTestApp } from '../setup/app-factory' +import { + cleanupExpedicaoFixtures, ExpedicaoFixtures, seedExpedicaoFixtures +} from '../setup/seeds/expedicao.seed' + +type ListaEventosResponse = { + itens: Array<{ id: number }> + total: number + limite: number + pagina: number +} + +type EventoResponse = { + id: number + tipo: string +} + +describe('Eventos routes', () => { + const { agent, knex } = createTestApp() + + let fixtures: ExpedicaoFixtures + let expedicaoId: number + + beforeAll(async () => { + fixtures = await seedExpedicaoFixtures(knex) + + const [expedicao] = await knex('expedicoes') + .insert({ + descricao: 'XEXP rotas eventos', + data_inicio: '2026-02-14', + data_fim: '2026-02-18', + cidade_id: fixtures.cidades[0] + }) + .returning>(['id']) + + expedicaoId = expedicao.id + }) + + test('lista os eventos da expedição com paginação e ordenação estável', async () => { + const capturadoEm = '2026-02-15T14:32:00.000Z' + + const inserted = await knex('eventos') + .insert([ + { + expedicao_id: expedicaoId, + tipo: 'DIARIO', + capturado_em: capturadoEm + }, + { + expedicao_id: expedicaoId, + tipo: 'COLETA', + capturado_em: capturadoEm + } + ]) + .returning>(['id']) + + try { + const response = await agent + .get(`/api/v2/expedicoes/${expedicaoId}/eventos`) + .expect(200) + + const body = response.body as ListaEventosResponse + + expect(body.total).toBe(2) + expect(body.limite).toBe(20) + expect(body.pagina).toBe(1) + expect(body.itens.map(evento => evento.id)) + .toEqual([...inserted].map(evento => evento.id).sort((a, b) => b - a)) + } finally { + await knex('eventos').whereIn('id', inserted.map(evento => evento.id)).delete() + } + }) + + test('busca um evento pelo id', async () => { + const [evento] = await knex('eventos') + .insert({ + expedicao_id: expedicaoId, + tipo: 'DIARIO', + capturado_em: '2026-02-16T10:00:00.000Z' + }) + .returning>(['id']) + + try { + const response = await agent + .get(`/api/v2/eventos/${evento.id}`) + .expect(200) + + const body = response.body as EventoResponse + + expect(body.id).toBe(evento.id) + expect(body.tipo).toBe('DIARIO') + } finally { + await knex('eventos').where({ id: evento.id }).delete() + } + }) + + afterAll(async () => { + await knex('expedicoes').where({ id: expedicaoId }).delete() + await cleanupExpedicaoFixtures(knex, fixtures) + await knex.destroy() + }) +}) diff --git a/test/unit/application/evento/ListaEventosController.test.ts b/test/unit/application/evento/ListaEventosController.test.ts index e87411b6..40d80593 100644 --- a/test/unit/application/evento/ListaEventosController.test.ts +++ b/test/unit/application/evento/ListaEventosController.test.ts @@ -1,39 +1,47 @@ import { - describe, expect, test, vi + 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 + Headers, + HttpRequest, + Method, + StatusCode } from '@/library/http/common' import { BadRequestError } from '@/library/http/error/BadRequestError' +import { InternalServerError } from '@/library/http/error/InternalServerError' -describe('ListaEventosController', () => { - const headers = {} as Headers +const headers: Headers = { + 'Content-Length': 0, + 'Content-Type': 'application/json' +} +describe('ListaEventosController', () => { 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 + capturado_em: new Date('2026-09-01T10:00:00.000Z') } ] + const resultado = { + itens: lista, + total: 1, + limite: 20, + pagina: 1 + } + const listaEventosUseCase = { - execute: vi.fn().mockResolvedValue(Either.right(lista)) + execute: vi.fn().mockResolvedValue(Either.right(resultado)) } as unknown as ListaEventosUseCase const controller = new ListaEventosController({ listaEventosUseCase }) @@ -42,17 +50,18 @@ describe('ListaEventosController', () => { body: {}, headers, method: Method.Get, - params: { expedicaoId: '10' }, + 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(response).toEqual({ + statusCode: StatusCode.Ok, + body: resultado }) - 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 () => { @@ -66,19 +75,23 @@ describe('ListaEventosController', () => { body: {}, headers, method: Method.Get, - params: { expedicaoId: 'abc' }, + 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) + expect(listaEventosUseCase.execute).not.toHaveBeenCalled() }) test('returns InternalServerError when use case fails', async () => { const listaEventosUseCase = { - execute: vi.fn().mockResolvedValue(Either.left(new Error('boom'))) + execute: vi.fn().mockResolvedValue( + Either.left(new Error('erro ao listar eventos')) + ) } as unknown as ListaEventosUseCase const controller = new ListaEventosController({ listaEventosUseCase }) @@ -87,12 +100,55 @@ describe('ListaEventosController', () => { body: {}, headers, method: Method.Get, - params: { expedicaoId: '10' }, + params: { + expedicaoId: '10' + }, path: '/expedicoes/10/eventos' } satisfies HttpRequest const response = await controller.handle(request, vi.fn()) - expect((response as Error).name).toBe('InternalServerError') + expect(response).toBeInstanceOf(InternalServerError) + }) + + test('passes filters and pagination to use case', async () => { + const resultado = { + itens: [], + total: 0, + limite: 10, + pagina: 2 + } + + const listaEventosUseCase = { + execute: vi.fn().mockResolvedValue(Either.right(resultado)) + } as unknown as ListaEventosUseCase + + const controller = new ListaEventosController({ listaEventosUseCase }) + + const request = { + body: {}, + headers, + method: Method.Get, + params: { + expedicaoId: '10', + tipo: 'COLETA', + capturado_de: '2026-09-01T00:00:00.000Z', + capturado_ate: '2026-09-15T23:59:59.999Z', + limite: '10', + pagina: '2' + }, + path: '/expedicoes/10/eventos' + } satisfies HttpRequest + + await controller.handle(request, vi.fn()) + + expect(listaEventosUseCase.execute).toHaveBeenCalledWith({ + expedicao_id: 10, + tipo: 'COLETA', + capturado_de: new Date('2026-09-01T00:00:00.000Z'), + capturado_ate: new Date('2026-09-15T23:59:59.999Z'), + limite: 10, + pagina: 2 + }) }) })