diff --git a/src/app/api/team-project/user/route.ts b/src/app/api/team-project/user/route.ts index fc16dff..9d41a2c 100644 --- a/src/app/api/team-project/user/route.ts +++ b/src/app/api/team-project/user/route.ts @@ -138,11 +138,60 @@ export async function PATCH(request: NextRequest) { }); } - const updated = await prisma.project.update({ - where: { id: projectId }, - data: { status }, + if (status !== ProjectStatus.CONCLUIDO) { + return buildResponse({ + success: false, + message: MESSAGES.GENERAL.INVALID_DATA, + status: 400, + errors: ['O encerramento manual só permite status CONCLUIDO.'], + }); + } + + if (existing.ownerId !== session.user.id) { + return buildResponse({ + success: false, + message: MESSAGES.AUTH.UNAUTHORIZED, + status: 403, + errors: ['Apenas o owner pode concluir este projeto.'], + }); + } + + if (existing.status !== ProjectStatus.EM_ANDAMENTO) { + return buildResponse({ + success: false, + message: MESSAGES.GENERAL.INVALID_DATA, + status: 400, + errors: ['Projeto só pode ser concluído em status EM_ANDAMENTO.'], + }); + } + + const updated = await prisma.$transaction(async (tx) => { + const totalProjectStacks = await tx.projectStack.count({ + where: { projectId }, + }); + const totalStackTaken = await tx.stackTaken.count({ + where: { projectId }, + }); + + if (totalProjectStacks === 0 || totalStackTaken < totalProjectStacks) { + return null; + } + + return tx.project.update({ + where: { id: projectId }, + data: { status }, + }); }); + if (!updated) { + return buildResponse({ + success: false, + message: MESSAGES.GENERAL.INVALID_DATA, + status: 400, + errors: ['Todas as stacks devem estar assumidas para concluir.'], + }); + } + // se houver lógica de verificação pós-update await checkProjectStatus(projectId); 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 a412d45..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 @@ -3,19 +3,27 @@ */ import { GET, PATCH } from '@/app/api/team-project/user/route'; +import { MESSAGES } from '@/constants/messages'; import { checkAuth } from '@/lib/check-auth'; import { logger } from '@/lib/logger'; +import { checkProjectStatus } from '@/lib/check-project-status'; import { prisma } from '@/lib/prisma'; +import { ProjectStatus } from '@prisma/client'; import { NextRequest } from 'next/server'; jest.mock('@/lib/prisma', () => ({ prisma: { + $transaction: jest.fn(), project: { findMany: jest.fn(), findUnique: jest.fn(), update: jest.fn(), }, + projectStack: { + count: jest.fn(), + }, stackTaken: { + count: jest.fn(), findMany: jest.fn(), }, }, @@ -29,21 +37,36 @@ jest.mock('@/lib/check-project-status', () => ({ checkProjectStatus: jest.fn(), })); +const projectId = 'project-123456789012345678'; +const ownerId = 'owner-1'; + +const createRequest = (body: unknown) => + ({ + json: async () => body, + }) as NextRequest; + jest.mock('@/lib/logger', () => ({ logger: { error: jest.fn(), }, })); -const authorizeUser = () => { +const authorizeUser = (id = ownerId, role = 'USER') => { (checkAuth as jest.Mock).mockResolvedValue({ authorized: true, session: { - user: { id: 'user-1', role: 'USER' }, + user: { id, role }, }, }); }; +const mockProject = (overrides = {}) => ({ + id: projectId, + ownerId, + status: ProjectStatus.EM_ANDAMENTO, + ...overrides, +}); + describe('GET /api/team-project/user', () => { beforeEach(() => { jest.clearAllMocks(); @@ -72,35 +95,157 @@ describe('PATCH /api/team-project/user', () => { beforeEach(() => { jest.clearAllMocks(); authorizeUser(); + + (prisma.project.findUnique as jest.Mock).mockResolvedValue(mockProject()); + (prisma.projectStack as jest.Mock).count.mockResolvedValue(2); + (prisma.stackTaken.count as jest.Mock).mockResolvedValue(2); + + (prisma.project.update as jest.Mock).mockResolvedValue( + mockProject({ status: ProjectStatus.CONCLUIDO }) + ); + + (prisma.$transaction as jest.Mock).mockImplementation(async (callback) => + callback(prisma) + ); + + (checkProjectStatus as jest.Mock).mockResolvedValue(null); }); - it('deve registrar erro estruturado quando atualização falhar', async () => { - const projectId = '1234567890123456789012345'; + it('conclui projeto quando owner, status e stacks são elegíveis', async () => { + const response = await PATCH( + createRequest({ + id: projectId, + status: ProjectStatus.CONCLUIDO, + }) + ); + + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.status).toBe(ProjectStatus.CONCLUIDO); + + expect(prisma.$transaction).toHaveBeenCalledWith(expect.any(Function)); + + expect(prisma.project.update).toHaveBeenCalledWith({ + where: { id: projectId }, + data: { status: ProjectStatus.CONCLUIDO }, + }); + + expect(checkProjectStatus).toHaveBeenCalledWith(projectId); + }); + + it('bloqueia conclusão quando usuário não é owner', async () => { + authorizeUser('user-2'); + + const response = await PATCH( + createRequest({ + id: projectId, + status: ProjectStatus.CONCLUIDO, + }) + ); + + const body = await response.json(); + + expect(response.status).toBe(403); + + expect(body).toMatchObject({ + success: false, + message: MESSAGES.AUTH.UNAUTHORIZED, + }); + + expect(prisma.project.update).not.toHaveBeenCalled(); + }); + + it('bloqueia status diferente de CONCLUIDO', async () => { + const response = await PATCH( + createRequest({ + id: projectId, + status: ProjectStatus.BUSCANDO, + }) + ); + + const body = await response.json(); + + expect(response.status).toBe(400); - (prisma.project.findUnique as jest.Mock).mockResolvedValue({ - id: projectId, - ownerId: 'user-1', + expect(body).toMatchObject({ + success: false, + message: MESSAGES.GENERAL.INVALID_DATA, }); + + expect(prisma.project.update).not.toHaveBeenCalled(); + }); + + it('bloqueia conclusão quando projeto não está EM_ANDAMENTO', async () => { + (prisma.project.findUnique as jest.Mock).mockResolvedValue( + mockProject({ + status: ProjectStatus.BUSCANDO, + }) + ); + + const response = await PATCH( + createRequest({ + id: projectId, + status: ProjectStatus.CONCLUIDO, + }) + ); + + const body = await response.json(); + + expect(response.status).toBe(400); + + expect(body.errors).toContain( + 'Projeto só pode ser concluído em status EM_ANDAMENTO.' + ); + + expect(prisma.project.update).not.toHaveBeenCalled(); + }); + + it('bloqueia conclusão quando existem stacks não assumidas', async () => { + (prisma.projectStack.count as jest.Mock).mockResolvedValue(3); + (prisma.stackTaken.count as jest.Mock).mockResolvedValue(2); + + const response = await PATCH( + createRequest({ + id: projectId, + status: ProjectStatus.CONCLUIDO, + }) + ); + + const body = await response.json(); + + expect(response.status).toBe(400); + + expect(body.errors).toContain( + 'Todas as stacks devem estar assumidas para concluir.' + ); + + expect(prisma.project.update).not.toHaveBeenCalled(); + }); + + it('deve registrar erro estruturado quando atualização falhar', async () => { (prisma.project.update as jest.Mock).mockRejectedValue( new Error('Update failed') ); - const request = { - json: async () => ({ + const response = await PATCH( + createRequest({ id: projectId, - status: 'CONCLUIDO', - }), - } as unknown as NextRequest; + status: ProjectStatus.CONCLUIDO, + }) + ); - 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' } + { + error: 'Update failed', + } ); }); });