Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 52 additions & 3 deletions src/app/api/team-project/user/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@
}
}

export async function PATCH(request: NextRequest) {

Check failure on line 99 in src/app/api/team-project/user/route.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 16 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=TryCatch-ForMatch_trycatch&issues=AZ86EQ_j6X5OAj3YEEMt&open=AZ86EQ_j6X5OAj3YEEMt&pullRequest=679
// PATCH -> alterar somente o status do projeto (ex: marcar como CONCLUIDO)
const { authorized, response, session } = await checkAuth({
allowedRoles: ROLE_GROUPS.ALL,
Expand Down Expand Up @@ -138,11 +138,60 @@
});
}

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);

Expand Down
173 changes: 159 additions & 14 deletions src/tests/unit/api/team-project/team-project-user-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
},
},
Expand All @@ -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();
Expand Down Expand Up @@ -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',
}
);
});
});
Loading