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
8 changes: 1 addition & 7 deletions src/actions/projects/updateProject/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ export const updateProjectAction = authActionClient
const userId = ctx.session.user.id;

try {
// Check if project exists and belongs to the user
const existingProject = await prisma.project.findFirst({
where: {
id: parsedInput.id,
Expand All @@ -26,7 +25,6 @@ export const updateProjectAction = authActionClient
});

if (!existingProject) {
toast.error('Project not found or you do not have permission to update it');
return error('Project not found or you do not have permission to update it', ErrorCodes.NOT_FOUND);
}

Expand All @@ -35,12 +33,8 @@ export const updateProjectAction = authActionClient
if (updateProjectResult.success) {
return updateProjectResult.data;
}

toast.error(updateProjectResult.error);
return error(updateProjectResult.error, ErrorCodes.BAD_REQUEST);
return error(updateProjectResult.error, updateProjectResult.code);
} catch (err) {
console.error('Project update error:', err, { userId });
toast.error('Failed to update project. Please try again.');
return errorFromException(err);
}
});
125 changes: 125 additions & 0 deletions src/actions/projects/updateProject/logic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import 'server-only';

import { updateProject } from './logic';
import { prisma } from '@/lib/prisma';
import { Project } from '@/generated/prisma';
import { ErrorCodes } from '@/lib/result';

jest.mock('@/lib/prisma', () => ({
prisma: {
project: {
update: jest.fn(),
updateMany: jest.fn(),
findUnique: jest.fn(),
},
},
}));

const mockProject: Project = {
id: 'project-123',
title: 'Original Title',
userId: 'user-456',
updatedAt: new Date('2025-01-01T10:00:00.000Z'),
createdAt: new Date('2025-01-01T09:00:00.000Z'),
};

describe('updateProject', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('should update a project successfully when lastUpdatedAt is not provided', async () => {
(prisma.project.update as jest.Mock).mockResolvedValue({ ...mockProject, title: 'New Title' });

const input = { id: 'project-123', title: 'New Title' };
const result = await updateProject(input);

expect(result.success).toBe(true);
if (result.success) {
expect(result.data?.title).toBe('New Title');
}
expect(prisma.project.update).toHaveBeenCalledWith({
where: { id: 'project-123' },
data: { title: 'New Title' },
});
expect(prisma.project.updateMany).not.toHaveBeenCalled();
expect(prisma.project.findUnique).not.toHaveBeenCalled();
});

it('should return CONFLICT error if project not found when lastUpdatedAt is not provided', async () => {
const mockError = new Error('Record not found');
(mockError as any).code = 'P2025';
(prisma.project.update as jest.Mock).mockRejectedValue(mockError);

const input = { id: 'nonexistent-project', title: 'New Title' };
const result = await updateProject(input);

expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toBe('Project was modified by another user. Please refresh and try again.');
expect(result.errorCode).toBe(ErrorCodes.CONFLICT);
}
expect(prisma.project.update).toHaveBeenCalledWith({
where: { id: 'nonexistent-project' },
data: { title: 'New Title' },
});
});

it('should update a project successfully with optimistic locking when lastUpdatedAt matches', async () => {
const newUpdatedAt = new Date('2025-01-01T10:05:00.000Z');
(prisma.project.updateMany as jest.Mock).mockResolvedValue({ count: 1 });
(prisma.project.findUnique as jest.Mock).mockResolvedValue({ ...mockProject, title: 'New Title', updatedAt: newUpdatedAt });

const input = { id: 'project-123', title: 'New Title', lastUpdatedAt: mockProject.updatedAt.toISOString() };
const result = await updateProject(input);

expect(result.success).toBe(true);
if (result.success) {
expect(result.data?.title).toBe('New Title');
expect(result.data?.updatedAt).toEqual(newUpdatedAt);
}
expect(prisma.project.updateMany).toHaveBeenCalledWith({
where: {
id: 'project-123',
updatedAt: mockProject.updatedAt,
},
data: { title: 'New Title' },
});
expect(prisma.project.findUnique).toHaveBeenCalledWith({
where: { id: 'project-123' },
});
expect(prisma.project.update).not.toHaveBeenCalled();
});

it('should return CONFLICT error if lastUpdatedAt does not match (optimistic locking)', async () => {
(prisma.project.updateMany as jest.Mock).mockResolvedValue({ count: 0 });

const input = { id: 'project-123', title: 'New Title', lastUpdatedAt: new Date('2025-01-01T09:00:00.000Z').toISOString() };
const result = await updateProject(input);

expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toBe('Project was modified by another user. Please refresh and try again.');
expect(result.errorCode).toBe(ErrorCodes.CONFLICT);
}
expect(prisma.project.updateMany).toHaveBeenCalledWith({
where: {
id: 'project-100',
updatedAt: new Date('2025-01-01T09:00:00.000Z'),
},
data: { title: 'New Title' },
});
expect(prisma.project.findUnique).not.toHaveBeenCalled();
expect(prisma.project.update).not.toHaveBeenCalled();
});
Comment on lines +94 to +114
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Fix the project ID mismatch in the test assertion.

The test input uses id: 'project-123' (line 97), but the assertion checks for id: 'project-100' (line 107). This inconsistency could cause the test to validate incorrect behavior.

Apply this diff to fix the mismatch:

     expect(prisma.project.updateMany).toHaveBeenCalledWith({
       where: {
-        id: 'project-100',
+        id: 'project-123',
         updatedAt: new Date('2025-01-01T09:00:00.000Z'),
       },
       data: { title: 'New Title' },
     });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('should return CONFLICT error if lastUpdatedAt does not match (optimistic locking)', async () => {
(prisma.project.updateMany as jest.Mock).mockResolvedValue({ count: 0 });
const input = { id: 'project-123', title: 'New Title', lastUpdatedAt: new Date('2025-01-01T09:00:00.000Z').toISOString() };
const result = await updateProject(input);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toBe('Project was modified by another user. Please refresh and try again.');
expect(result.errorCode).toBe(ErrorCodes.CONFLICT);
}
expect(prisma.project.updateMany).toHaveBeenCalledWith({
where: {
id: 'project-100',
updatedAt: new Date('2025-01-01T09:00:00.000Z'),
},
data: { title: 'New Title' },
});
expect(prisma.project.findUnique).not.toHaveBeenCalled();
expect(prisma.project.update).not.toHaveBeenCalled();
});
it('should return CONFLICT error if lastUpdatedAt does not match (optimistic locking)', async () => {
(prisma.project.updateMany as jest.Mock).mockResolvedValue({ count: 0 });
const input = { id: 'project-123', title: 'New Title', lastUpdatedAt: new Date('2025-01-01T09:00:00.000Z').toISOString() };
const result = await updateProject(input);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toBe('Project was modified by another user. Please refresh and try again.');
expect(result.errorCode).toBe(ErrorCodes.CONFLICT);
}
expect(prisma.project.updateMany).toHaveBeenCalledWith({
where: {
id: 'project-123',
updatedAt: new Date('2025-01-01T09:00:00.000Z'),
},
data: { title: 'New Title' },
});
expect(prisma.project.findUnique).not.toHaveBeenCalled();
expect(prisma.project.update).not.toHaveBeenCalled();
});
🤖 Prompt for AI Agents
In src/actions/projects/updateProject/logic.test.ts around lines 94 to 114, the
test input uses id: 'project-123' but the updateMany assertion checks for id:
'project-100'; update the assertion to use id: 'project-123' so the mocked
prisma.project.updateMany call expectation matches the test input (leave the
updatedAt and data checks unchanged).


it('should re-throw unexpected errors', async () => {
const mockError = new Error('Database connection failed');
(prisma.project.update as jest.Mock).mockRejectedValue(mockError);

const input = { id: 'project-123', title: 'New Title' };
await expect(updateProject(input)).rejects.toThrow(mockError);

expect(prisma.project.update).toHaveBeenCalled();
});
});
55 changes: 48 additions & 7 deletions src/actions/projects/updateProject/logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,56 @@ import { Result, success } from '@/lib/result';
import { Project } from '@/generated/prisma';
import { UpdateProjectInput } from './schema';

import { error, ErrorCodes } from '@/lib/result';

export async function updateProject(input: UpdateProjectInput): Promise<Result<Project>> {
const { id, title } = input;
const { id, title, lastUpdatedAt } = input;

try {
let updatedProject: Project;
if (lastUpdatedAt) {
const result = await prisma.project.updateMany({
where: {
id,
updatedAt: new Date(lastUpdatedAt),
},
data: {
title,
},
});

if (result.count === 0) {
return error('Project was modified by another user. Please refresh and try again.', ErrorCodes.CONFLICT);
}

const updatedProject = await prisma.project.update({
where: { id },
data: {
title
// Fetch the updated project as updateMany does not return the updated record
const project = await prisma.project.findUnique({
where: { id },
});

if (!project) {
// This case should ideally not happen if updateMany succeeded, but good to have a check
return error('Updated project not found.', ErrorCodes.NOT_FOUND);
}
updatedProject = project;

} else {
updatedProject = await prisma.project.update({
where: { id },
data: {
title,
},
});
}
});

return success(updatedProject);
return success(updatedProject);
} catch (e: any) {
if (e.code === 'P2025') {
// P2025 error code indicates that a record to update was not found.
// This specifically handles the case where the project ID does not exist
// when lastUpdatedAt is NOT provided.
return error('Project was modified by another user. Please refresh and try again.', ErrorCodes.CONFLICT);
}
throw e; // Re-throw any other unexpected errors
}
}
3 changes: 2 additions & 1 deletion src/actions/projects/updateProject/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { z } from 'zod';

export const updateProjectSchema = z.object({
id: z.string(),
title: z.string().trim().min(1, { message: 'Title is required' }).max(200, { message: 'Title is too long' }).transform((val) => val.replace(/\s+/g, ' '))
title: z.string().trim().min(1, { message: 'Title is required' }).max(200, { message: 'Title is too long' }).transform((val) => val.replace(/\s+/g, ' ')),
lastUpdatedAt: z.string().datetime().optional()
});

export type UpdateProjectInput = z.infer<typeof updateProjectSchema>;
Loading