diff --git a/apps/server/trpc/routers/project.test.ts b/apps/server/trpc/routers/project.test.ts new file mode 100644 index 0000000..4e0beb7 --- /dev/null +++ b/apps/server/trpc/routers/project.test.ts @@ -0,0 +1,92 @@ +/** + * Tests for registerProject CONFLICT error disambiguation. + * Verifies that UNIQUE constraint failures on specific columns produce + * column-specific error messages. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { TRPCError } from '@trpc/server'; +import { router, publicProcedure, createCallerFactory } from '../trpc.js'; +import { projectProcedures } from './project.js'; +import type { TRPCContext } from '../context.js'; +import type { ProjectRepository } from '../../db/repositories/project-repository.js'; + +const testRouter = router({ + ...projectProcedures(publicProcedure), +}); + +const createCaller = createCallerFactory(testRouter); + +function makeCtx(mockCreate: () => Promise): TRPCContext { + const projectRepository: ProjectRepository = { + create: mockCreate as unknown as ProjectRepository['create'], + findById: vi.fn().mockResolvedValue(null), + findByName: vi.fn().mockResolvedValue(null), + findAll: vi.fn().mockResolvedValue([]), + update: vi.fn(), + delete: vi.fn(), + addProjectToInitiative: vi.fn(), + removeProjectFromInitiative: vi.fn(), + findProjectsByInitiativeId: vi.fn().mockResolvedValue([]), + setInitiativeProjects: vi.fn(), + }; + + return { + eventBus: {} as TRPCContext['eventBus'], + serverStartedAt: null, + processCount: 0, + projectRepository, + // No workspaceRoot — prevents cloneProject from running + }; +} + +const INPUT = { name: 'my-project', url: 'https://github.com/example/repo' }; + +describe('registerProject — CONFLICT error disambiguation', () => { + it('throws CONFLICT with name-specific message on projects.name UNIQUE violation', async () => { + const caller = createCaller(makeCtx(() => { + throw new Error('UNIQUE constraint failed: projects.name'); + })); + + const err = await caller.registerProject(INPUT).catch(e => e); + expect(err).toBeInstanceOf(TRPCError); + expect(err.code).toBe('CONFLICT'); + expect(err.message).toBe('A project with this name already exists'); + }); + + it('throws CONFLICT with url-specific message on projects.url UNIQUE violation', async () => { + const caller = createCaller(makeCtx(() => { + throw new Error('UNIQUE constraint failed: projects.url'); + })); + + const err = await caller.registerProject(INPUT).catch(e => e); + expect(err).toBeInstanceOf(TRPCError); + expect(err.code).toBe('CONFLICT'); + expect(err.message).toBe('A project with this URL already exists'); + }); + + it('throws CONFLICT with fallback message on unknown UNIQUE constraint violation', async () => { + const caller = createCaller(makeCtx(() => { + throw new Error('UNIQUE constraint failed: projects.unknown_col'); + })); + + const err = await caller.registerProject(INPUT).catch(e => e); + expect(err).toBeInstanceOf(TRPCError); + expect(err.code).toBe('CONFLICT'); + expect(err.message).toBe('A project with this name or URL already exists'); + }); + + it('rethrows non-UNIQUE errors without wrapping in a CONFLICT', async () => { + const originalError = new Error('SQLITE_BUSY'); + const caller = createCaller(makeCtx(() => { + throw originalError; + })); + + const err = await caller.registerProject(INPUT).catch(e => e); + expect(err).toBeDefined(); + // Must not be surfaced as a CONFLICT — the catch block should re-throw as-is + expect(err).not.toMatchObject({ code: 'CONFLICT' }); + // The original error message must be preserved somewhere + expect(err.message).toContain('SQLITE_BUSY'); + }); +}); diff --git a/apps/server/trpc/routers/project.ts b/apps/server/trpc/routers/project.ts index 5d79400..cb0188e 100644 --- a/apps/server/trpc/routers/project.ts +++ b/apps/server/trpc/routers/project.ts @@ -30,11 +30,24 @@ export function projectProcedures(publicProcedure: ProcedureBuilder) { ...(input.defaultBranch && { defaultBranch: input.defaultBranch }), }); } catch (error) { - const msg = (error as Error).message; + const msg = (error as Error).message ?? ''; if (msg.includes('UNIQUE') || msg.includes('unique')) { + if (msg.includes('projects.name') || (msg.includes('name') && !msg.includes('url'))) { + throw new TRPCError({ + code: 'CONFLICT', + message: 'A project with this name already exists', + }); + } + if (msg.includes('projects.url') || msg.includes('url')) { + throw new TRPCError({ + code: 'CONFLICT', + message: 'A project with this URL already exists', + }); + } + // fallback: neither column identifiable throw new TRPCError({ code: 'CONFLICT', - message: `A project with that name or URL already exists`, + message: 'A project with this name or URL already exists', }); } throw error; diff --git a/apps/web/src/components/RegisterProjectDialog.tsx b/apps/web/src/components/RegisterProjectDialog.tsx index e0b42b9..f3b2937 100644 --- a/apps/web/src/components/RegisterProjectDialog.tsx +++ b/apps/web/src/components/RegisterProjectDialog.tsx @@ -10,6 +10,7 @@ import { import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { Loader2 } from "lucide-react"; import { toast } from "sonner"; import { trpc } from "@/lib/trpc"; @@ -27,13 +28,20 @@ export function RegisterProjectDialog({ const [defaultBranch, setDefaultBranch] = useState("main"); const [error, setError] = useState(null); + const utils = trpc.useUtils(); + const registerMutation = trpc.registerProject.useMutation({ onSuccess: () => { onOpenChange(false); toast.success("Project registered"); + void utils.listProjects.invalidate(); }, onError: (err) => { - setError(err.message); + if (err.data?.code === "INTERNAL_SERVER_ERROR") { + setError("Failed to clone repository. Check the URL and try again."); + } else { + setError(err.message); + } }, }); @@ -109,7 +117,14 @@ export function RegisterProjectDialog({ Cancel