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.
+
+
+
+
+
+ Solicitar convite
+
+
+
+
+
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(Salvar );
+
+ 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(
+
+ Portfólios
+
+ );
+
+ 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(
+
+
+
+ );
+
+ expect(screen.getByText('Bio')).toBeInTheDocument();
+ expect(screen.getByLabelText('Bio do usuário')).toBeInTheDocument();
+ expect(screen.getByText('Bio muito curta')).toBeInTheDocument();
+ });
+
+ it('navigates back when the back button is clicked', () => {
+ render( );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Voltar' }));
+
+ expect(mockBack).toHaveBeenCalledTimes(1);
+ });
+});
From 3ed6e296d95795f013e81b0c994502baf4e59d8f Mon Sep 17 00:00:00 2001
From: Usman Liaqat
Date: Sun, 5 Jul 2026 12:26:39 +0500
Subject: [PATCH 15/35] docs: clarify collaboration git flow
Fixes: #5
---
CONTRIBUTING.en.md | 24 ++++++++++++++++++++++++
CONTRIBUTING.md | 24 ++++++++++++++++++++++++
2 files changed, 48 insertions(+)
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):
From 0485c6aff65b4f75dfa4caf3c9a1c1436d031c32 Mon Sep 17 00:00:00 2001
From: Usman Liaqat
Date: Sun, 5 Jul 2026 12:41:23 +0500
Subject: [PATCH 16/35] test: add auth api coverage
Fixes: #83
---
src/tests/unit/api/auth/auth-route.test.ts | 419 +++++++++++++++++++++
1 file changed, 419 insertions(+)
create mode 100644 src/tests/unit/api/auth/auth-route.test.ts
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),
+ ]);
+ });
+});
From 38ecb6a90aa740b32414f14b7c8c4a262fd05154 Mon Sep 17 00:00:00 2001
From: Usman Liaqat
Date: Mon, 6 Jul 2026 00:15:03 +0500
Subject: [PATCH 17/35] fix: reuse shared prisma in seed scripts
Fixes: #687
---
README.md | Bin 9930 -> 9930 bytes
package.json | 2 +-
.../{createTestUser.js => createTestUser.ts} | 15 ++++++---------
scripts/seed.local.ts | 8 ++------
tsconfig.json | 2 +-
5 files changed, 10 insertions(+), 17 deletions(-)
rename scripts/{createTestUser.js => createTestUser.ts} (69%)
diff --git a/README.md b/README.md
index 15c9bc662d485a43350597bec1c4f354192b3a28..22776ee79fa9c8a59c44dd2ecee976079d5d628e 100644
GIT binary patch
delta 14
VcmX@*d&+l%fDB{FW {
+ .then(async () => {
console.log('✅ Script finalizado.');
+ await prisma.$disconnect();
process.exit(0);
})
- .catch((error) => {
+ .catch(async (error) => {
console.error('❌ Ocorreu um erro:', error);
+ await prisma.$disconnect();
process.exit(1);
});
diff --git a/scripts/seed.local.ts b/scripts/seed.local.ts
index 89eeb0ce..2abe6591 100644
--- a/scripts/seed.local.ts
+++ b/scripts/seed.local.ts
@@ -10,13 +10,9 @@
* Rodar: npx tsx scripts/seed.local.ts
*/
-import { PrismaClient, ProjectStatus } from '@prisma/client';
-import { PrismaPg } from '@prisma/adapter-pg';
+import { ProjectStatus } from '@prisma/client';
import bcrypt from 'bcryptjs';
-
-const connectionString = process.env.DATABASE_URL!;
-const adapter = new PrismaPg({ connectionString });
-const prisma = new PrismaClient({ adapter });
+import { prisma } from '../src/lib/prisma';
async function main() {
console.log('🌱 Iniciando seed local para feature de portfólio...\n');
diff --git a/tsconfig.json b/tsconfig.json
index 2516d455..9bb93887 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -29,7 +29,7 @@
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
- "./scripts/createTestUser.js",
+ "./scripts/createTestUser.ts",
"src/**/*"
],
"exclude": ["node_modules"]
From 4d46ef9b817ae27183944e2f89c58d89328877ab Mon Sep 17 00:00:00 2001
From: Usman Liaqat
Date: Mon, 6 Jul 2026 00:34:53 +0500
Subject: [PATCH 18/35] test: cover prisma seed scripts
Fixes: #687
---
.env.example => .exapmle.env | 0
.gitignore | 1 -
build.log | Bin 0 -> 16530 bytes
scripts/createTestUser.ts | 37 +++---
scripts/seed.local.ts | 34 ++++--
src/tests/unit/app/root-layout.test.tsx | 39 ++++++
.../unit/scripts/create-test-user.test.ts | 52 ++++++++
src/tests/unit/scripts/seed-local.test.ts | 115 ++++++++++++++++++
8 files changed, 251 insertions(+), 27 deletions(-)
rename .env.example => .exapmle.env (100%)
create mode 100644 build.log
create mode 100644 src/tests/unit/app/root-layout.test.tsx
create mode 100644 src/tests/unit/scripts/create-test-user.test.ts
create mode 100644 src/tests/unit/scripts/seed-local.test.ts
diff --git a/.env.example b/.exapmle.env
similarity index 100%
rename from .env.example
rename to .exapmle.env
diff --git a/.gitignore b/.gitignore
index 394da33e..7487089b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -30,7 +30,6 @@ 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
new file mode 100644
index 0000000000000000000000000000000000000000..6fa8d6ba4fd375d169ae0f6c136862c149e5f20d
GIT binary patch
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 {
- 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 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 2abe6591..679ef0dd 100644
--- a/scripts/seed.local.ts
+++ b/scripts/seed.local.ts
@@ -14,7 +14,7 @@ import { ProjectStatus } from '@prisma/client';
import bcrypt from 'bcryptjs';
import { prisma } from '../src/lib/prisma';
-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);
@@ -297,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]
+ ?.replace(/\\/g, '/')
+ .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/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 }) => (
+
+ ),
+}));
+
+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/scripts/create-test-user.test.ts b/src/tests/unit/scripts/create-test-user.test.ts
new file mode 100644
index 00000000..311c14d8
--- /dev/null
+++ b/src/tests/unit/scripts/create-test-user.test.ts
@@ -0,0 +1,52 @@
+/**
+ * @jest-environment node
+ */
+
+import bcrypt from 'bcryptjs';
+import { createTestUser } from '../../../../scripts/createTestUser';
+import { prisma } from '../../../../src/lib/prisma';
+
+jest.mock('../../../../src/lib/prisma', () => ({
+ prisma: {
+ user: {
+ upsert: jest.fn(),
+ },
+ },
+}));
+
+jest.mock('bcryptjs', () => ({
+ __esModule: true,
+ default: {
+ hash: jest.fn(),
+ },
+}));
+
+describe('createTestUser', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('creates or updates the admin test user with a hashed password', async () => {
+ const adminUser = {
+ id: 'admin-1',
+ email: 'admin@admin.com',
+ role: 'ADMIN',
+ };
+
+ (bcrypt.hash as jest.Mock).mockResolvedValue('hashed-password');
+ (prisma.user.upsert as jest.Mock).mockResolvedValue(adminUser);
+
+ await expect(createTestUser()).resolves.toEqual(adminUser);
+
+ expect(bcrypt.hash).toHaveBeenCalledWith('teste123', 10);
+ expect(prisma.user.upsert).toHaveBeenCalledWith({
+ where: { email: 'admin@admin.com' },
+ update: {},
+ create: expect.objectContaining({
+ email: 'admin@admin.com',
+ password: 'hashed-password',
+ role: 'ADMIN',
+ }),
+ });
+ });
+});
diff --git a/src/tests/unit/scripts/seed-local.test.ts b/src/tests/unit/scripts/seed-local.test.ts
new file mode 100644
index 00000000..eb7402cc
--- /dev/null
+++ b/src/tests/unit/scripts/seed-local.test.ts
@@ -0,0 +1,115 @@
+/**
+ * @jest-environment node
+ */
+
+import bcrypt from 'bcryptjs';
+import { seedLocalPortfolio } from '../../../../scripts/seed.local';
+import { prisma } from '../../../../src/lib/prisma';
+
+jest.mock('../../../../src/lib/prisma', () => ({
+ prisma: {
+ feedback: {
+ upsert: jest.fn(),
+ },
+ project: {
+ upsert: jest.fn(),
+ },
+ projectStack: {
+ upsert: jest.fn(),
+ },
+ skill: {
+ upsert: jest.fn(),
+ },
+ stack: {
+ upsert: jest.fn(),
+ },
+ stackTaken: {
+ upsert: jest.fn(),
+ },
+ user: {
+ upsert: jest.fn(),
+ },
+ userCertificate: {
+ upsert: jest.fn(),
+ },
+ userSkill: {
+ upsert: jest.fn(),
+ },
+ },
+}));
+
+jest.mock('bcryptjs', () => ({
+ __esModule: true,
+ default: {
+ hash: jest.fn(),
+ },
+}));
+
+const resolveCreateData = ({ create }: { create: Record }) =>
+ Promise.resolve(create);
+
+describe('seedLocalPortfolio', () => {
+ let consoleLogSpy: jest.SpyInstance;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
+
+ (bcrypt.hash as jest.Mock).mockResolvedValue('hashed-password');
+
+ (prisma.user.upsert as jest.Mock).mockImplementation(({ create, where }) =>
+ Promise.resolve({
+ id: `user-${create.userName ?? where.email}`,
+ ...create,
+ })
+ );
+ (prisma.skill.upsert as jest.Mock).mockImplementation(({ create }) =>
+ Promise.resolve({ id: `skill-${create.name}`, ...create })
+ );
+ (prisma.stack.upsert as jest.Mock).mockImplementation(({ create }) =>
+ Promise.resolve({ id: `stack-${create.name}`, ...create })
+ );
+ (prisma.project.upsert as jest.Mock).mockImplementation(resolveCreateData);
+ (prisma.projectStack.upsert as jest.Mock).mockImplementation(
+ resolveCreateData
+ );
+ (prisma.stackTaken.upsert as jest.Mock).mockImplementation(
+ resolveCreateData
+ );
+ (prisma.userCertificate.upsert as jest.Mock).mockImplementation(
+ resolveCreateData
+ );
+ (prisma.userSkill.upsert as jest.Mock).mockImplementation(
+ resolveCreateData
+ );
+ (prisma.feedback.upsert as jest.Mock).mockImplementation(resolveCreateData);
+ });
+
+ afterEach(() => {
+ consoleLogSpy.mockRestore();
+ });
+
+ it('creates the local portfolio seed records through the shared Prisma client', async () => {
+ await seedLocalPortfolio();
+
+ expect(bcrypt.hash).toHaveBeenCalledWith('teste123', 10);
+ expect(prisma.user.upsert).toHaveBeenCalledTimes(4);
+ expect(prisma.project.upsert).toHaveBeenCalledWith(
+ expect.objectContaining({
+ create: expect.objectContaining({
+ id: 'seed-project-concluido',
+ ownerId: 'user-joao_public',
+ }),
+ })
+ );
+ expect(prisma.stackTaken.upsert).toHaveBeenCalledTimes(2);
+ expect(prisma.feedback.upsert).toHaveBeenCalledWith(
+ expect.objectContaining({
+ create: expect.objectContaining({
+ stackTakenId: 'seed-st-1',
+ toUserId: 'user-joao_public',
+ }),
+ })
+ );
+ });
+});
From e817d3c6efff4dc450e0288f5511592e3ce187f3 Mon Sep 17 00:00:00 2001
From: Usman Liaqat
Date: Mon, 6 Jul 2026 00:41:01 +0500
Subject: [PATCH 19/35] ci: configure sonar coverage report
---
sonar-project.properties | 1 +
1 file changed, 1 insertion(+)
diff --git a/sonar-project.properties b/sonar-project.properties
index c8dfc603..cc8f6b68 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.
From 21af50505d0b5a7b6353524a8d19cc203ea07663 Mon Sep 17 00:00:00 2001
From: Usman Liaqat
Date: Mon, 6 Jul 2026 00:46:41 +0500
Subject: [PATCH 20/35] fix: address sonar script path normalization
---
scripts/createTestUser.ts | 2 +-
scripts/seed.local.ts | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/scripts/createTestUser.ts b/scripts/createTestUser.ts
index f9effa80..e3e7b408 100644
--- a/scripts/createTestUser.ts
+++ b/scripts/createTestUser.ts
@@ -23,7 +23,7 @@ export async function createTestUser() {
function isDirectRun() {
return process.argv[1]
- ?.replace(/\\/g, '/')
+ ?.replaceAll('\\', '/')
.endsWith('/scripts/createTestUser.ts');
}
diff --git a/scripts/seed.local.ts b/scripts/seed.local.ts
index 679ef0dd..01252881 100644
--- a/scripts/seed.local.ts
+++ b/scripts/seed.local.ts
@@ -299,7 +299,7 @@ export async function seedLocalPortfolio() {
function isDirectRun() {
return process.argv[1]
- ?.replace(/\\/g, '/')
+ ?.replaceAll('\\', '/')
.endsWith('/scripts/seed.local.ts');
}
From e6eaa03d5edae1a0a95212f2c2cf248e50352889 Mon Sep 17 00:00:00 2001
From: Usman Liaqat
Date: Mon, 6 Jul 2026 01:16:25 +0500
Subject: [PATCH 21/35] ci: configure sonar coverage report
---
sonar-project.properties | 1 +
1 file changed, 1 insertion(+)
diff --git a/sonar-project.properties b/sonar-project.properties
index c8dfc603..cc8f6b68 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.
From 80b3f000fcdba51a288948e67e7a57a6293e1f08 Mon Sep 17 00:00:00 2001
From: Usman Liaqat
Date: Mon, 6 Jul 2026 01:25:36 +0500
Subject: [PATCH 22/35] ci: configure sonar coverage report
---
sonar-project.properties | 1 +
1 file changed, 1 insertion(+)
diff --git a/sonar-project.properties b/sonar-project.properties
index c8dfc603..cc8f6b68 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.
From e2d325fa3301fd0f3a8a34f2b352894f7756b7e3 Mon Sep 17 00:00:00 2001
From: Usman Liaqat
Date: Mon, 6 Jul 2026 01:38:52 +0500
Subject: [PATCH 23/35] ci: configure sonar coverage report
---
sonar-project.properties | 1 +
1 file changed, 1 insertion(+)
diff --git a/sonar-project.properties b/sonar-project.properties
index c8dfc603..cc8f6b68 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.
From cc17ab4595f5f0131e9912e90634a6cd1ccd6da8 Mon Sep 17 00:00:00 2001
From: Usman Liaqat
Date: Mon, 6 Jul 2026 01:52:43 +0500
Subject: [PATCH 24/35] ci: configure sonar coverage report
---
sonar-project.properties | 1 +
1 file changed, 1 insertion(+)
diff --git a/sonar-project.properties b/sonar-project.properties
index c8dfc603..1be47a74 100644
--- a/sonar-project.properties
+++ b/sonar-project.properties
@@ -12,3 +12,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
From 37ab4dd717ef3df88fa289afc50f58791ea6c9b7 Mon Sep 17 00:00:00 2001
From: Usman Liaqat
Date: Mon, 6 Jul 2026 02:08:33 +0500
Subject: [PATCH 25/35] test: cover structured logging changes
---
sonar-project.properties | 1 +
.../team-project-user-route.test.ts | 106 +++++++++++++
src/tests/unit/lib/check-auth.test.ts | 94 +++++++++++
.../unit/lib/check-project-status.test.ts | 149 ++++++++++++++++++
4 files changed, 350 insertions(+)
create mode 100644 src/tests/unit/api/team-project/team-project-user-route.test.ts
create mode 100644 src/tests/unit/lib/check-auth.test.ts
create mode 100644 src/tests/unit/lib/check-project-status.test.ts
diff --git a/sonar-project.properties b/sonar-project.properties
index c8dfc603..1be47a74 100644
--- a/sonar-project.properties
+++ b/sonar-project.properties
@@ -12,3 +12,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/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..a412d45c
--- /dev/null
+++ b/src/tests/unit/api/team-project/team-project-user-route.test.ts
@@ -0,0 +1,106 @@
+/**
+ * @jest-environment node
+ */
+
+import { GET, PATCH } from '@/app/api/team-project/user/route';
+import { checkAuth } from '@/lib/check-auth';
+import { logger } from '@/lib/logger';
+import { prisma } from '@/lib/prisma';
+import { NextRequest } from 'next/server';
+
+jest.mock('@/lib/prisma', () => ({
+ prisma: {
+ project: {
+ findMany: jest.fn(),
+ findUnique: jest.fn(),
+ update: jest.fn(),
+ },
+ stackTaken: {
+ findMany: 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 authorizeUser = () => {
+ (checkAuth as jest.Mock).mockResolvedValue({
+ authorized: true,
+ session: {
+ user: { id: 'user-1', role: 'USER' },
+ },
+ });
+};
+
+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();
+ });
+
+ it('deve registrar erro estruturado quando atualização falhar', async () => {
+ const projectId = '1234567890123456789012345';
+
+ (prisma.project.findUnique as jest.Mock).mockResolvedValue({
+ id: projectId,
+ ownerId: 'user-1',
+ });
+ (prisma.project.update as jest.Mock).mockRejectedValue(
+ new Error('Update failed')
+ );
+
+ const request = {
+ json: async () => ({
+ id: projectId,
+ status: 'CONCLUIDO',
+ }),
+ } as unknown as NextRequest;
+
+ const response = await PATCH(request);
+ 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/lib/check-auth.test.ts b/src/tests/unit/lib/check-auth.test.ts
new file mode 100644
index 00000000..b2987710
--- /dev/null
+++ b/src/tests/unit/lib/check-auth.test.ts
@@ -0,0 +1,94 @@
+/**
+ * @jest-environment node
+ */
+
+import { checkAuth } from '@/lib/check-auth';
+import { logger } from '@/lib/logger';
+import { getServerSession } from 'next-auth';
+
+jest.mock('next-auth', () => ({
+ getServerSession: jest.fn(),
+}));
+
+jest.mock('@/lib/auth', () => ({
+ authOptions: {},
+}));
+
+jest.mock('@/lib/logger', () => ({
+ logger: {
+ info: jest.fn(),
+ warn: jest.fn(),
+ },
+}));
+
+describe('checkAuth', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('deve registrar aviso quando a sessão estiver ausente', async () => {
+ (getServerSession as jest.Mock).mockResolvedValue(null);
+
+ const result = await checkAuth();
+ const body = await result.response?.json();
+
+ expect(result.authorized).toBe(false);
+ expect(result.response?.status).toBe(401);
+ expect(body).toEqual({ error: 'Não autenticado' });
+ expect(logger.warn).toHaveBeenCalledWith('Sessão ausente', 'checkAuth');
+ });
+
+ it('deve registrar sessão ativa quando o usuário estiver autenticado', async () => {
+ (getServerSession as jest.Mock).mockResolvedValue({
+ user: { id: 'user-1', role: 'ADMIN' },
+ });
+
+ const result = await checkAuth({ requireAdmin: true });
+
+ expect(result.authorized).toBe(true);
+ expect(result.session?.user.id).toBe('user-1');
+ expect(logger.info).toHaveBeenCalledWith('Sessão ativa', 'checkAuth', {
+ userId: 'user-1',
+ role: 'ADMIN',
+ });
+ });
+
+ it('deve registrar aviso quando acesso administrativo for negado', async () => {
+ (getServerSession as jest.Mock).mockResolvedValue({
+ user: { id: 'user-2', role: 'USER' },
+ });
+
+ const result = await checkAuth({ requireAdmin: true });
+
+ expect(result.authorized).toBe(false);
+ expect(result.response?.status).toBe(403);
+ expect(logger.warn).toHaveBeenCalledWith(
+ 'Acesso administrativo negado',
+ 'checkAuth',
+ {
+ userId: 'user-2',
+ role: 'USER',
+ }
+ );
+ });
+
+ it('deve registrar aviso quando o perfil não estiver autorizado', async () => {
+ (getServerSession as jest.Mock).mockResolvedValue({
+ user: { id: 'user-3', role: 'USER' },
+ });
+
+ const result = await checkAuth({ allowedRoles: ['ADMIN'] });
+
+ expect(result.authorized).toBe(false);
+ expect(result.response?.status).toBe(403);
+ expect(logger.warn).toHaveBeenCalledWith(
+ 'Acesso negado por perfil',
+ 'checkAuth',
+ {
+ userId: 'user-3',
+ role: 'USER',
+ allowedRoles: ['ADMIN'],
+ }
+ );
+ });
+});
diff --git a/src/tests/unit/lib/check-project-status.test.ts b/src/tests/unit/lib/check-project-status.test.ts
new file mode 100644
index 00000000..c291eb72
--- /dev/null
+++ b/src/tests/unit/lib/check-project-status.test.ts
@@ -0,0 +1,149 @@
+/**
+ * @jest-environment node
+ */
+
+import { checkProjectStatus } from '@/lib/check-project-status';
+import { logger } from '@/lib/logger';
+import { prisma } from '@/lib/prisma';
+import { ProjectStatus } from '@prisma/client';
+
+jest.mock('@/lib/prisma', () => ({
+ prisma: {
+ stackTaken: {
+ findFirst: jest.fn(),
+ count: jest.fn(),
+ },
+ project: {
+ findUnique: jest.fn(),
+ update: jest.fn(),
+ },
+ projectStack: {
+ count: jest.fn(),
+ },
+ },
+}));
+
+jest.mock('@/lib/logger', () => ({
+ logger: {
+ info: jest.fn(),
+ warn: jest.fn(),
+ },
+}));
+
+describe('checkProjectStatus', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('deve registrar aviso quando o projeto não existir', async () => {
+ (prisma.stackTaken.findFirst as jest.Mock).mockResolvedValue(null);
+ (prisma.project.findUnique as jest.Mock).mockResolvedValue(null);
+
+ const response = await checkProjectStatus('project-1');
+ const body = await response.json();
+
+ expect(response.status).toBe(404);
+ expect(body.success).toBe(false);
+ expect(logger.warn).toHaveBeenCalledWith(
+ 'Nenhum registro encontrado na tabela StackTaken',
+ 'checkProjectStatus',
+ { projectId: 'project-1' }
+ );
+ expect(logger.warn).toHaveBeenCalledWith(
+ 'Projeto não encontrado',
+ 'checkProjectStatus',
+ { projectId: 'project-1' }
+ );
+ });
+
+ it('deve manter projeto concluído sem atualizar status', async () => {
+ (prisma.stackTaken.findFirst as jest.Mock).mockResolvedValue({
+ id: 'taken-1',
+ });
+ (prisma.project.findUnique as jest.Mock).mockResolvedValue({
+ status: ProjectStatus.CONCLUIDO,
+ });
+
+ const response = await checkProjectStatus('project-2');
+ const body = await response.json();
+
+ expect(response.status).toBe(200);
+ expect(body.data).toEqual({ status: ProjectStatus.CONCLUIDO });
+ expect(prisma.project.update).not.toHaveBeenCalled();
+ expect(logger.info).toHaveBeenCalledWith(
+ 'Projeto já está concluído sem alterações',
+ 'checkProjectStatus',
+ {
+ projectId: 'project-2',
+ status: ProjectStatus.CONCLUIDO,
+ }
+ );
+ });
+
+ it('deve registrar atualização quando o status mudar', async () => {
+ (prisma.stackTaken.findFirst as jest.Mock).mockResolvedValue({
+ id: 'taken-1',
+ });
+ (prisma.project.findUnique as jest.Mock).mockResolvedValue({
+ status: ProjectStatus.BUSCANDO,
+ });
+ (prisma.projectStack.count as jest.Mock).mockResolvedValue(2);
+ (prisma.stackTaken.count as jest.Mock).mockResolvedValue(2);
+
+ const result = await checkProjectStatus('project-3');
+
+ expect(result).toEqual({
+ totalProjectStack: 2,
+ totalStackTaken: 2,
+ newStatus: ProjectStatus.EM_ANDAMENTO,
+ });
+ expect(prisma.project.update).toHaveBeenCalledWith({
+ where: { id: 'project-3' },
+ data: { status: ProjectStatus.EM_ANDAMENTO },
+ });
+ expect(logger.info).toHaveBeenCalledWith(
+ 'Contagem de stacks do projeto calculada',
+ 'checkProjectStatus',
+ {
+ projectId: 'project-3',
+ totalProjectStack: 2,
+ totalStackTaken: 2,
+ }
+ );
+ expect(logger.info).toHaveBeenCalledWith(
+ 'Status do projeto atualizado',
+ 'checkProjectStatus',
+ {
+ projectId: 'project-3',
+ previousStatus: ProjectStatus.BUSCANDO,
+ newStatus: ProjectStatus.EM_ANDAMENTO,
+ }
+ );
+ });
+
+ it('deve registrar manutenção quando o status não mudar', async () => {
+ (prisma.stackTaken.findFirst as jest.Mock).mockResolvedValue(null);
+ (prisma.project.findUnique as jest.Mock).mockResolvedValue({
+ status: ProjectStatus.BUSCANDO,
+ });
+ (prisma.projectStack.count as jest.Mock).mockResolvedValue(3);
+ (prisma.stackTaken.count as jest.Mock).mockResolvedValue(1);
+
+ const result = await checkProjectStatus('project-4');
+
+ expect(result).toEqual({
+ totalProjectStack: 3,
+ totalStackTaken: 1,
+ newStatus: ProjectStatus.BUSCANDO,
+ });
+ expect(prisma.project.update).not.toHaveBeenCalled();
+ expect(logger.info).toHaveBeenCalledWith(
+ 'Status do projeto mantido',
+ 'checkProjectStatus',
+ {
+ projectId: 'project-4',
+ status: ProjectStatus.BUSCANDO,
+ }
+ );
+ });
+});
From 21a28b627da3b32848e92cff0180a58f83f3c344 Mon Sep 17 00:00:00 2001
From: Usman Liaqat
Date: Mon, 6 Jul 2026 02:21:27 +0500
Subject: [PATCH 26/35] ci: configure sonar coverage report
---
sonar-project.properties | 1 +
1 file changed, 1 insertion(+)
diff --git a/sonar-project.properties b/sonar-project.properties
index c8dfc603..1be47a74 100644
--- a/sonar-project.properties
+++ b/sonar-project.properties
@@ -12,3 +12,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
From fef574d73c24cd8a5228793e60bf1a849e05ce2e Mon Sep 17 00:00:00 2001
From: Usman Liaqat
Date: Mon, 6 Jul 2026 02:31:22 +0500
Subject: [PATCH 27/35] fix: resolve team project route sonar issues
---
sonar-project.properties | 1 +
src/app/api/team-project/[id]/route.ts | 75 ++++++++++++++------------
2 files changed, 42 insertions(+), 34 deletions(-)
diff --git a/sonar-project.properties b/sonar-project.properties
index c8dfc603..1be47a74 100644
--- a/sonar-project.properties
+++ b/sonar-project.properties
@@ -12,3 +12,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/api/team-project/[id]/route.ts b/src/app/api/team-project/[id]/route.ts
index 91a9d850..a34f05c7 100644
--- a/src/app/api/team-project/[id]/route.ts
+++ b/src/app/api/team-project/[id]/route.ts
@@ -43,7 +43,7 @@ type StructuralProjectState = {
type IncomingProjectState = z.infer;
function sortedValues(values: string[]) {
- return [...values].sort();
+ return [...values].sort((first, second) => first.localeCompare(second));
}
function hasSameSkills(
@@ -89,6 +89,45 @@ function hasStructuralProjectChanges(
);
}
+async function applyProjectStackChanges(
+ projectId: string,
+ incomingStacks: NonNullable
+) {
+ const existingStacks = await prisma.projectStack.findMany({
+ where: { projectId },
+ select: { id: true, stackId: true, percentage: true },
+ });
+
+ const { toUpdate, toCreate, toDelete } = planProjectStackChanges(
+ existingStacks,
+ incomingStacks
+ );
+
+ for (const stackToDelete of toDelete) {
+ await prisma.stackTaken.deleteMany({
+ where: { projectStackId: stackToDelete.id },
+ });
+ 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 },
+ });
+ }
+
+ for (const stackToCreate of toCreate) {
+ await prisma.projectStack.create({
+ data: {
+ projectId,
+ stackId: stackToCreate.stackId,
+ percentage: stackToCreate.percentage,
+ },
+ });
+ }
+}
+
export async function GET(
request: NextRequest,
context: { params: { id: string } }
@@ -269,39 +308,7 @@ export async function PUT(
});
}
- 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 },
- });
- 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 },
- });
- }
-
- for (const stackToCreate of toCreate) {
- await prisma.projectStack.create({
- data: {
- projectId,
- stackId: stackToCreate.stackId,
- percentage: stackToCreate.percentage,
- },
- });
- }
+ await applyProjectStackChanges(projectId, stacks ?? []);
const updated = await prisma.project.update({
where: { id: projectId },
From cf1e4f268bdecae850f407bc961c50db9107b66e Mon Sep 17 00:00:00 2001
From: Usman Liaqat
Date: Mon, 6 Jul 2026 02:41:40 +0500
Subject: [PATCH 28/35] test: cover how to join invite flow
---
sonar-project.properties | 1 +
src/app/(public)/how-to-join/page.tsx | 7 +---
.../unit/pages/how-to-join-page.test.tsx | 37 +++++++++++++++++++
3 files changed, 39 insertions(+), 6 deletions(-)
create mode 100644 src/tests/unit/pages/how-to-join-page.test.tsx
diff --git a/sonar-project.properties b/sonar-project.properties
index c8dfc603..1be47a74 100644
--- a/sonar-project.properties
+++ b/sonar-project.properties
@@ -12,3 +12,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 6bc7d169..1ac65cb4 100644
--- a/src/app/(public)/how-to-join/page.tsx
+++ b/src/app/(public)/how-to-join/page.tsx
@@ -93,12 +93,7 @@ const participationPaths: ParticipationPath[] = [
},
];
-const entryProfiles = [
- 'Membro de equipe',
- 'Mentor técnico',
- 'Colaborador open source',
- 'Autor de projeto',
-];
+const entryProfiles = participationPaths.map((path) => path.title);
export default function HowToJoinPage() {
return (
diff --git a/src/tests/unit/pages/how-to-join-page.test.tsx b/src/tests/unit/pages/how-to-join-page.test.tsx
new file mode 100644
index 00000000..ea2dce71
--- /dev/null
+++ b/src/tests/unit/pages/how-to-join-page.test.tsx
@@ -0,0 +1,37 @@
+import { render, screen } from '@testing-library/react';
+
+import HowToJoinPage from '@/app/(public)/how-to-join/page';
+
+describe('HowToJoinPage', () => {
+ it('deve destacar o fluxo de solicitação de convite', () => {
+ render( );
+
+ expect(
+ screen.getByRole('heading', {
+ name: 'Como participar do TryCatch',
+ level: 1,
+ })
+ ).toBeInTheDocument();
+
+ expect(
+ screen.getByRole('link', { name: /Solicitar convite/i })
+ ).toHaveAttribute('href', '/invite-request');
+ });
+
+ it('deve renderizar perfis derivados dos caminhos de participação', () => {
+ render( );
+
+ for (const profile of [
+ 'Contribuir com a plataforma',
+ 'Cadastrar um projeto',
+ 'Participar como membro',
+ 'Participar como mentor',
+ ]) {
+ expect(screen.getAllByText(profile).length).toBeGreaterThanOrEqual(2);
+ }
+
+ expect(
+ screen.getByRole('link', { name: /Quero participar de uma equipe/i })
+ ).toHaveAttribute('href', '/invite-request?role=USER');
+ });
+});
From 22071c706643b1951b9b8fd3742f6977ce6ff80b Mon Sep 17 00:00:00 2001
From: Usman Liaqat
Date: Mon, 6 Jul 2026 04:03:22 +0500
Subject: [PATCH 29/35] fix: wrap dashboard summary response
---
src/app/api/dashboard/summary/route.ts | 11 ++++++-----
.../api/dashboard/dashboard-summary-route.test.ts | 13 +++++++++----
2 files changed, 15 insertions(+), 9 deletions(-)
diff --git a/src/app/api/dashboard/summary/route.ts b/src/app/api/dashboard/summary/route.ts
index b6db6166..3437fa75 100644
--- a/src/app/api/dashboard/summary/route.ts
+++ b/src/app/api/dashboard/summary/route.ts
@@ -1,5 +1,4 @@
import { ProjectStatus } from '@prisma/client';
-import { NextResponse } from 'next/server';
import { MESSAGES, buildResponse } from '@/constants/messages';
import { checkAuth } from '@/lib/check-auth';
@@ -65,14 +64,16 @@ export async function GET() {
}
);
- return NextResponse.json(
- {
+ return buildResponse({
+ success: true,
+ message: MESSAGES.GENERAL.SUCCESS,
+ data: {
...projectCounts,
skills,
feedbacks,
},
- { status: 200 }
- );
+ status: 200,
+ });
} catch (error) {
logger.error(
'Erro ao buscar resumo do dashboard:',
diff --git a/src/tests/unit/api/dashboard/dashboard-summary-route.test.ts b/src/tests/unit/api/dashboard/dashboard-summary-route.test.ts
index 7313c3cb..d8a90f1e 100644
--- a/src/tests/unit/api/dashboard/dashboard-summary-route.test.ts
+++ b/src/tests/unit/api/dashboard/dashboard-summary-route.test.ts
@@ -64,10 +64,15 @@ describe('GET /api/dashboard/summary', () => {
expect(response.status).toBe(200);
expect(body).toEqual({
- projectsInProgress: 1,
- projectsCompleted: 2,
- skills: 4,
- feedbacks: 6,
+ success: true,
+ message: 'Request realizada com sucesso',
+ data: {
+ projectsInProgress: 1,
+ projectsCompleted: 2,
+ skills: 4,
+ feedbacks: 6,
+ },
+ errors: null,
});
expect(checkAuth).toHaveBeenCalledWith({
From b642441ae447e296898c5e4aa89850e3d392c8b2 Mon Sep 17 00:00:00 2001
From: Usman Liaqat
Date: Mon, 6 Jul 2026 04:11:00 +0500
Subject: [PATCH 30/35] test: cover user route negative paths
---
sonar-project.properties | 1 +
src/tests/unit/api/user/user-id-route.test.ts | 131 ++++++++++++++++++
2 files changed, 132 insertions(+)
diff --git a/sonar-project.properties b/sonar-project.properties
index c8dfc603..1be47a74 100644
--- a/sonar-project.properties
+++ b/sonar-project.properties
@@ -12,3 +12,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/tests/unit/api/user/user-id-route.test.ts b/src/tests/unit/api/user/user-id-route.test.ts
index ab8e2a4d..5745880a 100644
--- a/src/tests/unit/api/user/user-id-route.test.ts
+++ b/src/tests/unit/api/user/user-id-route.test.ts
@@ -118,6 +118,48 @@ describe('GET /api/user/[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]', () => {
@@ -157,6 +199,52 @@ describe('PUT /api/user/[id]', () => {
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 })
@@ -229,6 +317,49 @@ describe('DELETE /api/user/[id]', () => {
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({
From 25df558f8100103dff11bde5896c55d3594f5625 Mon Sep 17 00:00:00 2001
From: Usman Liaqat
Date: Mon, 6 Jul 2026 04:15:04 +0500
Subject: [PATCH 31/35] docs: include install and format validation
---
docs/04 - processo/ci-e-validacao.md | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/docs/04 - processo/ci-e-validacao.md b/docs/04 - processo/ci-e-validacao.md
index b49d2294..e50bf7aa 100644
--- a/docs/04 - processo/ci-e-validacao.md
+++ b/docs/04 - processo/ci-e-validacao.md
@@ -31,6 +31,8 @@ Antes de abrir um Pull Request, siga este fluxo:
3. Rode as validações locais:
```bash
+npm ci
+npm run format
npm run lint
npm run test
npm run test:push
@@ -45,6 +47,8 @@ npm run build
Para mudanças apenas em documentação, rode pelo menos:
```bash
+npm ci
+npm run format
npm run lint
```
@@ -234,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
@@ -273,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
From d4b653e12842a9e815a06d75cad7cb340631aed9 Mon Sep 17 00:00:00 2001
From: Usman Liaqat
Date: Mon, 6 Jul 2026 04:19:51 +0500
Subject: [PATCH 32/35] fix: make project completion update atomic
---
src/app/api/team-project/user/route.ts | 28 +++++++++++--------
.../team-project-user-route.test.ts | 5 ++++
2 files changed, 22 insertions(+), 11 deletions(-)
diff --git a/src/app/api/team-project/user/route.ts b/src/app/api/team-project/user/route.ts
index 148f1804..b47b5912 100644
--- a/src/app/api/team-project/user/route.ts
+++ b/src/app/api/team-project/user/route.ts
@@ -157,14 +157,25 @@ export async function PATCH(request: NextRequest) {
});
}
- const totalProjectStacks = await prisma.projectStack.count({
- where: { projectId },
- });
- const totalStackTaken = await prisma.stackTaken.count({
- where: { projectId },
+ 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 (totalProjectStacks === 0 || totalStackTaken < totalProjectStacks) {
+ if (!updated) {
return buildResponse({
success: false,
message: MESSAGES.GENERAL.INVALID_DATA,
@@ -173,11 +184,6 @@ export async function PATCH(request: NextRequest) {
});
}
- const updated = await prisma.project.update({
- where: { id: projectId },
- data: { status },
- });
-
// se houver lógica de verificação pós-update
await checkProjectStatus(projectId);
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
index 42f3bec7..9f1a150b 100644
--- 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
@@ -12,6 +12,7 @@ import { NextRequest } from 'next/server';
jest.mock('@/lib/prisma', () => ({
prisma: {
+ $transaction: jest.fn(),
project: {
findUnique: jest.fn(),
update: jest.fn(),
@@ -68,6 +69,9 @@ describe('PATCH /api/team-project/user', () => {
(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);
});
@@ -79,6 +83,7 @@ describe('PATCH /api/team-project/user', () => {
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 },
From 7b983b1dd88025ef8469421371646d175fbf5a9d Mon Sep 17 00:00:00 2001
From: Usman Liaqat
Date: Mon, 6 Jul 2026 04:25:01 +0500
Subject: [PATCH 33/35] fix: make project edit updates atomic
---
src/app/api/team-project/[id]/route.ts | 48 ++++++++++---------
.../team-project-id-route.test.ts | 5 ++
2 files changed, 31 insertions(+), 22 deletions(-)
diff --git a/src/app/api/team-project/[id]/route.ts b/src/app/api/team-project/[id]/route.ts
index a34f05c7..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';
@@ -90,10 +91,11 @@ function hasStructuralProjectChanges(
}
async function applyProjectStackChanges(
+ tx: Prisma.TransactionClient,
projectId: string,
incomingStacks: NonNullable
) {
- const existingStacks = await prisma.projectStack.findMany({
+ const existingStacks = await tx.projectStack.findMany({
where: { projectId },
select: { id: true, stackId: true, percentage: true },
});
@@ -104,21 +106,21 @@ async function applyProjectStackChanges(
);
for (const stackToDelete of toDelete) {
- await prisma.stackTaken.deleteMany({
+ await tx.stackTaken.deleteMany({
where: { projectStackId: stackToDelete.id },
});
- await prisma.projectStack.delete({ where: { id: stackToDelete.id } });
+ await tx.projectStack.delete({ where: { id: stackToDelete.id } });
}
for (const stackToUpdate of toUpdate) {
- await prisma.projectStack.update({
+ await tx.projectStack.update({
where: { id: stackToUpdate.id },
data: { percentage: stackToUpdate.percentage },
});
}
for (const stackToCreate of toCreate) {
- await prisma.projectStack.create({
+ await tx.projectStack.create({
data: {
projectId,
stackId: stackToCreate.stackId,
@@ -308,24 +310,26 @@ export async function PUT(
});
}
- await applyProjectStackChanges(projectId, stacks ?? []);
-
- 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 } },
- })),
+ const updated = await prisma.$transaction(async (tx) => {
+ await applyProjectStackChanges(tx, projectId, stacks ?? []);
+
+ return tx.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/tests/unit/api/team-project/team-project-id-route.test.ts b/src/tests/unit/api/team-project/team-project-id-route.test.ts
index 49f910ea..ca47e4de 100644
--- 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
@@ -12,6 +12,7 @@ import { NextRequest } from 'next/server';
jest.mock('@/lib/prisma', () => ({
prisma: {
+ $transaction: jest.fn(),
project: {
findUnique: jest.fn(),
update: jest.fn(),
@@ -109,6 +110,9 @@ describe('PUT /api/team-project/[id]', () => {
id: projectId,
...validPayload,
});
+ (prisma.$transaction as jest.Mock).mockImplementation(async (callback) =>
+ callback(prisma)
+ );
(checkProjectStatus as jest.Mock).mockResolvedValue(null);
});
@@ -124,6 +128,7 @@ describe('PUT /api/team-project/[id]', () => {
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: {
From 88b8890a8f42be0d16f003ff8150826a20d4ff09 Mon Sep 17 00:00:00 2001
From: Usman Liaqat
Date: Tue, 7 Jul 2026 04:25:30 +0500
Subject: [PATCH 34/35] feat: add dashboard skills count endpoint (#669)
* feat: add dashboard skills count endpoint
Fixes: #640
* ci: configure sonar coverage report
---
src/app/api/dashboard/skills/route.ts | 36 ++++++
src/lib/dashboard-summary.ts | 9 ++
.../dashboard/dashboard-skills-route.test.ts | 112 ++++++++++++++++++
3 files changed, 157 insertions(+)
create mode 100644 src/app/api/dashboard/skills/route.ts
create mode 100644 src/lib/dashboard-summary.ts
create mode 100644 src/tests/unit/api/dashboard/dashboard-skills-route.test.ts
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/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/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',
+ }
+ );
+ });
+});
From 18bbe5698a820d31c313aed444cd8daa8691c08f Mon Sep 17 00:00:00 2001
From: Karina Peres <134704973+karinaperes@users.noreply.github.com>
Date: Mon, 6 Jul 2026 21:49:19 -0300
Subject: [PATCH 35/35] Update team-project-user-route.test.ts
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
correção de lint
---
src/tests/unit/api/team-project/team-project-user-route.test.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
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
index 4fd6d501..6008aa3a 100644
--- 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
@@ -97,7 +97,7 @@ describe('PATCH /api/team-project/user', () => {
authorizeUser();
(prisma.project.findUnique as jest.Mock).mockResolvedValue(mockProject());
- (prisma.projectStack as any).count.mockResolvedValue(2);
+ (prisma.projectStack as jest.Mock).count.mockResolvedValue(2);
(prisma.stackTaken.count as jest.Mock).mockResolvedValue(2);
(prisma.project.update as jest.Mock).mockResolvedValue(