From 2c9bdac1398352d831f88f3b2a0a2989ba914229 Mon Sep 17 00:00:00 2001 From: josuemc Date: Wed, 26 Aug 2026 10:05:24 -0300 Subject: [PATCH 1/3] Feat: Listar e buscar tipos de solo --- src/application/create-app.ts | 4 +- src/application/solo/BuscarSoloController.ts | 41 +++++++++ src/application/solo/ListaSolosController.ts | 50 ++++++++++ src/application/solo/index.ts | 35 +++++++ src/domain/solo/BuscarSoloPorIdUseCase.ts | 20 ++++ src/domain/solo/ListaSolosUseCase.ts | 20 ++++ src/domain/solo/Solo.ts | 24 +++++ src/domain/solo/SoloCollection.ts | 18 ++++ .../SoloCollectionKnexAdapter.ts | 46 ++++++++++ test/integration/solo/lista-solos.test.ts | 91 +++++++++++++++++++ 10 files changed, 348 insertions(+), 1 deletion(-) create mode 100644 src/application/solo/BuscarSoloController.ts create mode 100644 src/application/solo/ListaSolosController.ts create mode 100644 src/application/solo/index.ts create mode 100644 src/domain/solo/BuscarSoloPorIdUseCase.ts create mode 100644 src/domain/solo/ListaSolosUseCase.ts create mode 100644 src/domain/solo/Solo.ts create mode 100644 src/domain/solo/SoloCollection.ts create mode 100644 src/infrastructure/SoloCollectionKnexAdapter.ts create mode 100644 test/integration/solo/lista-solos.test.ts diff --git a/src/application/create-app.ts b/src/application/create-app.ts index 0f53e37..08421ac 100644 --- a/src/application/create-app.ts +++ b/src/application/create-app.ts @@ -14,6 +14,7 @@ import legacyErrors from '../middlewares/erros-middleware' import { generatePreview, reportPreview } from '../reports/controller' import { routes as createEstadoRoutes } from './estado' import { routes as createPaisRoutes } from './pais' +import { routes as createSoloRoutes } from './solo' interface CorsParameters { origins: string[] @@ -55,7 +56,8 @@ export function createApp({ }: Parameters) { const routes: Route[] = [ ...createPaisRoutes(knex), - ...createEstadoRoutes(knex) + ...createEstadoRoutes(knex), + ...createSoloRoutes(knex) ] const application = new ExpressApplication({ logger }) diff --git a/src/application/solo/BuscarSoloController.ts b/src/application/solo/BuscarSoloController.ts new file mode 100644 index 0000000..fe7352e --- /dev/null +++ b/src/application/solo/BuscarSoloController.ts @@ -0,0 +1,41 @@ +import { BuscarSoloPorIdUseCase } from '@/domain/solo/BuscarSoloPorIdUseCase' +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 { + buscarSoloPorIdUseCase: BuscarSoloPorIdUseCase +} + +export class BuscarSoloController implements RequestHandler { + private readonly buscarSoloPorIdUseCase: BuscarSoloPorIdUseCase + + constructor(dependencies: Dependencies) { + this.buscarSoloPorIdUseCase = dependencies.buscarSoloPorIdUseCase + } + + async handle(request: HttpRequest, _next: NextHandler): Promise { + const { soloId } = request.params as { soloId?: string } + + if (soloId === undefined || soloId === null || soloId === '' || !/^\d+$/.test(soloId)) { + return new BadRequestError({ message: 'soloId inválido' }) + } + + const result = await this.buscarSoloPorIdUseCase.execute({ id: Number(soloId) }) + + if (result.left()) { + return new InternalServerError({ message: result.value.message }) + } + + if (!result.value) { + return new NotFoundError({ message: 'Solo não encontrado' }) + } + + return { statusCode: StatusCode.Ok, body: result.value } + } +} diff --git a/src/application/solo/ListaSolosController.ts b/src/application/solo/ListaSolosController.ts new file mode 100644 index 0000000..f1b62e0 --- /dev/null +++ b/src/application/solo/ListaSolosController.ts @@ -0,0 +1,50 @@ +import { ListaSolosUseCase } from '@/domain/solo/ListaSolosUseCase' +import { + HttpRequest, HttpResponse, StatusCode +} from '@/library/http/common' +import { HttpError } from '@/library/http/error/HttpError' +import { InternalServerError } from '@/library/http/error/InternalServerError' +import { NextHandler, RequestHandler } from '@/library/http/Server' + +interface Dependencies { + listaSolosUseCase: ListaSolosUseCase +} + +export class ListaSolosController implements RequestHandler { + private readonly listaSolosUseCase: ListaSolosUseCase + + constructor(dependencies: Dependencies) { + this.listaSolosUseCase = dependencies.listaSolosUseCase + } + + async handle(request: HttpRequest, _next: NextHandler): Promise { + const { nome, order } = request.params as { + nome?: string + order?: string + } + + const result = await this.listaSolosUseCase.execute({ + nome, + order: parseOrder(order) + }) + + 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' } | undefined { + if (!order) return undefined + + const [column, direction] = order.split(':') + const normalizedColumn = column === 'nome' || column === 'id' ? column : 'id' + const normalizedDirection = direction === 'asc' || direction === 'desc' ? direction : 'desc' + + return { + column: normalizedColumn, + direction: normalizedDirection + } +} diff --git a/src/application/solo/index.ts b/src/application/solo/index.ts new file mode 100644 index 0000000..37b8535 --- /dev/null +++ b/src/application/solo/index.ts @@ -0,0 +1,35 @@ +import { type Knex } from 'knex' + +import { BuscarSoloPorIdUseCase } from '@/domain/solo/BuscarSoloPorIdUseCase' +import { ListaSolosUseCase } from '@/domain/solo/ListaSolosUseCase' +import { SoloCollectionKnexAdapter } from '@/infrastructure/SoloCollectionKnexAdapter' +import { Method } from '@/library/http/common' +import { Route } from '@/library/http/Router' + +import { BuscarSoloController } from './BuscarSoloController' +import { ListaSolosController } from './ListaSolosController' + +export function routes(knex: Knex): Route[] { + const soloCollection = new SoloCollectionKnexAdapter({ knex }) + + return [ + { + handlers: [ + new ListaSolosController({ + listaSolosUseCase: new ListaSolosUseCase({ soloCollection }) + }) + ], + method: Method.Get, + path: '/v2/solos' + }, + { + handlers: [ + new BuscarSoloController({ + buscarSoloPorIdUseCase: new BuscarSoloPorIdUseCase({ soloCollection }) + }) + ], + method: Method.Get, + path: '/v2/solos/:soloId' + } + ] +} diff --git a/src/domain/solo/BuscarSoloPorIdUseCase.ts b/src/domain/solo/BuscarSoloPorIdUseCase.ts new file mode 100644 index 0000000..2b7dc01 --- /dev/null +++ b/src/domain/solo/BuscarSoloPorIdUseCase.ts @@ -0,0 +1,20 @@ +import { Either } from '@/library/either/Either' + +import { Attributes } from './Solo' +import { SoloCollection } from './SoloCollection' + +interface Dependencies { + soloCollection: SoloCollection +} + +export class BuscarSoloPorIdUseCase { + private readonly soloCollection: SoloCollection + + constructor(dependencies: Dependencies) { + this.soloCollection = dependencies.soloCollection + } + + execute({ id }: { id: number }): Promise> { + return this.soloCollection.findById(id) + } +} diff --git a/src/domain/solo/ListaSolosUseCase.ts b/src/domain/solo/ListaSolosUseCase.ts new file mode 100644 index 0000000..a90894e --- /dev/null +++ b/src/domain/solo/ListaSolosUseCase.ts @@ -0,0 +1,20 @@ +import { Either } from '@/library/either/Either' + +import { Attributes } from './Solo' +import { SoloCollection, SoloFilters } from './SoloCollection' + +interface Dependencies { + soloCollection: SoloCollection +} + +export class ListaSolosUseCase { + private readonly soloCollection: SoloCollection + + constructor(dependencies: Dependencies) { + this.soloCollection = dependencies.soloCollection + } + + execute(filters: SoloFilters): Promise> { + return this.soloCollection.findAll(filters) + } +} diff --git a/src/domain/solo/Solo.ts b/src/domain/solo/Solo.ts new file mode 100644 index 0000000..acc2121 --- /dev/null +++ b/src/domain/solo/Solo.ts @@ -0,0 +1,24 @@ +import { Either } from '@/library/either/Either' + +export interface Attributes { + id: number + nome: string +} + +export class Solo { + 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 solo não pode ser vazio')) + } + + return Either.right(new Solo(attributes)) + } +} diff --git a/src/domain/solo/SoloCollection.ts b/src/domain/solo/SoloCollection.ts new file mode 100644 index 0000000..37dfe5c --- /dev/null +++ b/src/domain/solo/SoloCollection.ts @@ -0,0 +1,18 @@ +import { Either } from '@/library/either/Either' + +import { Attributes } from './Solo' + +export interface SoloOrder { + column: 'id' | 'nome' + direction: 'asc' | 'desc' +} + +export interface SoloFilters { + nome?: string + order?: SoloOrder +} + +export interface SoloCollection { + findAll(filters: SoloFilters): Promise> + findById(id: number): Promise> +} diff --git a/src/infrastructure/SoloCollectionKnexAdapter.ts b/src/infrastructure/SoloCollectionKnexAdapter.ts new file mode 100644 index 0000000..92e4001 --- /dev/null +++ b/src/infrastructure/SoloCollectionKnexAdapter.ts @@ -0,0 +1,46 @@ +import { Knex } from 'knex' + +import { Attributes } from '@/domain/solo/Solo' +import { SoloCollection, SoloFilters } from '@/domain/solo/SoloCollection' +import { Either } from '@/library/either/Either' + +import { CollectionError } from './error/CollectionError' + +interface Dependencies { + knex: Knex +} + +export class SoloCollectionKnexAdapter implements SoloCollection { + private readonly knex: Knex + + constructor(dependencies: Dependencies) { + this.knex = dependencies.knex + } + + async findAll(filters: SoloFilters): Promise> { + try { + const query = this.knex('solos') + .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 solos', cause: error })) + } + } + + async findById(id: number): Promise> { + try { + const solo = await this.knex('solos').select(['id', 'nome']).where({ id }).first() + return Either.right(solo ?? null) + } catch (error) { + return Either.left(new CollectionError({ message: 'Failed to find solo', cause: error })) + } + } +} diff --git a/test/integration/solo/lista-solos.test.ts b/test/integration/solo/lista-solos.test.ts new file mode 100644 index 0000000..06acd4e --- /dev/null +++ b/test/integration/solo/lista-solos.test.ts @@ -0,0 +1,91 @@ +import { + afterAll, describe, expect, test +} from 'vitest' + +import { createTestApp } from '../setup/app-factory' + +type Solo = { id: number; nome: string } + +const returning = ['id', 'nome'] as const + +describe('GET /api/v2/solos', () => { + const { agent, knex } = createTestApp() + + afterAll(() => knex.destroy()) + + test('retorna a lista ordenada por id decrescente como padrão', async () => { + const nomes = ['XSOL Arenoso', 'XSOL Argiloso', 'XSOL Pedregoso'] + + const inserted = await knex('solos') + .insert(nomes.map(nome => ({ nome }))) + .returning(returning) + + try { + const response = await agent.get('/api/v2/solos').expect(200) + const expected = [...inserted].sort((a, b) => b.id - a.id) + expect(response.body).toEqual(expected) + } finally { + await knex('solos').whereIn('nome', nomes).delete() + } + }) + + test('filtra por nome sem diferenciar maiúsculas e minúsculas', async () => { + const nomes = ['XSOL Arenoso', 'XSOL Argiloso', 'XSOL Pedregoso'] + const inserted = await knex('solos') + .insert(nomes.map(nome => ({ nome }))) + .returning(returning) + + try { + const response = await agent.get('/api/v2/solos?nome=arenoso').expect(200) + expect(response.body).toEqual(inserted.filter(item => item.nome === 'XSOL Arenoso')) + } finally { + await knex('solos').whereIn('nome', nomes).delete() + } + }) + + test('aceita ordenação customizada por nome e id', async () => { + const nomes = ['XSOL Z', 'XSOL A', 'XSOL M'] + const inserted = await knex('solos') + .insert(nomes.map(nome => ({ nome }))) + .returning(returning) + + try { + const byNameAsc = await agent.get('/api/v2/solos?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/solos?order=id:asc').expect(200) + expect(byIdAsc.body).toEqual([...inserted].sort((a, b) => a.id - b.id)) + } finally { + await knex('solos').whereIn('nome', nomes).delete() + } + }) +}) + +describe('GET /api/v2/solos/:soloId', () => { + const { agent, knex } = createTestApp() + + afterAll(() => knex.destroy()) + + test('retorna o registro encontrado', async () => { + const [solo] = await knex('solos') + .insert({ nome: 'XSOL Solo Encontrado' }) + .returning(returning) + + try { + const response = await agent.get(`/api/v2/solos/${solo.id}`).expect(200) + expect(response.body).toEqual({ id: solo.id, nome: solo.nome }) + } finally { + await knex('solos').where({ id: solo.id }).delete() + } + }) + + test('retorna 404 para id inexistente', async () => { + const response = await agent.get('/api/v2/solos/999999').expect(404) + expect(response.body.error.message).toMatch(/não encontrado|not found|not found/i) + }) + + test('retorna 400 para id inválido', async () => { + const response = await agent.get('/api/v2/solos/abc').expect(400) + expect(response.body.error.message).toMatch(/inválido|invalid/i) + }) +}) From bc220de4b298a34dbe15c6152bd8486596161cd8 Mon Sep 17 00:00:00 2001 From: josuemc Date: Wed, 26 Aug 2026 10:22:32 -0300 Subject: [PATCH 2/3] fix: yarn lint --- test/integration/solo/lista-solos.test.ts | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/test/integration/solo/lista-solos.test.ts b/test/integration/solo/lista-solos.test.ts index 06acd4e..af4b483 100644 --- a/test/integration/solo/lista-solos.test.ts +++ b/test/integration/solo/lista-solos.test.ts @@ -14,7 +14,11 @@ describe('GET /api/v2/solos', () => { afterAll(() => knex.destroy()) test('retorna a lista ordenada por id decrescente como padrão', async () => { - const nomes = ['XSOL Arenoso', 'XSOL Argiloso', 'XSOL Pedregoso'] + const nomes = [ + 'XSOL Arenoso', + 'XSOL Argiloso', + 'XSOL Pedregoso' + ] const inserted = await knex('solos') .insert(nomes.map(nome => ({ nome }))) @@ -30,7 +34,11 @@ describe('GET /api/v2/solos', () => { }) test('filtra por nome sem diferenciar maiúsculas e minúsculas', async () => { - const nomes = ['XSOL Arenoso', 'XSOL Argiloso', 'XSOL Pedregoso'] + const nomes = [ + 'XSOL Arenoso', + 'XSOL Argiloso', + 'XSOL Pedregoso' + ] const inserted = await knex('solos') .insert(nomes.map(nome => ({ nome }))) .returning(returning) @@ -44,7 +52,11 @@ describe('GET /api/v2/solos', () => { }) test('aceita ordenação customizada por nome e id', async () => { - const nomes = ['XSOL Z', 'XSOL A', 'XSOL M'] + const nomes = [ + 'XSOL Z', + 'XSOL A', + 'XSOL M' + ] const inserted = await knex('solos') .insert(nomes.map(nome => ({ nome }))) .returning(returning) @@ -81,11 +93,13 @@ describe('GET /api/v2/solos/:soloId', () => { test('retorna 404 para id inexistente', async () => { const response = await agent.get('/api/v2/solos/999999').expect(404) + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access expect(response.body.error.message).toMatch(/não encontrado|not found|not found/i) }) test('retorna 400 para id inválido', async () => { const response = await agent.get('/api/v2/solos/abc').expect(400) + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access expect(response.body.error.message).toMatch(/inválido|invalid/i) }) }) From 96f85e92692459a16814229ce65039c18f9bd555 Mon Sep 17 00:00:00 2001 From: josuemc Date: Wed, 26 Aug 2026 11:13:53 -0300 Subject: [PATCH 3/3] fix:test --- test/integration/solo/lista-solos.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/test/integration/solo/lista-solos.test.ts b/test/integration/solo/lista-solos.test.ts index af4b483..996b813 100644 --- a/test/integration/solo/lista-solos.test.ts +++ b/test/integration/solo/lista-solos.test.ts @@ -93,13 +93,15 @@ describe('GET /api/v2/solos/:soloId', () => { test('retorna 404 para id inexistente', async () => { const response = await agent.get('/api/v2/solos/999999').expect(404) - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - expect(response.body.error.message).toMatch(/não encontrado|not found|not found/i) + 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/solos/abc').expect(400) - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - expect(response.body.error.message).toMatch(/inválido|invalid/i) + const body = response.body as { error: { message: string } } + + expect(body.error.message).toMatch(/inválido|invalid/i) }) })