Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/application/create-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down Expand Up @@ -55,7 +56,8 @@ export function createApp({
}: Parameters) {
const routes: Route[] = [
...createPaisRoutes(knex),
...createEstadoRoutes(knex)
...createEstadoRoutes(knex),
...createSoloRoutes(knex)
]
const application = new ExpressApplication({ logger })

Expand Down
41 changes: 41 additions & 0 deletions src/application/solo/BuscarSoloController.ts
Original file line number Diff line number Diff line change
@@ -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<HttpResponse | HttpError> {
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 }
}
}
50 changes: 50 additions & 0 deletions src/application/solo/ListaSolosController.ts
Original file line number Diff line number Diff line change
@@ -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<HttpResponse | HttpError> {
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
}
}
35 changes: 35 additions & 0 deletions src/application/solo/index.ts
Original file line number Diff line number Diff line change
@@ -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'
}
]
}
20 changes: 20 additions & 0 deletions src/domain/solo/BuscarSoloPorIdUseCase.ts
Original file line number Diff line number Diff line change
@@ -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<Either<Error, Attributes | null>> {
return this.soloCollection.findById(id)
}
}
20 changes: 20 additions & 0 deletions src/domain/solo/ListaSolosUseCase.ts
Original file line number Diff line number Diff line change
@@ -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<Either<Error, Attributes[]>> {
return this.soloCollection.findAll(filters)
}
}
24 changes: 24 additions & 0 deletions src/domain/solo/Solo.ts
Original file line number Diff line number Diff line change
@@ -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<Error, Solo> {
if (!attributes.nome.trim()) {
return Either.left(new Error('Nome do solo não pode ser vazio'))
}

return Either.right(new Solo(attributes))
}
}
18 changes: 18 additions & 0 deletions src/domain/solo/SoloCollection.ts
Original file line number Diff line number Diff line change
@@ -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<Either<Error, Attributes[]>>
findById(id: number): Promise<Either<Error, Attributes | null>>
}
46 changes: 46 additions & 0 deletions src/infrastructure/SoloCollectionKnexAdapter.ts
Original file line number Diff line number Diff line change
@@ -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<Either<Error, Attributes[]>> {
try {
const query = this.knex<Attributes>('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<Either<Error, Attributes | null>> {
try {
const solo = await this.knex<Attributes>('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 }))
}
}
}
107 changes: 107 additions & 0 deletions test/integration/solo/lista-solos.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
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<Solo[]>(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<Solo[]>(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<Solo[]>(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<Solo[]>(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)
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)
const body = response.body as { error: { message: string } }

expect(body.error.message).toMatch(/inválido|invalid/i)
})
})
Loading