diff --git a/src/application/create-app.ts b/src/application/create-app.ts index a3b3901c..0e18b894 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' @@ -59,7 +60,8 @@ export function createApp({ ...createPaisRoutes(knex), ...createEstadoRoutes(knex), ...createFaseSucessionalRoutes(knex), - ...createVegetacaoRoutes(knex) + ...createVegetacaoRoutes(knex), + ...createEventoRoutes(knex) ] const application = new ExpressApplication({ logger }) diff --git a/src/application/evento/AtualizarEventoController.ts b/src/application/evento/AtualizarEventoController.ts new file mode 100644 index 00000000..edf6838d --- /dev/null +++ b/src/application/evento/AtualizarEventoController.ts @@ -0,0 +1,153 @@ +import { AtualizarEventoUseCase, Input as AtualizarEventoInput } from '@/domain/evento/AtualizarEventoUseCase' +import { EVENTO_TIPOS, EventoTipo } from '@/domain/evento/Evento' +import { CheckViolationError } from '@/infrastructure/error/CheckViolationError' +import { ForeignKeyViolationError } from '@/infrastructure/error/ForeignKeyViolationError' +import { InfrastructureError } from '@/infrastructure/error/InfrastructureError' +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 { UnprocessableEntityError } from '@/library/http/error/UnprocessableEntityError' +import { NextHandler, RequestHandler } from '@/library/http/Server' + +import { parseColeta } from './coleta-parsing' + +interface Dependencies { + atualizarEventoUseCase: AtualizarEventoUseCase +} + +interface Body { + tipo?: unknown + capturado_em?: unknown + latitude?: unknown + longitude?: unknown + altitude?: unknown + observacoes?: unknown + coleta?: unknown +} + +export class AtualizarEventoController implements RequestHandler { + private readonly atualizarEventoUseCase: AtualizarEventoUseCase + + constructor(dependencies: Dependencies) { + this.atualizarEventoUseCase = dependencies.atualizarEventoUseCase + } + + async handle(request: HttpRequest, _next: NextHandler): Promise { + const { eventoId: rawEventoId } = request.params as { eventoId?: string } + const eventoId = parseId(rawEventoId, 'eventoId') + if (eventoId instanceof Error) return new BadRequestError({ message: eventoId.message }) + + const body = (request.body ?? {}) as Body + + // Substituir por request.usuario.id assim que a + // autenticação for integrada. + const input: AtualizarEventoInput = { id: eventoId, updated_by: null } + + if (body.tipo !== undefined) { + const tipo = parseTipo(body.tipo) + if (tipo instanceof Error) return new BadRequestError({ message: tipo.message }) + input.tipo = tipo + } + + if (body.capturado_em !== undefined) { + const capturadoEm = parseDate(body.capturado_em, 'capturado_em') + if (capturadoEm instanceof Error) return new BadRequestError({ message: capturadoEm.message }) + input.capturado_em = capturadoEm + } + + if (body.latitude !== undefined) { + const latitude = parseOptionalNumber(body.latitude, 'latitude') + if (latitude instanceof Error) return new BadRequestError({ message: latitude.message }) + input.latitude = latitude + } + + if (body.longitude !== undefined) { + const longitude = parseOptionalNumber(body.longitude, 'longitude') + if (longitude instanceof Error) return new BadRequestError({ message: longitude.message }) + input.longitude = longitude + } + + if (body.altitude !== undefined) { + const altitude = parseOptionalNumber(body.altitude, 'altitude') + if (altitude instanceof Error) return new BadRequestError({ message: altitude.message }) + input.altitude = altitude + } + + if (body.observacoes !== undefined) { + const observacoes = parseOptionalString(body.observacoes, 'observacoes') + if (observacoes instanceof Error) return new BadRequestError({ message: observacoes.message }) + input.observacoes = observacoes + } + + if (body.coleta !== undefined) { + const coleta = body.coleta === null ? null : parseColeta(body.coleta) + if (coleta instanceof Error) return new BadRequestError({ message: coleta.message }) + input.coleta = coleta + } + + const result = await this.atualizarEventoUseCase.execute(input) + + if (result.left()) { + const error = result.value + if (error instanceof ForeignKeyViolationError) return new NotFoundError({ message: error.message }) + if (error instanceof CheckViolationError) return new UnprocessableEntityError({ message: error.message }) + if (!(error instanceof InfrastructureError)) return new BadRequestError({ message: error.message }) + return new InternalServerError({ message: error.message }) + } + + if (!result.value) { + return new NotFoundError({ message: 'Evento não encontrado' }) + } + + return { body: result.value, statusCode: StatusCode.Ok } + } +} + +function parseId(raw: unknown, field: string): number | Error { + if (typeof raw !== 'string' || !/^\d+$/.test(raw)) { + return new Error(`${field} inválido`) + } + return Number(raw) +} + +function parseTipo(raw: unknown): EventoTipo | Error { + if (typeof raw !== 'string' || !EVENTO_TIPOS.includes(raw as EventoTipo)) { + return new Error(`tipo inválido. Use um de: ${EVENTO_TIPOS.join(', ')}`) + } + return raw as EventoTipo +} + +function parseDate(raw: unknown, field: string): Date | Error { + if (typeof raw !== 'string') { + return new Error(`${field} inválido. Use uma data no formato ISO 8601`) + } + const date = new Date(raw) + if (Number.isNaN(date.getTime())) { + return new Error(`${field} inválido. Use uma data no formato ISO 8601`) + } + return date +} + +function parseOptionalNumber(raw: unknown, field: string): number | null | Error { + if (raw === undefined || raw === null) { + return null + } + if (typeof raw !== 'number' || Number.isNaN(raw)) { + return new Error(`${field} inválido`) + } + return raw +} + +function parseOptionalString(raw: unknown, field: string): string | null | Error { + if (raw === undefined || raw === null) { + return null + } + if (typeof raw !== 'string') { + return new Error(`${field} inválido`) + } + return raw +} diff --git a/src/application/evento/CriarEventoController.ts b/src/application/evento/CriarEventoController.ts new file mode 100644 index 00000000..6fb0276c --- /dev/null +++ b/src/application/evento/CriarEventoController.ts @@ -0,0 +1,138 @@ +import { CriarEventoUseCase } from '@/domain/evento/CriarEventoUseCase' +import { EVENTO_TIPOS, EventoTipo } from '@/domain/evento/Evento' +import { CheckViolationError } from '@/infrastructure/error/CheckViolationError' +import { ForeignKeyViolationError } from '@/infrastructure/error/ForeignKeyViolationError' +import { InfrastructureError } from '@/infrastructure/error/InfrastructureError' +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 { UnprocessableEntityError } from '@/library/http/error/UnprocessableEntityError' +import { NextHandler, RequestHandler } from '@/library/http/Server' + +import { parseColeta } from './coleta-parsing' + +interface Dependencies { + criarEventoUseCase: CriarEventoUseCase +} + +interface Body { + tipo?: unknown + capturado_em?: unknown + latitude?: unknown + longitude?: unknown + altitude?: unknown + observacoes?: unknown + coleta?: unknown +} + +export class CriarEventoController implements RequestHandler { + private readonly criarEventoUseCase: CriarEventoUseCase + + constructor(dependencies: Dependencies) { + this.criarEventoUseCase = dependencies.criarEventoUseCase + } + + async handle(request: HttpRequest, _next: NextHandler): Promise { + const { expedicaoId: rawExpedicaoId } = request.params as { expedicaoId?: string } + const expedicaoId = parseId(rawExpedicaoId, 'expedicaoId') + if (expedicaoId instanceof Error) return new BadRequestError({ message: expedicaoId.message }) + + const body = (request.body ?? {}) as Body + + const tipo = parseTipo(body.tipo) + if (tipo instanceof Error) return new BadRequestError({ message: tipo.message }) + + const capturadoEm = parseDate(body.capturado_em, 'capturado_em') + if (capturadoEm instanceof Error) return new BadRequestError({ message: capturadoEm.message }) + + const latitude = parseOptionalNumber(body.latitude, 'latitude') + if (latitude instanceof Error) return new BadRequestError({ message: latitude.message }) + + const longitude = parseOptionalNumber(body.longitude, 'longitude') + if (longitude instanceof Error) return new BadRequestError({ message: longitude.message }) + + const altitude = parseOptionalNumber(body.altitude, 'altitude') + if (altitude instanceof Error) return new BadRequestError({ message: altitude.message }) + + const observacoes = parseOptionalString(body.observacoes, 'observacoes') + if (observacoes instanceof Error) return new BadRequestError({ message: observacoes.message }) + + const coleta = body.coleta === undefined ? null : parseColeta(body.coleta) + if (coleta instanceof Error) return new BadRequestError({ message: coleta.message }) + + // Substituir por request.usuario.id assim que a + // autenticação for integrada. + const usuarioId = null + + const result = await this.criarEventoUseCase.execute({ + altitude, + capturado_em: capturadoEm, + coleta, + created_by: usuarioId, + expedicao_id: expedicaoId, + latitude, + longitude, + observacoes, + tipo + }) + + if (result.left()) { + const error = result.value + if (error instanceof ForeignKeyViolationError) return new NotFoundError({ message: error.message }) + if (error instanceof CheckViolationError) return new UnprocessableEntityError({ message: error.message }) + if (!(error instanceof InfrastructureError)) return new BadRequestError({ message: error.message }) + return new InternalServerError({ message: error.message }) + } + + return { body: result.value, statusCode: StatusCode.Created } + } +} + +function parseId(raw: unknown, field: string): number | Error { + if (typeof raw !== 'string' || !/^\d+$/.test(raw)) { + return new Error(`${field} inválido`) + } + return Number(raw) +} + +function parseTipo(raw: unknown): EventoTipo | Error { + if (typeof raw !== 'string' || !EVENTO_TIPOS.includes(raw as EventoTipo)) { + return new Error(`tipo inválido. Use um de: ${EVENTO_TIPOS.join(', ')}`) + } + return raw as EventoTipo +} + +function parseDate(raw: unknown, field: string): Date | Error { + if (typeof raw !== 'string') { + return new Error(`${field} inválido. Use uma data no formato ISO 8601`) + } + const date = new Date(raw) + if (Number.isNaN(date.getTime())) { + return new Error(`${field} inválido. Use uma data no formato ISO 8601`) + } + return date +} + +function parseOptionalNumber(raw: unknown, field: string): number | null | Error { + if (raw === undefined || raw === null) { + return null + } + if (typeof raw !== 'number' || Number.isNaN(raw)) { + return new Error(`${field} inválido`) + } + return raw +} + +function parseOptionalString(raw: unknown, field: string): string | null | Error { + if (raw === undefined || raw === null) { + return null + } + if (typeof raw !== 'string') { + return new Error(`${field} inválido`) + } + return raw +} diff --git a/src/application/evento/RemoverEventoController.ts b/src/application/evento/RemoverEventoController.ts new file mode 100644 index 00000000..f2bb5a70 --- /dev/null +++ b/src/application/evento/RemoverEventoController.ts @@ -0,0 +1,51 @@ +import { RemoverEventoUseCase } from '@/domain/evento/RemoverEventoUseCase' +import { CheckViolationError } from '@/infrastructure/error/CheckViolationError' +import { ForeignKeyViolationError } from '@/infrastructure/error/ForeignKeyViolationError' +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 { UnprocessableEntityError } from '@/library/http/error/UnprocessableEntityError' +import { NextHandler, RequestHandler } from '@/library/http/Server' + +interface Dependencies { + removerEventoUseCase: RemoverEventoUseCase +} + +export class RemoverEventoController implements RequestHandler { + private readonly removerEventoUseCase: RemoverEventoUseCase + + constructor(dependencies: Dependencies) { + this.removerEventoUseCase = dependencies.removerEventoUseCase + } + + async handle(request: HttpRequest, _next: NextHandler): Promise { + const { eventoId: rawEventoId } = request.params as { eventoId?: string } + const eventoId = parseId(rawEventoId, 'eventoId') + if (eventoId instanceof Error) return new BadRequestError({ message: eventoId.message }) + + const result = await this.removerEventoUseCase.execute({ id: eventoId }) + + if (result.left()) { + const error = result.value + if (error instanceof ForeignKeyViolationError) return new NotFoundError({ message: error.message }) + if (error instanceof CheckViolationError) return new UnprocessableEntityError({ message: error.message }) + return new InternalServerError({ message: error.message }) + } + if (!result.value) { + return new NotFoundError({ message: 'Evento não encontrado' }) + } + + return { statusCode: StatusCode.NoContent } + } +} + +function parseId(raw: unknown, field: string): number | Error { + if (typeof raw !== 'string' || !/^\d+$/.test(raw)) { + return new Error(`${field} inválido`) + } + return Number(raw) +} diff --git a/src/application/evento/coleta-parsing.ts b/src/application/evento/coleta-parsing.ts new file mode 100644 index 00000000..92c09ca0 --- /dev/null +++ b/src/application/evento/coleta-parsing.ts @@ -0,0 +1,25 @@ +import { COLETA_FIELDS, ColetaAttributes } from '@/domain/evento/Evento' + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function parseColeta(raw: unknown): ColetaAttributes | Error { + if (!isPlainObject(raw)) { + return new Error('coleta inválido. Envie um objeto com os campos da ficha') + } + + const result = {} as Record + for (const campo of COLETA_FIELDS) { + const value = raw[campo] + if (value === undefined) { + result[campo] = null + continue + } + if (value !== null && typeof value !== 'string') { + return new Error(`coleta.${campo} inválido`) + } + result[campo] = value + } + return result as unknown as ColetaAttributes +} diff --git a/src/application/evento/index.ts b/src/application/evento/index.ts new file mode 100644 index 00000000..0cd3f0cc --- /dev/null +++ b/src/application/evento/index.ts @@ -0,0 +1,46 @@ +import { type Knex } from 'knex' + +import { AtualizarEventoUseCase } from '@/domain/evento/AtualizarEventoUseCase' +import { CriarEventoUseCase } from '@/domain/evento/CriarEventoUseCase' +import { RemoverEventoUseCase } from '@/domain/evento/RemoverEventoUseCase' +import { EventoCollectionKnexAdapter } from '@/infrastructure/EventoCollectionKnexAdapter' +import { Method } from '@/library/http/common' +import { Route } from '@/library/http/Router' + +import { AtualizarEventoController } from './AtualizarEventoController' +import { CriarEventoController } from './CriarEventoController' +import { RemoverEventoController } from './RemoverEventoController' + +export function routes(knex: Knex): Route[] { + const eventoCollection = new EventoCollectionKnexAdapter({ knex }) + + return [ + { + handlers: [ + new CriarEventoController({ + criarEventoUseCase: new CriarEventoUseCase({ eventoCollection }) + }) + ], + method: Method.Post, + path: '/v2/expedicoes/:expedicaoId/eventos' + }, + { + handlers: [ + new AtualizarEventoController({ + atualizarEventoUseCase: new AtualizarEventoUseCase({ eventoCollection }) + }) + ], + method: Method.Put, + path: '/v2/eventos/:eventoId' + }, + { + handlers: [ + new RemoverEventoController({ + removerEventoUseCase: new RemoverEventoUseCase({ eventoCollection }) + }) + ], + method: Method.Delete, + path: '/v2/eventos/:eventoId' + } + ] +} diff --git a/src/domain/evento/AtualizarEventoUseCase.ts b/src/domain/evento/AtualizarEventoUseCase.ts new file mode 100644 index 00000000..8bb94495 --- /dev/null +++ b/src/domain/evento/AtualizarEventoUseCase.ts @@ -0,0 +1,57 @@ +import { Either } from '@/library/either/Either' + +import { AtualizarEventoInput, AtualizarEventoValidator } from './AtualizarEventoValidator' +import { Attributes } from './Evento' +import { AtualizarEventoAttributes, EventoCollection } from './EventoCollection' + +interface Dependencies { + eventoCollection: EventoCollection + atualizarEventoValidator?: AtualizarEventoValidator +} + +export type Input = AtualizarEventoInput & { id: number } + +/** + * O PUT do agregado evento+ficha. A regra de coerência própria do update + * (quando `tipo` da ficha muda), agora está em AtualizarEventoValidator. + */ +export class AtualizarEventoUseCase { + private readonly eventoCollection: EventoCollection + private readonly atualizarEventoValidator: AtualizarEventoValidator + + constructor(dependencies: Dependencies) { + this.eventoCollection = dependencies.eventoCollection + this.atualizarEventoValidator = dependencies.atualizarEventoValidator ?? new AtualizarEventoValidator() + } + + async execute(input: Input): Promise> { + const existente = await this.eventoCollection.findById(input.id) + if (existente.left()) { + return Either.left(existente.value) + } + + if (!existente.value) { + return Either.right(null) + } + + const validado = this.atualizarEventoValidator.validar(existente.value, input) + if (validado.left()) { + return Either.left(validado.value) + } + + const mesclado = validado.value + + const patch: AtualizarEventoAttributes = { + altitude: mesclado.altitude, + capturado_em: mesclado.capturado_em, + coleta: mesclado.coleta, + latitude: mesclado.latitude, + longitude: mesclado.longitude, + observacoes: mesclado.observacoes, + tipo: mesclado.tipo, + updated_by: mesclado.updated_by + } + + return this.eventoCollection.update(input.id, patch) + } +} diff --git a/src/domain/evento/AtualizarEventoValidator.ts b/src/domain/evento/AtualizarEventoValidator.ts new file mode 100644 index 00000000..d90a66b8 --- /dev/null +++ b/src/domain/evento/AtualizarEventoValidator.ts @@ -0,0 +1,54 @@ +import { Either } from '@/library/either/Either' + +import { + Attributes, ColetaAttributes, Evento, EventoTipo +} from './Evento' + +export interface AtualizarEventoInput { + tipo?: EventoTipo + capturado_em?: Date + latitude?: number | null + longitude?: number | null + altitude?: number | null + observacoes?: string | null + coleta?: ColetaAttributes | null + updated_by: number | null +} + +/** + * Regra de coerência própria do update, `tipo` e `coleta` não são independentes: + * - vira DIARIO -> a ficha é descartada, não importa o que o cliente mandou + * - vira COLETA sem `coleta` no corpo -> mantém a ficha atual + * - vira COLETA com `coleta` no corpo -> substitui a ficha + * + * Reaproveita de Evento.create() para checagem final de coerência (tipo/ficha, + * coordenadas, capturado_em) sobre o estado já mesclado, afim não duplicar + * essa regra em dois lugares. + */ + +export class AtualizarEventoValidator { + validar(atual: Attributes, input: AtualizarEventoInput): Either { + const mesclado = this.mesclar(atual, input) + return Evento.create(mesclado) + } + + private mesclar(atual: Attributes, input: AtualizarEventoInput): Attributes { + const tipo = input.tipo ?? atual.tipo + + const coleta = tipo === 'DIARIO' + ? null + : (input.coleta !== undefined ? input.coleta : atual.coleta) + + return { + ...atual, + altitude: input.altitude !== undefined ? input.altitude : atual.altitude, + capturado_em: input.capturado_em ?? atual.capturado_em, + coleta, + latitude: input.latitude !== undefined ? input.latitude : atual.latitude, + longitude: input.longitude !== undefined ? input.longitude : atual.longitude, + observacoes: input.observacoes !== undefined ? input.observacoes : atual.observacoes, + tipo, + updated_by: input.updated_by + } + } +} diff --git a/src/domain/evento/CriarEventoUseCase.ts b/src/domain/evento/CriarEventoUseCase.ts new file mode 100644 index 00000000..3c868feb --- /dev/null +++ b/src/domain/evento/CriarEventoUseCase.ts @@ -0,0 +1,36 @@ +import { Either } from '@/library/either/Either' + +import { + Attributes, CreateAttributes, Evento +} from './Evento' +import { EventoCollection } from './EventoCollection' + +interface Dependencies { + eventoCollection: EventoCollection +} + +export class CriarEventoUseCase { + private readonly eventoCollection: EventoCollection + + constructor(dependencies: Dependencies) { + this.eventoCollection = dependencies.eventoCollection + } + + async execute(input: CreateAttributes): Promise> { + // Evento.create() exige um Attributes completo, usado para validar + // a coerência de negócio (tipo/ficha, coordenadas,capturado_em). + const validated = Evento.create({ + ...input, + created_at: new Date(), + id: 0, + updated_at: new Date(), + updated_by: input.created_by + }) + + if (validated.left()) { + return Either.left(validated.value) + } + + return this.eventoCollection.create(input) + } +} diff --git a/src/domain/evento/Evento.ts b/src/domain/evento/Evento.ts index 7813a99c..1ff2eedd 100644 --- a/src/domain/evento/Evento.ts +++ b/src/domain/evento/Evento.ts @@ -24,6 +24,29 @@ export interface ColetaAttributes { luminosidade: string | null } +/** + * Lista de campos da ficha de coleta. + */ +export const COLETA_FIELDS = [ + 'familia', + 'nome_popular', + 'nome_cientifico', + 'municipio', + 'estado', + 'referencia_local', + 'tipo_vegetacao', + 'solo', + 'relevo', + 'substrato', + 'tronco_com_casca', + 'associacoes', + 'folhas', + 'habito', + 'frutos', + 'flores', + 'luminosidade' +] as const satisfies ReadonlyArray + export interface Attributes { id: number expedicao_id: number diff --git a/src/domain/evento/EventoCollection.ts b/src/domain/evento/EventoCollection.ts index f329460d..1be6a2c4 100644 --- a/src/domain/evento/EventoCollection.ts +++ b/src/domain/evento/EventoCollection.ts @@ -1,7 +1,7 @@ import { Either } from '@/library/either/Either' import { - Attributes, CreateAttributes, EventoTipo + Attributes, ColetaAttributes, CreateAttributes, EventoTipo } from './Evento' export interface EventoOrder { @@ -17,8 +17,21 @@ export interface EventoFilters { order?: EventoOrder } +export interface AtualizarEventoAttributes { + tipo: EventoTipo + capturado_em: Date + latitude: number | null + longitude: number | null + altitude: number | null + observacoes: string | null + coleta: ColetaAttributes | null + updated_by: number | null +} + export interface EventoCollection { findAll(filters: EventoFilters): Promise> findById(id: number): Promise> create(attributes: CreateAttributes): Promise> + update(id: number, attributes: AtualizarEventoAttributes): Promise> + delete(id: number): Promise> } diff --git a/src/domain/evento/RemoverEventoUseCase.ts b/src/domain/evento/RemoverEventoUseCase.ts new file mode 100644 index 00000000..40e5a66d --- /dev/null +++ b/src/domain/evento/RemoverEventoUseCase.ts @@ -0,0 +1,19 @@ +import { Either } from '@/library/either/Either' + +import { EventoCollection } from './EventoCollection' + +interface Dependencies { + eventoCollection: EventoCollection +} + +export class RemoverEventoUseCase { + private readonly eventoCollection: EventoCollection + + constructor(dependencies: Dependencies) { + this.eventoCollection = dependencies.eventoCollection + } + + execute({ id }: { id: number }): Promise> { + return this.eventoCollection.delete(id) + } +} diff --git a/src/infrastructure/EventoCollectionKnexAdapter.ts b/src/infrastructure/EventoCollectionKnexAdapter.ts index bda60696..f2ba0477 100644 --- a/src/infrastructure/EventoCollectionKnexAdapter.ts +++ b/src/infrastructure/EventoCollectionKnexAdapter.ts @@ -1,38 +1,25 @@ import { Knex } from 'knex' import { - Attributes, ColetaAttributes, CreateAttributes, EventoTipo + Attributes, COLETA_FIELDS, ColetaAttributes, CreateAttributes, EventoTipo } from '@/domain/evento/Evento' -import { EventoCollection, EventoFilters } from '@/domain/evento/EventoCollection' +import { + AtualizarEventoAttributes, EventoCollection, EventoFilters +} from '@/domain/evento/EventoCollection' import { Either } from '@/library/either/Either' +import { CheckViolationError } from './error/CheckViolationError' import { CollectionError } from './error/CollectionError' +import { ForeignKeyViolationError } from './error/ForeignKeyViolationError' import { toNullableNumber } from './pg-column' +import { + PG_CHECK_VIOLATION, PG_FOREIGN_KEY_VIOLATION, pgErrorCode +} from './pg-error' interface Dependencies { knex: Knex } -const CAMPOS_DA_FICHA = [ - 'familia', - 'nome_popular', - 'nome_cientifico', - 'municipio', - 'estado', - 'referencia_local', - 'tipo_vegetacao', - 'solo', - 'relevo', - 'substrato', - 'tronco_com_casca', - 'associacoes', - 'folhas', - 'habito', - 'frutos', - 'flores', - 'luminosidade' -] as const - interface Row { id: number expedicao_id: number @@ -54,7 +41,7 @@ function toAttributes(row: Row & Record): Attributes { if (row.coleta_evento_id !== null) { coleta = Object.fromEntries( - CAMPOS_DA_FICHA.map(campo => [campo, row[`coleta_${campo}`] ?? null]) + COLETA_FIELDS.map(campo => [campo, row[`coleta_${campo}`] ?? null]) ) as unknown as ColetaAttributes } @@ -75,6 +62,20 @@ function toAttributes(row: Row & Record): Attributes { } } +function toInfrastructureError(message: string, error: unknown): Error { + const code = pgErrorCode(error) + + if (code === PG_FOREIGN_KEY_VIOLATION) { + return new ForeignKeyViolationError({ message: 'Expedição não encontrada', cause: error }) + } + + if (code === PG_CHECK_VIOLATION) { + return new CheckViolationError({ message: 'tipo deve ser "DIARIO" ou "COLETA"', cause: error }) + } + + return new CollectionError({ message, cause: error }) +} + export class EventoCollectionKnexAdapter implements EventoCollection { private readonly knex: Knex @@ -101,7 +102,7 @@ export class EventoCollectionKnexAdapter implements EventoCollection { 'eventos.created_by', 'eventos.updated_by', 'eventos_coletas.evento_id as coleta_evento_id', - ...CAMPOS_DA_FICHA.map(campo => `eventos_coletas.${campo} as coleta_${campo}`) + ...COLETA_FIELDS.map(campo => `eventos_coletas.${campo} as coleta_${campo}`) ]) } @@ -179,7 +180,66 @@ export class EventoCollectionKnexAdapter implements EventoCollection { return Either.right(toAttributes(created)) } catch (error) { - return Either.left(new CollectionError({ message: 'Failed to create evento', cause: error })) + return Either.left(toInfrastructureError('Failed to create evento', error)) + } + } + + /** + * O PUT do agregado: trocar tipo de COLETA para DIARIO apaga a ficha; o + * caminho inverso cria a ficha; manter o tipo e mandar `coleta` faz upsert + * (evento_id é PK de eventos_coletas). + */ + async update(id: number, attributes: AtualizarEventoAttributes): Promise> { + try { + const updated = await this.knex.transaction(async trx => { + const updatedRows = await trx('eventos') + .where({ id }) + .update({ + tipo: attributes.tipo, + capturado_em: attributes.capturado_em, + latitude: attributes.latitude, + longitude: attributes.longitude, + altitude: attributes.altitude, + observacoes: attributes.observacoes, + updated_by: attributes.updated_by, + updated_at: trx.fn.now() + }) + .returning>(['id']) + + if (updatedRows.length === 0) { + return null + } + + if (attributes.coleta) { + await trx('eventos_coletas') + .insert({ evento_id: id, ...attributes.coleta }) + .onConflict('evento_id') + .merge() + } else { + await trx('eventos_coletas').where({ evento_id: id }).delete() + } + + return await this.select(trx) + .where('eventos.id', id) + .first() as (Row & Record) | undefined + }) + + return Either.right(updated ? toAttributes(updated) : null) + } catch (error) { + return Either.left(toInfrastructureError('Failed to update evento', error)) + } + } + + /** + * ON DELETE CASCADE de eventos_coletas.evento_id cuida da ficha; impedindo + * limpeza duplicada. + */ + async delete(id: number): Promise> { + try { + const deletedCount = await this.knex('eventos').where({ id }).delete() + return Either.right(deletedCount > 0) + } catch (error) { + return Either.left(new CollectionError({ message: 'Failed to delete evento', cause: error })) } } } diff --git a/src/infrastructure/error/CheckViolationError.ts b/src/infrastructure/error/CheckViolationError.ts new file mode 100644 index 00000000..70149ae6 --- /dev/null +++ b/src/infrastructure/error/CheckViolationError.ts @@ -0,0 +1,7 @@ +import { InfrastructureError } from './InfrastructureError' + +export class CheckViolationError extends InfrastructureError { + constructor(params: { message: string; cause?: unknown }) { + super(params) + } +} diff --git a/src/infrastructure/error/ForeignKeyViolationError.ts b/src/infrastructure/error/ForeignKeyViolationError.ts new file mode 100644 index 00000000..067234d8 --- /dev/null +++ b/src/infrastructure/error/ForeignKeyViolationError.ts @@ -0,0 +1,7 @@ +import { InfrastructureError } from './InfrastructureError' + +export class ForeignKeyViolationError extends InfrastructureError { + constructor(params: { message: string; cause?: unknown }) { + super(params) + } +} diff --git a/src/infrastructure/pg-error.ts b/src/infrastructure/pg-error.ts new file mode 100644 index 00000000..32f8c0d7 --- /dev/null +++ b/src/infrastructure/pg-error.ts @@ -0,0 +1,15 @@ +/** + * Códigos de erro (SQLSTATE) do Postgres que a camada de infraestrutura + * precisa reconhecer para traduzir Either.left em um status HTTP correto, + * em vez de cair tudo como 500 genérico. + */ +export const PG_FOREIGN_KEY_VIOLATION = '23503' +export const PG_CHECK_VIOLATION = '23514' + +export function pgErrorCode(error: unknown): string | undefined { + if (typeof error === 'object' && error !== null && 'code' in error) { + const code = (error as { code?: unknown }).code + return typeof code === 'string' ? code : undefined + } + return undefined +} diff --git a/src/library/http/error/UnprocessableEntityError.ts b/src/library/http/error/UnprocessableEntityError.ts new file mode 100644 index 00000000..5cf783d7 --- /dev/null +++ b/src/library/http/error/UnprocessableEntityError.ts @@ -0,0 +1,7 @@ +import { HttpError } from './HttpError' + +export class UnprocessableEntityError extends HttpError { + constructor(params: { message: string; report?: unknown; cause?: unknown }) { + super({ ...params, statusCode: 422 }) + } +} diff --git a/test/integration/evento/eventos-http.test.ts b/test/integration/evento/eventos-http.test.ts new file mode 100644 index 00000000..ff9df775 --- /dev/null +++ b/test/integration/evento/eventos-http.test.ts @@ -0,0 +1,217 @@ +import { + afterAll, beforeAll, describe, expect, test +} from 'vitest' + +import { ColetaAttributes } from '@/domain/evento/Evento' + +import { createTestApp } from '../setup/app-factory' +import { + cleanupExpedicaoFixtures, ExpedicaoFixtures, seedExpedicaoFixtures +} from '../setup/seeds/expedicao.seed' + +const ficha: ColetaAttributes = { + associacoes: 'Lychnophora ericoides', + estado: 'MG', + familia: 'Velloziaceae', + flores: 'Presentes, lilases', + folhas: 'Lineares, rígidas', + frutos: 'Ausentes', + habito: 'Herbácea', + luminosidade: 'Pleno sol', + municipio: 'Serra do Cipó', + nome_cientifico: 'Vellozia squamata', + nome_popular: 'canela-de-ema', + referencia_local: 'Trilha da cachoeira, 300 m após a ponte', + relevo: 'Ondulado', + solo: 'Arenoso', + substrato: 'Afloramento rochoso', + tipo_vegetacao: 'Campo rupestre', + tronco_com_casca: 'Sim, gretada' +} + +interface EventoBody { + id: number + tipo: string + observacoes: string | null + coleta: Record | null +} + +function asEvento(body: unknown): EventoBody { + return body as EventoBody +} + +describe('Eventos (escrita, HTTP)', () => { + const { agent, knex } = createTestApp() + + let fixtures: ExpedicaoFixtures + let expedicaoId: number + + beforeAll(async () => { + fixtures = await seedExpedicaoFixtures(knex) + + const [expedicao] = await knex('expedicoes') + .insert({ + cidade_id: fixtures.cidades[0], data_fim: '2026-02-18', data_inicio: '2026-02-14', descricao: 'XEVT eventos http' + }) + .returning>(['id']) + + expedicaoId = expedicao.id + }) + + afterAll(async () => { + await knex('expedicoes').where({ id: expedicaoId }).delete() + await cleanupExpedicaoFixtures(knex, fixtures) + await knex.destroy() + }) + + test('POST cria um evento DIARIO', async () => { + const response = await agent + .post(`/api/v2/expedicoes/${expedicaoId}/eventos`) + .send({ + capturado_em: '2026-02-15T14:32:00Z', + latitude: -20.2508, + longitude: -46.4167, + observacoes: 'Área de transição entre cerrado e campo rupestre.', + tipo: 'DIARIO' + }) + .expect(201) + + const body = asEvento(response.body) + + try { + expect(body).toMatchObject({ coleta: null, tipo: 'DIARIO' }) + } finally { + await knex('eventos').where({ id: body.id }).delete() + } + }) + + test('POST cria um evento COLETA com a ficha', async () => { + const response = await agent + .post(`/api/v2/expedicoes/${expedicaoId}/eventos`) + .send({ + capturado_em: '2026-02-15T14:32:00Z', coleta: ficha, latitude: -20.2508, longitude: -46.4167, tipo: 'COLETA' + }) + .expect(201) + + const body = asEvento(response.body) + + try { + expect(body.coleta).toEqual(ficha) + } finally { + await knex('eventos').where({ id: body.id }).delete() + } + }) + + test('POST retorna 400 quando COLETA não envia ficha', async () => { + await agent + .post(`/api/v2/expedicoes/${expedicaoId}/eventos`) + .send({ + capturado_em: '2026-02-15T14:32:00Z', latitude: -20.25, longitude: -46.41, tipo: 'COLETA' + }) + .expect(400) + }) + + test('POST retorna 404 quando a expedição não existe', async () => { + await agent + .post('/api/v2/expedicoes/999999999/eventos') + .send({ + capturado_em: '2026-02-15T14:32:00Z', latitude: -20.25, longitude: -46.41, observacoes: 'x', tipo: 'DIARIO' + }) + .expect(404) + }) + + test('PUT atualiza campos comuns', async () => { + const created = await agent + .post(`/api/v2/expedicoes/${expedicaoId}/eventos`) + .send({ + capturado_em: '2026-02-15T14:32:00Z', latitude: -20.25, longitude: -46.41, observacoes: 'original', tipo: 'DIARIO' + }) + .expect(201) + + const eventoId = asEvento(created.body).id + + try { + const response = await agent + .put(`/api/v2/eventos/${eventoId}`) + .send({ observacoes: 'atualizado' }) + .expect(200) + + expect(asEvento(response.body).observacoes).toBe('atualizado') + } finally { + await knex('eventos').where({ id: eventoId }).delete() + } + }) + + test('PUT troca DIARIO para COLETA criando a ficha', async () => { + const created = await agent + .post(`/api/v2/expedicoes/${expedicaoId}/eventos`) + .send({ + capturado_em: '2026-02-15T14:32:00Z', latitude: -20.25, longitude: -46.41, observacoes: 'original', tipo: 'DIARIO' + }) + .expect(201) + + const eventoId = asEvento(created.body).id + + try { + const response = await agent + .put(`/api/v2/eventos/${eventoId}`) + .send({ coleta: ficha, tipo: 'COLETA' }) + .expect(200) + + expect(asEvento(response.body)).toMatchObject({ coleta: ficha, tipo: 'COLETA' }) + } finally { + await knex('eventos').where({ id: eventoId }).delete() + } + }) + + test('PUT troca COLETA para DIARIO apagando a ficha', async () => { + const created = await agent + .post(`/api/v2/expedicoes/${expedicaoId}/eventos`) + .send({ + capturado_em: '2026-02-15T14:32:00Z', coleta: ficha, latitude: -20.25, longitude: -46.41, tipo: 'COLETA' + }) + .expect(201) + + const eventoId = asEvento(created.body).id + + try { + const response = await agent + .put(`/api/v2/eventos/${eventoId}`) + .send({ observacoes: 'virou diário', tipo: 'DIARIO' }) + .expect(200) + + expect(asEvento(response.body)).toMatchObject({ coleta: null, tipo: 'DIARIO' }) + + const fichas = await knex('eventos_coletas').where({ evento_id: eventoId }) + expect(fichas).toHaveLength(0) + } finally { + await knex('eventos').where({ id: eventoId }).delete() + } + }) + + test('PUT retorna 404 quando o evento não existe', async () => { + await agent.put('/api/v2/eventos/999999999').send({ observacoes: 'x' }).expect(404) + }) + + test('DELETE remove o evento e a ficha (cascade)', async () => { + const created = await agent + .post(`/api/v2/expedicoes/${expedicaoId}/eventos`) + .send({ + capturado_em: '2026-02-15T14:32:00Z', coleta: ficha, latitude: -20.25, longitude: -46.41, tipo: 'COLETA' + }) + .expect(201) + + const eventoId = asEvento(created.body).id + + await agent.delete(`/api/v2/eventos/${eventoId}`).expect(204) + + const eventoRow = await knex('eventos').where({ id: eventoId }).first() + const coletaRow = await knex('eventos_coletas').where({ evento_id: eventoId }).first() + expect(eventoRow).toBeUndefined() + expect(coletaRow).toBeUndefined() + }) + + test('DELETE retorna 404 quando o evento não existe', async () => { + await agent.delete('/api/v2/eventos/999999999').expect(404) + }) +}) diff --git a/test/unit/application/evento/AtualizarEventoController.test.ts b/test/unit/application/evento/AtualizarEventoController.test.ts new file mode 100644 index 00000000..868b2e5e --- /dev/null +++ b/test/unit/application/evento/AtualizarEventoController.test.ts @@ -0,0 +1,125 @@ +import { + describe, expect, test, vi +} from 'vitest' + +import { AtualizarEventoController } from '@/application/evento/AtualizarEventoController' +import { AtualizarEventoUseCase } from '@/domain/evento/AtualizarEventoUseCase' +import { Attributes } from '@/domain/evento/Evento' +import { CheckViolationError } from '@/infrastructure/error/CheckViolationError' +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' +import { UnprocessableEntityError } from '@/library/http/error/UnprocessableEntityError' + +const headers = {} as Headers + +const attributes: Attributes = { + altitude: null, + capturado_em: new Date('2026-02-15T14:32:00Z'), + coleta: null, + created_at: new Date(), + created_by: null, + expedicao_id: 1, + id: 10, + latitude: -21, + longitude: -46.4167, + observacoes: 'Solo arenoso', + tipo: 'DIARIO', + updated_at: new Date(), + updated_by: null +} + +function makeRequest(params: Record, body: unknown): HttpRequest { + return { + body, headers, method: Method.Put, params, path: '/v2/eventos/10' + } satisfies HttpRequest +} + +describe('AtualizarEventoController', () => { + test('retorna 200 com o evento atualizado', async () => { + const atualizarEventoUseCase = { + execute: vi.fn().mockResolvedValue(Either.right(attributes)) + } as unknown as AtualizarEventoUseCase + + const controller = new AtualizarEventoController({ atualizarEventoUseCase }) + const response = await controller.handle(makeRequest({ eventoId: '10' }, { latitude: -21 }), vi.fn()) + + expect(atualizarEventoUseCase.execute).toHaveBeenCalledWith({ + id: 10, latitude: -21, updated_by: null + }) + expect('statusCode' in response ? response.statusCode : undefined).toBe(StatusCode.Ok) + expect('body' in response ? response.body : undefined).toEqual(attributes) + }) + + test('só envia os campos presentes no corpo (atualização parcial)', async () => { + const atualizarEventoUseCase = { + execute: vi.fn().mockResolvedValue(Either.right(attributes)) + } as unknown as AtualizarEventoUseCase + + const controller = new AtualizarEventoController({ atualizarEventoUseCase }) + await controller.handle(makeRequest({ eventoId: '10' }, { observacoes: 'novo texto' }), vi.fn()) + + expect(atualizarEventoUseCase.execute).toHaveBeenCalledWith({ + id: 10, observacoes: 'novo texto', updated_by: null + }) + }) + + test('aceita a troca de tipo para DIARIO sem exigir coleta no corpo', async () => { + const atualizarEventoUseCase = { + execute: vi.fn().mockResolvedValue(Either.right(attributes)) + } as unknown as AtualizarEventoUseCase + + const controller = new AtualizarEventoController({ atualizarEventoUseCase }) + await controller.handle(makeRequest({ eventoId: '10' }, { tipo: 'DIARIO' }), vi.fn()) + + expect(atualizarEventoUseCase.execute).toHaveBeenCalledWith({ + id: 10, tipo: 'DIARIO', updated_by: null + }) + }) + + test('retorna 400 quando eventoId é inválido', async () => { + const atualizarEventoUseCase = { execute: vi.fn() } as unknown as AtualizarEventoUseCase + const controller = new AtualizarEventoController({ atualizarEventoUseCase }) + + const response = await controller.handle(makeRequest({ eventoId: 'abc' }, {}), vi.fn()) + + expect(atualizarEventoUseCase.execute).not.toHaveBeenCalled() + expect(response).toBeInstanceOf(BadRequestError) + }) + + test('retorna 404 quando o evento não existe', async () => { + const atualizarEventoUseCase = { + execute: vi.fn().mockResolvedValue(Either.right(null)) + } as unknown as AtualizarEventoUseCase + + const controller = new AtualizarEventoController({ atualizarEventoUseCase }) + const response = await controller.handle(makeRequest({ eventoId: '999' }, { latitude: -21 }), vi.fn()) + + expect(response).toBeInstanceOf(NotFoundError) + }) + + test('retorna 400 quando a validação de domínio falha', async () => { + const atualizarEventoUseCase = { + execute: vi.fn().mockResolvedValue(Either.left(new Error('Latitude do evento deve estar entre -90 e 90'))) + } as unknown as AtualizarEventoUseCase + + const controller = new AtualizarEventoController({ atualizarEventoUseCase }) + const response = await controller.handle(makeRequest({ eventoId: '10' }, { latitude: 999 }), vi.fn()) + + expect(response).toBeInstanceOf(BadRequestError) + }) + + test('retorna 422 quando o banco rejeita o tipo (CheckViolationError)', async () => { + const atualizarEventoUseCase = { + execute: vi.fn().mockResolvedValue(Either.left(new CheckViolationError({ message: 'tipo inválido' }))) + } as unknown as AtualizarEventoUseCase + + const controller = new AtualizarEventoController({ atualizarEventoUseCase }) + const response = await controller.handle(makeRequest({ eventoId: '10' }, { tipo: 'DIARIO' }), vi.fn()) + + expect(response).toBeInstanceOf(UnprocessableEntityError) + }) +}) diff --git a/test/unit/application/evento/CriarEventoController.test.ts b/test/unit/application/evento/CriarEventoController.test.ts new file mode 100644 index 00000000..cbb94912 --- /dev/null +++ b/test/unit/application/evento/CriarEventoController.test.ts @@ -0,0 +1,133 @@ +import { + describe, expect, test, vi +} from 'vitest' + +import { CriarEventoController } from '@/application/evento/CriarEventoController' +import { CriarEventoUseCase } from '@/domain/evento/CriarEventoUseCase' +import { Attributes } from '@/domain/evento/Evento' +import { CheckViolationError } from '@/infrastructure/error/CheckViolationError' +import { CollectionError } from '@/infrastructure/error/CollectionError' +import { ForeignKeyViolationError } from '@/infrastructure/error/ForeignKeyViolationError' +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' +import { UnprocessableEntityError } from '@/library/http/error/UnprocessableEntityError' + +const headers = {} as Headers + +const attributes: Attributes = { + altitude: null, + capturado_em: new Date('2026-02-15T14:32:00Z'), + coleta: null, + created_at: new Date(), + created_by: null, + expedicao_id: 1, + id: 10, + latitude: -20.2508, + longitude: -46.4167, + observacoes: 'Solo arenoso', + tipo: 'DIARIO', + updated_at: new Date(), + updated_by: null +} + +const validBody = { + capturado_em: '2026-02-15T14:32:00Z', + latitude: -20.2508, + longitude: -46.4167, + observacoes: 'Solo arenoso', + tipo: 'DIARIO' +} + +function makeRequest(params: Record, body: unknown): HttpRequest { + return { + body, headers, method: Method.Post, params, path: '/v2/expedicoes/1/eventos' + } satisfies HttpRequest +} + +describe('CriarEventoController', () => { + test('retorna 201 com o evento criado', async () => { + const criarEventoUseCase = { + execute: vi.fn().mockResolvedValue(Either.right(attributes)) + } as unknown as CriarEventoUseCase + + const controller = new CriarEventoController({ criarEventoUseCase }) + const response = await controller.handle(makeRequest({ expedicaoId: '1' }, validBody), vi.fn()) + + expect(criarEventoUseCase.execute).toHaveBeenCalledWith(expect.objectContaining({ + expedicao_id: 1, tipo: 'DIARIO' + })) + expect('statusCode' in response ? response.statusCode : undefined).toBe(StatusCode.Created) + expect('body' in response ? response.body : undefined).toEqual(attributes) + }) + + test('retorna 400 quando expedicaoId é inválido', async () => { + const criarEventoUseCase = { execute: vi.fn() } as unknown as CriarEventoUseCase + const controller = new CriarEventoController({ criarEventoUseCase }) + + const response = await controller.handle(makeRequest({ expedicaoId: 'abc' }, validBody), vi.fn()) + + expect(criarEventoUseCase.execute).not.toHaveBeenCalled() + expect(response).toBeInstanceOf(BadRequestError) + }) + + test('retorna 400 quando tipo é inválido', async () => { + const criarEventoUseCase = { execute: vi.fn() } as unknown as CriarEventoUseCase + const controller = new CriarEventoController({ criarEventoUseCase }) + + const response = await controller.handle( + makeRequest({ expedicaoId: '1' }, { ...validBody, tipo: 'FOTO' }), + vi.fn() + ) + + expect(criarEventoUseCase.execute).not.toHaveBeenCalled() + expect(response).toBeInstanceOf(BadRequestError) + }) + + test('retorna 400 quando a validação de domínio falha (Either.left com Error simples)', async () => { + const criarEventoUseCase = { + execute: vi.fn().mockResolvedValue(Either.left(new Error('Evento de coleta exige a ficha de coleta'))) + } as unknown as CriarEventoUseCase + + const controller = new CriarEventoController({ criarEventoUseCase }) + const response = await controller.handle(makeRequest({ expedicaoId: '1' }, validBody), vi.fn()) + + expect(response).toBeInstanceOf(BadRequestError) + }) + + test('retorna 404 quando a expedição não existe (ForeignKeyViolationError)', async () => { + const criarEventoUseCase = { + execute: vi.fn().mockResolvedValue(Either.left(new ForeignKeyViolationError({ message: 'Expedição não encontrada' }))) + } as unknown as CriarEventoUseCase + + const controller = new CriarEventoController({ criarEventoUseCase }) + const response = await controller.handle(makeRequest({ expedicaoId: '999' }, validBody), vi.fn()) + + expect(response).toBeInstanceOf(NotFoundError) + }) + + test('retorna 422 quando o banco rejeita o tipo (CheckViolationError)', async () => { + const criarEventoUseCase = { + execute: vi.fn().mockResolvedValue(Either.left(new CheckViolationError({ message: 'tipo inválido' }))) + } as unknown as CriarEventoUseCase + + const controller = new CriarEventoController({ criarEventoUseCase }) + const response = await controller.handle(makeRequest({ expedicaoId: '1' }, validBody), vi.fn()) + + expect(response).toBeInstanceOf(UnprocessableEntityError) + }) + + test('retorna 500 quando ocorre erro de infraestrutura genérico', async () => { + const criarEventoUseCase = { + execute: vi.fn().mockResolvedValue(Either.left(new CollectionError({ message: 'DB down' }))) + } as unknown as CriarEventoUseCase + + const controller = new CriarEventoController({ criarEventoUseCase }) + const response = await controller.handle(makeRequest({ expedicaoId: '1' }, validBody), vi.fn()) + + expect((response as Error).name).toBe('InternalServerError') + }) +}) diff --git a/test/unit/application/evento/RemoverEventoController.test.ts b/test/unit/application/evento/RemoverEventoController.test.ts new file mode 100644 index 00000000..f1a91eec --- /dev/null +++ b/test/unit/application/evento/RemoverEventoController.test.ts @@ -0,0 +1,70 @@ +import { + describe, expect, test, vi +} from 'vitest' + +import { RemoverEventoController } from '@/application/evento/RemoverEventoController' +import { RemoverEventoUseCase } from '@/domain/evento/RemoverEventoUseCase' +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' + +const headers = {} as Headers + +function makeRequest(params: Record): HttpRequest { + return { + body: {}, + headers, + method: Method.Delete, + params, + path: '/v2/eventos/10' + } satisfies HttpRequest +} + +describe('RemoverEventoController', () => { + test('retorna 204 quando o evento é removido', async () => { + const removerEventoUseCase = { + execute: vi.fn().mockResolvedValue(Either.right(true)) + } as unknown as RemoverEventoUseCase + + const controller = new RemoverEventoController({ removerEventoUseCase }) + const response = await controller.handle(makeRequest({ eventoId: '10' }), vi.fn()) + + expect(removerEventoUseCase.execute).toHaveBeenCalledWith({ id: 10 }) + expect('statusCode' in response ? response.statusCode : undefined).toBe(StatusCode.NoContent) + }) + + test('retorna 404 quando o evento não existe', async () => { + const removerEventoUseCase = { + execute: vi.fn().mockResolvedValue(Either.right(false)) + } as unknown as RemoverEventoUseCase + + const controller = new RemoverEventoController({ removerEventoUseCase }) + const response = await controller.handle(makeRequest({ eventoId: '999' }), vi.fn()) + + expect(response).toBeInstanceOf(NotFoundError) + }) + + test('retorna 400 quando eventoId é inválido', async () => { + const removerEventoUseCase = { execute: vi.fn() } as unknown as RemoverEventoUseCase + const controller = new RemoverEventoController({ removerEventoUseCase }) + + const response = await controller.handle(makeRequest({ eventoId: 'abc' }), vi.fn()) + + expect(removerEventoUseCase.execute).not.toHaveBeenCalled() + expect(response).toBeInstanceOf(BadRequestError) + }) + + test('retorna 500 quando ocorre erro de infraestrutura', async () => { + const removerEventoUseCase = { + execute: vi.fn().mockResolvedValue(Either.left(new Error('DB down'))) + } as unknown as RemoverEventoUseCase + + const controller = new RemoverEventoController({ removerEventoUseCase }) + const response = await controller.handle(makeRequest({ eventoId: '10' }), vi.fn()) + + expect((response as Error).name).toBe('InternalServerError') + }) +}) diff --git a/test/unit/domain/evento/AtualizarEventoUseCase.test.ts b/test/unit/domain/evento/AtualizarEventoUseCase.test.ts new file mode 100644 index 00000000..9bd3dec8 --- /dev/null +++ b/test/unit/domain/evento/AtualizarEventoUseCase.test.ts @@ -0,0 +1,192 @@ +import { + describe, expect, test, vi +} from 'vitest' + +import { AtualizarEventoUseCase } from '@/domain/evento/AtualizarEventoUseCase' +import { Attributes, ColetaAttributes } from '@/domain/evento/Evento' +import { EventoCollection } from '@/domain/evento/EventoCollection' +import { Either } from '@/library/either/Either' + +function fichaCompleta(overrides: Partial = {}): ColetaAttributes { + return { + associacoes: null, + estado: null, + familia: null, + flores: null, + folhas: null, + frutos: null, + habito: null, + luminosidade: null, + municipio: null, + nome_cientifico: 'Vellozia squamata', + nome_popular: null, + referencia_local: null, + relevo: null, + solo: null, + substrato: null, + tipo_vegetacao: null, + tronco_com_casca: null, + ...overrides + } +} + +const diarioAttributes: Attributes = { + altitude: null, + capturado_em: new Date('2026-02-15T14:32:00Z'), + coleta: null, + created_at: new Date(), + created_by: null, + expedicao_id: 1, + id: 10, + latitude: -20.2508, + longitude: -46.4167, + observacoes: 'Solo arenoso', + tipo: 'DIARIO', + updated_at: new Date(), + updated_by: null +} + +const coletaAttributes: Attributes = { + ...diarioAttributes, + coleta: fichaCompleta(), + id: 11, + observacoes: null, + tipo: 'COLETA' +} + +const makeMockCollection = (overrides?: Partial): EventoCollection => ({ + create: vi.fn(), + delete: vi.fn(), + findAll: vi.fn(), + findById: vi.fn().mockResolvedValue(Either.right(diarioAttributes)), + update: vi.fn().mockResolvedValue(Either.right(diarioAttributes)), + ...overrides +}) + +describe('AtualizarEventoUseCase', () => { + test('atualiza campos comuns sem tocar no tipo', async () => { + const collection = makeMockCollection() + const useCase = new AtualizarEventoUseCase({ eventoCollection: collection }) + + const result = await useCase.execute({ + id: 10, latitude: -21, updated_by: 5 + }) + + expect(result.right()).toBe(true) + expect(collection.update).toHaveBeenCalledWith(10, { + altitude: null, + capturado_em: diarioAttributes.capturado_em, + coleta: null, + latitude: -21, + longitude: diarioAttributes.longitude, + observacoes: diarioAttributes.observacoes, + tipo: 'DIARIO', + updated_by: 5 + }) + }) + + test('retorna null quando o evento não existe', async () => { + const collection = makeMockCollection({ + findById: vi.fn().mockResolvedValue(Either.right(null)) + }) + const useCase = new AtualizarEventoUseCase({ eventoCollection: collection }) + + const result = await useCase.execute({ id: 999, updated_by: null }) + + expect(result.right()).toBe(true) + expect(result.value).toBeNull() + expect(collection.update).not.toHaveBeenCalled() + }) + + test('rejeita coordenadas fora de faixa sem chamar update', async () => { + const collection = makeMockCollection() + const useCase = new AtualizarEventoUseCase({ eventoCollection: collection }) + + const result = await useCase.execute({ + id: 10, latitude: 200, updated_by: null + }) + + expect(result.left()).toBe(true) + expect(collection.update).not.toHaveBeenCalled() + }) + + test('COLETA -> DIARIO descarta a ficha mesmo sem o cliente mandar coleta', async () => { + const collection = makeMockCollection({ + findById: vi.fn().mockResolvedValue(Either.right(coletaAttributes)), + update: vi.fn().mockResolvedValue(Either.right({ + ...coletaAttributes, coleta: null, tipo: 'DIARIO' + })) + }) + const useCase = new AtualizarEventoUseCase({ eventoCollection: collection }) + + const result = await useCase.execute({ + id: 11, observacoes: 'virou diário', tipo: 'DIARIO', updated_by: null + }) + + expect(result.right()).toBe(true) + expect(collection.update).toHaveBeenCalledWith(11, expect.objectContaining({ + coleta: null, + tipo: 'DIARIO' + })) + }) + + test('DIARIO -> COLETA exige a ficha no corpo da requisição', async () => { + const collection = makeMockCollection() + const useCase = new AtualizarEventoUseCase({ eventoCollection: collection }) + + const result = await useCase.execute({ + id: 10, tipo: 'COLETA', updated_by: null + }) + + expect(result.left()).toBe(true) + expect(collection.update).not.toHaveBeenCalled() + }) + + test('DIARIO -> COLETA com ficha no corpo é aceito', async () => { + const collection = makeMockCollection() + const useCase = new AtualizarEventoUseCase({ eventoCollection: collection }) + + const ficha = fichaCompleta({ habito: 'Herbácea' }) + const result = await useCase.execute({ + coleta: ficha, id: 10, tipo: 'COLETA', updated_by: null + }) + + expect(result.right()).toBe(true) + expect(collection.update).toHaveBeenCalledWith(10, expect.objectContaining({ + coleta: ficha, + tipo: 'COLETA' + })) + }) + + test('mantém o tipo COLETA e mantém a ficha atual quando coleta não é enviado', async () => { + const collection = makeMockCollection({ + findById: vi.fn().mockResolvedValue(Either.right(coletaAttributes)), + update: vi.fn().mockResolvedValue(Either.right(coletaAttributes)) + }) + const useCase = new AtualizarEventoUseCase({ eventoCollection: collection }) + + const result = await useCase.execute({ + id: 11, latitude: -22, updated_by: null + }) + + expect(result.right()).toBe(true) + expect(collection.update).toHaveBeenCalledWith(11, expect.objectContaining({ + coleta: coletaAttributes.coleta, + tipo: 'COLETA' + })) + }) + + test('mantém o tipo COLETA e substitui a ficha inteira quando coleta é enviado', async () => { + const collection = makeMockCollection({ + findById: vi.fn().mockResolvedValue(Either.right(coletaAttributes)) + }) + const useCase = new AtualizarEventoUseCase({ eventoCollection: collection }) + + const novaFicha = fichaCompleta({ habito: 'Arbustiva', nome_cientifico: 'Outra espécie' }) + await useCase.execute({ + coleta: novaFicha, id: 11, updated_by: null + }) + + expect(collection.update).toHaveBeenCalledWith(11, expect.objectContaining({ coleta: novaFicha })) + }) +}) diff --git a/test/unit/domain/evento/AtualizarEventoValidator.test.ts b/test/unit/domain/evento/AtualizarEventoValidator.test.ts new file mode 100644 index 00000000..2fdb9c4e --- /dev/null +++ b/test/unit/domain/evento/AtualizarEventoValidator.test.ts @@ -0,0 +1,116 @@ +import { + describe, expect, test +} from 'vitest' + +import { AtualizarEventoValidator } from '@/domain/evento/AtualizarEventoValidator' +import { Attributes, ColetaAttributes } from '@/domain/evento/Evento' + +function fichaCompleta(overrides: Partial = {}): ColetaAttributes { + return { + associacoes: null, + estado: null, + familia: null, + flores: null, + folhas: null, + frutos: null, + habito: null, + luminosidade: null, + municipio: null, + nome_cientifico: 'Vellozia squamata', + nome_popular: null, + referencia_local: null, + relevo: null, + solo: null, + substrato: null, + tipo_vegetacao: null, + tronco_com_casca: null, + ...overrides + } +} + +const diarioAttributes: Attributes = { + altitude: null, + capturado_em: new Date('2026-02-15T14:32:00Z'), + coleta: null, + created_at: new Date(), + created_by: null, + expedicao_id: 1, + id: 10, + latitude: -20.2508, + longitude: -46.4167, + observacoes: 'Solo arenoso', + tipo: 'DIARIO', + updated_at: new Date(), + updated_by: null +} + +const coletaAttributes: Attributes = { + ...diarioAttributes, + coleta: fichaCompleta(), + id: 11, + observacoes: null, + tipo: 'COLETA' +} + +describe('AtualizarEventoValidator', () => { + const validator = new AtualizarEventoValidator() + + test('mescla campos comuns sem tocar no tipo nem na ficha', () => { + const result = validator.validar(diarioAttributes, { latitude: -21, updated_by: 5 }) + + expect(result.right()).toBe(true) + if (!result.right()) return + expect(result.value).toMatchObject({ + coleta: null, latitude: -21, tipo: 'DIARIO', updated_by: 5 + }) + }) + + test('COLETA -> DIARIO descarta a ficha mesmo sem o cliente mandar coleta', () => { + const result = validator.validar(coletaAttributes, { tipo: 'DIARIO', updated_by: null }) + + expect(result.right()).toBe(true) + if (!result.right()) return + expect(result.value.coleta).toBeNull() + expect(result.value.tipo).toBe('DIARIO') + }) + + test('DIARIO -> COLETA sem ficha no corpo é rejeitado', () => { + const result = validator.validar(diarioAttributes, { tipo: 'COLETA', updated_by: null }) + + expect(result.left()).toBe(true) + }) + + test('DIARIO -> COLETA com ficha no corpo é aceito', () => { + const ficha = fichaCompleta({ habito: 'Herbácea' }) + const result = validator.validar(diarioAttributes, { + coleta: ficha, tipo: 'COLETA', updated_by: null + }) + + expect(result.right()).toBe(true) + if (!result.right()) return + expect(result.value.coleta).toEqual(ficha) + }) + + test('mantém COLETA e mantém a ficha atual quando coleta não é enviado', () => { + const result = validator.validar(coletaAttributes, { latitude: -22, updated_by: null }) + + expect(result.right()).toBe(true) + if (!result.right()) return + expect(result.value.coleta).toEqual(coletaAttributes.coleta) + }) + + test('mantém COLETA e substitui a ficha inteira quando coleta é enviado', () => { + const novaFicha = fichaCompleta({ habito: 'Arbustiva', nome_cientifico: 'Outra espécie' }) + const result = validator.validar(coletaAttributes, { coleta: novaFicha, updated_by: null }) + + expect(result.right()).toBe(true) + if (!result.right()) return + expect(result.value.coleta).toEqual(novaFicha) + }) + + test('rejeita coordenadas fora de faixa (reaproveita Evento.create)', () => { + const result = validator.validar(diarioAttributes, { latitude: 200, updated_by: null }) + + expect(result.left()).toBe(true) + }) +}) diff --git a/test/unit/domain/evento/CriarEventoUseCase.test.ts b/test/unit/domain/evento/CriarEventoUseCase.test.ts new file mode 100644 index 00000000..9ec197c6 --- /dev/null +++ b/test/unit/domain/evento/CriarEventoUseCase.test.ts @@ -0,0 +1,81 @@ +import { + describe, expect, test, vi +} from 'vitest' + +import { CriarEventoUseCase } from '@/domain/evento/CriarEventoUseCase' +import { Attributes, CreateAttributes } from '@/domain/evento/Evento' +import { EventoCollection } from '@/domain/evento/EventoCollection' +import { Either } from '@/library/either/Either' + +const attributes: Attributes = { + altitude: null, + capturado_em: new Date('2026-02-15T14:32:00Z'), + coleta: null, + created_at: new Date(), + created_by: null, + expedicao_id: 1, + id: 10, + latitude: -20.2508, + longitude: -46.4167, + observacoes: 'Solo arenoso', + tipo: 'DIARIO', + updated_at: new Date(), + updated_by: null +} + +const validInput: CreateAttributes = { + altitude: null, + capturado_em: new Date('2026-02-15T14:32:00Z'), + coleta: null, + created_by: null, + expedicao_id: 1, + latitude: -20.2508, + longitude: -46.4167, + observacoes: 'Solo arenoso', + tipo: 'DIARIO' +} + +const makeMockCollection = (overrides?: Partial): EventoCollection => ({ + create: vi.fn().mockResolvedValue(Either.right(attributes)), + delete: vi.fn(), + findAll: vi.fn(), + findById: vi.fn(), + update: vi.fn(), + ...overrides +}) + +describe('CriarEventoUseCase', () => { + test('cria o evento quando os dados são válidos', async () => { + const collection = makeMockCollection() + const useCase = new CriarEventoUseCase({ eventoCollection: collection }) + + const result = await useCase.execute(validInput) + + expect(result.right()).toBe(true) + expect(result.value).toEqual(attributes) + expect(collection.create).toHaveBeenCalledWith(validInput) + }) + + test('não chama a collection quando a validação de domínio falha (COLETA sem ficha)', async () => { + const collection = makeMockCollection() + const useCase = new CriarEventoUseCase({ eventoCollection: collection }) + + const result = await useCase.execute({ ...validInput, tipo: 'COLETA' }) + + expect(result.left()).toBe(true) + expect(collection.create).not.toHaveBeenCalled() + }) + + test('propaga erro de infraestrutura da collection', async () => { + const error = new Error('DB failure') + const collection = makeMockCollection({ + create: vi.fn().mockResolvedValue(Either.left(error)) + }) + const useCase = new CriarEventoUseCase({ eventoCollection: collection }) + + const result = await useCase.execute(validInput) + + expect(result.left()).toBe(true) + expect(result.value).toBe(error) + }) +}) diff --git a/test/unit/domain/evento/RemoverEventoUseCase.test.ts b/test/unit/domain/evento/RemoverEventoUseCase.test.ts new file mode 100644 index 00000000..ad70f49d --- /dev/null +++ b/test/unit/domain/evento/RemoverEventoUseCase.test.ts @@ -0,0 +1,54 @@ +import { + describe, expect, test, vi +} from 'vitest' + +import { EventoCollection } from '@/domain/evento/EventoCollection' +import { RemoverEventoUseCase } from '@/domain/evento/RemoverEventoUseCase' +import { Either } from '@/library/either/Either' + +const makeMockCollection = (overrides?: Partial): EventoCollection => ({ + create: vi.fn(), + delete: vi.fn().mockResolvedValue(Either.right(true)), + findAll: vi.fn(), + findById: vi.fn(), + update: vi.fn(), + ...overrides +}) + +describe('RemoverEventoUseCase', () => { + test('retorna true quando o evento é removido', async () => { + const collection = makeMockCollection() + const useCase = new RemoverEventoUseCase({ eventoCollection: collection }) + + const result = await useCase.execute({ id: 10 }) + + expect(result.right()).toBe(true) + expect(result.value).toBe(true) + expect(collection.delete).toHaveBeenCalledWith(10) + }) + + test('retorna false quando o evento não existe', async () => { + const collection = makeMockCollection({ + delete: vi.fn().mockResolvedValue(Either.right(false)) + }) + const useCase = new RemoverEventoUseCase({ eventoCollection: collection }) + + const result = await useCase.execute({ id: 999 }) + + expect(result.right()).toBe(true) + expect(result.value).toBe(false) + }) + + test('propaga erro de infraestrutura', async () => { + const error = new Error('DB failure') + const collection = makeMockCollection({ + delete: vi.fn().mockResolvedValue(Either.left(error)) + }) + const useCase = new RemoverEventoUseCase({ eventoCollection: collection }) + + const result = await useCase.execute({ id: 10 }) + + expect(result.left()).toBe(true) + expect(result.value).toBe(error) + }) +})