From 7583f12dba104dbd491474f78548e427e1fe9e69 Mon Sep 17 00:00:00 2001 From: Usman Liaqat Date: Sun, 5 Jul 2026 10:59:15 +0500 Subject: [PATCH 1/3] 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 294be02..91a9d85 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 0000000..49f910e --- /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 fef574d73c24cd8a5228793e60bf1a849e05ce2e Mon Sep 17 00:00:00 2001 From: Usman Liaqat Date: Mon, 6 Jul 2026 02:31:22 +0500 Subject: [PATCH 2/3] 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 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 diff --git a/src/app/api/team-project/[id]/route.ts b/src/app/api/team-project/[id]/route.ts index 91a9d85..a34f05c 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 7b983b1dd88025ef8469421371646d175fbf5a9d Mon Sep 17 00:00:00 2001 From: Usman Liaqat Date: Mon, 6 Jul 2026 04:25:01 +0500 Subject: [PATCH 3/3] 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 a34f05c..465fea6 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 49f910e..ca47e4d 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: {