From 029ed2c2d5fd51ea75d9c911862aa5ee22114e33 Mon Sep 17 00:00:00 2001 From: Edvaldo Szymonek Date: Wed, 26 Aug 2026 23:04:35 -0300 Subject: [PATCH 1/7] =?UTF-8?q?corrige=20dump=20para=20execu=C3=A7=C3=A3o?= =?UTF-8?q?=20dos=20testes=20de=20integra=C3=A7=C3=A3o=20(#509)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/pull_request.yml | 10 +++--- compose.integration.yml | 6 ++-- package.json | 4 +-- test/integration/README.md | 47 ++++++++++++-------------- test/integration/setup/global-setup.ts | 9 ++--- test/integration/setup/load-env.ts | 7 ++++ test/integration/setup/schema.sql | 2 +- 7 files changed, 44 insertions(+), 41 deletions(-) diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 19c7ea6a..79c61cc1 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -47,16 +47,14 @@ jobs: PG_PORT: 5432 PG_DATABASE: herbario_test PG_USERNAME: postgres - PG_PASSWORD: testpassword - PG_MIGRATION_USERNAME: postgres - PG_MIGRATION_PASSWORD: testpassword + PG_PASSWORD: secret services: postgres: image: postgis/postgis:18-3.6 env: POSTGRES_DB: herbario_test POSTGRES_USER: postgres - POSTGRES_PASSWORD: testpassword + POSTGRES_PASSWORD: secret ports: - 5432:5432 options: >- @@ -74,9 +72,9 @@ jobs: - name: Install dependencies run: yarn install --frozen-lockfile - name: Apply base schema - run: psql -h $PG_HOST -p $PG_PORT -U $PG_MIGRATION_USERNAME -d $PG_DATABASE -f test/integration/setup/schema.sql + run: psql -h $PG_HOST -p $PG_PORT -U $PG_USERNAME -d $PG_DATABASE -f test/integration/setup/schema.sql env: - PGPASSWORD: ${{ env.PG_MIGRATION_PASSWORD }} + PGPASSWORD: ${{ env.PG_PASSWORD }} - name: Run integration tests run: yarn test:integration diff --git a/compose.integration.yml b/compose.integration.yml index ce3d1f86..ebffc49e 100644 --- a/compose.integration.yml +++ b/compose.integration.yml @@ -1,11 +1,11 @@ services: postgres: image: postgis/postgis:18-3.6 - container_name: herbario_postgresql_e2e + container_name: herbario_postgresql_test environment: - POSTGRES_DB: herbario_e2e + POSTGRES_DB: herbario_test POSTGRES_USER: postgres - POSTGRES_PASSWORD: testpassword + POSTGRES_PASSWORD: secret ports: - "5433:5432" volumes: diff --git a/package.json b/package.json index 38a89a20..2f353c7c 100644 --- a/package.json +++ b/package.json @@ -19,9 +19,9 @@ "build": "node build.mjs", "test": "vitest --run", "test:unit": "vitest --run --project unit", - "test:unit:watch": "vitest --project unit", + "test:unit:watch": "vitest --project unit", "test:integration": "vitest --run --project integration", - "test:integration:watch": "vitest --project integration", + "test:integration:watch": "vitest --project integration", "test:coverage": "vitest --run --project unit --coverage", "prepare": "husky", "audit": "npm audit --audit-level=moderate", diff --git a/test/integration/README.md b/test/integration/README.md index 37d66346..291d37d8 100644 --- a/test/integration/README.md +++ b/test/integration/README.md @@ -1,7 +1,9 @@ # Testes de integração -Os testes de integração rodam contra um banco PostgreSQL real. **Você é responsável por -iniciar o container e aplicar as migrations antes de executar os testes.** +Os testes de integração sobem o Express in-process (via `supertest`) e falam com +um PostgreSQL/PostGIS real. **Você é responsável por iniciar o container antes +de executar os testes.** O schema é aplicado automaticamente na primeira +inicialização do banco (`schema.sql` montado em `docker-entrypoint-initdb.d`). ## Pré-requisitos @@ -16,30 +18,34 @@ iniciar o container e aplicar as migrations antes de executar os testes.** docker compose -f compose.integration.yml up -d ``` -Isso sobe um container PostgreSQL na porta **5433** usando as credenciais do `.env`. +Isso sobe um PostgreSQL na porta **5433** (`herbario_test` / `postgres` / `secret`), +os mesmos valores de `.env.test`. O arquivo Compose já fixa `POSTGRES_*` e a porta; +não depende do `.env` da aplicação. -### 2. Aplique as migrations +### 2. Schema (sem migrations) -```bash -npm run migration:apply -``` +A suíte **não** executa `migration:apply`. Muitos arquivos em +`src/database/migration/` são incrementais, dependem de dados reais ou ainda +usam SQL de MySQL — não dá para replayar o histórico no Postgres de teste. -Isso executa o stack completo de migrations no banco definido no `.env`. Você só precisa -rodar de novo quando novas migrations forem adicionadas. +O contrato é: `schema.sql` é um dump do schema **já migrado**. Quando uma +migration alterar o schema que os testes usam, regenere o dump e commite junto. ## Executando os testes ```bash -npm run test:integration +yarn test:integration ``` -A suíte de testes conecta ao banco já em execução e roda todos os testes -em `test/integration/`. Nenhuma alteração de schema é feita no momento do teste. +A suíte conecta ao banco já em execução e roda os arquivos em +`test/integration/**/*.test.ts`. Nenhuma alteração de schema é feita no momento +do teste. O `globalSetup` apenas verifica a conexão e faz `TRUNCATE` das tabelas +usadas pelos testes. Para o modo watch (reexecuta ao mudar arquivos): ```bash -npm run test:integration:watch +yarn test:integration:watch ``` ## Parando o banco @@ -48,8 +54,8 @@ npm run test:integration:watch docker compose -f compose.integration.yml down ``` -Como o container usa `tmpfs`, todos os dados são perdidos ao parar. Na próxima vez, -comece do zero com `docker compose up -d` seguido de `migration:apply`. +Como o container usa `tmpfs`, todos os dados (e o schema) são perdidos ao parar. +Na próxima vez, comece do zero com `docker compose -f compose.integration.yml up -d`. --- @@ -61,15 +67,6 @@ Cada teste deve ser dono dos seus dados: coluna identificadora (sigla, nome, etc.) que distinga suas linhas das de outros arquivos de teste. - Limpe os dados inseridos em um bloco `finally`, para que a limpeza rode mesmo se a asserção falhar. - **`afterAll`** — chame `knex.destroy()` para liberar o pool de conexões. -- Nunca use `TRUNCATE` — isso apagaria dados de outros arquivos de teste que rodam em paralelo. +- Nunca use `TRUNCATE` no arquivo de teste — isso apagaria dados de outros arquivos que rodam em paralelo. Veja `test/integration/pais/lista-paises.test.ts` para um exemplo concreto. - -### Convenção de namespace - -Use um prefixo curto e único nos identificadores para evitar colisões entre arquivos de teste: - -| Arquivo de teste | Prefixo usado | -|---|---| -| `lista-paises.test.ts` | `XPBR`/`XPAR`/`XPCB` (sigla do país), `XPAI`/`XPCI` (prefixo do nome) | -| `lista-estados.test.ts` | `XEBR` (sigla do país), `XEPR`/`XESP` (sigla do estado) | diff --git a/test/integration/setup/global-setup.ts b/test/integration/setup/global-setup.ts index b1841471..b9cb050d 100644 --- a/test/integration/setup/global-setup.ts +++ b/test/integration/setup/global-setup.ts @@ -39,10 +39,11 @@ export async function setup(): Promise { try { await knex.raw('SELECT 1') await truncateTables(knex) - } catch { + } catch (error) { + const reason = error instanceof Error ? error.message : String(error) throw new Error( - `Cannot connect to the test database (${PG_HOST}:${PG_PORT}/${PG_DATABASE}). ` - + 'Start the container and apply the schema before running e2e tests — ' + `Cannot prepare the test database (${PG_HOST}:${PG_PORT}/${PG_DATABASE}): ${reason}. ` + + 'Start the container (schema is applied on first boot) — ' + 'see test/integration/README.md' ) } finally { @@ -51,5 +52,5 @@ export async function setup(): Promise { } export async function teardown(): Promise { - // Container lifecycle is managed by the developer — see test/e2e/README.md + // Container lifecycle is managed by the developer — see test/integration/README.md } diff --git a/test/integration/setup/load-env.ts b/test/integration/setup/load-env.ts index b9608d2f..a80f0212 100644 --- a/test/integration/setup/load-env.ts +++ b/test/integration/setup/load-env.ts @@ -1,4 +1,11 @@ import { mkdirSync } from 'node:fs' import path from 'node:path' +import { loadEnvFile } from 'node:process' + +try { + loadEnvFile('.env.test') +} catch { + // In CI, environment variables are injected directly into the process +} mkdirSync(path.resolve(process.cwd(), 'uploads'), { recursive: true }) diff --git a/test/integration/setup/schema.sql b/test/integration/setup/schema.sql index f6214b46..d0cd624a 100644 --- a/test/integration/setup/schema.sql +++ b/test/integration/setup/schema.sql @@ -35,7 +35,7 @@ COMMENT ON SCHEMA public IS ''; -- Name: topology; Type: SCHEMA; Schema: -; Owner: - -- -CREATE SCHEMA topology; +CREATE SCHEMA IF NOT EXISTS topology; -- From 53153ca2c8d5522ab3116826c28f94a9cbce1117 Mon Sep 17 00:00:00 2001 From: Josue Modesto <91280773+JosueModesto@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:47:48 -0300 Subject: [PATCH 2/7] Fix: A coluna numero da fase sucessional foi renomeada para id (#508) --- src/controllers/locais-coleta-controller.js | 8 +--- src/controllers/pendencias-controller.js | 4 +- src/controllers/tombos-controller.js | 6 +-- ...03_rename_fase_sucessional_numero_to_id.ts | 34 +++++++++++++++++ src/models/FaseSucessional.js | 2 +- test/integration/setup/schema.sql | 38 +++++++++++++++++-- 6 files changed, 76 insertions(+), 16 deletions(-) create mode 100644 src/database/migration/20260826120203_rename_fase_sucessional_numero_to_id.ts diff --git a/src/controllers/locais-coleta-controller.js b/src/controllers/locais-coleta-controller.js index 7ca28e0e..54766851 100644 --- a/src/controllers/locais-coleta-controller.js +++ b/src/controllers/locais-coleta-controller.js @@ -168,11 +168,7 @@ export const cadastrarFaseSucessional = (request, response, next) => { throw new BadRequestExeption(306); } }) - .then(() => FaseSucessional.max('numero', { transaction })) - .then(maxNumero => { - const proximoNumero = (Number(maxNumero) || 0) + 1; - return FaseSucessional.create({ numero: proximoNumero, nome }, transaction); - }); + .then(() => FaseSucessional.create({ nome }, { transaction })); sequelize.transaction(callback) .then(faseCriada => { @@ -196,7 +192,7 @@ export const buscarFasesSucessionais = (request, response, next) => { Promise.resolve() .then(() => FaseSucessional.findAndCountAll({ - attributes: ['numero', 'nome'], + attributes: ['id', 'nome'], where, order: [['nome', 'ASC']], })) diff --git a/src/controllers/pendencias-controller.js b/src/controllers/pendencias-controller.js index 32f0b2c6..ae2cadc8 100644 --- a/src/controllers/pendencias-controller.js +++ b/src/controllers/pendencias-controller.js @@ -1450,7 +1450,7 @@ export const aprovarPendencia = async (alteracao, hcf, transaction) => { if (alteracao.fase_sucessional_id !== undefined) { if (alteracao.fase_sucessional_id !== null) { const faseSucessional = await FaseSucessional.findOne({ - where: { numero: alteracao.fase_sucessional_id }, + where: { id: alteracao.fase_sucessional_id }, transaction, raw: true, nest: true, @@ -1850,7 +1850,7 @@ export async function visualizar(request, response, next) { } if (objetoAlterado.fase_sucessional_id !== undefined) { - parametros.faseSucessional = await FaseSucessional.findOne({ where: { numero: objetoAlterado.fase_sucessional_id }, raw: true, nest: true }); + parametros.faseSucessional = await FaseSucessional.findOne({ where: { id: objetoAlterado.fase_sucessional_id }, raw: true, nest: true }); } if (objetoAlterado.vegetacao_id !== undefined) { diff --git a/src/controllers/tombos-controller.js b/src/controllers/tombos-controller.js index f1292e9d..095298c1 100644 --- a/src/controllers/tombos-controller.js +++ b/src/controllers/tombos-controller.js @@ -131,7 +131,7 @@ export const cadastro = (request, response, next) => { if (paisagem && paisagem.fase_sucessional_id) { return FaseSucessional.findOne({ where: { - numero: paisagem.fase_sucessional_id, + id: paisagem.fase_sucessional_id, }, transaction, }); @@ -950,7 +950,7 @@ export const getDadosCadTombo = (request, response, next) => { retorno.vegetacoes = vegetacoes.rows; }) .then(() => FaseSucessional.findAndCountAll({ - attributes: ['numero', 'nome'], + attributes: ['id', 'nome'], order: [['nome', 'ASC']], transaction, })) @@ -1256,7 +1256,7 @@ export const obterTombo = async (request, response, next) => { relevoInicial: tombo.relevo !== null ? tombo.relevo?.nome : '', idVegetacaoInicial: tombo.vegetaco !== null ? tombo.vegetaco?.id : '', vegetacaoInicial: tombo.vegetaco !== null ? tombo.vegetaco?.nome : '', - idFaseInicial: tombo.fase_sucessional !== null ? tombo.fase_sucessional?.numero : '', + idFaseInicial: tombo.fase_sucessional !== null ? tombo.fase_sucessional?.id : '', faseInicial: tombo.fase_sucessional !== null ? tombo.fase_sucessional?.nome : '', colecaoInicial: tombo.colecoes_anexa !== null ? tombo.colecoes_anexa?.tipo : '', complementoInicial: tombo.localizacao !== null && tombo.localizacao !== undefined ? tombo.localizacao?.complemento : '', diff --git a/src/database/migration/20260826120203_rename_fase_sucessional_numero_to_id.ts b/src/database/migration/20260826120203_rename_fase_sucessional_numero_to_id.ts new file mode 100644 index 00000000..91b3ab03 --- /dev/null +++ b/src/database/migration/20260826120203_rename_fase_sucessional_numero_to_id.ts @@ -0,0 +1,34 @@ +import { Knex } from 'knex' + +export async function run(knex: Knex): Promise { + const hasNumero = await knex.schema.hasColumn('fase_sucessional', 'numero') + const hasId = await knex.schema.hasColumn('fase_sucessional', 'id') + + if (hasNumero && !hasId) { + await knex.schema.alterTable('fase_sucessional', table => { + table.renameColumn('numero', 'id') + }) + } + + await knex.raw(` + CREATE SEQUENCE IF NOT EXISTS fase_sucessional_id_seq; + `) + + await knex.raw(` + SELECT setval( + 'fase_sucessional_id_seq', + COALESCE((SELECT MAX(id) FROM fase_sucessional), 1), + true + ); + `) + + await knex.raw(` + ALTER TABLE fase_sucessional + ALTER COLUMN id SET DEFAULT nextval('fase_sucessional_id_seq'); + `) + + await knex.raw(` + ALTER SEQUENCE fase_sucessional_id_seq + OWNED BY fase_sucessional.id; + `) +} diff --git a/src/models/FaseSucessional.js b/src/models/FaseSucessional.js index c3e4dea2..48cb705b 100644 --- a/src/models/FaseSucessional.js +++ b/src/models/FaseSucessional.js @@ -5,7 +5,7 @@ function associate(/* modelos */) { export default (Sequelize, DataTypes) => { const attributes = { - numero: { + id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true, diff --git a/test/integration/setup/schema.sql b/test/integration/setup/schema.sql index d0cd624a..99ab6f08 100644 --- a/test/integration/setup/schema.sql +++ b/test/integration/setup/schema.sql @@ -566,7 +566,7 @@ ALTER SEQUENCE public.familias_id_seq OWNED BY public.familias.id; -- CREATE TABLE public.fase_sucessional ( - numero bigint NOT NULL, + id bigint NOT NULL, nome character varying(200) NOT NULL, created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL @@ -575,6 +575,28 @@ CREATE TABLE public.fase_sucessional ( -- -- TOC entry 244 (class 1259 OID 30988) +-- Name: fase_sucessional_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.fase_sucessional_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- TOC entry 5014 (class 0 OID 0) +-- Dependencies: 244 +-- Name: fase_sucessional_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.fase_sucessional_id_seq OWNED BY public.fase_sucessional.id; + + +-- +-- TOC entry 245 (class 1259 OID 30988) -- Name: generos; Type: TABLE; Schema: public; Owner: - -- @@ -1897,7 +1919,15 @@ ALTER TABLE ONLY public.familias -- ALTER TABLE ONLY public.fase_sucessional - ADD CONSTRAINT idx_41101_primary PRIMARY KEY (numero); + ADD CONSTRAINT idx_41101_primary PRIMARY KEY (id); + + +-- +-- TOC entry 4530 (class 2604 OID 32603) +-- Name: fase_sucessional id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.fase_sucessional ALTER COLUMN id SET DEFAULT nextval('public.fase_sucessional_id_seq'::regclass); -- @@ -2548,7 +2578,7 @@ CREATE INDEX idx_41296_fk_variedades_genero ON public.variedades USING btree (ge -- ALTER TABLE ONLY public.locais_coleta - ADD CONSTRAINT fk_99i0itontmoklfxmoo8armtnv FOREIGN KEY (fase_numero) REFERENCES public.fase_sucessional(numero) ON UPDATE RESTRICT ON DELETE RESTRICT; + ADD CONSTRAINT fk_99i0itontmoklfxmoo8armtnv FOREIGN KEY (fase_numero) REFERENCES public.fase_sucessional(id) ON UPDATE RESTRICT ON DELETE RESTRICT; -- @@ -2665,7 +2695,7 @@ ALTER TABLE ONLY public.locais_coleta -- ALTER TABLE ONLY public.locais_coleta - ADD CONSTRAINT fk_locais_coleta_fase_sucessional1 FOREIGN KEY (fase_sucessional_id) REFERENCES public.fase_sucessional(numero) ON UPDATE RESTRICT ON DELETE RESTRICT; + ADD CONSTRAINT fk_locais_coleta_fase_sucessional1 FOREIGN KEY (fase_sucessional_id) REFERENCES public.fase_sucessional(id) ON UPDATE RESTRICT ON DELETE RESTRICT; -- From ddd43df5f1ef6b1638b329ce1b3f2c410275d1d8 Mon Sep 17 00:00:00 2001 From: Josue Modesto <91280773+JosueModesto@users.noreply.github.com> Date: Thu, 3 Sep 2026 22:30:34 -0300 Subject: [PATCH 3/7] =?UTF-8?q?Feat:=20listar=20buscar=20tipos=20vegeta?= =?UTF-8?q?=C3=A7=C3=A3o=20Api.v2=20(#507)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/application/create-app.ts | 4 +- .../vegetacao/BuscarVegetacaoController.ts | 41 ++++++ .../vegetacao/ListaVegetacoesController.ts | 65 +++++++++ src/application/vegetacao/index.ts | 35 +++++ .../vegetacao/BuscarVegetacaoPorIdUseCase.ts | 20 +++ .../vegetacao/ListaVegetacoesUseCase.ts | 20 +++ src/domain/vegetacao/Vegetacao.ts | 24 ++++ src/domain/vegetacao/VegetacaoCollection.ts | 18 +++ .../VegetacaoCollectionKnexAdapter.ts | 46 +++++++ .../vegetacao/lista-vegetacoes.test.ts | 129 ++++++++++++++++++ 10 files changed, 401 insertions(+), 1 deletion(-) create mode 100644 src/application/vegetacao/BuscarVegetacaoController.ts create mode 100644 src/application/vegetacao/ListaVegetacoesController.ts create mode 100644 src/application/vegetacao/index.ts create mode 100644 src/domain/vegetacao/BuscarVegetacaoPorIdUseCase.ts create mode 100644 src/domain/vegetacao/ListaVegetacoesUseCase.ts create mode 100644 src/domain/vegetacao/Vegetacao.ts create mode 100644 src/domain/vegetacao/VegetacaoCollection.ts create mode 100644 src/infrastructure/VegetacaoCollectionKnexAdapter.ts create mode 100644 test/integration/vegetacao/lista-vegetacoes.test.ts diff --git a/src/application/create-app.ts b/src/application/create-app.ts index 0f53e372..f43fd845 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 createVegetacaoRoutes } from './vegetacao' interface CorsParameters { origins: string[] @@ -55,7 +56,8 @@ export function createApp({ }: Parameters) { const routes: Route[] = [ ...createPaisRoutes(knex), - ...createEstadoRoutes(knex) + ...createEstadoRoutes(knex), + ...createVegetacaoRoutes(knex) ] const application = new ExpressApplication({ logger }) diff --git a/src/application/vegetacao/BuscarVegetacaoController.ts b/src/application/vegetacao/BuscarVegetacaoController.ts new file mode 100644 index 00000000..69376b07 --- /dev/null +++ b/src/application/vegetacao/BuscarVegetacaoController.ts @@ -0,0 +1,41 @@ +import { BuscarVegetacaoPorIdUseCase } from '@/domain/vegetacao/BuscarVegetacaoPorIdUseCase' +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 { + buscarVegetacaoPorIdUseCase: BuscarVegetacaoPorIdUseCase +} + +export class BuscarVegetacaoController implements RequestHandler { + private readonly buscarVegetacaoPorIdUseCase: BuscarVegetacaoPorIdUseCase + + constructor(dependencies: Dependencies) { + this.buscarVegetacaoPorIdUseCase = dependencies.buscarVegetacaoPorIdUseCase + } + + async handle(request: HttpRequest, _next: NextHandler): Promise { + const { vegetacaoId } = request.params as { vegetacaoId?: string } + + if (vegetacaoId === undefined || vegetacaoId === null || vegetacaoId === '' || !/^\d+$/.test(vegetacaoId)) { + return new BadRequestError({ message: 'vegetacaoId inválido' }) + } + + const result = await this.buscarVegetacaoPorIdUseCase.execute({ id: Number(vegetacaoId) }) + + if (result.left()) { + return new InternalServerError({ message: result.value.message }) + } + + if (!result.value) { + return new NotFoundError({ message: 'Vegetação não encontrada' }) + } + + return { statusCode: StatusCode.Ok, body: result.value } + } +} diff --git a/src/application/vegetacao/ListaVegetacoesController.ts b/src/application/vegetacao/ListaVegetacoesController.ts new file mode 100644 index 00000000..18d05692 --- /dev/null +++ b/src/application/vegetacao/ListaVegetacoesController.ts @@ -0,0 +1,65 @@ +import { ListaVegetacoesUseCase } from '@/domain/vegetacao/ListaVegetacoesUseCase' +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 { + listaVegetacoesUseCase: ListaVegetacoesUseCase +} + +export class ListaVegetacoesController implements RequestHandler { + private readonly listaVegetacoesUseCase: ListaVegetacoesUseCase + + constructor(dependencies: Dependencies) { + this.listaVegetacoesUseCase = dependencies.listaVegetacoesUseCase + } + + 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.listaVegetacoesUseCase.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/vegetacao/index.ts b/src/application/vegetacao/index.ts new file mode 100644 index 00000000..e591fe33 --- /dev/null +++ b/src/application/vegetacao/index.ts @@ -0,0 +1,35 @@ +import { type Knex } from 'knex' + +import { BuscarVegetacaoPorIdUseCase } from '@/domain/vegetacao/BuscarVegetacaoPorIdUseCase' +import { ListaVegetacoesUseCase } from '@/domain/vegetacao/ListaVegetacoesUseCase' +import { VegetacaoCollectionKnexAdapter } from '@/infrastructure/VegetacaoCollectionKnexAdapter' +import { Method } from '@/library/http/common' +import { Route } from '@/library/http/Router' + +import { BuscarVegetacaoController } from './BuscarVegetacaoController' +import { ListaVegetacoesController } from './ListaVegetacoesController' + +export function routes(knex: Knex): Route[] { + const vegetacaoCollection = new VegetacaoCollectionKnexAdapter({ knex }) + + return [ + { + handlers: [ + new ListaVegetacoesController({ + listaVegetacoesUseCase: new ListaVegetacoesUseCase({ vegetacaoCollection }) + }) + ], + method: Method.Get, + path: '/v2/vegetacoes' + }, + { + handlers: [ + new BuscarVegetacaoController({ + buscarVegetacaoPorIdUseCase: new BuscarVegetacaoPorIdUseCase({ vegetacaoCollection }) + }) + ], + method: Method.Get, + path: '/v2/vegetacoes/:vegetacaoId' + } + ] +} diff --git a/src/domain/vegetacao/BuscarVegetacaoPorIdUseCase.ts b/src/domain/vegetacao/BuscarVegetacaoPorIdUseCase.ts new file mode 100644 index 00000000..2a855098 --- /dev/null +++ b/src/domain/vegetacao/BuscarVegetacaoPorIdUseCase.ts @@ -0,0 +1,20 @@ +import { Either } from '@/library/either/Either' + +import { Attributes } from './Vegetacao' +import { VegetacaoCollection } from './VegetacaoCollection' + +interface Dependencies { + vegetacaoCollection: VegetacaoCollection +} + +export class BuscarVegetacaoPorIdUseCase { + private readonly vegetacaoCollection: VegetacaoCollection + + constructor(dependencies: Dependencies) { + this.vegetacaoCollection = dependencies.vegetacaoCollection + } + + execute({ id }: { id: number }): Promise> { + return this.vegetacaoCollection.findById(id) + } +} diff --git a/src/domain/vegetacao/ListaVegetacoesUseCase.ts b/src/domain/vegetacao/ListaVegetacoesUseCase.ts new file mode 100644 index 00000000..42165b2d --- /dev/null +++ b/src/domain/vegetacao/ListaVegetacoesUseCase.ts @@ -0,0 +1,20 @@ +import { Either } from '@/library/either/Either' + +import { Attributes } from './Vegetacao' +import { VegetacaoCollection, VegetacaoFilters } from './VegetacaoCollection' + +interface Dependencies { + vegetacaoCollection: VegetacaoCollection +} + +export class ListaVegetacoesUseCase { + private readonly vegetacaoCollection: VegetacaoCollection + + constructor(dependencies: Dependencies) { + this.vegetacaoCollection = dependencies.vegetacaoCollection + } + + execute(filters: VegetacaoFilters): Promise> { + return this.vegetacaoCollection.findAll(filters) + } +} diff --git a/src/domain/vegetacao/Vegetacao.ts b/src/domain/vegetacao/Vegetacao.ts new file mode 100644 index 00000000..824e17e7 --- /dev/null +++ b/src/domain/vegetacao/Vegetacao.ts @@ -0,0 +1,24 @@ +import { Either } from '@/library/either/Either' + +export interface Attributes { + id: number + nome: string +} + +export class Vegetacao { + 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 da vegetação não pode ser vazio')) + } + + return Either.right(new Vegetacao(attributes)) + } +} diff --git a/src/domain/vegetacao/VegetacaoCollection.ts b/src/domain/vegetacao/VegetacaoCollection.ts new file mode 100644 index 00000000..13ab350d --- /dev/null +++ b/src/domain/vegetacao/VegetacaoCollection.ts @@ -0,0 +1,18 @@ +import { Either } from '@/library/either/Either' + +import { Attributes } from './Vegetacao' + +export interface VegetacaoOrder { + column: 'id' | 'nome' + direction: 'asc' | 'desc' +} + +export interface VegetacaoFilters { + nome?: string + order?: VegetacaoOrder +} + +export interface VegetacaoCollection { + findAll(filters: VegetacaoFilters): Promise> + findById(id: number): Promise> +} diff --git a/src/infrastructure/VegetacaoCollectionKnexAdapter.ts b/src/infrastructure/VegetacaoCollectionKnexAdapter.ts new file mode 100644 index 00000000..42de847d --- /dev/null +++ b/src/infrastructure/VegetacaoCollectionKnexAdapter.ts @@ -0,0 +1,46 @@ +import { Knex } from 'knex' + +import { Attributes } from '@/domain/vegetacao/Vegetacao' +import { VegetacaoCollection, VegetacaoFilters } from '@/domain/vegetacao/VegetacaoCollection' +import { Either } from '@/library/either/Either' + +import { CollectionError } from './error/CollectionError' + +interface Dependencies { + knex: Knex +} + +export class VegetacaoCollectionKnexAdapter implements VegetacaoCollection { + private readonly knex: Knex + + constructor(dependencies: Dependencies) { + this.knex = dependencies.knex + } + + async findAll(filters: VegetacaoFilters): Promise> { + try { + const query = this.knex('vegetacoes') + .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 vegetações', cause: error })) + } + } + + async findById(id: number): Promise> { + try { + const vegetacao = await this.knex('vegetacoes').select(['id', 'nome']).where({ id }).first() + return Either.right(vegetacao ?? null) + } catch (error) { + return Either.left(new CollectionError({ message: 'Failed to find vegetação', cause: error })) + } + } +} diff --git a/test/integration/vegetacao/lista-vegetacoes.test.ts b/test/integration/vegetacao/lista-vegetacoes.test.ts new file mode 100644 index 00000000..9f64d264 --- /dev/null +++ b/test/integration/vegetacao/lista-vegetacoes.test.ts @@ -0,0 +1,129 @@ +import { + afterAll, describe, expect, test +} from 'vitest' + +import { createTestApp } from '../setup/app-factory' + +type Vegetacao = { id: number; nome: string } + +const returning = ['id', 'nome'] as const + +describe('GET /api/v2/vegetacoes', () => { + 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 = 'XVEG' + const nomes = [ + `${prefix} Mata Atlântica`, + `${prefix} Restinga`, + `${prefix} Campo` + ] + + const inserted = await knex('vegetacoes') + .insert(nomes.map(nome => ({ nome }))) + .returning(returning) + + try { + const response = await agent.get(`/api/v2/vegetacoes?nome=${prefix}`).expect(200) + const expected = [...inserted].sort((a, b) => b.id - a.id) + expect(response.body).toEqual(expected) + } finally { + await knex('vegetacoes').whereIn('nome', nomes).delete() + } + }) + + test('filtra por nome sem diferenciar maiúsculas e minúsculas', async () => { + const prefix = 'XVEG' + const nomes = [ + `${prefix} Floresta`, + `${prefix} Cerrado`, + `${prefix} Outros` + ] + const inserted = await knex('vegetacoes') + .insert(nomes.map(nome => ({ nome }))) + .returning(returning) + + try { + const response = await agent.get(`/api/v2/vegetacoes?nome=${prefix} floresta`).expect(200) + expect(response.body).toEqual(inserted.filter(item => item.nome === `${prefix} Floresta`)) + } finally { + await knex('vegetacoes').whereIn('nome', nomes).delete() + } + }) + + test('aceita ordenação customizada por nome e id', async () => { + const prefix = 'XVEG' + const nomes = [ + `${prefix} Z`, + `${prefix} A`, + `${prefix} M` + ] + const inserted = await knex('vegetacoes') + .insert(nomes.map(nome => ({ nome }))) + .returning(returning) + + try { + const byNameAsc = await agent.get(`/api/v2/vegetacoes?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/vegetacoes?nome=${prefix}&order=id:asc`).expect(200) + expect(byIdAsc.body).toEqual([...inserted].sort((a, b) => a.id - b.id)) + } finally { + await knex('vegetacoes').whereIn('nome', nomes).delete() + } + }) + + test('retorna 400 quando a ordenação é inválida', async () => { + const prefix = 'XVEG' + const nomes = [ + `${prefix} Z`, + `${prefix} A`, + `${prefix} M` + ] + + await knex('vegetacoes').insert(nomes.map(nome => ({ nome }))) + + try { + const response = await agent.get(`/api/v2/vegetacoes?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('vegetacoes').whereIn('nome', nomes).delete() + } + }) +}) + +describe('GET /api/v2/vegetacoes/:vegetacaoId', () => { + const { agent, knex } = createTestApp() + + afterAll(() => knex.destroy()) + + test('retorna o registro encontrado', async () => { + const [vegetacao] = await knex('vegetacoes') + .insert({ nome: 'XVEG Vegetação Encontrada' }) + .returning(returning) + + try { + const response = await agent.get(`/api/v2/vegetacoes/${vegetacao.id}`).expect(200) + expect(response.body).toEqual({ id: vegetacao.id, nome: vegetacao.nome }) + } finally { + await knex('vegetacoes').where({ id: vegetacao.id }).delete() + } + }) + + test('retorna 404 para id inexistente', async () => { + const response = await agent.get('/api/v2/vegetacoes/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/vegetacoes/abc').expect(400) + const body = response.body as { error: { message: string } } + + expect(body.error.message).toMatch(/inválido|invalid/i) + }) +}) From 28cfa21d3a5352110e4b2e1a4284bd2ad28ea795 Mon Sep 17 00:00:00 2001 From: Alisson Vieira Date: Tue, 8 Sep 2026 22:00:28 -0300 Subject: [PATCH 4/7] HCF-API-502 (#505) --- src/routes/rfids.js | 76 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 65 insertions(+), 11 deletions(-) diff --git a/src/routes/rfids.js b/src/routes/rfids.js index 0fda6cbb..2cc55102 100644 --- a/src/routes/rfids.js +++ b/src/routes/rfids.js @@ -1,4 +1,5 @@ import * as RfidsController from '../controllers/rfids-controller.js'; +import tokensMiddleware, { TIPOS_USUARIOS } from '../middlewares/tokens-middleware'; /** * @swagger @@ -7,6 +8,7 @@ import * as RfidsController from '../controllers/rfids-controller.js'; * description: Operações de integração com RFID */ export default app => { + const somenteCurador = tokensMiddleware([TIPOS_USUARIOS.CURADOR]); /** * @swagger @@ -25,17 +27,21 @@ export default app => { * type: integer * required: * - tombo_foto_id - * example: - * tombo_foto_id: 15 + * example: + * tombo_foto_id: 15 * responses: * '201': * description: Registro criado com status PENDENTE e EPC gerado. + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' * '404': * $ref: '#/components/responses/NotFound' * '500': * $ref: '#/components/responses/InternalServerError' */ - app.route('/rfids/iniciar-gravacao').post(RfidsController.iniciarGravacao); + app.route('/rfids/iniciar-gravacao').post(somenteCurador, RfidsController.iniciarGravacao); /** * @swagger @@ -62,18 +68,22 @@ export default app => { * enum: [CONCLUIDO, FALHA] * tid: * type: string - * example: - * status: "CONCLUIDO" - * tid: "E200001B4413" + * example: + * status: CONCLUIDO + * tid: E200001B4413 * responses: * '200': * description: RFID atualizado com sucesso. + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' * '404': * $ref: '#/components/responses/NotFound' * '500': * $ref: '#/components/responses/InternalServerError' */ - app.route('/rfids/finalizar-gravacao/:id').put(RfidsController.finalizarGravacao); + app.route('/rfids/finalizar-gravacao/:id').put(somenteCurador, RfidsController.finalizarGravacao); /** * @swagger @@ -95,13 +105,57 @@ export default app => { * responses: * '200': * description: Lista retornada com sucesso + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' * '500': * $ref: '#/components/responses/InternalServerError' */ - app.route('/rfids').get(RfidsController.listagem); + app.route('/rfids').get(somenteCurador, RfidsController.listagem); - app.route('/rfids/tombos-pendentes').get(RfidsController.listarPendentesRfid); - - app.route('/rfids/validar-tid/:tid').get(RfidsController.validarTid); + /** + * @swagger + * /rfids/tombos-pendentes: + * get: + * summary: Lista tombos pendentes de gravação RFID + * tags: [RFIDs] + * responses: + * '200': + * description: Lista paginada de tombos/fotos pendentes. + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + * '500': + * $ref: '#/components/responses/InternalServerError' + */ + app.route('/rfids/tombos-pendentes').get(somenteCurador, RfidsController.listarPendentesRfid); + /** + * @swagger + * /rfids/validar-tid/{tid}: + * get: + * summary: Valida uma tag RFID pelo TID + * tags: [RFIDs] + * parameters: + * - in: path + * name: tid + * required: true + * schema: + * type: string + * description: TID lido da tag física + * responses: + * '200': + * description: TID validado com sucesso. + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + * '404': + * $ref: '#/components/responses/NotFound' + * '500': + * $ref: '#/components/responses/InternalServerError' + */ + app.route('/rfids/validar-tid/:tid').get(somenteCurador, RfidsController.validarTid); }; From 4aafb6936824382794080f95ee05cd83117f7539 Mon Sep 17 00:00:00 2001 From: Edvaldo Szymonek Date: Tue, 8 Sep 2026 22:59:09 -0300 Subject: [PATCH 5/7] =?UTF-8?q?corrige=20sincroniza=C3=A7=C3=A3o=20da=20ba?= =?UTF-8?q?se=20com=20exclus=C3=A3o=20de=20algumas=20tabelas=20(#526)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/rules/type-and-file-names.mdc | 32 +++++++++++++++++++++++++ script/database-sync/Dockerfile | 4 ---- script/database-sync/database-sync.sh | 22 +++++++++++++---- script/database-sync/docker-compose.yml | 1 + 4 files changed, 51 insertions(+), 8 deletions(-) create mode 100644 .cursor/rules/type-and-file-names.mdc diff --git a/.cursor/rules/type-and-file-names.mdc b/.cursor/rules/type-and-file-names.mdc new file mode 100644 index 00000000..415914eb --- /dev/null +++ b/.cursor/rules/type-and-file-names.mdc @@ -0,0 +1,32 @@ +--- +description: Type and file names use 3rd-person present verbs, not the infinitive, in Portuguese or English +globs: "{src,test}/**/*.{ts,js}" +alwaysApply: false +--- + +# Type and file names + +Name new controllers, use cases, and their tests after the **action as a finite verb**: 3rd-person singular present (the form that agrees with “it does X”). Do not use the infinitive (“to X”) or the bare uninflected stem. + +The same conjugation rule applies in Portuguese and in English. Keep the language of a module consistent; do not mix languages in one type or file name. + +## How to name a type + +1. Choose the action verb in the module’s language. +2. Inflect it to 3rd-person singular present. +3. Append the resource noun (plural for a collection, singular for one entity). +4. Append the role suffix (`Controller`, `UseCase`, and the same for other application/domain types). + +| Role | Pattern | +| --- | --- | +| List a collection | `{Verb}{Resources}Controller` / `{Verb}{Resources}UseCase` | +| Get one by id | `{Verb}{Resource}UseCase` (and matching controller) | +| Tests | One file per action; kebab-case of the same verb + resource stem | + +Type files are PascalCase and match the exported type. Test files use kebab-case of that stem (for example `lists-countries.test.ts`), not a different verb and not several actions in one file. + +## When renaming + +Delete the old file. Do not leave unused duplicates that use the infinitive or a different inflection. + +Do not copy drifted names already in the tree. Match the inflection rule above for every new module. diff --git a/script/database-sync/Dockerfile b/script/database-sync/Dockerfile index d38515fa..274a127c 100644 --- a/script/database-sync/Dockerfile +++ b/script/database-sync/Dockerfile @@ -8,19 +8,15 @@ RUN \ gnupg \ curl \ ca-certificates && \ - curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /usr/share/keyrings/postgresql.gpg && \ echo "deb [signed-by=/usr/share/keyrings/postgresql.gpg] https://apt.postgresql.org/pub/repos/apt bookworm-pgdg main" > /etc/apt/sources.list.d/pgdg.list && \ - apt-get update && \ apt-get install -y \ postgresql-client-18 && \ - apt-get remove -y \ curl \ ca-certificates \ gnupg && \ - rm -rf /var/lib/apt/lists/* ENV TZ=America/Sao_Paulo diff --git a/script/database-sync/database-sync.sh b/script/database-sync/database-sync.sh index 431a01bd..91562579 100755 --- a/script/database-sync/database-sync.sh +++ b/script/database-sync/database-sync.sh @@ -4,6 +4,18 @@ set -euo pipefail echo "Starting synchronization process..." +exclude_args=() +if [[ -n "${SYNC_EXCLUDE_TABLES:-}" ]]; then + IFS=$' \t\n,' read -r -a exclude_tables <<< "$SYNC_EXCLUDE_TABLES" + for table in "${exclude_tables[@]}"; do + [[ -z "$table" ]] && continue + exclude_args+=(--exclude-table="$table" --exclude-table="${table}_id_seq") + done + echo "Excluding tables: ${exclude_tables[*]}" +fi + +# Plain SQL so we can CASCADE drops. Destination excluded tables keep their +# rows; CASCADE only removes leftover FKs that would block --clean. PGPASSWORD="$SYNC_SOURCE_PASSWORD" pg_dump \ -h "$SYNC_SOURCE_HOST" \ -p "$SYNC_SOURCE_PORT" \ @@ -13,13 +25,15 @@ PGPASSWORD="$SYNC_SOURCE_PASSWORD" pg_dump \ --no-privileges \ --clean \ --if-exists \ - --format=c | \ -PGPASSWORD="$SYNC_DEST_PASSWORD" pg_restore \ + "${exclude_args[@]}" | \ +sed -E \ + -e 's/^(DROP TABLE IF EXISTS .+);$/\1 CASCADE;/' \ + -e 's/^(ALTER TABLE .+ DROP CONSTRAINT IF EXISTS .+);$/\1 CASCADE;/' | \ +PGPASSWORD="$SYNC_DEST_PASSWORD" psql \ -h "$SYNC_DEST_HOST" \ -p "$SYNC_DEST_PORT" \ -U "$SYNC_DEST_USER" \ -d "$SYNC_DEST_DATABASE" \ - --clean \ - --if-exists + -v ON_ERROR_STOP=1 echo "Synchronization completed successfully: ${SYNC_SOURCE_DATABASE} -> ${SYNC_DEST_DATABASE}" diff --git a/script/database-sync/docker-compose.yml b/script/database-sync/docker-compose.yml index b88a7a65..1f396801 100644 --- a/script/database-sync/docker-compose.yml +++ b/script/database-sync/docker-compose.yml @@ -14,5 +14,6 @@ services: SYNC_DEST_USER: "postgres" SYNC_DEST_PASSWORD: "masterkey" SYNC_DEST_DATABASE: "herbario_dev" + # SYNC_EXCLUDE_TABLES: "public.usuarios" CRON_SCHEDULE: "*/2 * * * *" TZ: "America/Sao_Paulo" From fe0ed81101a8c3158ad88e51851ef30de184ab11 Mon Sep 17 00:00:00 2001 From: Josue Modesto <91280773+JosueModesto@users.noreply.github.com> Date: Wed, 9 Sep 2026 18:40:51 -0300 Subject: [PATCH 6/7] =?UTF-8?q?Feat:=20Implementa=C3=A7=C3=A3o=20listar=20?= =?UTF-8?q?e=20buscar=20fases=20sucessionais=20Api.v2=20(#512)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/application/create-app.ts | 2 + .../BuscaFaseSucessionalController.ts | 42 ++++++++ .../ListaFasesSucessionaisController.ts | 65 ++++++++++++ src/application/fase-sucessional/index.ts | 35 +++++++ .../BuscaFaseSucessionalUseCase.ts | 20 ++++ src/domain/faseSucessional/FaseSucessional.ts | 24 +++++ .../FaseSucessionalCollection.ts | 16 +++ .../ListaFasesSucessionaisUseCase.ts | 20 ++++ .../FaseSucessionalCollectionKnexAdapter.ts | 53 ++++++++++ .../busca-fases-sucessionais.test.ts | 50 ++++++++++ .../lista-fases-sucessionais.test.ts | 98 +++++++++++++++++++ test/integration/setup/global-setup.ts | 3 +- 12 files changed, 427 insertions(+), 1 deletion(-) create mode 100644 src/application/fase-sucessional/BuscaFaseSucessionalController.ts create mode 100644 src/application/fase-sucessional/ListaFasesSucessionaisController.ts create mode 100644 src/application/fase-sucessional/index.ts create mode 100644 src/domain/faseSucessional/BuscaFaseSucessionalUseCase.ts create mode 100644 src/domain/faseSucessional/FaseSucessional.ts create mode 100644 src/domain/faseSucessional/FaseSucessionalCollection.ts create mode 100644 src/domain/faseSucessional/ListaFasesSucessionaisUseCase.ts create mode 100644 src/infrastructure/FaseSucessionalCollectionKnexAdapter.ts create mode 100644 test/integration/fase-sucessional/busca-fases-sucessionais.test.ts create mode 100644 test/integration/fase-sucessional/lista-fases-sucessionais.test.ts diff --git a/src/application/create-app.ts b/src/application/create-app.ts index f43fd845..a3b3901c 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 createFaseSucessionalRoutes } from './fase-sucessional' import { routes as createPaisRoutes } from './pais' import { routes as createVegetacaoRoutes } from './vegetacao' @@ -57,6 +58,7 @@ export function createApp({ const routes: Route[] = [ ...createPaisRoutes(knex), ...createEstadoRoutes(knex), + ...createFaseSucessionalRoutes(knex), ...createVegetacaoRoutes(knex) ] const application = new ExpressApplication({ logger }) diff --git a/src/application/fase-sucessional/BuscaFaseSucessionalController.ts b/src/application/fase-sucessional/BuscaFaseSucessionalController.ts new file mode 100644 index 00000000..c7065fdb --- /dev/null +++ b/src/application/fase-sucessional/BuscaFaseSucessionalController.ts @@ -0,0 +1,42 @@ +import { BuscaFaseSucessionalUseCase } from '@/domain/faseSucessional/BuscaFaseSucessionalUseCase' +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 { + buscaFaseSucessionalUseCase: BuscaFaseSucessionalUseCase +} + +export class BuscaFaseSucessionalController implements RequestHandler { + private readonly buscaFaseSucessionalUseCase: BuscaFaseSucessionalUseCase + + constructor(dependencies: Dependencies) { + this.buscaFaseSucessionalUseCase = dependencies.buscaFaseSucessionalUseCase + } + + async handle(request: HttpRequest, _next: NextHandler): Promise { + const { faseSucessionalId } = request.params as { faseSucessionalId?: string } + + if (!faseSucessionalId || Number.isNaN(Number(faseSucessionalId)) || Number(faseSucessionalId) <= 0) { + return new BadRequestError({ message: 'faseSucessionalId inválido' }) + } + + const id = Number(faseSucessionalId) + const result = await this.buscaFaseSucessionalUseCase.execute({ id }) + + if (result.left()) { + return new InternalServerError({ message: result.value.message }) + } + + if (!result.value) { + return new NotFoundError({ message: 'Fase sucessional não encontrada' }) + } + + return { statusCode: StatusCode.Ok, body: result.value } + } +} diff --git a/src/application/fase-sucessional/ListaFasesSucessionaisController.ts b/src/application/fase-sucessional/ListaFasesSucessionaisController.ts new file mode 100644 index 00000000..b5ed1dbd --- /dev/null +++ b/src/application/fase-sucessional/ListaFasesSucessionaisController.ts @@ -0,0 +1,65 @@ +import { ListaFasesSucessionaisUseCase } from '@/domain/faseSucessional/ListaFasesSucessionaisUseCase' +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 { + listaFasesSucessionaisUseCase: ListaFasesSucessionaisUseCase +} + +export class ListaFasesSucessionaisController implements RequestHandler { + private readonly listaFasesSucessionaisUseCase: ListaFasesSucessionaisUseCase + + constructor(dependencies: Dependencies) { + this.listaFasesSucessionaisUseCase = dependencies.listaFasesSucessionaisUseCase + } + + 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.listaFasesSucessionaisUseCase.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/fase-sucessional/index.ts b/src/application/fase-sucessional/index.ts new file mode 100644 index 00000000..ea9274f5 --- /dev/null +++ b/src/application/fase-sucessional/index.ts @@ -0,0 +1,35 @@ +import { type Knex } from 'knex' + +import { BuscaFaseSucessionalUseCase } from '@/domain/faseSucessional/BuscaFaseSucessionalUseCase' +import { ListaFasesSucessionaisUseCase } from '@/domain/faseSucessional/ListaFasesSucessionaisUseCase' +import { FaseSucessionalCollectionKnexAdapter } from '@/infrastructure/FaseSucessionalCollectionKnexAdapter' +import { Method } from '@/library/http/common' +import { Route } from '@/library/http/Router' + +import { BuscaFaseSucessionalController } from './BuscaFaseSucessionalController' +import { ListaFasesSucessionaisController } from './ListaFasesSucessionaisController' + +export function routes(knex: Knex): Route[] { + const faseSucessionalCollection = new FaseSucessionalCollectionKnexAdapter({ knex }) + + return [ + { + handlers: [ + new ListaFasesSucessionaisController({ + listaFasesSucessionaisUseCase: new ListaFasesSucessionaisUseCase({ faseSucessionalCollection }) + }) + ], + method: Method.Get, + path: '/v2/fases-sucessionais' + }, + { + handlers: [ + new BuscaFaseSucessionalController({ + buscaFaseSucessionalUseCase: new BuscaFaseSucessionalUseCase({ faseSucessionalCollection }) + }) + ], + method: Method.Get, + path: '/v2/fases-sucessionais/:faseSucessionalId' + } + ] +} diff --git a/src/domain/faseSucessional/BuscaFaseSucessionalUseCase.ts b/src/domain/faseSucessional/BuscaFaseSucessionalUseCase.ts new file mode 100644 index 00000000..6f88101a --- /dev/null +++ b/src/domain/faseSucessional/BuscaFaseSucessionalUseCase.ts @@ -0,0 +1,20 @@ +import { Either } from '@/library/either/Either' + +import { Attributes } from './FaseSucessional' +import { FaseSucessionalCollection } from './FaseSucessionalCollection' + +interface Dependencies { + faseSucessionalCollection: FaseSucessionalCollection +} + +export class BuscaFaseSucessionalUseCase { + private readonly faseSucessionalCollection: FaseSucessionalCollection + + constructor(dependencies: Dependencies) { + this.faseSucessionalCollection = dependencies.faseSucessionalCollection + } + + async execute({ id }: { id: number }): Promise> { + return this.faseSucessionalCollection.findById(id) + } +} diff --git a/src/domain/faseSucessional/FaseSucessional.ts b/src/domain/faseSucessional/FaseSucessional.ts new file mode 100644 index 00000000..97ddc29a --- /dev/null +++ b/src/domain/faseSucessional/FaseSucessional.ts @@ -0,0 +1,24 @@ +import { Either } from '@/library/either/Either' + +export interface Attributes { + id: number + nome: string +} + +export class FaseSucessional { + 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 da fase sucessional não pode ser vazio')) + } + + return Either.right(new FaseSucessional(attributes)) + } +} diff --git a/src/domain/faseSucessional/FaseSucessionalCollection.ts b/src/domain/faseSucessional/FaseSucessionalCollection.ts new file mode 100644 index 00000000..351a7cfd --- /dev/null +++ b/src/domain/faseSucessional/FaseSucessionalCollection.ts @@ -0,0 +1,16 @@ +import { Either } from '@/library/either/Either' + +import { Attributes } from './FaseSucessional' + +export interface FaseSucessionalFilters { + nome?: string + order?: { + column: 'id' | 'nome' + direction: 'asc' | 'desc' + } +} + +export interface FaseSucessionalCollection { + findAll(filters: FaseSucessionalFilters): Promise> + findById(id: number): Promise> +} diff --git a/src/domain/faseSucessional/ListaFasesSucessionaisUseCase.ts b/src/domain/faseSucessional/ListaFasesSucessionaisUseCase.ts new file mode 100644 index 00000000..ccd78084 --- /dev/null +++ b/src/domain/faseSucessional/ListaFasesSucessionaisUseCase.ts @@ -0,0 +1,20 @@ +import { Either } from '@/library/either/Either' + +import { Attributes } from './FaseSucessional' +import { FaseSucessionalCollection, FaseSucessionalFilters } from './FaseSucessionalCollection' + +interface Dependencies { + faseSucessionalCollection: FaseSucessionalCollection +} + +export class ListaFasesSucessionaisUseCase { + private readonly faseSucessionalCollection: FaseSucessionalCollection + + constructor(dependencies: Dependencies) { + this.faseSucessionalCollection = dependencies.faseSucessionalCollection + } + + async execute(filters: FaseSucessionalFilters): Promise> { + return this.faseSucessionalCollection.findAll(filters) + } +} diff --git a/src/infrastructure/FaseSucessionalCollectionKnexAdapter.ts b/src/infrastructure/FaseSucessionalCollectionKnexAdapter.ts new file mode 100644 index 00000000..0b2103e0 --- /dev/null +++ b/src/infrastructure/FaseSucessionalCollectionKnexAdapter.ts @@ -0,0 +1,53 @@ +import { Knex } from 'knex' + +import { Attributes } from '@/domain/faseSucessional/FaseSucessional' +import { FaseSucessionalCollection, FaseSucessionalFilters } from '@/domain/faseSucessional/FaseSucessionalCollection' +import { Either } from '@/library/either/Either' + +import { CollectionError } from './error/CollectionError' + +interface Dependencies { + knex: Knex +} + +export class FaseSucessionalCollectionKnexAdapter implements FaseSucessionalCollection { + private readonly knex: Knex + + constructor(dependencies: Dependencies) { + this.knex = dependencies.knex + } + + async findAll(filters: FaseSucessionalFilters): Promise> { + try { + const query = this.knex('fase_sucessional') + .select(['id', 'nome']) + + if (filters.nome) { + query.whereILike('nome', `%${filters.nome}%`) + } + + if (filters.order) { + query.orderBy(filters.order.column, filters.order.direction) + } else { + query.orderBy('id', 'desc') + } + + return Either.right(await query) + } catch (error) { + return Either.left(new CollectionError({ message: 'Failed to list fases sucessionais', cause: error })) + } + } + + async findById(id: number): Promise> { + try { + const faseSucessional = await this.knex('fase_sucessional') + .select(['id', 'nome']) + .where({ id }) + .first() + + return Either.right(faseSucessional ?? null) + } catch (error) { + return Either.left(new CollectionError({ message: 'Failed to find fase sucessional by id', cause: error })) + } + } +} diff --git a/test/integration/fase-sucessional/busca-fases-sucessionais.test.ts b/test/integration/fase-sucessional/busca-fases-sucessionais.test.ts new file mode 100644 index 00000000..420f447e --- /dev/null +++ b/test/integration/fase-sucessional/busca-fases-sucessionais.test.ts @@ -0,0 +1,50 @@ +import { + afterAll, describe, expect, test +} from 'vitest' + +import { createTestApp } from '../setup/app-factory' + +type FaseSucessional = { id: number; nome: string } + +const returning = ['id', 'nome'] as const + +describe('GET /api/v2/fases-sucessionais/:faseSucessionalId', () => { + const { agent, knex } = createTestApp() + + afterAll(() => knex.destroy()) + + test('retorna o registro encontrado', async () => { + const nome = `XFAS_BUSCA_${Date.now()}` + const [faseSucessional] = await knex('fase_sucessional') + .insert({ nome }) + .returning(returning) + + try { + const response = await agent.get(`/api/v2/fases-sucessionais/${faseSucessional.id}`).expect(200) + expect(response.body).toEqual({ id: faseSucessional.id, nome: faseSucessional.nome }) + } finally { + await knex('fase_sucessional').where({ id: faseSucessional.id }).delete() + } + }) + + test('retorna 404 para id inexistente', async () => { + const response = await agent.get('/api/v2/fases-sucessionais/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/fases-sucessionais/abc').expect(400) + const body = response.body as { error: { message: string } } + + expect(body.error.message).toMatch(/inválido|invalid/i) + }) + + test('retorna 400 para id negativo', async () => { + const response = await agent.get('/api/v2/fases-sucessionais/-12').expect(400) + const body = response.body as { error: { message: string } } + + expect(body.error.message).toMatch(/inválido|invalid/i) + }) +}) diff --git a/test/integration/fase-sucessional/lista-fases-sucessionais.test.ts b/test/integration/fase-sucessional/lista-fases-sucessionais.test.ts new file mode 100644 index 00000000..a9603577 --- /dev/null +++ b/test/integration/fase-sucessional/lista-fases-sucessionais.test.ts @@ -0,0 +1,98 @@ +import { + afterAll, describe, expect, test +} from 'vitest' + +import { createTestApp } from '../setup/app-factory' + +type FaseSucessional = { id: number; nome: string } + +const returning = ['id', 'nome'] as const + +describe('GET /api/v2/fases-sucessionais', () => { + 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 = `XFAS_LISTA_${Date.now()}` + const nomes = [ + `${prefix} Inicial`, + `${prefix} Médio`, + `${prefix} Avançado` + ] + + const inserted = await knex('fase_sucessional') + .insert(nomes.map(nome => ({ nome }))) + .returning(returning) + + try { + const response = await agent.get(`/api/v2/fases-sucessionais?nome=${prefix}`).expect(200) + const expected = [...inserted].sort((a, b) => b.id - a.id) + expect(response.body).toEqual(expected) + } finally { + await knex('fase_sucessional').whereIn('nome', nomes).delete() + } + }) + + test('filtra por nome sem diferenciar maiúsculas e minúsculas', async () => { + const prefix = `XFAS_FILTRO_${Date.now()}` + const nomes = [ + `${prefix} Inicial`, + `${prefix} Médio`, + `${prefix} Avançado` + ] + + const inserted = await knex('fase_sucessional') + .insert(nomes.map(nome => ({ nome }))) + .returning(returning) + + try { + const response = await agent.get(`/api/v2/fases-sucessionais?nome=${prefix} inicial`).expect(200) + expect(response.body).toEqual(inserted.filter(item => item.nome === `${prefix} Inicial`)) + } finally { + await knex('fase_sucessional').whereIn('nome', nomes).delete() + } + }) + + test('aceita ordenação customizada por nome e id', async () => { + const prefix = `XFAS_ORDEM_${Date.now()}` + const nomes = [ + `${prefix} Z`, + `${prefix} A`, + `${prefix} M` + ] + + const inserted = await knex('fase_sucessional') + .insert(nomes.map(nome => ({ nome }))) + .returning(returning) + + try { + const byNameAsc = await agent.get(`/api/v2/fases-sucessionais?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/fases-sucessionais?nome=${prefix}&order=id:asc`).expect(200) + expect(byIdAsc.body).toEqual([...inserted].sort((a, b) => a.id - b.id)) + } finally { + await knex('fase_sucessional').whereIn('nome', nomes).delete() + } + }) + + test('retorna 400 quando a ordenação é inválida', async () => { + const prefix = `XFAS_ORDEM_INVALIDA_${Date.now()}` + const nomes = [ + `${prefix} Z`, + `${prefix} A`, + `${prefix} M` + ] + + await knex('fase_sucessional').insert(nomes.map(nome => ({ nome }))) + + try { + const response = await agent.get(`/api/v2/fases-sucessionais?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('fase_sucessional').whereIn('nome', nomes).delete() + } + }) +}) diff --git a/test/integration/setup/global-setup.ts b/test/integration/setup/global-setup.ts index b9cb050d..12b00421 100644 --- a/test/integration/setup/global-setup.ts +++ b/test/integration/setup/global-setup.ts @@ -9,7 +9,8 @@ try { const TABLES = [ 'estados', - 'paises' + 'paises', + 'fase_sucessional' ] async function truncateTables(knex: Knex): Promise { From 2eb3568326bff457917d0b72e6f2d9c4c43fcb1d Mon Sep 17 00:00:00 2001 From: Moran <105233020+feliperm17@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:39:36 -0300 Subject: [PATCH 7/7] Darwincore/correcao relatorio (#510) Co-authored-by: Edvaldo Szymonek --- src/controllers/darwincore-controller.js | 5 ++--- src/resources/darwincore/licenca.js | 13 +------------ 2 files changed, 3 insertions(+), 15 deletions(-) diff --git a/src/controllers/darwincore-controller.js b/src/controllers/darwincore-controller.js index 513596f6..de8ea2f7 100644 --- a/src/controllers/darwincore-controller.js +++ b/src/controllers/darwincore-controller.js @@ -306,8 +306,7 @@ const obterModeloDarwinCoreLotes = async (limit, offset, request, response) => { const dataAtualizacao = format(tombo.updated_at, 'yyyy-MM-dd'); const campos = [ - 'PreservedSpecimen', 'Colecao', 'pt', dataAtualizacao, '02.032.297/0005-26', - 'UTFPR', 'Herbario da Universidade Tecnologica Federal do Parana – Campus Campo Mourao – HCF', + 'PreservedSpecimen', 'Colecao', 'pt', dataAtualizacao, '02.032.297/0005-26', 'UTFPR', 'HCF', license, 'UTFPR', `{"barcode":"${foto.codigo_barra}"}`, `Br:UTFPR:HCF:${tombo.hcf}`, tombo.hcf, coletores, tombo.numero_coleta, '', tombo.observacao, dataColeta, @@ -329,7 +328,7 @@ const obterModeloDarwinCoreLotes = async (limit, offset, request, response) => { const campos = [ 'PreservedSpecimen', 'Colecao', 'pt', dataAtualizacao, '02.032.297/0005-26', - 'UTFPR', 'Herbario da Universidade Tecnologica Federal do Parana – Campus Campo Mourao – HCF', + 'UTFPR', 'HCF', license, 'UTFPR', '{"barcode":""}', `Br:UTFPR:HCF:${tombo.hcf}`, tombo.hcf, coletores, tombo.numero_coleta, '', tombo.observacao, dataColeta, diff --git a/src/resources/darwincore/licenca.js b/src/resources/darwincore/licenca.js index e5a39b7a..0a11648b 100644 --- a/src/resources/darwincore/licenca.js +++ b/src/resources/darwincore/licenca.js @@ -1,14 +1,3 @@ export default {}; -export const license = ` -Os dados disponibilizados pelo Herbário HCF são de uso livre, desde que citada a fonte. \ -Os dados não podem ser usados para fins comerciais. \ -Qualquer uso dos dados de registros em análises ou publicações devem constar nos agradecimentos. \ -Não podem ser redistribuídos com a devida indicação de procedência dos dados originais \ -e o responsável pela coleção deverá ser notificado. Os dados, mesmo parciais, \ -não podem ser redistribuídos sem explícita autorização escrita do responsável pela coleção. \ -Uma cópia de qualquer publicação em que os dados sejam citados deve ser enviada ao Herbário HCF. \ -Pesquisadores e suas instituições são responsáveis pelo uso adequado dos dados. \ -Este herbário procura minimizar a entrada de erros dos dados, entretanto não garantimos que a base \ -de dados esteja livre de erros, tanto na identificação quanto na transcrição dos dados de coleta das amostras. -`; +export const license = 'CC BY-NC';