diff --git a/src/actions/projects/updateProject/action.ts b/src/actions/projects/updateProject/action.ts index 3bfb26a..888e4be 100644 --- a/src/actions/projects/updateProject/action.ts +++ b/src/actions/projects/updateProject/action.ts @@ -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, @@ -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); } @@ -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); } - }); diff --git a/src/actions/projects/updateProject/logic.test.ts b/src/actions/projects/updateProject/logic.test.ts new file mode 100644 index 0000000..dc1aa70 --- /dev/null +++ b/src/actions/projects/updateProject/logic.test.ts @@ -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(); + }); + + 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(); + }); +}); \ No newline at end of file diff --git a/src/actions/projects/updateProject/logic.ts b/src/actions/projects/updateProject/logic.ts index 07442e4..5723512 100644 --- a/src/actions/projects/updateProject/logic.ts +++ b/src/actions/projects/updateProject/logic.ts @@ -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> { - 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 + } } diff --git a/src/actions/projects/updateProject/schema.ts b/src/actions/projects/updateProject/schema.ts index a26a877..7b72ff8 100644 --- a/src/actions/projects/updateProject/schema.ts +++ b/src/actions/projects/updateProject/schema.ts @@ -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;