Merge branch 'cw/project-management-operations-to-ui' into cw-merge-1772805244951

This commit is contained in:
Lukas May
2026-03-06 14:54:05 +01:00
3 changed files with 124 additions and 4 deletions

View File

@@ -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<never>): 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');
});
});

View File

@@ -30,11 +30,24 @@ export function projectProcedures(publicProcedure: ProcedureBuilder) {
...(input.defaultBranch && { defaultBranch: input.defaultBranch }), ...(input.defaultBranch && { defaultBranch: input.defaultBranch }),
}); });
} catch (error) { } catch (error) {
const msg = (error as Error).message; const msg = (error as Error).message ?? '';
if (msg.includes('UNIQUE') || msg.includes('unique')) { 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({ throw new TRPCError({
code: 'CONFLICT', code: 'CONFLICT',
message: `A project with that name or URL already exists`, message: 'A project with this name or URL already exists',
}); });
} }
throw error; throw error;

View File

@@ -10,6 +10,7 @@ import {
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Loader2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { trpc } from "@/lib/trpc"; import { trpc } from "@/lib/trpc";
@@ -27,13 +28,20 @@ export function RegisterProjectDialog({
const [defaultBranch, setDefaultBranch] = useState("main"); const [defaultBranch, setDefaultBranch] = useState("main");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const utils = trpc.useUtils();
const registerMutation = trpc.registerProject.useMutation({ const registerMutation = trpc.registerProject.useMutation({
onSuccess: () => { onSuccess: () => {
onOpenChange(false); onOpenChange(false);
toast.success("Project registered"); toast.success("Project registered");
void utils.listProjects.invalidate();
}, },
onError: (err) => { 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 Cancel
</Button> </Button>
<Button type="submit" disabled={!canSubmit}> <Button type="submit" disabled={!canSubmit}>
{registerMutation.isPending ? "Registering..." : "Register"} {registerMutation.isPending ? (
<>
<Loader2 className="animate-spin mr-2 h-4 w-4" />
Cloning repository
</>
) : (
"Register"
)}
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>