diff --git a/src/application/create-app.ts b/src/application/create-app.ts index a3b3901..9011c67 100644 --- a/src/application/create-app.ts +++ b/src/application/create-app.ts @@ -15,6 +15,7 @@ import { generatePreview, reportPreview } from '../reports/controller' import { routes as createEstadoRoutes } from './estado' import { routes as createFaseSucessionalRoutes } from './fase-sucessional' import { routes as createPaisRoutes } from './pais' +import { routes as createRelevoRoutes } from './relevo' import { routes as createVegetacaoRoutes } from './vegetacao' interface CorsParameters { @@ -58,6 +59,7 @@ export function createApp({ const routes: Route[] = [ ...createPaisRoutes(knex), ...createEstadoRoutes(knex), + ...createRelevoRoutes(knex), ...createFaseSucessionalRoutes(knex), ...createVegetacaoRoutes(knex) ] diff --git a/src/application/relevo/BuscaRelevoController.ts b/src/application/relevo/BuscaRelevoController.ts new file mode 100644 index 0000000..0e39f57 --- /dev/null +++ b/src/application/relevo/BuscaRelevoController.ts @@ -0,0 +1,41 @@ +import { BuscaRelevoPorIdUseCase } from '@/domain/relevo/BuscaRelevoPorIdUseCase' +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 { + buscaRelevoPorIdUseCase: BuscaRelevoPorIdUseCase +} + +export class BuscaRelevoController implements RequestHandler { + private readonly buscaRelevoPorIdUseCase: BuscaRelevoPorIdUseCase + + constructor(dependencies: Dependencies) { + this.buscaRelevoPorIdUseCase = dependencies.buscaRelevoPorIdUseCase + } + + async handle(request: HttpRequest, _next: NextHandler): Promise { + const { relevoId } = request.params as { relevoId?: string } + + if (relevoId === undefined || relevoId === null || relevoId === '' || !/^\d+$/.test(relevoId)) { + return new BadRequestError({ message: 'relevoId inválido' }) + } + + const result = await this.buscaRelevoPorIdUseCase.execute({ id: Number(relevoId) }) + + if (result.left()) { + return new InternalServerError({ message: result.value.message }) + } + + if (!result.value) { + return new NotFoundError({ message: 'Relevo não encontrado' }) + } + + return { statusCode: StatusCode.Ok, body: result.value } + } +} diff --git a/src/application/relevo/ListaRelevosController.ts b/src/application/relevo/ListaRelevosController.ts new file mode 100644 index 0000000..f26ecbb --- /dev/null +++ b/src/application/relevo/ListaRelevosController.ts @@ -0,0 +1,65 @@ +import { ListaRelevosUseCase } from '@/domain/relevo/ListaRelevosUseCase' +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 { + listaRelevosUseCase: ListaRelevosUseCase +} + +export class ListaRelevosController implements RequestHandler { + private readonly listaRelevosUseCase: ListaRelevosUseCase + + constructor(dependencies: Dependencies) { + this.listaRelevosUseCase = dependencies.listaRelevosUseCase + } + + async handle(request: HttpRequest, _next: NextHandler): Promise { + const { nome, order } = request.params as { + nome?: string + order?: string + } + + const parsedOrder = parseOrder(order) + if (parsedOrder instanceof Error) { + return new BadRequestError({ message: parsedOrder.message }) + } + + const result = await this.listaRelevosUseCase.execute({ + nome, + order: parsedOrder + }) + + if (result.left()) { + return new InternalServerError({ message: result.value.message }) + } + + return { statusCode: StatusCode.Ok, body: result.value } + } +} + +function parseOrder(order?: string): { column: 'id' | 'nome'; direction: 'asc' | 'desc' } | Error | undefined { + if (!order) return undefined + + const pieces = order.split(':') + if (pieces.length !== 2) { + return new Error('order inválido. Use o formato "id:asc", "id:desc", "nome:asc" ou "nome:desc"') + } + + const [rawColumn, rawDirection] = pieces + const column = rawColumn.trim().toLowerCase() + const direction = rawDirection.trim().toLowerCase() + + if ((column !== 'id' && column !== 'nome') || (direction !== 'asc' && direction !== 'desc')) { + return new Error('order inválido. Use o formato "id:asc", "id:desc", "nome:asc" ou "nome:desc"') + } + + return { + column, + direction + } +} diff --git a/src/application/relevo/index.ts b/src/application/relevo/index.ts new file mode 100644 index 0000000..e7e1699 --- /dev/null +++ b/src/application/relevo/index.ts @@ -0,0 +1,35 @@ +import { type Knex } from 'knex' + +import { BuscaRelevoPorIdUseCase } from '@/domain/relevo/BuscaRelevoPorIdUseCase' +import { ListaRelevosUseCase } from '@/domain/relevo/ListaRelevosUseCase' +import { RelevoCollectionKnexAdapter } from '@/infrastructure/RelevoCollectionKnexAdapter' +import { Method } from '@/library/http/common' +import { Route } from '@/library/http/Router' + +import { BuscaRelevoController } from './BuscaRelevoController' +import { ListaRelevosController } from './ListaRelevosController' + +export function routes(knex: Knex): Route[] { + const relevoCollection = new RelevoCollectionKnexAdapter({ knex }) + + return [ + { + handlers: [ + new ListaRelevosController({ + listaRelevosUseCase: new ListaRelevosUseCase({ relevoCollection }) + }) + ], + method: Method.Get, + path: '/v2/relevos' + }, + { + handlers: [ + new BuscaRelevoController({ + buscaRelevoPorIdUseCase: new BuscaRelevoPorIdUseCase({ relevoCollection }) + }) + ], + method: Method.Get, + path: '/v2/relevos/:relevoId' + } + ] +} diff --git a/src/domain/relevo/BuscaRelevoPorIdUseCase.ts b/src/domain/relevo/BuscaRelevoPorIdUseCase.ts new file mode 100644 index 0000000..7cf4ff1 --- /dev/null +++ b/src/domain/relevo/BuscaRelevoPorIdUseCase.ts @@ -0,0 +1,20 @@ +import { Either } from '@/library/either/Either' + +import { Attributes } from './Relevo' +import { RelevoCollection } from './RelevoCollection' + +interface Dependencies { + relevoCollection: RelevoCollection +} + +export class BuscaRelevoPorIdUseCase { + private readonly relevoCollection: RelevoCollection + + constructor(dependencies: Dependencies) { + this.relevoCollection = dependencies.relevoCollection + } + + execute({ id }: { id: number }): Promise> { + return this.relevoCollection.findById(id) + } +} diff --git a/src/domain/relevo/ListaRelevosUseCase.ts b/src/domain/relevo/ListaRelevosUseCase.ts new file mode 100644 index 0000000..993bab0 --- /dev/null +++ b/src/domain/relevo/ListaRelevosUseCase.ts @@ -0,0 +1,20 @@ +import { Either } from '@/library/either/Either' + +import { Attributes } from './Relevo' +import { RelevoCollection, RelevoFilters } from './RelevoCollection' + +interface Dependencies { + relevoCollection: RelevoCollection +} + +export class ListaRelevosUseCase { + private readonly relevoCollection: RelevoCollection + + constructor(dependencies: Dependencies) { + this.relevoCollection = dependencies.relevoCollection + } + + execute(filters: RelevoFilters): Promise> { + return this.relevoCollection.findAll(filters) + } +} diff --git a/src/domain/relevo/Relevo.ts b/src/domain/relevo/Relevo.ts new file mode 100644 index 0000000..073ed1b --- /dev/null +++ b/src/domain/relevo/Relevo.ts @@ -0,0 +1,24 @@ +import { Either } from '@/library/either/Either' + +export interface Attributes { + id: number + nome: string +} + +export class Relevo { + readonly id: number + readonly nome: string + + private constructor(attributes: Attributes) { + this.id = attributes.id + this.nome = attributes.nome + } + + static create(attributes: Attributes): Either { + if (!attributes.nome.trim()) { + return Either.left(new Error('Nome do relevo não pode ser vazio')) + } + + return Either.right(new Relevo(attributes)) + } +} diff --git a/src/domain/relevo/RelevoCollection.ts b/src/domain/relevo/RelevoCollection.ts new file mode 100644 index 0000000..c63f3cd --- /dev/null +++ b/src/domain/relevo/RelevoCollection.ts @@ -0,0 +1,18 @@ +import { Either } from '@/library/either/Either' + +import { Attributes } from './Relevo' + +export interface RelevoOrder { + column: 'id' | 'nome' + direction: 'asc' | 'desc' +} + +export interface RelevoFilters { + nome?: string + order?: RelevoOrder +} + +export interface RelevoCollection { + findAll(filters: RelevoFilters): Promise> + findById(id: number): Promise> +} diff --git a/src/infrastructure/RelevoCollectionKnexAdapter.ts b/src/infrastructure/RelevoCollectionKnexAdapter.ts new file mode 100644 index 0000000..ba4581d --- /dev/null +++ b/src/infrastructure/RelevoCollectionKnexAdapter.ts @@ -0,0 +1,46 @@ +import { Knex } from 'knex' + +import { Attributes } from '@/domain/relevo/Relevo' +import { RelevoCollection, RelevoFilters } from '@/domain/relevo/RelevoCollection' +import { Either } from '@/library/either/Either' + +import { CollectionError } from './error/CollectionError' + +interface Dependencies { + knex: Knex +} + +export class RelevoCollectionKnexAdapter implements RelevoCollection { + private readonly knex: Knex + + constructor(dependencies: Dependencies) { + this.knex = dependencies.knex + } + + async findAll(filters: RelevoFilters): Promise> { + try { + const query = this.knex('relevos') + .select(['id', 'nome']) + + if (filters.nome) { + query.whereILike('nome', `%${filters.nome}%`) + } + + const order = filters.order ?? { column: 'id', direction: 'desc' } + query.orderBy(order.column, order.direction) + + return Either.right(await query) + } catch (error) { + return Either.left(new CollectionError({ message: 'Failed to list relevos', cause: error })) + } + } + + async findById(id: number): Promise> { + try { + const relevo = await this.knex('relevos').select(['id', 'nome']).where({ id }).first() + return Either.right(relevo ?? null) + } catch (error) { + return Either.left(new CollectionError({ message: 'Failed to find relevo', cause: error })) + } + } +} diff --git a/test/integration/relevo/busca-relevos.test.ts b/test/integration/relevo/busca-relevos.test.ts new file mode 100644 index 0000000..582638e --- /dev/null +++ b/test/integration/relevo/busca-relevos.test.ts @@ -0,0 +1,42 @@ +import { + afterAll, describe, expect, test +} from 'vitest' + +import { createTestApp } from '../setup/app-factory' + +type Relevo = { id: number; nome: string } + +const returning = ['id', 'nome'] as const + +describe('GET /api/v2/relevos/:relevoId', () => { + const { agent, knex } = createTestApp() + + afterAll(() => knex.destroy()) + + test('retorna o registro encontrado', async () => { + const [relevo] = await knex('relevos') + .insert({ nome: 'XREL Relevo Encontrado' }) + .returning(returning) + + try { + const response = await agent.get(`/api/v2/relevos/${relevo.id}`).expect(200) + expect(response.body).toEqual({ id: relevo.id, nome: relevo.nome }) + } finally { + await knex('relevos').where({ id: relevo.id }).delete() + } + }) + + test('retorna 404 para id inexistente', async () => { + const response = await agent.get('/api/v2/relevos/999999').expect(404) + const body = response.body as { error: { message: string } } + + expect(body.error.message).toMatch(/não encontrad[ao]|not found/i) + }) + + test('retorna 400 para id inválido', async () => { + const response = await agent.get('/api/v2/relevos/abc').expect(400) + const body = response.body as { error: { message: string } } + + expect(body.error.message).toMatch(/inválido|invalid/i) + }) +}) diff --git a/test/integration/relevo/lista-relevos.test.ts b/test/integration/relevo/lista-relevos.test.ts new file mode 100644 index 0000000..dd3912f --- /dev/null +++ b/test/integration/relevo/lista-relevos.test.ts @@ -0,0 +1,98 @@ +import { + afterAll, describe, expect, test +} from 'vitest' + +import { createTestApp } from '../setup/app-factory' + +type Relevo = { id: number; nome: string } + +const returning = ['id', 'nome'] as const + +describe('GET /api/v2/relevos', () => { + const { agent, knex } = createTestApp() + + afterAll(() => knex.destroy()) + + test('retorna a lista ordenada por id decrescente como padrão dentro do prefixo do teste', async () => { + const prefix = `XREL-${Date.now()}` + const nomes = [ + `${prefix} Plano`, + `${prefix} Inclinado`, + `${prefix} Ondulado` + ] + + const inserted = await knex('relevos') + .insert(nomes.map(nome => ({ nome }))) + .returning(returning) + + try { + const response = await agent.get(`/api/v2/relevos?nome=${prefix}`).expect(200) + const expected = [...inserted].sort((a, b) => b.id - a.id) + expect(response.body).toEqual(expected) + } finally { + await knex('relevos').whereIn('nome', nomes).delete() + } + }) + + test('filtra por nome sem diferenciar maiúsculas e minúsculas', async () => { + const prefix = `XREL-${Date.now()}` + const nomes = [ + `${prefix} Plano`, + `${prefix} Inclinado`, + `${prefix} Ondulado` + ] + + const inserted = await knex('relevos') + .insert(nomes.map(nome => ({ nome }))) + .returning(returning) + + try { + const response = await agent.get(`/api/v2/relevos?nome=${prefix} plano`).expect(200) + expect(response.body).toEqual(inserted.filter(item => item.nome === `${prefix} Plano`)) + } finally { + await knex('relevos').whereIn('nome', nomes).delete() + } + }) + + test('aceita ordenação customizada por nome e id', async () => { + const prefix = `XREL-${Date.now()}` + const nomes = [ + `${prefix} Z`, + `${prefix} A`, + `${prefix} M` + ] + + const inserted = await knex('relevos') + .insert(nomes.map(nome => ({ nome }))) + .returning(returning) + + try { + const byNameAsc = await agent.get(`/api/v2/relevos?nome=${prefix}&order=nome:asc`).expect(200) + expect(byNameAsc.body).toEqual([...inserted].sort((a, b) => a.nome.localeCompare(b.nome))) + + const byIdAsc = await agent.get(`/api/v2/relevos?nome=${prefix}&order=id:asc`).expect(200) + expect(byIdAsc.body).toEqual([...inserted].sort((a, b) => a.id - b.id)) + } finally { + await knex('relevos').whereIn('nome', nomes).delete() + } + }) + + test('retorna 400 quando a ordenação é inválida', async () => { + const prefix = `XREL-${Date.now()}` + const nomes = [ + `${prefix} Z`, + `${prefix} A`, + `${prefix} M` + ] + + await knex('relevos').insert(nomes.map(nome => ({ nome }))) + + try { + const response = await agent.get(`/api/v2/relevos?nome=${prefix}&order=foo:bar`).expect(400) + const body = response.body as { error: { message: string } } + expect(body.error.message).toMatch(/inválido|invalid/i) + } finally { + await knex('relevos').whereIn('nome', nomes).delete() + } + }) +})