From 946386dcc9154dbf063335246326e64bc5c8fc2c Mon Sep 17 00:00:00 2001 From: Usman Liaqat Date: Sat, 4 Jul 2026 15:46:48 +0500 Subject: [PATCH 01/35] fix: prisma adapter initialization and hydration mismatch errors --- .exapmle.env => .env.example | 0 .gitignore | 1 + build.log | Bin 16530 -> 0 bytes scripts/createTestUser.js | 6 ++++-- scripts/seed.local.ts | 5 ++++- src/app/layout.tsx | 4 ++-- 6 files changed, 11 insertions(+), 5 deletions(-) rename .exapmle.env => .env.example (100%) delete mode 100644 build.log diff --git a/.exapmle.env b/.env.example similarity index 100% rename from .exapmle.env rename to .env.example diff --git a/.gitignore b/.gitignore index 7487089b..394da33e 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env.example # vercel .vercel diff --git a/build.log b/build.log deleted file mode 100644 index 6fa8d6ba4fd375d169ae0f6c136862c149e5f20d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16530 zcmd6u>uwvz702gufW8BPqOgmUqDaxYeQ478%g}M!DY3-b#L4cN`I`n&QzBETRn4M!;#+R zx}NLpRM%_e1w+RTR7c;pe4gmvfjd$9rLW15z7p0`eH$kb04amxp;f1)RA(ImGha>N5cA6QIK2^QPY7=caRSq-(*)n;UDitY!2S^5zccz>tzP77H zt@IiGp8MXPEBDlwM{?o6A%3Fg=lTw{JMJ$^yHGtH_fYlm1?hoG9qH4i`>pQiE*fy` zzZ#N2MI;oI)+W5`i2oP*hx%XZ4mx=9J3U`3??@Q!D{au$RC%WQPRrkT6rsO{J5Mc2 zQ5HF&ch~+sEej7l_Rv4$vFSBE?+TUmd@im+ZTd(JU_nBPvXrGB= zQ%O8BKf4!{9#*FG-@Puq6!QB(^GB6&Gb&cqzwz6ltZ| zN%I}>GHe8Tx8vrHY~8*_oFdHEeq3NAigJPHG51;;M){G-n+JsSn;&);qkeXDz@i_@ z;xk5L-+K$`xb!w331`${G)La?f*6q)C*n97eIQ1GGAo;|=VOgqkw)*z4_c2RjO+O7 zP^c@rJ8@rWOom#}$NGt1G4qsVq=te?e-Zz`j8^G7?+>l$`E&IK`DfPhsd{amD?Ty) zEc%JRfT!HiGu}8)Pvk#8RVtE%FNS{z#r~kTr+UIS#aqHZruQA`5M$azbmI9+7hcZ3 zB(kTRHV0!~wc$$~>+4ugNBYuJZOk_8K3(t#Z<6}(I44Sre4;E^&0 zW~2rR*bE8VWPYmUWVSV0c%e4GLEqzy#Y+C!BLNZqY$$2|Ht$C&6Y9Zxd?-uVlZNeh zfARLUp{=pL*rWQmwq(hhsR>U$TT_^e*2e{{LT#~sA9-2_FFw#cP&zw%Hr}AUc@937 zwB>mp*9(|Ez+IayC(DE8bCqVUhom#7L0*}~-1a(9|Cset4}E3EhsHfsdn3tp$Ng2B z2!glnNB5oH-@2dN_qzA=Yvy8wW5>@4PTZfC)QN}77)okD@<#6w$-hPM3*qj1Yr{Mi z5_G$u0+Uv}=Q0=`59C#mi zN{7-9RuikWoX*t-4(tf)u&b>`#L@%~M&95<$C~RsHH{KUA z9hJ!nYtj}Dn`u_3Oj|hEB|Fo_oR*!3RaZMW>Pty8IM~4qyJ@TzvG>P3X%!V>db>EP zTa$KhgdMfm-&IfT;<#IStRoLu`ZPh0h@*P{+s1*fT7PtDX5L^cPk5$XjWf1qTkdUS zsnI5mdOT`EKA1x?mNAD*TC{wxi@&sqqYg#d#Zg^q+r?3R)V)g_M5drmSJLe4h}RsXZ;&C(;{ zs6vAzmiBP0lFTHEX8BT=21z7M(qrAVLhv+;qaH7AOOLpR+r-XGK(nV^+My^@v}3uY zw4xnLVldLN-=Ws}_g%MJietAHj^w(&?G+Noxf)e}5plbGPkSJY%jgj~lc&gXBt6;s zxJ#4w(RQ>0y{FcScGm}r=Lo6EJ?(XnL@SraK@7N^sVG81ycP13m63IjuriRH=|oIJ zAsOE6_byDDfgnG$*$rsifOWagY&lB38P}9L$c?4LPC^KF_JwW2U?Fi&!yL2 zsm{9BIyzh~C)vH-bgLinnx1sXflfUxS;i<^un1kX@zga)^+^| zt|W$zB!E$vQ)SDgm2)^jzmrlDxx;CvYrg}*`L!2%TNU$=Mv#2FO|)Io?^!o01T36~ z+_@R=lj!WZuheqCB%HXbyBCvf3*PJqPcd({mhvWOaNeS0ErPsO+xHKch&@IQmRS-` zgp|uj9!Z%64Q=M{yQ0YO#%kr$k=AnFtRHchZq0ZT>}NWSH@w$n8XR~7ju1~*FXAxW z7C5$Ue8O~^;mGUnkHQ!Acm0^dbZdCgJKmxdS&~OLsaTTtg`zmxnR5u7n}kk8q#pXH z3}V2`q7snApf9umLSm-h=#Kx-PVI?m?ukDGp?Tz2w22q)YlV#awrt_Z%NSM>8y!x9 z$KDQAjx*uWJ{Pw$RGnkhZZ&VKy}oSAz`xV#mQQCrnbs!W9^1)tW=PO - + + {children} From e8971c76e12a65f3551d617b496307db90d49079 Mon Sep 17 00:00:00 2001 From: Usman Liaqat Date: Sat, 4 Jul 2026 21:54:18 +0500 Subject: [PATCH 02/35] feat: add dashboard feedbacks count endpoint Fixes: #641 --- src/app/api/dashboard/feedbacks/route.ts | 40 +++++++ .../dashboard-feedbacks-route.test.ts | 112 ++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 src/app/api/dashboard/feedbacks/route.ts create mode 100644 src/tests/unit/api/dashboard/dashboard-feedbacks-route.test.ts 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/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', + } + ); + }); +}); From 4e8d8c07e1c8ac9563eddc80170585a99e8b11d7 Mon Sep 17 00:00:00 2001 From: Usman Liaqat Date: Sat, 4 Jul 2026 22:05:09 +0500 Subject: [PATCH 03/35] feat: add dashboard projects count endpoint Fixes: #639 --- src/app/api/dashboard/projects/route.ts | 73 ++++++++++ .../dashboard-projects-route.test.ts | 134 ++++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 src/app/api/dashboard/projects/route.ts create mode 100644 src/tests/unit/api/dashboard/dashboard-projects-route.test.ts 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/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', + } + ); + }); +}); From a07f9d42f80774483950dab4b57b709b7f46290b Mon Sep 17 00:00:00 2001 From: Usman Liaqat Date: Sat, 4 Jul 2026 22:10:29 +0500 Subject: [PATCH 04/35] feat: add dashboard summary endpoint Fixes: #638 --- src/app/api/dashboard/summary/route.ts | 92 ++++++++++ .../dashboard/dashboard-summary-route.test.ts | 160 ++++++++++++++++++ 2 files changed, 252 insertions(+) create mode 100644 src/app/api/dashboard/summary/route.ts create mode 100644 src/tests/unit/api/dashboard/dashboard-summary-route.test.ts diff --git a/src/app/api/dashboard/summary/route.ts b/src/app/api/dashboard/summary/route.ts new file mode 100644 index 00000000..b6db6166 --- /dev/null +++ b/src/app/api/dashboard/summary/route.ts @@ -0,0 +1,92 @@ +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, 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 NextResponse.json( + { + ...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/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..7313c3cb --- /dev/null +++ b/src/tests/unit/api/dashboard/dashboard-summary-route.test.ts @@ -0,0 +1,160 @@ +/** + * @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({ + projectsInProgress: 1, + projectsCompleted: 2, + skills: 4, + feedbacks: 6, + }); + + 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', + } + ); + }); +}); From ebb84b9c62d2469bde870ea3f1f7b4c2296dcdfa Mon Sep 17 00:00:00 2001 From: Usman Liaqat Date: Sat, 4 Jul 2026 22:15:52 +0500 Subject: [PATCH 05/35] feat: add api error handling infrastructure Fixes: #508 --- src/lib/errors/app-error.ts | 11 ++ src/lib/errors/handle-api-error.ts | 28 +++++ src/lib/errors/with-error-handling.ts | 18 +++ .../unit/lib/errors/error-handling.test.ts | 119 ++++++++++++++++++ 4 files changed, 176 insertions(+) create mode 100644 src/lib/errors/app-error.ts create mode 100644 src/lib/errors/handle-api-error.ts create mode 100644 src/lib/errors/with-error-handling.ts create mode 100644 src/tests/unit/lib/errors/error-handling.test.ts 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/lib/errors/error-handling.test.ts b/src/tests/unit/lib/errors/error-handling.test.ts new file mode 100644 index 00000000..ad1a17d9 --- /dev/null +++ b/src/tests/unit/lib/errors/error-handling.test.ts @@ -0,0 +1,119 @@ +/** + * @jest-environment node + */ + +import { MESSAGES } from '@/constants/messages'; +import { AppError } from '@/lib/errors/app-error'; +import { handleApiError } from '@/lib/errors/handle-api-error'; +import { withErrorHandling } from '@/lib/errors/with-error-handling'; +import { logger } from '@/lib/logger'; + +jest.mock('@/lib/logger', () => ({ + logger: { + error: jest.fn(), + }, +})); + +describe('AppError', () => { + it('deve armazenar mensagem, status e detalhes do erro', () => { + const error = new AppError('Dados inválidos', 422, { + name: 'Campo obrigatório', + }); + + expect(error).toBeInstanceOf(Error); + expect(error.name).toBe('AppError'); + expect(error.message).toBe('Dados inválidos'); + expect(error.statusCode).toBe(422); + expect(error.errors).toEqual({ + name: 'Campo obrigatório', + }); + }); +}); + +describe('handleApiError', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('deve retornar resposta padronizada para AppError', async () => { + const response = handleApiError( + new AppError('Recurso não encontrado', 404, { + id: 'ID inválido', + }), + 'GET /api/example' + ); + const body = await response.json(); + + expect(response.status).toBe(404); + expect(body).toEqual({ + success: false, + message: 'Recurso não encontrado', + data: null, + errors: { + id: 'ID inválido', + }, + }); + expect(logger.error).not.toHaveBeenCalled(); + }); + + it('deve retornar erro 500 padronizado para erros inesperados', async () => { + const response = handleApiError( + new Error('Database error'), + 'GET /api/example' + ); + const body = await response.json(); + + expect(response.status).toBe(500); + expect(body).toEqual({ + success: false, + message: MESSAGES.GENERAL.INTERNAL_ERROR, + data: null, + errors: null, + }); + expect(logger.error).toHaveBeenCalledWith( + 'Erro inesperado na API:', + 'GET /api/example', + { + error: 'Database error', + } + ); + }); +}); + +describe('withErrorHandling', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('deve retornar a resposta original quando o handler executar com sucesso', async () => { + const handler = jest.fn(async (id: string) => + Response.json({ id }, { status: 200 }) + ); + + const wrappedHandler = withErrorHandling(handler, 'GET /api/example'); + const response = await wrappedHandler('user-1'); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body).toEqual({ id: 'user-1' }); + expect(handler).toHaveBeenCalledWith('user-1'); + }); + + it('deve capturar exceções do handler e retornar resposta padronizada', async () => { + const handler = jest.fn(async () => { + throw new AppError('Acesso negado', 403); + }); + + const wrappedHandler = withErrorHandling(handler, 'GET /api/example'); + const response = await wrappedHandler(); + const body = await response.json(); + + expect(response.status).toBe(403); + expect(body).toEqual({ + success: false, + message: 'Acesso negado', + data: null, + errors: null, + }); + }); +}); From a059e4ece99d86cdfaee62c1cba7f45abcdb19ab Mon Sep 17 00:00:00 2001 From: Usman Liaqat Date: Sat, 4 Jul 2026 22:22:29 +0500 Subject: [PATCH 06/35] test: add feedback domain rule coverage Fixes: #519 --- .../unit/api/feedback/feedback-route.test.ts | 231 ++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 src/tests/unit/api/feedback/feedback-route.test.ts 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(); + }); +}); From 6dd01c820ebb8d23c5dadd79750ba2452d964ed2 Mon Sep 17 00:00:00 2001 From: Usman Liaqat Date: Sat, 4 Jul 2026 22:32:26 +0500 Subject: [PATCH 07/35] test: add project domain rule coverage Fixes: #518 --- .../team-project/team-project-route.test.ts | 238 ++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 src/tests/unit/api/team-project/team-project-route.test.ts 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(); + }); +}); From 1066190a57cb16181713baf7a501c31636d09331 Mon Sep 17 00:00:00 2001 From: Usman Liaqat Date: Sat, 4 Jul 2026 22:44:42 +0500 Subject: [PATCH 08/35] feat: apply structured backend logging Fixes: #537 --- src/app/api/team-project/user/route.ts | 19 +++++++++-- src/lib/check-auth.ts | 22 +++++++++++- src/lib/check-project-status.ts | 47 ++++++++++++++++++-------- 3 files changed, 71 insertions(+), 17 deletions(-) diff --git a/src/app/api/team-project/user/route.ts b/src/app/api/team-project/user/route.ts index 271dd140..fc16dff6 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, @@ -140,7 +148,14 @@ export async function PATCH(request: NextRequest) { 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/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 { From adaead5341bac35942467c5f66671942021da6a7 Mon Sep 17 00:00:00 2001 From: Usman Liaqat Date: Sat, 4 Jul 2026 22:53:30 +0500 Subject: [PATCH 09/35] test: add user domain rule coverage Fixes: #532 --- src/tests/unit/api/user/user-id-route.test.ts | 277 ++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 src/tests/unit/api/user/user-id-route.test.ts 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..ab8e2a4d --- /dev/null +++ b/src/tests/unit/api/user/user-id-route.test.ts @@ -0,0 +1,277 @@ +/** + * @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(); + }); +}); + +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('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('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 }, + }); + }); +}); From f1271c9bbdc8f0e11b8240aa0a1d99c607043650 Mon Sep 17 00:00:00 2001 From: Usman Liaqat Date: Sat, 4 Jul 2026 23:07:26 +0500 Subject: [PATCH 10/35] docs: clarify ci validation flow Fixes: #531 --- docs/04 - processo/ci-e-validacao.md | 80 +++++++++++++++++++--------- 1 file changed, 56 insertions(+), 24 deletions(-) diff --git a/docs/04 - processo/ci-e-validacao.md b/docs/04 - processo/ci-e-validacao.md index cb898b61..b49d2294 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,38 @@ 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 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 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 +69,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 +81,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 +100,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 +114,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 +124,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 +134,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 +149,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 +210,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 +223,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. @@ -219,7 +251,7 @@ Observações: ------------------------------------------------------------------------ -## 6. Requisitos para abrir Pull Request +## 7. Requisitos para abrir Pull Request Antes de abrir o PR: @@ -254,7 +286,7 @@ issue. ------------------------------------------------------------------------ -## 7. O que fazer quando um check falhar +## 8. O que fazer quando um check falhar Quando a CI falhar: @@ -274,7 +306,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 +320,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 +337,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 +358,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 +387,7 @@ Diretrizes para evitar esse problema: ------------------------------------------------------------------------ -## 11. Responsabilidades +## 12. Responsabilidades Contribuidor: From 0401145ce35708f641a0a9c0dfb3085c70d5c86e Mon Sep 17 00:00:00 2001 From: Usman Liaqat Date: Sat, 4 Jul 2026 23:14:00 +0500 Subject: [PATCH 11/35] feat: add manual project completion validation Fixes: #554 --- src/app/api/team-project/user/route.ts | 43 +++++ .../team-project-user-route.test.ts | 151 ++++++++++++++++++ 2 files changed, 194 insertions(+) create mode 100644 src/tests/unit/api/team-project/team-project-user-route.test.ts diff --git a/src/app/api/team-project/user/route.ts b/src/app/api/team-project/user/route.ts index 271dd140..148f1804 100644 --- a/src/app/api/team-project/user/route.ts +++ b/src/app/api/team-project/user/route.ts @@ -130,6 +130,49 @@ export async function PATCH(request: NextRequest) { }); } + 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 totalProjectStacks = await prisma.projectStack.count({ + where: { projectId }, + }); + const totalStackTaken = await prisma.stackTaken.count({ + where: { projectId }, + }); + + if (totalProjectStacks === 0 || totalStackTaken < totalProjectStacks) { + return buildResponse({ + success: false, + message: MESSAGES.GENERAL.INVALID_DATA, + status: 400, + errors: ['Todas as stacks devem estar assumidas para concluir.'], + }); + } + const updated = await prisma.project.update({ where: { id: projectId }, data: { status }, 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..42f3bec7 --- /dev/null +++ b/src/tests/unit/api/team-project/team-project-user-route.test.ts @@ -0,0 +1,151 @@ +/** + * @jest-environment node + */ + +import { PATCH } from '@/app/api/team-project/user/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: { + project: { + 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; + +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('PATCH /api/team-project/user', () => { + beforeEach(() => { + jest.clearAllMocks(); + authorizeUser(); + (prisma.project.findUnique as jest.Mock).mockResolvedValue(mockProject()); + (prisma.projectStack.count as jest.Mock).mockResolvedValue(2); + (prisma.stackTaken.count as jest.Mock).mockResolvedValue(2); + (prisma.project.update as jest.Mock).mockResolvedValue( + mockProject({ status: ProjectStatus.CONCLUIDO }) + ); + (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.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(); + }); +}); From 7583f12dba104dbd491474f78548e427e1fe9e69 Mon Sep 17 00:00:00 2001 From: Usman Liaqat Date: Sun, 5 Jul 2026 10:59:15 +0500 Subject: [PATCH 12/35] feat: block structural project edits Fixes: #556 --- src/app/api/team-project/[id]/route.ts | 76 +++++++ .../team-project-id-route.test.ts | 207 ++++++++++++++++++ 2 files changed, 283 insertions(+) create mode 100644 src/tests/unit/api/team-project/team-project-id-route.test.ts diff --git a/src/app/api/team-project/[id]/route.ts b/src/app/api/team-project/[id]/route.ts index 294be026..91a9d850 100644 --- a/src/app/api/team-project/[id]/route.ts +++ b/src/app/api/team-project/[id]/route.ts @@ -32,6 +32,63 @@ 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(); +} + +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) + ); +} + export async function GET( request: NextRequest, context: { params: { id: string } } @@ -163,6 +220,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,6 +255,20 @@ export async function PUT( github, } = parse.data; + 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.', + ], + }); + } + const existingStacks = await prisma.projectStack.findMany({ where: { projectId }, select: { id: true, stackId: true, percentage: true }, 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..49f910ea --- /dev/null +++ b/src/tests/unit/api/team-project/team-project-id-route.test.ts @@ -0,0 +1,207 @@ +/** + * @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: { + 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, + }); + (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.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(); + }); +}); From 761f2f1612ffcc19fbae98ea4e82eef64a6aaaec Mon Sep 17 00:00:00 2001 From: Usman Liaqat Date: Sun, 5 Jul 2026 11:32:13 +0500 Subject: [PATCH 13/35] feat: highlight invite request flow Fixes: #623 --- src/app/(public)/how-to-join/page.tsx | 59 +++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/src/app/(public)/how-to-join/page.tsx b/src/app/(public)/how-to-join/page.tsx index 3bcc9a8e..6bc7d169 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,13 @@ const participationPaths: ParticipationPath[] = [ }, ]; +const entryProfiles = [ + 'Membro de equipe', + 'Mentor técnico', + 'Colaborador open source', + 'Autor de projeto', +]; + export default function HowToJoinPage() { return (
@@ -116,11 +124,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. +

+
+
+ +
    + {entryProfiles.map((profile) => ( +
  • + {profile} +
  • + ))} +
+
From 008709ac1aca6d0185dbe30ed6d75b3e878a50c0 Mon Sep 17 00:00:00 2001 From: Usman Liaqat Date: Sun, 5 Jul 2026 12:11:03 +0500 Subject: [PATCH 14/35] test: add main component coverage Fixes: #85 --- .../components/ui/main-components.test.tsx | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 src/tests/unit/components/ui/main-components.test.tsx 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( + +