Merge branch 'cw/project-management-operations-to-ui' into cw-merge-1772805244951
This commit is contained in:
92
apps/server/trpc/routers/project.test.ts
Normal file
92
apps/server/trpc/routers/project.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user