Files
Codewalkers/apps/server/trpc/routers/project.test.ts
Lukas May 28521e1c20 chore: merge main into cw/small-change-flow
Integrates main branch changes (headquarters dashboard, task retry count,
agent prompt persistence, remote sync improvements) with the initiative's
errand agent feature. Both features coexist in the merged result.

Key resolutions:
- Schema: take main's errands table (nullable projectId, no conflictFiles,
  with errandsRelations); migrate to 0035_faulty_human_fly
- Router: keep both errandProcedures and headquartersProcedures
- Errand prompt: take main's simpler version (no question-asking flow)
- Manager: take main's status check (running|idle only, no waiting_for_input)
- Tests: update to match removed conflictFiles field and undefined vs null
2026-03-06 16:48:12 +01:00

93 lines
3.4 KiB
TypeScript

/**
* 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');
});
});