From 0401145ce35708f641a0a9c0dfb3085c70d5c86e Mon Sep 17 00:00:00 2001 From: Usman Liaqat Date: Sat, 4 Jul 2026 23:14:00 +0500 Subject: [PATCH 1/4] 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 271dd14..148f180 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 0000000..42f3bec --- /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 21a28b627da3b32848e92cff0180a58f83f3c344 Mon Sep 17 00:00:00 2001 From: Usman Liaqat Date: Mon, 6 Jul 2026 02:21:27 +0500 Subject: [PATCH 2/4] 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 c8dfc60..1be47a7 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 d4b653e12842a9e815a06d75cad7cb340631aed9 Mon Sep 17 00:00:00 2001 From: Usman Liaqat Date: Mon, 6 Jul 2026 04:19:51 +0500 Subject: [PATCH 3/4] 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 148f180..b47b591 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 42f3bec..9f1a150 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 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 4/4] 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 4fd6d50..6008aa3 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(