diff --git a/CONTRIBUTING.en.md b/CONTRIBUTING.en.md index 6360b2a4..65401165 100644 --- a/CONTRIBUTING.en.md +++ b/CONTRIBUTING.en.md @@ -31,6 +31,30 @@ This flow guarantees control, equity in the distribution and traceability of res --- +## 🧭 Task tracking flow + +After the task is assigned, keep the card updated so the team knows the real state of the work. + +### ✔️ Card status: +- **In progress:** use this when you start implementing or reviewing the task. +- **Blocked:** use this when you need a decision, access, scope adjustment or technical help to continue. +- **Done:** use this only after opening the Pull Request, validating locally and leaving the PR link on the card. + +### ✔️ Communication on the card: +- Share the agreed deadline before starting. +- Record deadline changes on the card itself. +- Explain blockers with enough context for another person to help. +- When opening the PR, share the link and list which validations were executed. + +### ✔️ Branch and PR flow: +- Create the branch from `develop`. +- Use a prefix that matches the type of work: `feat`, `fix`, `docs`, `test`, `refactor`, `style` or `chore`. +- Make small and clear commits. +- Always open the Pull Request against `develop`. +- Link the PR to the issue with a closing keyword, for example `Fixes: #123`, when the PR completes the task. + +--- + ## 🗂️ Rules and Organization ### ✔️ When showing interest in a task (card): diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 286147ff..9d5796da 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,6 +31,30 @@ Esse fluxo garante controle, equidade na distribuição e rastreabilidade das re --- +## 🧭 Fluxo de acompanhamento da tarefa + +Depois que a tarefa for atribuída, mantenha o card sempre atualizado para que a equipe saiba o estado real do trabalho. + +### ✔️ Status do card: +- **Em andamento:** use quando começar a implementar ou revisar a tarefa. +- **Bloqueado:** use quando precisar de uma decisão, acesso, ajuste de escopo ou ajuda técnica para continuar. +- **Concluído:** use apenas depois de abrir o Pull Request, validar localmente e deixar o link do PR no card. + +### ✔️ Comunicação no card: +- Informe o prazo combinado antes de iniciar. +- Registre mudanças de prazo no próprio card. +- Explique bloqueios com contexto suficiente para outra pessoa ajudar. +- Ao abrir o PR, informe o link e diga quais validações foram executadas. + +### ✔️ Fluxo de branch e PR: +- Crie a branch a partir de `develop`. +- Use um prefixo coerente com o tipo de trabalho: `feat`, `fix`, `docs`, `test`, `refactor`, `style` ou `chore`. +- Faça commits pequenos e claros. +- Abra o Pull Request sempre apontando para `develop`. +- Relacione o PR com a issue usando uma palavra de fechamento, por exemplo `Fixes: #123`, quando o PR concluir a tarefa. + +--- + ## 🗂️ Regras e Organização ### ✔️ Ao demonstrar interesse em uma tarefa (card): diff --git a/README.md b/README.md index 15c9bc66..22776ee7 100644 Binary files a/README.md and b/README.md differ diff --git a/docs/04 - processo/ci-e-validacao.md b/docs/04 - processo/ci-e-validacao.md index cb898b61..e50bf7aa 100644 --- a/docs/04 - processo/ci-e-validacao.md +++ b/docs/04 - processo/ci-e-validacao.md @@ -2,7 +2,7 @@ **Classificação:** Documento de Processo\ **Camada:** 4 --- Processo\ -**Status:** Versão inicial +**Status:** Fluxo oficial documentado ------------------------------------------------------------------------ @@ -22,7 +22,42 @@ Ele deve ajudar novos contribuidores a entender: ------------------------------------------------------------------------ -## 2. O que é CI +## 2. Resumo rápido para contribuidores + +Antes de abrir um Pull Request, siga este fluxo: + +1. Crie a branch a partir de `develop`. +2. Faça a mudança mantendo o escopo da issue. +3. Rode as validações locais: + +```bash +npm ci +npm run format +npm run lint +npm run test +npm run test:push +npm run build +``` + +4. Abra o PR com destino para `develop`. +5. Inclua na descrição o que mudou, como foi validado e a issue + relacionada. +6. Se algum check falhar, corrija na mesma branch e envie novo commit. + +Para mudanças apenas em documentação, rode pelo menos: + +```bash +npm ci +npm run format +npm run lint +``` + +Se a mudança tocar código TypeScript, rotas, componentes, Prisma ou +dependências, rode o fluxo completo. + +------------------------------------------------------------------------ + +## 3. O que é CI CI, ou Integração Contínua, é o processo automatizado que executa validações sempre que uma mudança é enviada para revisão. @@ -38,7 +73,7 @@ No TryCatch, a CI protege a branch de desenvolvimento contra: ------------------------------------------------------------------------ -## 3. Quando a pipeline roda +## 4. Quando a pipeline roda O projeto possui dois workflows que atuam em conjunto: @@ -50,11 +85,12 @@ O **CI Pipeline** roda em: - Pull Requests direcionados para `main`; - Pull Requests direcionados para `develop`; -- Pushes na branch `test/tests-ci`. +- Pushes diretos em `main`; +- Pushes diretos em `develop`. -A branch `test/tests-ci` é usada para validar alterações no próprio -workflow. Para contribuições comuns, o fluxo esperado é abrir PR para -`develop`. +Para contribuições comuns, o fluxo esperado é abrir PR para `develop`. +Push direto em `main` ou `develop` deve ser reservado para mantenedores e +casos autorizados. O **SonarCloud** não roda diretamente em `pull_request`. Ele é disparado pelo gatilho `workflow_run`, ou seja, **somente depois que a CI Pipeline @@ -68,9 +104,9 @@ termina com sucesso**. O motivo dessa separação está explicado na seção ------------------------------------------------------------------------ -## 4. Validações executadas pela CI +## 5. Validações executadas pela CI -### 4.1 Build and Prepare Environment +### 5.1 Build and Prepare Environment Objetivo: garantir que a aplicação consiga compilar em ambiente limpo. @@ -82,7 +118,7 @@ Passos principais: - Cria arquivo `.env` com secrets configurados no GitHub; - Executa `npm run build`. -### 4.2 Lint Codebase +### 5.2 Lint Codebase Objetivo: verificar padrões de código e regras do Next.js/ESLint. @@ -92,7 +128,7 @@ Comando executado: npm run lint ``` -### 4.3 Run Tests (Jest) +### 5.3 Run Tests (Jest) Objetivo: garantir que os testes automatizados continuem passando. @@ -102,7 +138,7 @@ Comando executado: npm run test ``` -### 4.4 Run Tests with Coverage +### 5.4 Run Tests with Coverage Objetivo: gerar cobertura de testes e impedir queda abaixo do mínimo definido. @@ -117,12 +153,12 @@ npx jest --coverage \ O relatório de cobertura é enviado como artefato do workflow (`coverage-report`). Em Pull Requests, o job também publica um artefato `pr-metadata` (número do PR, branch de origem e branch de destino). Esses -artefatos são consumidos pelo workflow SonarCloud (seção 4.5). +artefatos são consumidos pelo workflow SonarCloud (seção 5.5). O padrão oficial de testes backend está documentado em `docs/04 - processo/testes-backend.md`. -### 4.5 SonarCloud Scan (workflow separado) +### 5.5 SonarCloud Scan (workflow separado) Objetivo: executar análise de qualidade e segurança via SonarCloud. @@ -178,7 +214,7 @@ como secret no repositório base. > que os demais checks estejam verdes; o Sonar passa a decorar o PR > quando o `SONAR_TOKEN` está configurado no repositório base. -### 4.6 Audit Dependencies +### 5.6 Audit Dependencies Objetivo: bloquear dependências com vulnerabilidades de severidade alta ou superior. @@ -191,7 +227,7 @@ npm audit --audit-level=high ------------------------------------------------------------------------ -## 5. Validação local antes de abrir PR +## 6. Validação local antes de abrir PR Antes de abrir um Pull Request, o contribuidor deve rodar as validações principais localmente. @@ -202,6 +238,7 @@ Fluxo recomendado: git checkout develop git pull origin develop npm ci +npm run format npm run lint npm run test npm run test:push @@ -219,7 +256,7 @@ Observações: ------------------------------------------------------------------------ -## 6. Requisitos para abrir Pull Request +## 7. Requisitos para abrir Pull Request Antes de abrir o PR: @@ -241,6 +278,8 @@ Resumo objetivo da mudança. ## Validação +- npm ci +- npm run format - npm run lint - npm run test - npm run test:push @@ -254,7 +293,7 @@ issue. ------------------------------------------------------------------------ -## 7. O que fazer quando um check falhar +## 8. O que fazer quando um check falhar Quando a CI falhar: @@ -274,7 +313,7 @@ aguarde orientação de um mantenedor. ------------------------------------------------------------------------ -## 8. Requisitos para merge +## 9. Requisitos para merge Um PR só deve ser considerado pronto para merge quando: @@ -288,7 +327,7 @@ Um PR só deve ser considerado pronto para merge quando: ------------------------------------------------------------------------ -## 9. Relação com Husky +## 10. Relação com Husky O projeto usa Husky para validações locais antes de commits e pushes. @@ -305,9 +344,9 @@ executadas manualmente. ------------------------------------------------------------------------ -## 10. Prisma Client e `package-lock.json` +## 11. Prisma Client e `package-lock.json` -### 10.1 Geração do Prisma Client (`postinstall`) +### 11.1 Geração do Prisma Client (`postinstall`) A partir do Prisma 7, o pacote `@prisma/client` só fica utilizável depois que o client é gerado. Sem isso, jobs que rodam testes falham com: @@ -326,7 +365,7 @@ local), existe o script `postinstall` no `package.json`: Ele roda automaticamente após `npm ci` / `npm install`. Não é necessário rodar `npx prisma generate` manualmente após instalar dependências. -### 10.2 Lockfile e diferenças entre sistemas operacionais +### 11.2 Lockfile e diferenças entre sistemas operacionais Algumas dependências nativas opcionais (por exemplo bindings de `@unrs/resolver-binding-*` e `@tailwindcss/oxide-*`, que embarcam @@ -355,7 +394,7 @@ Diretrizes para evitar esse problema: ------------------------------------------------------------------------ -## 11. Responsabilidades +## 12. Responsabilidades Contribuidor: diff --git a/package.json b/package.json index b4ee379b..6f4f7344 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "lint": "eslint . --no-cache", "lint:fix": "eslint . --no-cache --fix", "format": "prettier --write .", - "seed": "node scripts/createTestUser.js", + "seed": "tsx scripts/createTestUser.ts", "test": "jest --passWithNoTests --maxWorkers=50%", "test:watchAll": "jest --watchAll", "test:watch": "jest --watch --maxWorkers=25%", diff --git a/scripts/createTestUser.js b/scripts/createTestUser.js deleted file mode 100644 index 385ac8c6..00000000 --- a/scripts/createTestUser.js +++ /dev/null @@ -1,37 +0,0 @@ -/* eslint-disable @typescript-eslint/no-require-imports */ -const { PrismaClient } = require('@prisma/client'); -const bcrypt = require('bcryptjs'); - -const prisma = new PrismaClient(); - -async function main() { - const password = 'teste123'; - const hashedPassword = await bcrypt.hash(password, 10); - - const user = await prisma.user.upsert({ - where: { email: 'admin@admin.com' }, - update: {}, - create: { - name: 'Usuário Admin', - email: 'admin@admin.com', - password: hashedPassword, - avatar: '', - bio: 'Usuário admin para testes da API', - linkedin: 'https://www.linkedin.com/in/trycatch-app', - github: 'https://github.com/TryCatch-ForMatch/trycatch', - role: 'ADMIN', - }, - }); - - console.log('Usuário criado ou já existente:', user); -} - -main() - .then(() => { - console.log('✅ Script finalizado.'); - process.exit(0); - }) - .catch((error) => { - console.error('❌ Ocorreu um erro:', error); - process.exit(1); - }); diff --git a/scripts/createTestUser.ts b/scripts/createTestUser.ts new file mode 100644 index 00000000..e3e7b408 --- /dev/null +++ b/scripts/createTestUser.ts @@ -0,0 +1,45 @@ +import bcrypt from 'bcryptjs'; +import { prisma } from '../src/lib/prisma'; + +export async function createTestUser() { + const password = 'teste123'; + const hashedPassword = await bcrypt.hash(password, 10); + + return prisma.user.upsert({ + where: { email: 'admin@admin.com' }, + update: {}, + create: { + name: 'Usuário Admin', + email: 'admin@admin.com', + password: hashedPassword, + avatar: '', + bio: 'Usuário admin para testes da API', + linkedin: 'https://www.linkedin.com/in/trycatch-app', + github: 'https://github.com/TryCatch-ForMatch/trycatch', + role: 'ADMIN', + }, + }); +} + +function isDirectRun() { + return process.argv[1] + ?.replaceAll('\\', '/') + .endsWith('/scripts/createTestUser.ts'); +} + +/* c8 ignore start */ +if (isDirectRun()) { + createTestUser() + .then(async (user) => { + console.log('Usuário criado ou já existente:', user); + console.log('✅ Script finalizado.'); + await prisma.$disconnect(); + process.exit(0); + }) + .catch(async (error) => { + console.error('❌ Ocorreu um erro:', error); + await prisma.$disconnect(); + process.exit(1); + }); +} +/* c8 ignore stop */ diff --git a/scripts/seed.local.ts b/scripts/seed.local.ts index c977565a..01252881 100644 --- a/scripts/seed.local.ts +++ b/scripts/seed.local.ts @@ -10,12 +10,11 @@ * Rodar: npx tsx scripts/seed.local.ts */ -import { PrismaClient, ProjectStatus } from '@prisma/client'; +import { ProjectStatus } from '@prisma/client'; import bcrypt from 'bcryptjs'; +import { prisma } from '../src/lib/prisma'; -const prisma = new PrismaClient(); - -async function main() { +export async function seedLocalPortfolio() { console.log('🌱 Iniciando seed local para feature de portfólio...\n'); const hashedPassword = await bcrypt.hash('teste123', 10); @@ -298,14 +297,24 @@ async function main() { console.log('\n 🔑 Senha de todos os usuários de teste: teste123'); } -main() - .then(async () => { - console.log('\n✅ Seed local finalizado com sucesso!'); - await prisma.$disconnect(); - process.exit(0); - }) - .catch(async (e) => { - console.error('\n❌ Erro no seed:', e); - await prisma.$disconnect(); - process.exit(1); - }); +function isDirectRun() { + return process.argv[1] + ?.replaceAll('\\', '/') + .endsWith('/scripts/seed.local.ts'); +} + +/* c8 ignore start */ +if (isDirectRun()) { + seedLocalPortfolio() + .then(async () => { + console.log('\n✅ Seed local finalizado com sucesso!'); + await prisma.$disconnect(); + process.exit(0); + }) + .catch(async (e) => { + console.error('\n❌ Erro no seed:', e); + await prisma.$disconnect(); + process.exit(1); + }); +} +/* c8 ignore stop */ diff --git a/sonar-project.properties b/sonar-project.properties index c8dfc603..5eeb7de2 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,5 +1,6 @@ sonar.projectKey=TryCatch-ForMatch_trycatch sonar.organization=trycatch-formatch +sonar.javascript.lcov.reportPaths=coverage/lcov.info # This is the name and version displayed in the SonarCloud UI. @@ -12,3 +13,4 @@ sonar.organization=trycatch-formatch # Encoding of the source code. Default is default system encoding #sonar.sourceEncoding=UTF-8 +sonar.javascript.lcov.reportPaths=coverage/lcov.info diff --git a/src/app/(public)/how-to-join/page.tsx b/src/app/(public)/how-to-join/page.tsx index 3bcc9a8e..1ac65cb4 100644 --- a/src/app/(public)/how-to-join/page.tsx +++ b/src/app/(public)/how-to-join/page.tsx @@ -7,6 +7,7 @@ import { FolderKanban, GraduationCap, Handshake, + UserPlus, } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -92,6 +93,8 @@ const participationPaths: ParticipationPath[] = [ }, ]; +const entryProfiles = participationPaths.map((path) => path.title); + export default function HowToJoinPage() { return (
@@ -116,11 +119,62 @@ export default function HowToJoinPage() { organização, processos e colaboração.

+
+
+

+ + Entrada por convite +

+ +

+ O acesso à plataforma acontece por convite. Envie sua + solicitação e escolha o perfil de participação mais adequado. +

+
+ + +
+

O TryCatch reúne diferentes formas de participação para quem deseja contribuir com a plataforma, propor projetos, integrar equipes ou apoiar outras pessoas como mentor.

+ +
+
+
+

+ Principais perfis de participação +

+ +

+ Você pode entrar como membro, mentor, colaborador da + plataforma ou autor de projeto. A solicitação de convite ajuda + a comunidade a entender seu melhor caminho de entrada. +

+
+
+ + +
diff --git a/src/app/api/dashboard/feedbacks/route.ts b/src/app/api/dashboard/feedbacks/route.ts new file mode 100644 index 00000000..351db62c --- /dev/null +++ b/src/app/api/dashboard/feedbacks/route.ts @@ -0,0 +1,40 @@ +import { NextResponse } from 'next/server'; + +import { MESSAGES, buildResponse } from '@/constants/messages'; +import { checkAuth } from '@/lib/check-auth'; +import { logger } from '@/lib/logger'; +import { prisma } from '@/lib/prisma'; +import { ROLE_GROUPS } from '@/lib/roles'; + +export async function GET() { + const auth = await checkAuth({ + allowedRoles: ROLE_GROUPS.ALL, + }); + + if (!auth.authorized || !auth.session) return auth.response; + + try { + const feedbacks = await prisma.feedback.count({ + where: { + toUserId: auth.session.user.id, + }, + }); + + return NextResponse.json({ feedbacks }, { status: 200 }); + } catch (error) { + logger.error( + 'Erro ao buscar quantidade de feedbacks do dashboard:', + 'GET /api/dashboard/feedbacks', + { + error: error instanceof Error ? error.message : String(error), + } + ); + + return buildResponse({ + success: false, + message: MESSAGES.FEEDBACK.INTERNAL_ERROR, + status: 500, + errors: [error instanceof Error ? error.message : String(error)], + }); + } +} diff --git a/src/app/api/dashboard/projects/route.ts b/src/app/api/dashboard/projects/route.ts new file mode 100644 index 00000000..63f83285 --- /dev/null +++ b/src/app/api/dashboard/projects/route.ts @@ -0,0 +1,73 @@ +import { ProjectStatus } from '@prisma/client'; +import { NextResponse } from 'next/server'; + +import { MESSAGES, buildResponse } from '@/constants/messages'; +import { checkAuth } from '@/lib/check-auth'; +import { logger } from '@/lib/logger'; +import { prisma } from '@/lib/prisma'; +import { ROLE_GROUPS } from '@/lib/roles'; + +export async function GET() { + const auth = await checkAuth({ + allowedRoles: ROLE_GROUPS.ALL, + }); + + if (!auth.authorized || !auth.session) return auth.response; + + try { + const projects = await prisma.project.findMany({ + where: { + OR: [ + { + ownerId: auth.session.user.id, + }, + { + stacksTaken: { + some: { + userId: auth.session.user.id, + }, + }, + }, + ], + }, + select: { + status: true, + }, + }); + + const counts = projects.reduce( + (acc, project) => { + if (project.status === ProjectStatus.EM_ANDAMENTO) { + acc.projectsInProgress += 1; + } + + if (project.status === ProjectStatus.CONCLUIDO) { + acc.projectsCompleted += 1; + } + + return acc; + }, + { + projectsInProgress: 0, + projectsCompleted: 0, + } + ); + + return NextResponse.json(counts, { status: 200 }); + } catch (error) { + logger.error( + 'Erro ao buscar quantidade de projetos do dashboard:', + 'GET /api/dashboard/projects', + { + error: error instanceof Error ? error.message : String(error), + } + ); + + return buildResponse({ + success: false, + message: MESSAGES.PROJECT.INTERNAL_ERROR, + status: 500, + errors: [error instanceof Error ? error.message : String(error)], + }); + } +} diff --git a/src/app/api/dashboard/skills/route.ts b/src/app/api/dashboard/skills/route.ts new file mode 100644 index 00000000..aae711b2 --- /dev/null +++ b/src/app/api/dashboard/skills/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from 'next/server'; + +import { MESSAGES, buildResponse } from '@/constants/messages'; +import { checkAuth } from '@/lib/check-auth'; +import { getDashboardSkillsCount } from '@/lib/dashboard-summary'; +import { logger } from '@/lib/logger'; +import { ROLE_GROUPS } from '@/lib/roles'; + +export async function GET() { + const auth = await checkAuth({ + allowedRoles: ROLE_GROUPS.ALL, + }); + + if (!auth.authorized || !auth.session) return auth.response; + + try { + const skills = await getDashboardSkillsCount(auth.session.user.id); + + return NextResponse.json({ skills }, { status: 200 }); + } catch (error) { + logger.error( + 'Erro ao buscar quantidade de skills do dashboard:', + 'GET /api/dashboard/skills', + { + error: error instanceof Error ? error.message : String(error), + } + ); + + return buildResponse({ + success: false, + message: MESSAGES.SKILL.INTERNAL_ERROR, + status: 500, + errors: [error instanceof Error ? error.message : String(error)], + }); + } +} diff --git a/src/app/api/dashboard/summary/route.ts b/src/app/api/dashboard/summary/route.ts new file mode 100644 index 00000000..3437fa75 --- /dev/null +++ b/src/app/api/dashboard/summary/route.ts @@ -0,0 +1,93 @@ +import { ProjectStatus } from '@prisma/client'; + +import { MESSAGES, buildResponse } from '@/constants/messages'; +import { checkAuth } from '@/lib/check-auth'; +import { logger } from '@/lib/logger'; +import { prisma } from '@/lib/prisma'; +import { ROLE_GROUPS } from '@/lib/roles'; + +export async function GET() { + const auth = await checkAuth({ + allowedRoles: ROLE_GROUPS.ALL, + }); + + if (!auth.authorized || !auth.session) return auth.response; + + try { + const [projects, skills, feedbacks] = await Promise.all([ + prisma.project.findMany({ + where: { + OR: [ + { + ownerId: auth.session.user.id, + }, + { + stacksTaken: { + some: { + userId: auth.session.user.id, + }, + }, + }, + ], + }, + select: { + status: true, + }, + }), + prisma.userSkill.count({ + where: { + userId: auth.session.user.id, + }, + }), + prisma.feedback.count({ + where: { + toUserId: auth.session.user.id, + }, + }), + ]); + + const projectCounts = projects.reduce( + (acc, project) => { + if (project.status === ProjectStatus.EM_ANDAMENTO) { + acc.projectsInProgress += 1; + } + + if (project.status === ProjectStatus.CONCLUIDO) { + acc.projectsCompleted += 1; + } + + return acc; + }, + { + projectsInProgress: 0, + projectsCompleted: 0, + } + ); + + return buildResponse({ + success: true, + message: MESSAGES.GENERAL.SUCCESS, + data: { + ...projectCounts, + skills, + feedbacks, + }, + status: 200, + }); + } catch (error) { + logger.error( + 'Erro ao buscar resumo do dashboard:', + 'GET /api/dashboard/summary', + { + error: error instanceof Error ? error.message : String(error), + } + ); + + return buildResponse({ + success: false, + message: MESSAGES.GENERAL.INTERNAL_ERROR, + status: 500, + errors: [error instanceof Error ? error.message : String(error)], + }); + } +} diff --git a/src/app/api/team-project/[id]/route.ts b/src/app/api/team-project/[id]/route.ts index 294be026..465fea65 100644 --- a/src/app/api/team-project/[id]/route.ts +++ b/src/app/api/team-project/[id]/route.ts @@ -1,5 +1,6 @@ import { prisma } from '@/lib/prisma'; import { ProjectStatus } from '@prisma/client'; +import type { Prisma } from '@prisma/client'; import { NextRequest, NextResponse } from 'next/server'; import { checkAuth } from '@/lib/check-auth'; import { z } from 'zod'; @@ -32,6 +33,103 @@ const updateProjectSchema = z.object({ .optional(), }); +type StructuralProjectState = { + name: string; + deadline: Date; + totalValue: number; + skills: { skillId: string }[]; + stacks: { stackId: string; percentage: number }[]; +}; + +type IncomingProjectState = z.infer; + +function sortedValues(values: string[]) { + return [...values].sort((first, second) => first.localeCompare(second)); +} + +function hasSameSkills( + existingSkills: StructuralProjectState['skills'], + incomingSkills: string[] +) { + const existing = sortedValues(existingSkills.map((skill) => skill.skillId)); + const incoming = sortedValues(incomingSkills); + + return ( + existing.length === incoming.length && + existing.every((skillId, index) => skillId === incoming[index]) + ); +} + +function hasSameStacks( + existingStacks: StructuralProjectState['stacks'], + incomingStacks: NonNullable +) { + if (existingStacks.length !== incomingStacks.length) return false; + + const incomingByStackId = new Map( + incomingStacks.map((stack) => [stack.stackId, stack.percentage]) + ); + + return existingStacks.every( + (stack) => incomingByStackId.get(stack.stackId) === stack.percentage + ); +} + +function hasStructuralProjectChanges( + existing: StructuralProjectState, + incoming: IncomingProjectState +) { + const incomingStacks = incoming.stacks ?? []; + + return ( + existing.name !== incoming.name || + existing.deadline.getTime() !== new Date(incoming.deadline).getTime() || + existing.totalValue !== incoming.totalValue || + !hasSameSkills(existing.skills, incoming.skills) || + !hasSameStacks(existing.stacks, incomingStacks) + ); +} + +async function applyProjectStackChanges( + tx: Prisma.TransactionClient, + projectId: string, + incomingStacks: NonNullable +) { + const existingStacks = await tx.projectStack.findMany({ + where: { projectId }, + select: { id: true, stackId: true, percentage: true }, + }); + + const { toUpdate, toCreate, toDelete } = planProjectStackChanges( + existingStacks, + incomingStacks + ); + + for (const stackToDelete of toDelete) { + await tx.stackTaken.deleteMany({ + where: { projectStackId: stackToDelete.id }, + }); + await tx.projectStack.delete({ where: { id: stackToDelete.id } }); + } + + for (const stackToUpdate of toUpdate) { + await tx.projectStack.update({ + where: { id: stackToUpdate.id }, + data: { percentage: stackToUpdate.percentage }, + }); + } + + for (const stackToCreate of toCreate) { + await tx.projectStack.create({ + data: { + projectId, + stackId: stackToCreate.stackId, + percentage: stackToCreate.percentage, + }, + }); + } +} + export async function GET( request: NextRequest, context: { params: { id: string } } @@ -163,6 +261,11 @@ export async function PUT( try { const existing = await prisma.project.findUnique({ where: { id: projectId }, + include: { + skills: { select: { skillId: true } }, + stacks: { select: { stackId: true, percentage: true } }, + stacksTaken: { select: { id: true }, take: 1 }, + }, }); if (!existing) { @@ -193,56 +296,40 @@ export async function PUT( github, } = parse.data; - const existingStacks = await prisma.projectStack.findMany({ - where: { projectId }, - select: { id: true, stackId: true, percentage: true }, - }); - - const { toUpdate, toCreate, toDelete } = planProjectStackChanges( - existingStacks, - stacks ?? [] - ); - - for (const stackToDelete of toDelete) { - await prisma.stackTaken.deleteMany({ - where: { projectStackId: stackToDelete.id }, + if ( + existing.stacksTaken.length > 0 && + hasStructuralProjectChanges(existing, parse.data) + ) { + return buildResponse({ + success: false, + message: MESSAGES.AUTH.UNAUTHORIZED, + status: 403, + errors: [ + 'Não é permitido alterar estrutura do projeto após formação da equipe.', + ], }); - await prisma.projectStack.delete({ where: { id: stackToDelete.id } }); } - for (const stackToUpdate of toUpdate) { - await prisma.projectStack.update({ - where: { id: stackToUpdate.id }, - data: { percentage: stackToUpdate.percentage }, - }); - } + const updated = await prisma.$transaction(async (tx) => { + await applyProjectStackChanges(tx, projectId, stacks ?? []); - for (const stackToCreate of toCreate) { - await prisma.projectStack.create({ + return tx.project.update({ + where: { id: projectId }, data: { - projectId, - stackId: stackToCreate.stackId, - percentage: stackToCreate.percentage, + name, + description, + deadline: new Date(deadline), + totalValue, + status, + github: github || null, + skills: { + deleteMany: {}, + create: skills?.map((skillId: string) => ({ + skill: { connect: { id: skillId } }, + })), + }, }, }); - } - - const updated = await prisma.project.update({ - where: { id: projectId }, - data: { - name, - description, - deadline: new Date(deadline), - totalValue, - status, - github: github || null, - skills: { - deleteMany: {}, - create: skills?.map((skillId: string) => ({ - skill: { connect: { id: skillId } }, - })), - }, - }, }); await checkProjectStatus(projectId); diff --git a/src/app/api/team-project/user/route.ts b/src/app/api/team-project/user/route.ts index 271dd140..9d41a2c6 100644 --- a/src/app/api/team-project/user/route.ts +++ b/src/app/api/team-project/user/route.ts @@ -6,6 +6,7 @@ import { z } from 'zod'; import { MESSAGES, buildResponse } from '@/constants/messages'; import { checkProjectStatus } from '@/lib/check-project-status'; import { ROLE_GROUPS } from '@/lib/roles'; +import { logger } from '@/lib/logger'; const idSchema = z.string().min(25, 'ID inválido').max(36, 'ID inválido'); @@ -78,7 +79,14 @@ export async function GET() { return NextResponse.json(result, { status: 200 }); } catch (error) { - console.error('[team-project/user GET] Erro:', error); + logger.error( + 'Erro ao buscar projetos do usuário', + 'GET /api/team-project/user', + { + error: error instanceof Error ? error.message : String(error), + } + ); + return buildResponse({ success: false, message: MESSAGES.PROJECT.INTERNAL_ERROR, @@ -130,17 +138,73 @@ export async function PATCH(request: NextRequest) { }); } - const updated = await prisma.project.update({ - where: { id: projectId }, - data: { status }, + if (status !== ProjectStatus.CONCLUIDO) { + return buildResponse({ + success: false, + message: MESSAGES.GENERAL.INVALID_DATA, + status: 400, + errors: ['O encerramento manual só permite status CONCLUIDO.'], + }); + } + + if (existing.ownerId !== session.user.id) { + return buildResponse({ + success: false, + message: MESSAGES.AUTH.UNAUTHORIZED, + status: 403, + errors: ['Apenas o owner pode concluir este projeto.'], + }); + } + + if (existing.status !== ProjectStatus.EM_ANDAMENTO) { + return buildResponse({ + success: false, + message: MESSAGES.GENERAL.INVALID_DATA, + status: 400, + errors: ['Projeto só pode ser concluído em status EM_ANDAMENTO.'], + }); + } + + const updated = await prisma.$transaction(async (tx) => { + const totalProjectStacks = await tx.projectStack.count({ + where: { projectId }, + }); + const totalStackTaken = await tx.stackTaken.count({ + where: { projectId }, + }); + + if (totalProjectStacks === 0 || totalStackTaken < totalProjectStacks) { + return null; + } + + return tx.project.update({ + where: { id: projectId }, + data: { status }, + }); }); + if (!updated) { + return buildResponse({ + success: false, + message: MESSAGES.GENERAL.INVALID_DATA, + status: 400, + errors: ['Todas as stacks devem estar assumidas para concluir.'], + }); + } + // se houver lógica de verificação pós-update await checkProjectStatus(projectId); return NextResponse.json(updated, { status: 200 }); } catch (error) { - console.error('[team-project/user PATCH] Erro ao alterar status:', error); + logger.error( + 'Erro ao alterar status do projeto', + 'PATCH /api/team-project/user', + { + error: error instanceof Error ? error.message : String(error), + } + ); + return buildResponse({ success: false, message: MESSAGES.PROJECT.INTERNAL_ERROR, diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 5e1f3d61..0d479afb 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -12,8 +12,8 @@ const poppins = Poppins({ export default function RootLayout({ children }: { children: ReactNode }) { return ( - - + + {children} diff --git a/src/lib/check-auth.ts b/src/lib/check-auth.ts index 527853e7..acb30bc1 100644 --- a/src/lib/check-auth.ts +++ b/src/lib/check-auth.ts @@ -2,6 +2,7 @@ import { getServerSession } from 'next-auth'; import { authOptions } from './auth'; import { NextResponse, NextRequest } from 'next/server'; import { Role } from '@/lib/roles'; +import { logger } from '@/lib/logger'; type CheckAuthParams = { allowedRoles?: Role[]; @@ -11,9 +12,17 @@ type CheckAuthParams = { export async function checkAuth(params?: CheckAuthParams) { const session = await getServerSession(authOptions); - console.log('Sessão ativa:', session?.user); + + if (session?.user) { + logger.info('Sessão ativa', 'checkAuth', { + userId: session.user.id, + role: session.user.role, + }); + } if (!session) { + logger.warn('Sessão ausente', 'checkAuth'); + return { authorized: false, response: NextResponse.json( @@ -25,6 +34,11 @@ export async function checkAuth(params?: CheckAuthParams) { // Caso tenha passado requireAdmin if (params?.requireAdmin && session.user.role !== 'ADMIN') { + logger.warn('Acesso administrativo negado', 'checkAuth', { + userId: session.user.id, + role: session.user.role, + }); + return { authorized: false, response: NextResponse.json( @@ -39,6 +53,12 @@ export async function checkAuth(params?: CheckAuthParams) { params?.allowedRoles && !params.allowedRoles.includes(session.user.role as Role) ) { + logger.warn('Acesso negado por perfil', 'checkAuth', { + userId: session.user.id, + role: session.user.role, + allowedRoles: params.allowedRoles, + }); + return { authorized: false, response: NextResponse.json({ error: 'Acesso negado.' }, { status: 403 }), diff --git a/src/lib/check-project-status.ts b/src/lib/check-project-status.ts index cb0efe52..48c9a34f 100644 --- a/src/lib/check-project-status.ts +++ b/src/lib/check-project-status.ts @@ -1,11 +1,14 @@ import { prisma } from '@/lib/prisma'; import { ProjectStatus } from '@prisma/client'; import { MESSAGES, buildResponse } from '@/constants/messages'; +import { logger } from '@/lib/logger'; + +const CONTEXT = 'checkProjectStatus'; export async function checkProjectStatus(projectId: string) { - console.log( - `\n🔍 Verificando contagem de stacks para o projeto: ${projectId}` - ); + logger.info('Verificando contagem de stacks do projeto', CONTEXT, { + projectId, + }); // Verifica se existe algum registro em StackTaken com esse projectId const existsInStackTaken = await prisma.stackTaken.findFirst({ @@ -13,11 +16,13 @@ export async function checkProjectStatus(projectId: string) { }); if (!existsInStackTaken) { - console.log( - '⚠️ Nenhum registro encontrado na tabela StackTaken para este projeto.' - ); + logger.warn('Nenhum registro encontrado na tabela StackTaken', CONTEXT, { + projectId, + }); } else { - console.log('✅ Projeto encontrado na tabela StackTaken.'); + logger.info('Projeto encontrado na tabela StackTaken', CONTEXT, { + projectId, + }); } // Se o projeto já estiver concluído, não alterar o status @@ -27,7 +32,8 @@ export async function checkProjectStatus(projectId: string) { }); if (!project) { - console.log('❌ Projeto não encontrado.'); + logger.warn('Projeto não encontrado', CONTEXT, { projectId }); + return buildResponse({ success: false, message: MESSAGES.PROJECT.NOT_FOUND, @@ -36,7 +42,11 @@ export async function checkProjectStatus(projectId: string) { } if (project.status === ProjectStatus.CONCLUIDO) { - console.log('✅ Projeto já está concluído — sem alterações.'); + logger.info('Projeto já está concluído sem alterações', CONTEXT, { + projectId, + status: project.status, + }); + return buildResponse({ success: true, message: MESSAGES.PROJECT.FETCH_SUCCESS, @@ -53,9 +63,11 @@ export async function checkProjectStatus(projectId: string) { where: { projectId }, }); - // Exibe os resultados no console - console.log(`📊 Total de ProjectStack: ${totalProjectStack}`); - console.log(`📊 Total de StackTaken: ${totalStackTaken}`); + logger.info('Contagem de stacks do projeto calculada', CONTEXT, { + projectId, + totalProjectStack, + totalStackTaken, + }); // Decidir novo status let newStatus = project.status; @@ -72,9 +84,16 @@ export async function checkProjectStatus(projectId: string) { where: { id: projectId }, data: { status: newStatus }, }); - console.log(`✅ Status do projeto atualizado para: ${newStatus}`); + logger.info('Status do projeto atualizado', CONTEXT, { + projectId, + previousStatus: project.status, + newStatus, + }); } else { - console.log(`ℹ️ Nenhuma alteração no status (${newStatus})`); + logger.info('Status do projeto mantido', CONTEXT, { + projectId, + status: newStatus, + }); } return { diff --git a/src/lib/dashboard-summary.ts b/src/lib/dashboard-summary.ts new file mode 100644 index 00000000..4b262b5c --- /dev/null +++ b/src/lib/dashboard-summary.ts @@ -0,0 +1,9 @@ +import { prisma } from '@/lib/prisma'; + +export async function getDashboardSkillsCount(userId: string) { + return prisma.userSkill.count({ + where: { + userId, + }, + }); +} diff --git a/src/lib/errors/app-error.ts b/src/lib/errors/app-error.ts new file mode 100644 index 00000000..f6b0461c --- /dev/null +++ b/src/lib/errors/app-error.ts @@ -0,0 +1,11 @@ +export class AppError extends Error { + readonly statusCode: number; + readonly errors?: unknown; + + constructor(message: string, statusCode = 400, errors?: unknown) { + super(message); + this.name = 'AppError'; + this.statusCode = statusCode; + this.errors = errors; + } +} diff --git a/src/lib/errors/handle-api-error.ts b/src/lib/errors/handle-api-error.ts new file mode 100644 index 00000000..80c4919b --- /dev/null +++ b/src/lib/errors/handle-api-error.ts @@ -0,0 +1,28 @@ +import { MESSAGES, buildResponse } from '@/constants/messages'; +import { logger } from '@/lib/logger'; + +import { AppError } from './app-error'; + +const DEFAULT_CONTEXT = 'API handler'; + +export function handleApiError(error: unknown, context = DEFAULT_CONTEXT) { + if (error instanceof AppError) { + return buildResponse({ + success: false, + message: error.message, + status: error.statusCode, + errors: error.errors ?? null, + }); + } + + logger.error('Erro inesperado na API:', context, { + error: error instanceof Error ? error.message : String(error), + }); + + return buildResponse({ + success: false, + message: MESSAGES.GENERAL.INTERNAL_ERROR, + status: 500, + errors: null, + }); +} diff --git a/src/lib/errors/with-error-handling.ts b/src/lib/errors/with-error-handling.ts new file mode 100644 index 00000000..45ae98ad --- /dev/null +++ b/src/lib/errors/with-error-handling.ts @@ -0,0 +1,18 @@ +import { handleApiError } from './handle-api-error'; + +type AsyncRouteHandler = ( + ...args: TArgs +) => Promise; + +export function withErrorHandling( + handler: AsyncRouteHandler, + context?: string +) { + return async (...args: TArgs) => { + try { + return await handler(...args); + } catch (error) { + return handleApiError(error, context); + } + }; +} diff --git a/src/tests/unit/api/auth/auth-route.test.ts b/src/tests/unit/api/auth/auth-route.test.ts new file mode 100644 index 00000000..688572dc --- /dev/null +++ b/src/tests/unit/api/auth/auth-route.test.ts @@ -0,0 +1,419 @@ +/** + * @jest-environment node + */ + +import { POST as POST_FORGOT_PASSWORD } from '@/app/api/auth/forgot-password/route'; +import { POST as POST_REGISTER } from '@/app/api/auth/register/route'; +import { POST as POST_RESET_PASSWORD } from '@/app/api/auth/reset-password/route'; +import { POST as POST_SIGNUP } from '@/app/api/auth/signup/route'; +import { GET as GET_VALIDATE_RESET_TOKEN } from '@/app/api/auth/validate-reset-token/route'; +import { prisma } from '@/lib/prisma'; +import { sendResetPasswordEmail } from '@/lib/mail/send-reset-password-email'; +import { generateUniqueUsername } from '@/lib/generate-username'; +import { hash } from 'bcryptjs'; +import { NextRequest } from 'next/server'; + +jest.mock('@/lib/prisma', () => ({ + prisma: { + invite: { + findFirst: jest.fn(), + update: jest.fn(), + }, + passwordResetToken: { + create: jest.fn(), + delete: jest.fn(), + findFirst: jest.fn(), + }, + user: { + create: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + }, + $transaction: jest.fn(), + }, +})); + +jest.mock('@/lib/generate-username', () => ({ + generateUniqueUsername: jest.fn(), +})); + +jest.mock('@/lib/mail/send-reset-password-email', () => ({ + sendResetPasswordEmail: jest.fn(), +})); + +jest.mock('@/lib/logger', () => ({ + logger: { + error: jest.fn(), + }, +})); + +jest.mock('bcryptjs', () => { + const mockedHash = jest.fn(); + + return { + __esModule: true, + default: { + hash: mockedHash, + }, + hash: mockedHash, + }; +}); + +function createRequest(body: unknown): NextRequest { + return { + json: async () => body, + } as NextRequest; +} + +function createUrlRequest(url: string): NextRequest { + return { + nextUrl: new URL(url), + } as NextRequest; +} + +describe('POST /api/auth/register', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('deve validar convite ativo para o email informado', async () => { + (prisma.invite.findFirst as jest.Mock).mockResolvedValue({ + email: 'maria@example.com', + code: 'INVITE123', + role: 'USER', + }); + + const response = await POST_REGISTER( + createRequest({ + email: 'maria@example.com', + inviteCode: 'INVITE123', + }) + ); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.success).toBe(true); + expect(body.data).toEqual({ + email: 'maria@example.com', + role: 'USER', + code: 'INVITE123', + }); + expect(prisma.invite.findFirst).toHaveBeenCalledWith({ + where: { + email: 'maria@example.com', + code: 'INVITE123', + used: false, + }, + }); + }); + + it('deve rejeitar convite inexistente ou já utilizado', async () => { + (prisma.invite.findFirst as jest.Mock).mockResolvedValue(null); + + const response = await POST_REGISTER( + createRequest({ + email: 'maria@example.com', + inviteCode: 'INVITE123', + }) + ); + const body = await response.json(); + + expect(response.status).toBe(403); + expect(body.success).toBe(false); + }); +}); + +describe('POST /api/auth/signup', () => { + beforeEach(() => { + jest.clearAllMocks(); + (hash as jest.Mock).mockResolvedValue('hashed-password'); + (generateUniqueUsername as jest.Mock).mockResolvedValue('maria-silva'); + }); + + it('deve criar usuário com convite válido e marcar convite como usado', async () => { + (prisma.invite.findFirst as jest.Mock).mockResolvedValue({ + id: 'invite-1', + email: 'maria@example.com', + code: 'INVITE123', + role: 'USER', + used: false, + }); + (prisma.user.findUnique as jest.Mock).mockResolvedValue(null); + (prisma.user.create as jest.Mock).mockResolvedValue({ + id: 'user-1', + name: 'Maria Silva', + email: 'maria@example.com', + role: 'USER', + }); + (prisma.invite.update as jest.Mock).mockResolvedValue({ + id: 'invite-1', + used: true, + }); + + const response = await POST_SIGNUP( + createRequest({ + name: 'Maria Silva', + email: 'maria@example.com', + password: 'secret123', + avatar: '', + linkedin: '', + github: '', + bio: 'Frontend developer', + inviteCode: 'INVITE123', + }) + ); + const body = await response.json(); + + expect(response.status).toBe(201); + expect(body.success).toBe(true); + expect(body.data).toEqual({ + id: 'user-1', + name: 'Maria Silva', + email: 'maria@example.com', + role: 'USER', + }); + expect(hash).toHaveBeenCalledWith('secret123', 10); + expect(generateUniqueUsername).toHaveBeenCalledWith('Maria Silva'); + expect(prisma.user.create).toHaveBeenCalledWith({ + data: { + name: 'Maria Silva', + userName: 'maria-silva', + email: 'maria@example.com', + password: 'hashed-password', + avatar: '', + linkedin: '', + github: '', + bio: 'Frontend developer', + role: 'USER', + }, + }); + expect(prisma.invite.update).toHaveBeenCalledWith({ + where: { id: 'invite-1' }, + data: { used: true }, + }); + }); + + it('deve retornar 400 quando payload for inválido', async () => { + const response = await POST_SIGNUP( + createRequest({ + name: '', + email: 'email-invalido', + password: '123', + avatar: '', + linkedin: '', + github: '', + inviteCode: '', + }) + ); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body.success).toBe(false); + expect(prisma.user.create).not.toHaveBeenCalled(); + }); + + it('deve retornar 400 quando usuário já existir', async () => { + (prisma.invite.findFirst as jest.Mock).mockResolvedValue({ + id: 'invite-1', + email: 'maria@example.com', + code: 'INVITE123', + role: 'USER', + }); + (prisma.user.findUnique as jest.Mock).mockResolvedValue({ + id: 'user-existing', + email: 'maria@example.com', + }); + + const response = await POST_SIGNUP( + createRequest({ + name: 'Maria Silva', + email: 'maria@example.com', + password: 'secret123', + avatar: '', + linkedin: '', + github: '', + bio: '', + inviteCode: 'INVITE123', + }) + ); + + expect(response.status).toBe(400); + expect(prisma.user.create).not.toHaveBeenCalled(); + expect(prisma.invite.update).not.toHaveBeenCalled(); + }); +}); + +describe('POST /api/auth/forgot-password', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('deve retornar 400 para email inválido', async () => { + const response = await POST_FORGOT_PASSWORD( + createRequest({ + email: 'email-invalido', + }) + ); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body.success).toBe(false); + expect(prisma.passwordResetToken.create).not.toHaveBeenCalled(); + }); + + it('deve responder com sucesso sem revelar se email não existe', async () => { + (prisma.user.findUnique as jest.Mock).mockResolvedValue(null); + + const response = await POST_FORGOT_PASSWORD( + createRequest({ + email: 'missing@example.com', + }) + ); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.success).toBe(true); + expect(prisma.passwordResetToken.create).not.toHaveBeenCalled(); + expect(sendResetPasswordEmail).not.toHaveBeenCalled(); + }); + + it('deve gerar token e enviar email quando usuário existir', async () => { + (prisma.user.findUnique as jest.Mock).mockResolvedValue({ + email: 'maria@example.com', + name: 'Maria Silva', + }); + (prisma.passwordResetToken.create as jest.Mock).mockResolvedValue({ + id: 'token-1', + }); + (sendResetPasswordEmail as jest.Mock).mockResolvedValue(undefined); + + const response = await POST_FORGOT_PASSWORD( + createRequest({ + email: 'maria@example.com', + }) + ); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.success).toBe(true); + expect(prisma.passwordResetToken.create).toHaveBeenCalledWith({ + data: { + email: 'maria@example.com', + token: expect.any(String), + expiresAt: expect.any(Date), + }, + }); + expect(sendResetPasswordEmail).toHaveBeenCalledWith({ + email: 'maria@example.com', + name: 'Maria Silva', + token: expect.any(String), + expiresInMinutes: 30, + }); + }); +}); + +describe('GET /api/auth/validate-reset-token', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('deve retornar 400 quando token não for informado', async () => { + const response = await GET_VALIDATE_RESET_TOKEN( + createUrlRequest('http://localhost/api/auth/validate-reset-token') + ); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body.success).toBe(false); + expect(prisma.passwordResetToken.findFirst).not.toHaveBeenCalled(); + }); + + it('deve aceitar token existente e não expirado', async () => { + (prisma.passwordResetToken.findFirst as jest.Mock).mockResolvedValue({ + id: 'token-1', + email: 'maria@example.com', + expiresAt: new Date(Date.now() + 1000 * 60 * 30), + }); + + const response = await GET_VALIDATE_RESET_TOKEN( + createUrlRequest( + 'http://localhost/api/auth/validate-reset-token?token=valid-token' + ) + ); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.success).toBe(true); + expect(prisma.passwordResetToken.findFirst).toHaveBeenCalledWith({ + where: { + token: expect.any(String), + }, + }); + }); +}); + +describe('POST /api/auth/reset-password', () => { + beforeEach(() => { + jest.clearAllMocks(); + (hash as jest.Mock).mockResolvedValue('new-hashed-password'); + }); + + it('deve retornar 400 para token inválido', async () => { + (prisma.passwordResetToken.findFirst as jest.Mock).mockResolvedValue(null); + + const response = await POST_RESET_PASSWORD( + createRequest({ + token: 'invalid-token', + password: 'secret123', + }) + ); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body.success).toBe(false); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it('deve atualizar senha e remover token usado', async () => { + (prisma.passwordResetToken.findFirst as jest.Mock).mockResolvedValue({ + id: 'token-1', + email: 'maria@example.com', + expiresAt: new Date(Date.now() + 1000 * 60 * 30), + }); + (prisma.user.findUnique as jest.Mock).mockResolvedValue({ + id: 'user-1', + email: 'maria@example.com', + }); + (prisma.user.update as jest.Mock).mockResolvedValue({ + id: 'user-1', + password: 'new-hashed-password', + }); + (prisma.passwordResetToken.delete as jest.Mock).mockResolvedValue({ + id: 'token-1', + }); + (prisma.$transaction as jest.Mock).mockResolvedValue([]); + + const response = await POST_RESET_PASSWORD( + createRequest({ + token: 'valid-token', + password: 'secret123', + }) + ); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.success).toBe(true); + expect(hash).toHaveBeenCalledWith('secret123', 10); + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: 'user-1' }, + data: { password: 'new-hashed-password' }, + }); + expect(prisma.passwordResetToken.delete).toHaveBeenCalledWith({ + where: { id: 'token-1' }, + }); + expect(prisma.$transaction).toHaveBeenCalledWith([ + expect.any(Promise), + expect.any(Promise), + ]); + }); +}); diff --git a/src/tests/unit/api/dashboard/dashboard-feedbacks-route.test.ts b/src/tests/unit/api/dashboard/dashboard-feedbacks-route.test.ts new file mode 100644 index 00000000..1775d519 --- /dev/null +++ b/src/tests/unit/api/dashboard/dashboard-feedbacks-route.test.ts @@ -0,0 +1,112 @@ +/** + * @jest-environment node + */ + +import { GET } from '@/app/api/dashboard/feedbacks/route'; +import { checkAuth } from '@/lib/check-auth'; +import { logger } from '@/lib/logger'; +import { prisma } from '@/lib/prisma'; +import { ROLE_GROUPS } from '@/lib/roles'; + +jest.mock('@/lib/prisma', () => ({ + prisma: { + feedback: { + count: jest.fn(), + }, + }, +})); + +jest.mock('@/lib/check-auth', () => ({ + checkAuth: jest.fn(), +})); + +jest.mock('@/lib/logger', () => ({ + logger: { + error: jest.fn(), + }, +})); + +describe('GET /api/dashboard/feedbacks', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('deve retornar a quantidade de feedbacks recebidos pelo usuário autenticado', async () => { + (checkAuth as jest.Mock).mockResolvedValue({ + authorized: true, + session: { + user: { + id: 'user-1', + role: 'USER', + }, + }, + }); + + (prisma.feedback.count as jest.Mock).mockResolvedValue(5); + + const response = (await GET()) as Response; + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body).toEqual({ + feedbacks: 5, + }); + + expect(checkAuth).toHaveBeenCalledWith({ + allowedRoles: ROLE_GROUPS.ALL, + }); + + expect(prisma.feedback.count).toHaveBeenCalledWith({ + where: { + toUserId: 'user-1', + }, + }); + }); + + it('deve bloquear usuário não autenticado', async () => { + const authResponse = Response.json( + { error: 'Não autenticado' }, + { status: 401 } + ); + + (checkAuth as jest.Mock).mockResolvedValue({ + authorized: false, + response: authResponse, + }); + + const response = (await GET()) as Response; + + expect(response.status).toBe(401); + expect(prisma.feedback.count).not.toHaveBeenCalled(); + }); + + it('deve retornar 500 quando ocorrer erro ao contar feedbacks', async () => { + (checkAuth as jest.Mock).mockResolvedValue({ + authorized: true, + session: { + user: { + id: 'user-1', + role: 'USER', + }, + }, + }); + + (prisma.feedback.count as jest.Mock).mockRejectedValue( + new Error('Database error') + ); + + const response = (await GET()) as Response; + const body = await response.json(); + + expect(response.status).toBe(500); + expect(body.success).toBe(false); + + expect(logger.error).toHaveBeenCalledWith( + 'Erro ao buscar quantidade de feedbacks do dashboard:', + 'GET /api/dashboard/feedbacks', + { + error: 'Database error', + } + ); + }); +}); diff --git a/src/tests/unit/api/dashboard/dashboard-projects-route.test.ts b/src/tests/unit/api/dashboard/dashboard-projects-route.test.ts new file mode 100644 index 00000000..57456a2c --- /dev/null +++ b/src/tests/unit/api/dashboard/dashboard-projects-route.test.ts @@ -0,0 +1,134 @@ +/** + * @jest-environment node + */ + +import { ProjectStatus } from '@prisma/client'; + +import { GET } from '@/app/api/dashboard/projects/route'; +import { checkAuth } from '@/lib/check-auth'; +import { logger } from '@/lib/logger'; +import { prisma } from '@/lib/prisma'; +import { ROLE_GROUPS } from '@/lib/roles'; + +jest.mock('@/lib/prisma', () => ({ + prisma: { + project: { + findMany: jest.fn(), + }, + }, +})); + +jest.mock('@/lib/check-auth', () => ({ + checkAuth: jest.fn(), +})); + +jest.mock('@/lib/logger', () => ({ + logger: { + error: jest.fn(), + }, +})); + +describe('GET /api/dashboard/projects', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('deve retornar projetos em andamento e concluídos vinculados ao usuário autenticado', async () => { + (checkAuth as jest.Mock).mockResolvedValue({ + authorized: true, + session: { + user: { + id: 'user-1', + role: 'USER', + }, + }, + }); + + (prisma.project.findMany as jest.Mock).mockResolvedValue([ + { status: ProjectStatus.EM_ANDAMENTO }, + { status: ProjectStatus.EM_ANDAMENTO }, + { status: ProjectStatus.CONCLUIDO }, + { status: ProjectStatus.BUSCANDO }, + ]); + + const response = (await GET()) as Response; + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body).toEqual({ + projectsInProgress: 2, + projectsCompleted: 1, + }); + + expect(checkAuth).toHaveBeenCalledWith({ + allowedRoles: ROLE_GROUPS.ALL, + }); + + expect(prisma.project.findMany).toHaveBeenCalledWith({ + where: { + OR: [ + { + ownerId: 'user-1', + }, + { + stacksTaken: { + some: { + userId: 'user-1', + }, + }, + }, + ], + }, + select: { + status: true, + }, + }); + }); + + it('deve bloquear usuário não autenticado', async () => { + const authResponse = Response.json( + { error: 'Não autenticado' }, + { status: 401 } + ); + + (checkAuth as jest.Mock).mockResolvedValue({ + authorized: false, + response: authResponse, + }); + + const response = (await GET()) as Response; + + expect(response.status).toBe(401); + expect(prisma.project.findMany).not.toHaveBeenCalled(); + }); + + it('deve retornar 500 quando ocorrer erro ao buscar projetos', async () => { + (checkAuth as jest.Mock).mockResolvedValue({ + authorized: true, + session: { + user: { + id: 'user-1', + role: 'USER', + }, + }, + }); + + (prisma.project.findMany as jest.Mock).mockRejectedValue( + new Error('Database error') + ); + + const response = (await GET()) as Response; + const body = await response.json(); + + expect(response.status).toBe(500); + expect(body.success).toBe(false); + + expect(logger.error).toHaveBeenCalledWith( + 'Erro ao buscar quantidade de projetos do dashboard:', + 'GET /api/dashboard/projects', + { + error: 'Database error', + } + ); + }); +}); diff --git a/src/tests/unit/api/dashboard/dashboard-skills-route.test.ts b/src/tests/unit/api/dashboard/dashboard-skills-route.test.ts new file mode 100644 index 00000000..c08a9d6f --- /dev/null +++ b/src/tests/unit/api/dashboard/dashboard-skills-route.test.ts @@ -0,0 +1,112 @@ +/** + * @jest-environment node + */ + +import { GET } from '@/app/api/dashboard/skills/route'; +import { checkAuth } from '@/lib/check-auth'; +import { logger } from '@/lib/logger'; +import { prisma } from '@/lib/prisma'; +import { ROLE_GROUPS } from '@/lib/roles'; + +jest.mock('@/lib/prisma', () => ({ + prisma: { + userSkill: { + count: jest.fn(), + }, + }, +})); + +jest.mock('@/lib/check-auth', () => ({ + checkAuth: jest.fn(), +})); + +jest.mock('@/lib/logger', () => ({ + logger: { + error: jest.fn(), + }, +})); + +describe('GET /api/dashboard/skills', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('deve retornar a quantidade de skills do usuário autenticado', async () => { + (checkAuth as jest.Mock).mockResolvedValue({ + authorized: true, + session: { + user: { + id: 'user-1', + role: 'USER', + }, + }, + }); + + (prisma.userSkill.count as jest.Mock).mockResolvedValue(3); + + const response = (await GET()) as Response; + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body).toEqual({ + skills: 3, + }); + + expect(checkAuth).toHaveBeenCalledWith({ + allowedRoles: ROLE_GROUPS.ALL, + }); + + expect(prisma.userSkill.count).toHaveBeenCalledWith({ + where: { + userId: 'user-1', + }, + }); + }); + + it('deve bloquear usuário não autenticado', async () => { + const authResponse = Response.json( + { error: 'Não autenticado' }, + { status: 401 } + ); + + (checkAuth as jest.Mock).mockResolvedValue({ + authorized: false, + response: authResponse, + }); + + const response = (await GET()) as Response; + + expect(response.status).toBe(401); + expect(prisma.userSkill.count).not.toHaveBeenCalled(); + }); + + it('deve retornar 500 quando ocorrer erro ao contar skills', async () => { + (checkAuth as jest.Mock).mockResolvedValue({ + authorized: true, + session: { + user: { + id: 'user-1', + role: 'USER', + }, + }, + }); + + (prisma.userSkill.count as jest.Mock).mockRejectedValue( + new Error('Database error') + ); + + const response = (await GET()) as Response; + const body = await response.json(); + + expect(response.status).toBe(500); + expect(body.success).toBe(false); + + expect(logger.error).toHaveBeenCalledWith( + 'Erro ao buscar quantidade de skills do dashboard:', + 'GET /api/dashboard/skills', + { + error: 'Database error', + } + ); + }); +}); diff --git a/src/tests/unit/api/dashboard/dashboard-summary-route.test.ts b/src/tests/unit/api/dashboard/dashboard-summary-route.test.ts new file mode 100644 index 00000000..d8a90f1e --- /dev/null +++ b/src/tests/unit/api/dashboard/dashboard-summary-route.test.ts @@ -0,0 +1,165 @@ +/** + * @jest-environment node + */ + +import { ProjectStatus } from '@prisma/client'; + +import { GET } from '@/app/api/dashboard/summary/route'; +import { checkAuth } from '@/lib/check-auth'; +import { logger } from '@/lib/logger'; +import { prisma } from '@/lib/prisma'; +import { ROLE_GROUPS } from '@/lib/roles'; + +jest.mock('@/lib/prisma', () => ({ + prisma: { + feedback: { + count: jest.fn(), + }, + project: { + findMany: jest.fn(), + }, + userSkill: { + count: jest.fn(), + }, + }, +})); + +jest.mock('@/lib/check-auth', () => ({ + checkAuth: jest.fn(), +})); + +jest.mock('@/lib/logger', () => ({ + logger: { + error: jest.fn(), + }, +})); + +describe('GET /api/dashboard/summary', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('deve retornar resumo do dashboard para o usuário autenticado', async () => { + (checkAuth as jest.Mock).mockResolvedValue({ + authorized: true, + session: { + user: { + id: 'user-1', + role: 'USER', + }, + }, + }); + + (prisma.project.findMany as jest.Mock).mockResolvedValue([ + { status: ProjectStatus.EM_ANDAMENTO }, + { status: ProjectStatus.CONCLUIDO }, + { status: ProjectStatus.CONCLUIDO }, + { status: ProjectStatus.BUSCANDO }, + ]); + (prisma.userSkill.count as jest.Mock).mockResolvedValue(4); + (prisma.feedback.count as jest.Mock).mockResolvedValue(6); + + const response = (await GET()) as Response; + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body).toEqual({ + success: true, + message: 'Request realizada com sucesso', + data: { + projectsInProgress: 1, + projectsCompleted: 2, + skills: 4, + feedbacks: 6, + }, + errors: null, + }); + + expect(checkAuth).toHaveBeenCalledWith({ + allowedRoles: ROLE_GROUPS.ALL, + }); + + expect(prisma.project.findMany).toHaveBeenCalledWith({ + where: { + OR: [ + { + ownerId: 'user-1', + }, + { + stacksTaken: { + some: { + userId: 'user-1', + }, + }, + }, + ], + }, + select: { + status: true, + }, + }); + + expect(prisma.userSkill.count).toHaveBeenCalledWith({ + where: { + userId: 'user-1', + }, + }); + + expect(prisma.feedback.count).toHaveBeenCalledWith({ + where: { + toUserId: 'user-1', + }, + }); + }); + + it('deve bloquear usuário não autenticado', async () => { + const authResponse = Response.json( + { error: 'Não autenticado' }, + { status: 401 } + ); + + (checkAuth as jest.Mock).mockResolvedValue({ + authorized: false, + response: authResponse, + }); + + const response = (await GET()) as Response; + + expect(response.status).toBe(401); + expect(prisma.project.findMany).not.toHaveBeenCalled(); + expect(prisma.userSkill.count).not.toHaveBeenCalled(); + expect(prisma.feedback.count).not.toHaveBeenCalled(); + }); + + it('deve retornar 500 quando ocorrer erro ao buscar o resumo', async () => { + (checkAuth as jest.Mock).mockResolvedValue({ + authorized: true, + session: { + user: { + id: 'user-1', + role: 'USER', + }, + }, + }); + + (prisma.project.findMany as jest.Mock).mockRejectedValue( + new Error('Database error') + ); + (prisma.userSkill.count as jest.Mock).mockResolvedValue(4); + (prisma.feedback.count as jest.Mock).mockResolvedValue(6); + + const response = (await GET()) as Response; + const body = await response.json(); + + expect(response.status).toBe(500); + expect(body.success).toBe(false); + + expect(logger.error).toHaveBeenCalledWith( + 'Erro ao buscar resumo do dashboard:', + 'GET /api/dashboard/summary', + { + error: 'Database error', + } + ); + }); +}); diff --git a/src/tests/unit/api/feedback/feedback-route.test.ts b/src/tests/unit/api/feedback/feedback-route.test.ts new file mode 100644 index 00000000..032576b8 --- /dev/null +++ b/src/tests/unit/api/feedback/feedback-route.test.ts @@ -0,0 +1,231 @@ +/** + * @jest-environment node + */ + +import { POST } from '@/app/api/feedback/route'; +import { MESSAGES } from '@/constants/messages'; +import { checkAuth } from '@/lib/check-auth'; +import { logger } from '@/lib/logger'; +import { prisma } from '@/lib/prisma'; +import { ROLE_GROUPS } from '@/lib/roles'; +import { NextRequest } from 'next/server'; + +jest.mock('@/lib/prisma', () => ({ + prisma: { + feedback: { + create: jest.fn(), + findFirst: jest.fn(), + }, + project: { + findUnique: jest.fn(), + }, + stackTaken: { + findFirst: jest.fn(), + }, + }, +})); + +jest.mock('@/lib/check-auth', () => ({ + checkAuth: jest.fn(), +})); + +jest.mock('@/lib/logger', () => ({ + logger: { + error: jest.fn(), + }, +})); + +const validPayload = { + projectId: 'project-1', + toUserId: 'user-2', + rating: 5, + comment: 'Ótima colaboração.', + anonymous: false, + stackTakenId: 'stack-taken-1', +}; + +function createRequest(payload: unknown) { + return { + json: async () => payload, + } as unknown as NextRequest; +} + +function authorizeUser(userId = 'user-1') { + (checkAuth as jest.Mock).mockResolvedValue({ + authorized: true, + session: { + user: { + id: userId, + role: 'USER', + }, + }, + }); +} + +describe('POST /api/feedback', () => { + beforeEach(() => { + jest.clearAllMocks(); + authorizeUser(); + }); + + it('deve impedir autoavaliação', async () => { + const response = (await POST( + createRequest({ + ...validPayload, + toUserId: 'user-1', + }) + )) as Response; + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body).toEqual({ + success: false, + message: MESSAGES.FEEDBACK.SELF_FEEDBACK, + data: null, + errors: null, + }); + + expect(checkAuth).toHaveBeenCalledWith({ + allowedRoles: ROLE_GROUPS.ALL, + }); + expect(prisma.project.findUnique).not.toHaveBeenCalled(); + expect(prisma.feedback.create).not.toHaveBeenCalled(); + }); + + it('deve impedir envio quando o avaliador não participou do projeto', async () => { + (prisma.project.findUnique as jest.Mock).mockResolvedValue({ + id: 'project-1', + }); + (prisma.stackTaken.findFirst as jest.Mock).mockResolvedValueOnce(null); + + const response = (await POST(createRequest(validPayload))) as Response; + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body).toEqual({ + success: false, + message: MESSAGES.FEEDBACK.NO_PARTICIPATION, + data: null, + errors: null, + }); + + expect(prisma.stackTaken.findFirst).toHaveBeenCalledWith({ + where: { + projectId: 'project-1', + userId: 'user-1', + }, + }); + expect(prisma.feedback.create).not.toHaveBeenCalled(); + }); + + it('deve impedir envio quando o usuário avaliado não participou do projeto', async () => { + (prisma.project.findUnique as jest.Mock).mockResolvedValue({ + id: 'project-1', + }); + (prisma.stackTaken.findFirst as jest.Mock) + .mockResolvedValueOnce({ + id: 'from-participation', + }) + .mockResolvedValueOnce(null); + + const response = (await POST(createRequest(validPayload))) as Response; + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body).toEqual({ + success: false, + message: MESSAGES.FEEDBACK.NO_PARTICIPATION, + data: null, + errors: null, + }); + + expect(prisma.stackTaken.findFirst).toHaveBeenLastCalledWith({ + where: { + projectId: 'project-1', + userId: 'user-2', + }, + }); + expect(prisma.feedback.create).not.toHaveBeenCalled(); + }); + + it('deve impedir envio duplicado de feedback no mesmo projeto', async () => { + (prisma.project.findUnique as jest.Mock).mockResolvedValue({ + id: 'project-1', + }); + (prisma.stackTaken.findFirst as jest.Mock) + .mockResolvedValueOnce({ + id: 'from-participation', + }) + .mockResolvedValueOnce({ + id: 'to-participation', + }); + (prisma.feedback.findFirst as jest.Mock).mockResolvedValue({ + id: 'feedback-1', + }); + + const response = (await POST(createRequest(validPayload))) as Response; + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body).toEqual({ + success: false, + message: MESSAGES.FEEDBACK.ALREADY_GIVEN, + data: null, + errors: null, + }); + + expect(prisma.feedback.findFirst).toHaveBeenCalledWith({ + where: { + projectId: 'project-1', + fromUserId: 'user-1', + toUserId: 'user-2', + }, + }); + expect(prisma.feedback.create).not.toHaveBeenCalled(); + }); + + it('deve permitir envio quando todas as regras forem respeitadas', async () => { + const createdFeedback = { + id: 'feedback-1', + projectId: 'project-1', + fromUserId: 'user-1', + toUserId: 'user-2', + rating: 5, + comment: 'Ótima colaboração.', + anonymous: false, + stackTakenId: 'stack-taken-1', + }; + + (prisma.project.findUnique as jest.Mock).mockResolvedValue({ + id: 'project-1', + }); + (prisma.stackTaken.findFirst as jest.Mock) + .mockResolvedValueOnce({ + id: 'from-participation', + }) + .mockResolvedValueOnce({ + id: 'to-participation', + }); + (prisma.feedback.findFirst as jest.Mock).mockResolvedValue(null); + (prisma.feedback.create as jest.Mock).mockResolvedValue(createdFeedback); + + const response = (await POST(createRequest(validPayload))) as Response; + const body = await response.json(); + + expect(response.status).toBe(201); + expect(body).toEqual(createdFeedback); + + expect(prisma.feedback.create).toHaveBeenCalledWith({ + data: { + projectId: 'project-1', + fromUserId: 'user-1', + toUserId: 'user-2', + rating: 5, + comment: 'Ótima colaboração.', + anonymous: false, + stackTakenId: 'stack-taken-1', + }, + }); + expect(logger.error).not.toHaveBeenCalled(); + }); +}); diff --git a/src/tests/unit/api/team-project/team-project-id-route.test.ts b/src/tests/unit/api/team-project/team-project-id-route.test.ts new file mode 100644 index 00000000..ca47e4de --- /dev/null +++ b/src/tests/unit/api/team-project/team-project-id-route.test.ts @@ -0,0 +1,212 @@ +/** + * @jest-environment node + */ + +import { PUT } from '@/app/api/team-project/[id]/route'; +import { MESSAGES } from '@/constants/messages'; +import { checkAuth } from '@/lib/check-auth'; +import { checkProjectStatus } from '@/lib/check-project-status'; +import { prisma } from '@/lib/prisma'; +import { ProjectStatus } from '@prisma/client'; +import { NextRequest } from 'next/server'; + +jest.mock('@/lib/prisma', () => ({ + prisma: { + $transaction: jest.fn(), + project: { + findUnique: jest.fn(), + update: jest.fn(), + }, + projectStack: { + create: jest.fn(), + delete: jest.fn(), + findMany: jest.fn(), + update: jest.fn(), + }, + stackTaken: { + deleteMany: jest.fn(), + }, + }, +})); + +jest.mock('@/lib/check-auth', () => ({ + checkAuth: jest.fn(), +})); + +jest.mock('@/lib/check-project-status', () => ({ + checkProjectStatus: jest.fn(), +})); + +jest.mock('@/lib/logger', () => ({ + logger: { + error: jest.fn(), + }, +})); + +const projectId = 'project-123456789012345678'; +const ownerId = 'owner-1'; + +const validPayload = { + name: 'Projeto TryCatch', + description: 'Descrição atualizada do projeto.', + deadline: '2026-12-31T23:59:59.000Z', + totalValue: 10000, + status: ProjectStatus.EM_ANDAMENTO, + skills: ['skill-1', 'skill-2'], + github: 'https://github.com/TryCatch-ForMatch/trycatch', + stacks: [ + { stackId: 'stack-1', percentage: 60 }, + { stackId: 'stack-2', percentage: 40 }, + ], +}; + +const existingProject = (overrides = {}) => ({ + id: projectId, + ownerId, + name: validPayload.name, + description: 'Descrição original do projeto.', + deadline: new Date(validPayload.deadline), + totalValue: validPayload.totalValue, + status: ProjectStatus.EM_ANDAMENTO, + skills: [{ skillId: 'skill-1' }, { skillId: 'skill-2' }], + stacks: [ + { stackId: 'stack-1', percentage: 60 }, + { stackId: 'stack-2', percentage: 40 }, + ], + stacksTaken: [], + ...overrides, +}); + +const createRequest = (body: unknown) => + ({ + json: async () => body, + }) as NextRequest; + +const createContext = () => ({ + params: { id: projectId }, +}); + +const authorizeOwner = () => { + (checkAuth as jest.Mock).mockResolvedValue({ + authorized: true, + session: { + user: { id: ownerId, role: 'USER' }, + }, + }); +}; + +describe('PUT /api/team-project/[id]', () => { + beforeEach(() => { + jest.clearAllMocks(); + authorizeOwner(); + (prisma.project.findUnique as jest.Mock).mockResolvedValue( + existingProject() + ); + (prisma.projectStack.findMany as jest.Mock).mockResolvedValue([ + { id: 'project-stack-1', stackId: 'stack-1', percentage: 60 }, + { id: 'project-stack-2', stackId: 'stack-2', percentage: 40 }, + ]); + (prisma.project.update as jest.Mock).mockResolvedValue({ + id: projectId, + ...validPayload, + }); + (prisma.$transaction as jest.Mock).mockImplementation(async (callback) => + callback(prisma) + ); + (checkProjectStatus as jest.Mock).mockResolvedValue(null); + }); + + it('permite atualizar descrição quando existe StackTaken sem mudança estrutural', async () => { + (prisma.project.findUnique as jest.Mock).mockResolvedValue( + existingProject({ + stacksTaken: [{ id: 'stack-taken-1' }], + }) + ); + + const response = await PUT(createRequest(validPayload), createContext()); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.id).toBe(projectId); + expect(prisma.$transaction).toHaveBeenCalledWith(expect.any(Function)); + expect(prisma.project.update).toHaveBeenCalledWith({ + where: { id: projectId }, + data: { + name: validPayload.name, + description: validPayload.description, + deadline: new Date(validPayload.deadline), + totalValue: validPayload.totalValue, + status: validPayload.status, + github: validPayload.github, + skills: { + deleteMany: {}, + create: validPayload.skills.map((skillId) => ({ + skill: { connect: { id: skillId } }, + })), + }, + }, + }); + }); + + it('bloqueia alteração de name após formação da equipe', async () => { + (prisma.project.findUnique as jest.Mock).mockResolvedValue( + existingProject({ + stacksTaken: [{ id: 'stack-taken-1' }], + }) + ); + + const response = await PUT( + createRequest({ ...validPayload, name: 'Novo nome' }), + createContext() + ); + const body = await response.json(); + + expect(response.status).toBe(403); + expect(body).toMatchObject({ + success: false, + message: MESSAGES.AUTH.UNAUTHORIZED, + }); + expect(body.errors).toContain( + 'Não é permitido alterar estrutura do projeto após formação da equipe.' + ); + expect(prisma.project.update).not.toHaveBeenCalled(); + }); + + it('bloqueia alteração de skills após formação da equipe', async () => { + (prisma.project.findUnique as jest.Mock).mockResolvedValue( + existingProject({ + stacksTaken: [{ id: 'stack-taken-1' }], + }) + ); + + const response = await PUT( + createRequest({ ...validPayload, skills: ['skill-1', 'skill-3'] }), + createContext() + ); + + expect(response.status).toBe(403); + expect(prisma.project.update).not.toHaveBeenCalled(); + }); + + it('bloqueia alteração de stacks após formação da equipe', async () => { + (prisma.project.findUnique as jest.Mock).mockResolvedValue( + existingProject({ + stacksTaken: [{ id: 'stack-taken-1' }], + }) + ); + + const response = await PUT( + createRequest({ + ...validPayload, + stacks: [ + { stackId: 'stack-1', percentage: 50 }, + { stackId: 'stack-2', percentage: 50 }, + ], + }), + createContext() + ); + + expect(response.status).toBe(403); + expect(prisma.project.update).not.toHaveBeenCalled(); + }); +}); diff --git a/src/tests/unit/api/team-project/team-project-route.test.ts b/src/tests/unit/api/team-project/team-project-route.test.ts new file mode 100644 index 00000000..ff3c4f09 --- /dev/null +++ b/src/tests/unit/api/team-project/team-project-route.test.ts @@ -0,0 +1,238 @@ +/** + * @jest-environment node + */ + +import { ProjectStatus } from '@prisma/client'; +import { NextRequest } from 'next/server'; + +import { POST } from '@/app/api/team-project/route'; +import { MESSAGES } from '@/constants/messages'; +import { checkAuth } from '@/lib/check-auth'; +import { prisma } from '@/lib/prisma'; +import { ROLE_GROUPS } from '@/lib/roles'; + +jest.mock('@/lib/prisma', () => ({ + prisma: { + project: { + create: jest.fn(), + }, + }, +})); + +jest.mock('@/lib/check-auth', () => ({ + checkAuth: jest.fn(), +})); + +jest.mock('@/lib/check-project-status', () => ({ + checkProjectStatus: jest.fn(), +})); + +const validPayload = { + name: 'Projeto TryCatch', + description: 'Projeto para validar regras do domínio Project.', + deadline: '2026-12-31T23:59:59.000Z', + totalValue: 10000, + status: ProjectStatus.BUSCANDO, + skills: ['skill-1', 'skill-2'], + stacks: [ + { + stackId: 'stack-1', + percentage: 60, + }, + { + stackId: 'stack-2', + percentage: 40, + }, + ], + github: 'https://github.com/TryCatch-ForMatch/trycatch', +}; + +function createRequest(payload: unknown) { + return { + json: async () => payload, + } as unknown as NextRequest; +} + +function authorizeUser() { + (checkAuth as jest.Mock).mockResolvedValue({ + authorized: true, + session: { + user: { + id: 'user-1', + role: 'USER', + }, + }, + }); +} + +describe('POST /api/team-project', () => { + beforeEach(() => { + jest.clearAllMocks(); + authorizeUser(); + }); + + it('deve criar projeto quando os dados forem válidos', async () => { + const createdProject = { + id: 'project-1', + ownerId: 'user-1', + name: validPayload.name, + description: validPayload.description, + deadline: new Date(validPayload.deadline), + totalValue: validPayload.totalValue, + status: validPayload.status, + github: validPayload.github, + }; + + (prisma.project.create as jest.Mock).mockResolvedValue(createdProject); + + const response = (await POST(createRequest(validPayload))) as Response; + const body = await response.json(); + + expect(response.status).toBe(201); + expect(body).toEqual({ + success: true, + message: MESSAGES.PROJECT.CREATED, + data: { + ...createdProject, + deadline: validPayload.deadline, + }, + errors: null, + }); + + expect(checkAuth).toHaveBeenCalledWith({ + allowedRoles: ROLE_GROUPS.ALL, + }); + expect(prisma.project.create).toHaveBeenCalledWith({ + data: { + ownerId: 'user-1', + name: validPayload.name, + description: validPayload.description, + deadline: new Date(validPayload.deadline), + totalValue: validPayload.totalValue, + status: validPayload.status, + github: validPayload.github, + skills: { + create: [ + { + skill: { + connect: { + id: 'skill-1', + }, + }, + }, + { + skill: { + connect: { + id: 'skill-2', + }, + }, + }, + ], + }, + stacks: { + create: [ + { + stack: { + connect: { + id: 'stack-1', + }, + }, + percentage: 60, + }, + { + stack: { + connect: { + id: 'stack-2', + }, + }, + percentage: 40, + }, + ], + }, + }, + }); + }); + + it('deve converter github vazio para null ao criar projeto', async () => { + const payload = { + ...validPayload, + github: '', + }; + const createdProject = { + id: 'project-1', + ownerId: 'user-1', + github: null, + }; + + (prisma.project.create as jest.Mock).mockResolvedValue(createdProject); + + const response = (await POST(createRequest(payload))) as Response; + + expect(response.status).toBe(201); + expect(prisma.project.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + github: null, + }), + }) + ); + }); + + it('deve bloquear criação com payload inválido', async () => { + const response = (await POST( + createRequest({ + ...validPayload, + name: '', + deadline: 'data-invalida', + }) + )) as Response; + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body.success).toBe(false); + expect(body.message).toBe(MESSAGES.GENERAL.INVALID_DATA); + expect(body.errors.name._errors).toContain('O nome é obrigatório'); + expect(body.errors.deadline._errors).toContain('Data inválida'); + expect(prisma.project.create).not.toHaveBeenCalled(); + }); + + it('deve bloquear stack com percentual fora do intervalo permitido', async () => { + const response = (await POST( + createRequest({ + ...validPayload, + stacks: [ + { + stackId: 'stack-1', + percentage: 101, + }, + ], + }) + )) as Response; + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body.success).toBe(false); + expect(body.message).toBe(MESSAGES.GENERAL.INVALID_DATA); + expect(body.errors.stacks[0].percentage._errors).toContain( + 'Too big: expected number to be <=100' + ); + expect(prisma.project.create).not.toHaveBeenCalled(); + }); + + it('deve bloquear usuário não autenticado', async () => { + const authResponse = Response.json( + { error: 'Não autenticado' }, + { status: 401 } + ); + + (checkAuth as jest.Mock).mockResolvedValue({ + authorized: false, + response: authResponse, + }); + + const response = (await POST(createRequest(validPayload))) as Response; + + expect(response.status).toBe(401); + expect(prisma.project.create).not.toHaveBeenCalled(); + }); +}); diff --git a/src/tests/unit/api/team-project/team-project-user-route.test.ts b/src/tests/unit/api/team-project/team-project-user-route.test.ts new file mode 100644 index 00000000..6008aa3a --- /dev/null +++ b/src/tests/unit/api/team-project/team-project-user-route.test.ts @@ -0,0 +1,251 @@ +/** + * @jest-environment node + */ + +import { GET, PATCH } from '@/app/api/team-project/user/route'; +import { MESSAGES } from '@/constants/messages'; +import { checkAuth } from '@/lib/check-auth'; +import { logger } from '@/lib/logger'; +import { checkProjectStatus } from '@/lib/check-project-status'; +import { prisma } from '@/lib/prisma'; +import { ProjectStatus } from '@prisma/client'; +import { NextRequest } from 'next/server'; + +jest.mock('@/lib/prisma', () => ({ + prisma: { + $transaction: jest.fn(), + project: { + findMany: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + }, + projectStack: { + count: jest.fn(), + }, + stackTaken: { + count: jest.fn(), + findMany: jest.fn(), + }, + }, +})); + +jest.mock('@/lib/check-auth', () => ({ + checkAuth: jest.fn(), +})); + +jest.mock('@/lib/check-project-status', () => ({ + checkProjectStatus: jest.fn(), +})); + +const projectId = 'project-123456789012345678'; +const ownerId = 'owner-1'; + +const createRequest = (body: unknown) => + ({ + json: async () => body, + }) as NextRequest; + +jest.mock('@/lib/logger', () => ({ + logger: { + error: jest.fn(), + }, +})); + +const authorizeUser = (id = ownerId, role = 'USER') => { + (checkAuth as jest.Mock).mockResolvedValue({ + authorized: true, + session: { + user: { id, role }, + }, + }); +}; + +const mockProject = (overrides = {}) => ({ + id: projectId, + ownerId, + status: ProjectStatus.EM_ANDAMENTO, + ...overrides, +}); + +describe('GET /api/team-project/user', () => { + beforeEach(() => { + jest.clearAllMocks(); + authorizeUser(); + }); + + it('deve registrar erro estruturado quando a busca falhar', async () => { + (prisma.project.findMany as jest.Mock).mockRejectedValue( + new Error('Database error') + ); + + const response = await GET(); + const body = await response.json(); + + expect(response.status).toBe(500); + expect(body.success).toBe(false); + expect(logger.error).toHaveBeenCalledWith( + 'Erro ao buscar projetos do usuário', + 'GET /api/team-project/user', + { error: 'Database error' } + ); + }); +}); + +describe('PATCH /api/team-project/user', () => { + beforeEach(() => { + jest.clearAllMocks(); + authorizeUser(); + + (prisma.project.findUnique as jest.Mock).mockResolvedValue(mockProject()); + (prisma.projectStack as jest.Mock).count.mockResolvedValue(2); + (prisma.stackTaken.count as jest.Mock).mockResolvedValue(2); + + (prisma.project.update as jest.Mock).mockResolvedValue( + mockProject({ status: ProjectStatus.CONCLUIDO }) + ); + + (prisma.$transaction as jest.Mock).mockImplementation(async (callback) => + callback(prisma) + ); + + (checkProjectStatus as jest.Mock).mockResolvedValue(null); + }); + + it('conclui projeto quando owner, status e stacks são elegíveis', async () => { + const response = await PATCH( + createRequest({ + id: projectId, + status: ProjectStatus.CONCLUIDO, + }) + ); + + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.status).toBe(ProjectStatus.CONCLUIDO); + + expect(prisma.$transaction).toHaveBeenCalledWith(expect.any(Function)); + + expect(prisma.project.update).toHaveBeenCalledWith({ + where: { id: projectId }, + data: { status: ProjectStatus.CONCLUIDO }, + }); + + expect(checkProjectStatus).toHaveBeenCalledWith(projectId); + }); + + it('bloqueia conclusão quando usuário não é owner', async () => { + authorizeUser('user-2'); + + const response = await PATCH( + createRequest({ + id: projectId, + status: ProjectStatus.CONCLUIDO, + }) + ); + + const body = await response.json(); + + expect(response.status).toBe(403); + + expect(body).toMatchObject({ + success: false, + message: MESSAGES.AUTH.UNAUTHORIZED, + }); + + expect(prisma.project.update).not.toHaveBeenCalled(); + }); + + it('bloqueia status diferente de CONCLUIDO', async () => { + const response = await PATCH( + createRequest({ + id: projectId, + status: ProjectStatus.BUSCANDO, + }) + ); + + const body = await response.json(); + + expect(response.status).toBe(400); + + expect(body).toMatchObject({ + success: false, + message: MESSAGES.GENERAL.INVALID_DATA, + }); + + expect(prisma.project.update).not.toHaveBeenCalled(); + }); + + it('bloqueia conclusão quando projeto não está EM_ANDAMENTO', async () => { + (prisma.project.findUnique as jest.Mock).mockResolvedValue( + mockProject({ + status: ProjectStatus.BUSCANDO, + }) + ); + + const response = await PATCH( + createRequest({ + id: projectId, + status: ProjectStatus.CONCLUIDO, + }) + ); + + const body = await response.json(); + + expect(response.status).toBe(400); + + expect(body.errors).toContain( + 'Projeto só pode ser concluído em status EM_ANDAMENTO.' + ); + + expect(prisma.project.update).not.toHaveBeenCalled(); + }); + + it('bloqueia conclusão quando existem stacks não assumidas', async () => { + (prisma.projectStack.count as jest.Mock).mockResolvedValue(3); + (prisma.stackTaken.count as jest.Mock).mockResolvedValue(2); + + const response = await PATCH( + createRequest({ + id: projectId, + status: ProjectStatus.CONCLUIDO, + }) + ); + + const body = await response.json(); + + expect(response.status).toBe(400); + + expect(body.errors).toContain( + 'Todas as stacks devem estar assumidas para concluir.' + ); + + expect(prisma.project.update).not.toHaveBeenCalled(); + }); + + it('deve registrar erro estruturado quando atualização falhar', async () => { + (prisma.project.update as jest.Mock).mockRejectedValue( + new Error('Update failed') + ); + + const response = await PATCH( + createRequest({ + id: projectId, + status: ProjectStatus.CONCLUIDO, + }) + ); + + const body = await response.json(); + + expect(response.status).toBe(500); + expect(body.success).toBe(false); + + expect(logger.error).toHaveBeenCalledWith( + 'Erro ao alterar status do projeto', + 'PATCH /api/team-project/user', + { + error: 'Update failed', + } + ); + }); +}); diff --git a/src/tests/unit/api/user/user-id-route.test.ts b/src/tests/unit/api/user/user-id-route.test.ts new file mode 100644 index 00000000..5745880a --- /dev/null +++ b/src/tests/unit/api/user/user-id-route.test.ts @@ -0,0 +1,408 @@ +/** + * @jest-environment node + */ + +import { DELETE, GET, PUT } from '@/app/api/user/[id]/route'; +import { MESSAGES } from '@/constants/messages'; +import { checkAuth } from '@/lib/check-auth'; +import { prisma } from '@/lib/prisma'; +import { hash } from 'bcryptjs'; +import { NextRequest } from 'next/server'; + +jest.mock('@/lib/prisma', () => ({ + prisma: { + stackTaken: { + findFirst: jest.fn(), + }, + user: { + delete: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + }, + userSkill: { + deleteMany: jest.fn(), + }, + }, +})); + +jest.mock('@/lib/check-auth', () => ({ + checkAuth: jest.fn(), +})); + +jest.mock('@/lib/logger', () => ({ + logger: { + error: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + }, +})); + +jest.mock('bcryptjs', () => ({ + hash: jest.fn(), +})); + +type MockRequest = { + json?: () => Promise; +}; + +const userId = 'user-12345678901234567890'; +const otherUserId = 'user-09876543210987654321'; + +const createRequest = (body?: unknown) => + ({ + json: body === undefined ? undefined : async () => body, + }) as MockRequest as NextRequest; + +const createContext = (id = userId) => ({ + params: { id }, +}); + +const authorizeUser = (id = userId, role = 'USER') => { + (checkAuth as jest.Mock).mockResolvedValue({ + authorized: true, + session: { + user: { id, role }, + }, + }); +}; + +const validPayload = { + name: 'Usuário TryCatch', + email: 'user@trycatch.dev', + password: '', + avatar: null, + linkedin: '', + github: '', + bio: 'Perfil em atualização.', +}; + +describe('GET /api/user/[id]', () => { + beforeEach(() => { + jest.clearAllMocks(); + authorizeUser(); + }); + + it('retorna usuário por id com suas skills', async () => { + const user = { + id: userId, + name: 'Usuário TryCatch', + email: 'user@trycatch.dev', + skills: [], + }; + + (prisma.user.findUnique as jest.Mock).mockResolvedValue(user); + + const response = await GET(createRequest(), createContext()); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body).toEqual(user); + expect(prisma.user.findUnique).toHaveBeenCalledWith({ + where: { id: userId }, + include: { + skills: { + include: { skill: true }, + }, + }, + }); + }); + + it('retorna 400 quando id é inválido', async () => { + const response = await GET(createRequest(), createContext('short')); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body).toMatchObject({ + success: false, + message: MESSAGES.GENERAL.INVALID_ID, + }); + expect(prisma.user.findUnique).not.toHaveBeenCalled(); + }); + + it('retorna 401 quando checkAuth não autoriza', async () => { + (checkAuth as jest.Mock).mockResolvedValue({ + authorized: false, + response: Response.json({ error: 'Não autenticado' }, { status: 401 }), + }); + + const response = await GET(createRequest(), createContext()); + const body = await response.json(); + + expect(response.status).toBe(401); + expect(body).toEqual({ error: 'Não autenticado' }); + expect(prisma.user.findUnique).not.toHaveBeenCalled(); + }); + + it('retorna 404 quando usuário não existe', async () => { + (prisma.user.findUnique as jest.Mock).mockResolvedValue(null); + + const response = await GET(createRequest(), createContext()); + const body = await response.json(); + + expect(response.status).toBe(404); + expect(body).toMatchObject({ + success: false, + message: MESSAGES.USER.NOT_FOUND, + }); + }); + + it('retorna 500 quando ocorre erro inesperado', async () => { + (prisma.user.findUnique as jest.Mock).mockRejectedValue( + new Error('Database error') + ); + + const response = await GET(createRequest(), createContext()); + const body = await response.json(); + + expect(response.status).toBe(500); + expect(body).toMatchObject({ + success: false, + message: MESSAGES.USER.INTERNAL_ERROR, + }); + }); +}); + +describe('PUT /api/user/[id]', () => { + beforeEach(() => { + jest.clearAllMocks(); + authorizeUser(); + (hash as jest.Mock).mockResolvedValue('hashed-password'); + }); + + it('bloqueia atualização quando usuário não é dono do perfil', async () => { + authorizeUser(otherUserId); + + const response = await PUT(createRequest(validPayload), createContext()); + const body = await response.json(); + + expect(response.status).toBe(403); + expect(body).toMatchObject({ + success: false, + message: MESSAGES.AUTH.UNAUTHORIZED, + }); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it('retorna 400 quando payload é inválido', async () => { + const response = await PUT( + createRequest({ + ...validPayload, + name: '', + email: 'email inválido', + }), + createContext() + ); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body.success).toBe(false); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it('retorna 401 quando checkAuth não autoriza atualização', async () => { + (checkAuth as jest.Mock).mockResolvedValue({ + authorized: false, + response: Response.json({ error: 'Não autenticado' }, { status: 401 }), + }); + + const response = await PUT(createRequest(validPayload), createContext()); + const body = await response.json(); + + expect(response.status).toBe(401); + expect(body).toEqual({ error: 'Não autenticado' }); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it('retorna 404 quando usuário de atualização não existe', async () => { + (prisma.user.findUnique as jest.Mock).mockResolvedValueOnce(null); + + const response = await PUT(createRequest(validPayload), createContext()); + const body = await response.json(); + + expect(response.status).toBe(404); + expect(body).toMatchObject({ + success: false, + message: MESSAGES.USER.NOT_FOUND, + }); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it('retorna 500 quando atualização falha inesperadamente', async () => { + (prisma.user.findUnique as jest.Mock) + .mockResolvedValueOnce({ id: userId }) + .mockResolvedValueOnce(null); + (prisma.user.update as jest.Mock).mockRejectedValue( + new Error('Update failed') + ); + + const response = await PUT(createRequest(validPayload), createContext()); + const body = await response.json(); + + expect(response.status).toBe(500); + expect(body).toMatchObject({ + success: false, + message: MESSAGES.USER.INTERNAL_ERROR, + }); + }); + + it('bloqueia e-mail já usado por outro usuário', async () => { + (prisma.user.findUnique as jest.Mock) + .mockResolvedValueOnce({ id: userId }) + .mockResolvedValueOnce({ id: otherUserId, email: validPayload.email }); + + const response = await PUT(createRequest(validPayload), createContext()); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body).toMatchObject({ + success: false, + message: MESSAGES.USER.ALREADY_EXISTS, + }); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it('atualiza usuário válido e envia senha hasheada quando informada', async () => { + const payload = { + ...validPayload, + password: 'secret123', + avatar: 'https://cdn.example.com/avatar.png', + linkedin: 'https://www.linkedin.com/in/trycatch', + github: 'https://github.com/TryCatch-ForMatch', + }; + const updatedUser = { id: userId, ...payload, password: 'hashed-password' }; + + (prisma.user.findUnique as jest.Mock) + .mockResolvedValueOnce({ id: userId }) + .mockResolvedValueOnce(null); + (prisma.user.update as jest.Mock).mockResolvedValue(updatedUser); + + const response = await PUT(createRequest(payload), createContext()); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body).toEqual(updatedUser); + expect(hash).toHaveBeenCalledWith('secret123', 10); + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: userId }, + data: { + name: payload.name, + email: payload.email, + password: 'hashed-password', + avatar: payload.avatar, + linkedin: payload.linkedin, + github: payload.github, + bio: payload.bio, + }, + }); + }); +}); + +describe('DELETE /api/user/[id]', () => { + beforeEach(() => { + jest.clearAllMocks(); + authorizeUser(); + }); + + it('bloqueia remoção quando usuário não é dono nem admin', async () => { + authorizeUser(otherUserId); + + const response = await DELETE(createRequest(), createContext()); + const body = await response.json(); + + expect(response.status).toBe(403); + expect(body).toMatchObject({ + success: false, + message: MESSAGES.AUTH.UNAUTHORIZED, + }); + expect(prisma.user.delete).not.toHaveBeenCalled(); + }); + + it('retorna 401 quando checkAuth não autoriza remoção', async () => { + (checkAuth as jest.Mock).mockResolvedValue({ + authorized: false, + response: Response.json({ error: 'Não autenticado' }, { status: 401 }), + }); + + const response = await DELETE(createRequest(), createContext()); + const body = await response.json(); + + expect(response.status).toBe(401); + expect(body).toEqual({ error: 'Não autenticado' }); + expect(prisma.user.delete).not.toHaveBeenCalled(); + }); + + it('retorna 404 quando usuário de remoção não existe', async () => { + (prisma.user.findUnique as jest.Mock).mockResolvedValue(null); + + const response = await DELETE(createRequest(), createContext()); + const body = await response.json(); + + expect(response.status).toBe(404); + expect(body).toMatchObject({ + success: false, + message: MESSAGES.USER.NOT_FOUND, + }); + expect(prisma.user.delete).not.toHaveBeenCalled(); + }); + + it('retorna 500 quando remoção falha inesperadamente', async () => { + (prisma.user.findUnique as jest.Mock).mockRejectedValue( + new Error('Delete failed') + ); + + const response = await DELETE(createRequest(), createContext()); + const body = await response.json(); + + expect(response.status).toBe(500); + expect(body).toMatchObject({ + success: false, + message: MESSAGES.USER.INTERNAL_ERROR, + }); + }); + + it('desativa usuário quando existem vínculos em StackTaken', async () => { + (prisma.user.findUnique as jest.Mock).mockResolvedValue({ id: userId }); + (prisma.stackTaken.findFirst as jest.Mock).mockResolvedValue({ + id: 'stack-taken-1', + }); + (prisma.user.update as jest.Mock).mockResolvedValue({ + id: userId, + isActive: false, + }); + + const response = await DELETE(createRequest(), createContext()); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.message).toBe( + 'Usuário desativado (soft delete) devido a vínculos em StackTaken.' + ); + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: userId }, + data: { isActive: false }, + }); + expect(prisma.user.delete).not.toHaveBeenCalled(); + }); + + it('remove usuário e skills quando não existem vínculos em StackTaken', async () => { + (prisma.user.findUnique as jest.Mock).mockResolvedValue({ id: userId }); + (prisma.stackTaken.findFirst as jest.Mock).mockResolvedValue(null); + (prisma.userSkill.deleteMany as jest.Mock).mockResolvedValue({ count: 2 }); + (prisma.user.delete as jest.Mock).mockResolvedValue({ id: userId }); + + const response = await DELETE(createRequest(), createContext()); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body).toMatchObject({ + success: true, + message: MESSAGES.USER.DELETED, + }); + expect(prisma.userSkill.deleteMany).toHaveBeenCalledWith({ + where: { userId }, + }); + expect(prisma.user.delete).toHaveBeenCalledWith({ + where: { id: userId }, + }); + }); +}); diff --git a/src/tests/unit/app/root-layout.test.tsx b/src/tests/unit/app/root-layout.test.tsx new file mode 100644 index 00000000..521339fb --- /dev/null +++ b/src/tests/unit/app/root-layout.test.tsx @@ -0,0 +1,39 @@ +/** + * @jest-environment node + */ + +import { renderToStaticMarkup } from 'react-dom/server'; +import RootLayout from '@/app/layout'; +import { ReactNode } from 'react'; + +jest.mock('next/font/google', () => ({ + Poppins: () => ({ variable: 'font-poppins' }), +})); + +jest.mock('@/components/ui/sonner', () => ({ + Toaster: ({ position }: { position: string }) => ( +
Toaster
+ ), +})); + +jest.mock('@/providers', () => ({ + __esModule: true, + default: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), +})); + +describe('RootLayout', () => { + it('renders the application shell around children', () => { + const view = renderToStaticMarkup( + +
Page content
+
+ ); + + expect(view).toContain('lang="pt-BR"'); + expect(view).toContain('class="font-poppins"'); + expect(view).toContain('data-position="top-center"'); + expect(view).toContain('Page content'); + }); +}); diff --git a/src/tests/unit/components/ui/main-components.test.tsx b/src/tests/unit/components/ui/main-components.test.tsx new file mode 100644 index 00000000..906f1455 --- /dev/null +++ b/src/tests/unit/components/ui/main-components.test.tsx @@ -0,0 +1,88 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; + +import GoBackButton from '@/components/ui/go-back-button'; +import { Button } from '@/components/ui/button'; +import { FormField } from '@/components/ui/form-field'; +import { FormInput } from '@/components/ui/form-input'; + +const mockBack = jest.fn(); + +jest.mock('next/navigation', () => ({ + useRouter: () => ({ + back: mockBack, + }), +})); + +describe('main reusable UI components', () => { + beforeEach(() => { + mockBack.mockClear(); + }); + + it('renders the button with default semantics and handles clicks', () => { + const onClick = jest.fn(); + + render(); + + const button = screen.getByRole('button', { name: 'Salvar' }); + + expect(button).toHaveAttribute('data-slot', 'button'); + expect(button).toHaveClass('bg-[#3B38A0]'); + + fireEvent.click(button); + + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('renders the button as a child element when requested', () => { + render( + + ); + + const link = screen.getByRole('link', { name: 'Portfólios' }); + + expect(link).toHaveAttribute('href', '/portfolios'); + expect(link).toHaveAttribute('data-slot', 'button'); + }); + + it('connects form input labels, values and validation messages', () => { + render( + + ); + + const input = screen.getByLabelText('Nome'); + + expect(input).toHaveAttribute('name', 'name'); + expect(input).toHaveAttribute('placeholder', 'Digite seu nome'); + expect(input).toHaveValue('Maria'); + expect(screen.getByText('Nome é obrigatório')).toBeInTheDocument(); + }); + + it('renders a form field label with children and optional error text', () => { + render( + +