diff --git a/.github/workflows/experimento-ci-cd.yml b/.github/workflows/experimento-ci-cd.yml index a6259ecd..8d9f6dd1 100644 --- a/.github/workflows/experimento-ci-cd.yml +++ b/.github/workflows/experimento-ci-cd.yml @@ -12,20 +12,29 @@ jobs: # Primeiro job: Executar testes unitários unit-test: - runs-on: ubuntu-latest # Define o sistema operacional usado no runner (Ubuntu na versão mais recente) - steps: - - name: Checkout do código - uses: actions/checkout@v4 # Faz o download do repositório no runner - - - name: Instalar dependências do backend - run: | - cd backend # Acessa a pasta do backend - npm install # Instala as dependências do Node.js - - - name: Executar testes unitários com Jest - run: | - cd backend # Acessa novamente a pasta do backend - npm test -- --coverage # Executa os testes e gera relatório de cobertura + runs-on: ubuntu-latest + steps: + - name: Checkout do código + uses: actions/checkout@v4 + + + - name: Cache de dependências do backend + uses: actions/cache@v4 + with: + path: backend/node_modules + key: npm-${{ hashFiles('backend/package-lock.json') }} + restore-keys: | + npm- + + - name: Instalar dependências do backend + run: | + cd backend + npm install + + - name: Executar testes unitários com Jest + run: | + cd backend + npm test -- --coverage # Segundo job: Build (construção) das imagens Docker build: diff --git a/backend/src/repositories/animal.dto.d.ts b/backend/src/repositories/animal.dto.d.ts index 890f2789..77a7f79a 100644 --- a/backend/src/repositories/animal.dto.d.ts +++ b/backend/src/repositories/animal.dto.d.ts @@ -1,4 +1,51 @@ -import { Animal } from '@prisma/client' +import { Animal as PrismaAnimal } from '@prisma/client' + +export type AnimalStatus = 'available' | 'adopted' | 'pending' + +export class Animal { + constructor( + public id: string, + public name: string, + public type: string, + public gender: 'Macho' | 'Fêmea', + public userId: string, + public status: AnimalStatus, + public race?: string, + public description?: string, + public createdAt?: Date, + public updatedAt?: Date, + ) {} + + isAvailable(): boolean { + return this.status === 'available' + } + + isAdopted(): boolean { + return this.status === 'adopted' + } + + shortDescription(maxLength = 50): string { + if (!this.description) return '' + return this.description.length > maxLength + ? this.description.slice(0, maxLength) + '...' + : this.description + } + + static fromPrisma(animal: PrismaAnimal): Animal { + return new Animal( + animal.id, + animal.name, + animal.type, + animal.gender as 'Macho' | 'Fêmea', + animal.userId, + animal.status as AnimalStatus, + animal.race, + animal.description, + animal.createdAt, + animal.updatedAt, + ) + } +} export namespace CreateAnimalRepositoryDTO { export type Params = { @@ -21,4 +68,4 @@ export namespace UpdateAnimalStatusRepositoryDTO { } export type Result = Animal | null -} +} \ No newline at end of file diff --git a/cypress.config.js b/cypress.config.js new file mode 100644 index 00000000..2721b159 --- /dev/null +++ b/cypress.config.js @@ -0,0 +1,10 @@ +const { defineConfig } = require('cypress') + +module.exports = defineConfig({ + e2e: { + experimentalStudio: true, // <--- ativa o Cypress Studio + setupNodeEvents(on, config) { + // eventos de teste aqui, se precisar + } + } +}) diff --git a/cypress/e2e/adotar.cy.js b/cypress/e2e/adotar.cy.js new file mode 100644 index 00000000..35cfbfe0 --- /dev/null +++ b/cypress/e2e/adotar.cy.js @@ -0,0 +1,13 @@ +/* ==== Test Created with Cypress Studio ==== */ +it('adotar', function() { + /* ==== Generated with Cypress Studio ==== */ + cy.visit('localhost:3000'); + cy.get('.sc-29a00fbd-5 > :nth-child(1) > input').clear('ka'); + cy.get('.sc-29a00fbd-5 > :nth-child(1) > input').type('kaiquerennan@gmail.com'); + cy.get('.sc-b3c6402b-0 > input').clear('1'); + cy.get('.sc-b3c6402b-0 > input').type('12345678'); + cy.get('.sc-ea747762-0').click(); + cy.get('.sc-26506e6-5 > .sc-c7cdb42d-0 > [href="/area_logada/meus_animais"] > .sc-c7cdb42d-1 > span').click(); + cy.get(':nth-child(1) > .sc-66a75d78-2 > .sc-66a75d78-4 > .sc-ea747762-0').click(); + /* ==== End Cypress Studio ==== */ +}); \ No newline at end of file diff --git a/cypress/e2e/disponibilizar.cy.js b/cypress/e2e/disponibilizar.cy.js new file mode 100644 index 00000000..3ca709a3 --- /dev/null +++ b/cypress/e2e/disponibilizar.cy.js @@ -0,0 +1,42 @@ +/* ==== Test Created with Cypress Studio ==== */ +it('disponibilizar', function() { + /* ==== Generated with Cypress Studio ==== */ + cy.visit('localhost:3000'); + + cy.get('.sc-29a00fbd-5 > :nth-child(1) > input') + .clear() + .type('kaiquerennan@gmail.com'); + + cy.get('.sc-b3c6402b-0 > input') + .clear() + .type('12345678{enter}'); + + cy.get('.sc-ea747762-0').click(); + + cy.get('.sc-26506e6-5 > .sc-c7cdb42d-0 > [href="/area_logada/disponibilizar_animal"] > .sc-c7cdb42d-1').click(); + + cy.get('.sc-b77a2f6d-7 > label > input') + .clear() + .type('rony'); + + // Selecionar o tipo (exemplo: "Cachorro") + cy.contains('button', 'Selecione um tipo').click(); + cy.get('[role="option"]').contains('Cachorro').click({ force: true }); + + // Selecionar o gênero (exemplo: "Macho") + cy.contains('button', 'Selecione um gênero').click(); + cy.get('[role="option"]').contains('Macho').click({ force: true }); + + cy.get('.sc-b77a2f6d-10 > label > input') + .clear() + .type('BAGRE'); + + cy.get('.sc-b77a2f6d-16 > div > svg').click(); + + // Upload correto do arquivo rony.png + cy.get('#animalPictures') + .selectFile('cypress/fixtures/rony.jpg', { force: true }); + + cy.get('.sc-ea747762-0').click(); + /* ==== End Cypress Studio ==== */ +}); diff --git a/cypress/e2e/login.cy.js b/cypress/e2e/login.cy.js new file mode 100644 index 00000000..e69de29b diff --git a/cypress/e2e/rony.jpg b/cypress/e2e/rony.jpg new file mode 100644 index 00000000..0ce9c074 Binary files /dev/null and b/cypress/e2e/rony.jpg differ diff --git a/cypress/fixtures/example.json b/cypress/fixtures/example.json new file mode 100644 index 00000000..02e42543 --- /dev/null +++ b/cypress/fixtures/example.json @@ -0,0 +1,5 @@ +{ + "name": "Using fixtures to represent data", + "email": "hello@cypress.io", + "body": "Fixtures are a great way to mock data for responses to routes" +} diff --git a/cypress/fixtures/rony.jpg b/cypress/fixtures/rony.jpg new file mode 100644 index 00000000..0ce9c074 Binary files /dev/null and b/cypress/fixtures/rony.jpg differ diff --git a/cypress/support/commands.js b/cypress/support/commands.js new file mode 100644 index 00000000..66ea16ef --- /dev/null +++ b/cypress/support/commands.js @@ -0,0 +1,25 @@ +// *********************************************** +// This example commands.js shows you how to +// create various custom commands and overwrite +// existing commands. +// +// For more comprehensive examples of custom +// commands please read more here: +// https://on.cypress.io/custom-commands +// *********************************************** +// +// +// -- This is a parent command -- +// Cypress.Commands.add('login', (email, password) => { ... }) +// +// +// -- This is a child command -- +// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) +// +// +// -- This is a dual command -- +// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) +// +// +// -- This will overwrite an existing command -- +// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) \ No newline at end of file diff --git a/cypress/support/e2e.js b/cypress/support/e2e.js new file mode 100644 index 00000000..3eaffffa --- /dev/null +++ b/cypress/support/e2e.js @@ -0,0 +1,17 @@ +// *********************************************************** +// This example support/e2e.js is processed and +// loaded automatically before your test files. +// +// This is a great place to put global configuration and +// behavior that modifies Cypress. +// +// You can change the location of this file or turn off +// automatically serving support files with the +// 'supportFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/configuration +// *********************************************************** + +// Import commands.js using ES2015 syntax: +import './commands' \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index a55627d7..388e102d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,21 +1,16 @@ services: adote-facil-postgres: - image: postgres:14-alpine - container_name: 'adote-facil-postgres' - env_file: - - ./backend/.env - environment: - POSTGRES_USER: ${POSTGRES_USER} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} - POSTGRES_DB: ${POSTGRES_DB} - POSTGRES_CONTAINER_PORT: ${POSTGRES_CONTAINER_PORT} - ports: - - ${POSTGRES_CONTAINER_PORT}:5432 - volumes: - - db_data:/var/lib/postgresql/data - networks: - - adote-facil-network - healthcheck: + image: postgres:14-alpine + container_name: 'adote-facil-postgres' + env_file: + ./backend/.env + ports: + ${POSTGRES_CONTAINER_PORT}:5432 + volumes: + db_data:/var/lib/postgresql/data + networks: + adote-facil-network + healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] interval: 3s timeout: 3s diff --git a/documentacao/arquitetura.md b/documentacao/arquitetura.md new file mode 100644 index 00000000..7f3ddc7d --- /dev/null +++ b/documentacao/arquitetura.md @@ -0,0 +1,17 @@ +# 1. Arquitetura adotada + +O sistema adota uma arquitetura **Cliente-Servidor** com as seguintes características: + +## 1.1 Backend Monolítico com Estrutura em Camadas + +- O backend é construído com **Node.js** e **Express**. Utiliza o ORM **Prisma** para acesso ao banco de dados **PostgreSQL**. A autenticação é feita via **JWT**, e o upload de imagens utiliza o **Multer** + +- O backend é organizado em camadas para separar responsabilidades, como a camada de Roteamento, Middlewares (para autenticação) e Acesso a Dados (abstraído pelo Prisma) + +## 1.2 Frontend Desacoplado + +- O frontend está em `frontend` e é desenvolvido com **Next.js** (React). Ele consome as APIs do backend via chamadas HTTP usando **Axios**. Para validação de formulários, utiliza **Zod**. O frontend é desacoplado do backend, rodando em seu próprio container Docker e se comunicando apenas por API REST. As páginas e componentes estão organizados em `frontend/src/app`. +Essa separação de contêineres entre o frontend e o backend é orquestrada pelo `docker-compose.yml` + +# 2. +![diagrama](image.png) diff --git a/documentacao/devops.md b/documentacao/devops.md new file mode 100644 index 00000000..2c58f5f7 --- /dev/null +++ b/documentacao/devops.md @@ -0,0 +1,13 @@ +## Há pipeline CI/CD? +Sim, há um pipeline CI/CD definido no arquivo `.github\workflows\experimento-ci-cd.yml`, que é executado via GitHub Actions em pull requests para a branch `main`. + +## Há testes automatizados no pipeline? +Sim, o pipeline inclui um job (`unit-test`) que executa os testes unitários do backend. + +## Há uso de containers? +Sim, existem `Dockerfile`s para o backend e frontend, e um arquivo `docker-compose.yml` que orquestra todos os serviços (aplicações e banco de dados), garantindo consistência entre os ambientes. + +## Sugestão de melhoria +- Remover variáveis env_file duplicadas no docker-compose. Já é definido um env_file: ./backend/.env, então não precisa repetir manualmente as mesmas variáveis em environment, pois isso gera duplicação e risco de inconsistência. + +- Adicionar cache de dependências no job unit-test para reduzir o tempo de execução do pipeline. \ No newline at end of file diff --git a/documentacao/image.png b/documentacao/image.png new file mode 100644 index 00000000..52d61aba Binary files /dev/null and b/documentacao/image.png differ diff --git a/documentacao/padroes.md b/documentacao/padroes.md new file mode 100644 index 00000000..fd9e0d0a --- /dev/null +++ b/documentacao/padroes.md @@ -0,0 +1,9 @@ +# 1. Princípios +- Single Responsibility Principle (Princípio da Responsabilidade Única - SRP): A classe Authenticator tem a responsabilidade de gerenciar a criação e validação de tokens JWT. Ela não se preocupa com a lógica de negócio do usuário ou com o armazenamento de dados, delegando essas tarefas a outras partes do sistema. A separação de responsabilidades também é visível na estrutura de pastas (controllers, services, repositories). + +- Dependency Inversion Principle (Princípio da Inversão de Dependência - DIP): Os serviços (módulos de alto nível, em services/) dependem de abstrações, como as classes de repositório (em repositories/), em vez de implementações concretas de acesso a dados. Da mesma forma, os controladores (em controllers/) dependem de serviços. Isso desacopla os componentes, tornando o sistema mais flexível e fácil de testar, pois as dependências concretas podem ser substituídas (por exemplo, por mocks em testes) + +# 2. Padrões de Projeto (Design Patterns) +- Singleton: A classe Authenticator no arquivo authenticator.ts é exportada como uma única instância (authenticatorInstance). Isso garante que apenas um objeto de autenticação seja utilizado em toda a aplicação, centralizando o controle e o estado (como o segredo do JWT) em um único ponto. + +- Repository: A estrutura do projeto inclui uma pasta repositories (ex: repositories/user.ts, repositories/animal.ts). Este padrão abstrai a camada de acesso a dados, separando a lógica de negócio das interações diretas com o banco de dados. As classes de serviço (em services/) dependem desses repositórios para persistir e recuperar dados, sem conhecer os detalhes da implementação da persistência. \ No newline at end of file diff --git a/documentacao/smells-refatoracoes.md b/documentacao/smells-refatoracoes.md new file mode 100644 index 00000000..da112ab0 --- /dev/null +++ b/documentacao/smells-refatoracoes.md @@ -0,0 +1,687 @@ +## 1 - Smell Identificado: Long Method +### Código Original +#### AnimalRegisterForm.tsx +``` +import { animalTypesForSelect } from '@/constants/animal-types-for-select' +import { DefaultSelect } from '../DefaultSelect' +import * as S from './AnimalRegisterForm.styles' +import { animalGenderForSelect } from '@/constants/animal-gender-for-select' +import { Plus, Trash } from '@phosphor-icons/react' +import { useState } from 'react' +import { z } from 'zod' + +import * as Dialog from '@radix-ui/react-dialog' +import { DefaultDialog } from '../DefaultDialog' +import { Button } from '../Button' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { getCookie } from 'cookies-next' +import { animalRegister } from '@/api/register-animal' +import { AnimalTypeEnum } from '@/enums/animal-type' +import { AnimalGenderEnum } from '@/enums/animal-gender' + +const animalRegisterFormSchema = z.object({ + name: z.string().min(1, { message: 'O nome é obrigatório' }), + type: z.nativeEnum(AnimalTypeEnum, { + required_error: 'O tipo é obrigatório', + }), + gender: z.nativeEnum(AnimalGenderEnum, { + required_error: 'O gênero é obrigatório', + }), + race: z + .string() + .optional() + .transform((value) => (value === '' ? undefined : value)), + description: z + .string() + .optional() + .transform((value) => (value === '' ? undefined : value)), + pictures: z + .array(z.instanceof(File)) + .min(1, 'Adicione ao menos uma foto do animal') + .max(5, { message: 'Você pode adicionar no máximo 5 fotos' }), +}) + +export type AnimalRegisterFormData = z.infer + +export function AnimalRegisterForm() { + const [animalPictures, setAnimalPictures] = useState([]) + const [maxPicsWarningModalOpen, setMaxPicsWarningModalOpen] = useState(false) + + const { + register, + handleSubmit, + formState: { errors }, + setValue, + watch, + } = useForm({ + resolver: zodResolver(animalRegisterFormSchema), + defaultValues: { pictures: [] }, + }) + + const descriptionValue = watch('description', '') + + const handleAnimalImageUpload = (e: React.ChangeEvent) => { + if (e.target.files) { + const uploadedFiles = Array.from(e.target.files) + + if ( + uploadedFiles.length > 5 || + animalPictures.length + uploadedFiles.length > 5 + ) { + setMaxPicsWarningModalOpen(true) + return + } + + const newPictures = [...animalPictures, ...uploadedFiles] + setAnimalPictures(newPictures) + setValue('pictures', newPictures) // Atualiza no react-hook-form + } + } + + const handleRemoveAnimalPicture = (picIndex: number) => { + const newAnimalPictures = animalPictures.filter( + (pic, index) => picIndex !== index, + ) + + setAnimalPictures(newAnimalPictures) + setValue('pictures', newAnimalPictures) // Atualiza no react-hook-form + } + + const onSubmit = async (data: AnimalRegisterFormData) => { + try { + const token = getCookie('token') + + const response = await animalRegister(data, token) + + if (response.status === 201) { + alert('Animal cadastrado com sucesso!') + window.location.href = '/area_logada/meus_animais' + } else { + alert( + response.data.message || + 'Ocorreu um erro ao tentar registrar o animal.', + ) + } + } catch (err) { + const error = err as Error + console.error('Erro no registro do animal:', error) + alert(error.message || 'Ocorreu um erro ao tentar registrar o animal.') + } + } + + return ( + <> + + + Atenção + + + + Você pode adicionar no máximo 5 fotos! + + + + + + + + + + + + + + + + + + + Tipo + {errors.type && {errors.type.message}} + + + + setValue('type', value as AnimalTypeEnum) + } + /> + + + + + + + Gênero + {errors.gender && {errors.gender.message}} + + + + setValue('gender', value as AnimalGenderEnum) + } + /> + + + + + + + + + +