Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions src/api/routers/integrationsDiscovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,35 @@ export const integrationsDiscoveryRouter = router({
return { successes, errors };
}),

createTrelloCustomField: protectedProcedure
.input(
trelloCredsInput.extend({
boardId: z
.string()
.regex(/^[a-zA-Z0-9]+$/)
.max(32),
name: z.string().min(1).max(100),
type: z.enum(['number', 'text', 'checkbox', 'date', 'list']),
}),
)
.mutation(async ({ ctx, input }) => {
logger.debug('integrationsDiscovery.createTrelloCustomField called', {
orgId: ctx.effectiveOrgId,
boardId: input.boardId,
name: input.name,
type: input.type,
});
return withResolvedTrelloCreds(
input,
ctx.effectiveOrgId,
'Failed to create Trello custom field',
(creds) =>
withTrelloCredentials(creds, () =>
trelloClient.createBoardCustomField(input.boardId, input.name, input.type),
),
);
}),

jiraProjects: protectedProcedure.input(jiraCredsInput).mutation(async ({ ctx, input }) => {
logger.debug('integrationsDiscovery.jiraProjects called', { orgId: ctx.effectiveOrgId });
return withResolvedJiraCreds(
Expand Down
21 changes: 21 additions & 0 deletions src/trello/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,27 @@ export const trelloClient = {
}));
},

async createBoardCustomField(
boardId: string,
name: string,
type: string,
): Promise<{ id: string; name: string; type: string }> {
logger.debug('Creating board custom field', { boardId, name, type });
const field = await trelloFetch<{ id?: string; name?: string; type?: string }>(
'/customFields',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: { idModel: boardId, modelType: 'board', name, type, pos: 'bottom' },
},
);
return {
id: field.id || '',
name: field.name || '',
type: field.type || '',
};
},

// ===== Member / Actions =====

async getMe(): Promise<{ id: string; fullName: string; username: string }> {
Expand Down
137 changes: 137 additions & 0 deletions tests/unit/api/routers/integrationsDiscovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const mockTrelloGetBoards = vi.fn();
const mockTrelloGetBoardLists = vi.fn();
const mockTrelloGetBoardLabels = vi.fn();
const mockTrelloGetBoardCustomFields = vi.fn();
const mockTrelloCreateBoardCustomField = vi.fn();

vi.mock('../../../../src/trello/client.js', () => ({
withTrelloCredentials: (...args: unknown[]) => {
Expand All @@ -39,6 +40,7 @@ vi.mock('../../../../src/trello/client.js', () => ({
getBoardLists: (...args: unknown[]) => mockTrelloGetBoardLists(...args),
getBoardLabels: (...args: unknown[]) => mockTrelloGetBoardLabels(...args),
getBoardCustomFields: (...args: unknown[]) => mockTrelloGetBoardCustomFields(...args),
createBoardCustomField: (...args: unknown[]) => mockTrelloCreateBoardCustomField(...args),
},
}));

Expand Down Expand Up @@ -481,4 +483,139 @@ describe('integrationsDiscoveryRouter', () => {
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
});
});

// ── createTrelloCustomField ──────────────────────────────────────────

describe('createTrelloCustomField', () => {
it('returns id, name, and type on success', async () => {
setupDbCredentials([
{ orgId: 'org-1', value: 'api-key' },
{ orgId: 'org-1', value: 'token' },
]);
mockTrelloCreateBoardCustomField.mockResolvedValue({
id: 'cf-123',
name: 'Cost',
type: 'number',
});

const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId });
const result = await caller.createTrelloCustomField({
...trelloCredsInput,
boardId: 'boardabc',
name: 'Cost',
type: 'number',
});

expect(result).toEqual({
id: 'cf-123',
name: 'Cost',
type: 'number',
});
expect(mockTrelloCreateBoardCustomField).toHaveBeenCalledWith('boardabc', 'Cost', 'number');
});

it('throws UNAUTHORIZED when not authenticated', async () => {
const caller = createCaller({ user: null, effectiveOrgId: null });
await expect(
caller.createTrelloCustomField({
...trelloCredsInput,
boardId: 'boardabc',
name: 'Cost',
type: 'number',
}),
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
});

it('throws NOT_FOUND when credential does not exist', async () => {
mockDbWhere.mockResolvedValueOnce([]);
const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId });

await expect(
caller.createTrelloCustomField({
...trelloCredsInput,
boardId: 'boardabc',
name: 'Cost',
type: 'number',
}),
).rejects.toMatchObject({ code: 'NOT_FOUND' });
});

it('validates boardId with alphanumeric regex', async () => {
const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId });
await expect(
caller.createTrelloCustomField({
...trelloCredsInput,
boardId: 'board-with-hyphens',
name: 'Cost',
type: 'number',
}),
).rejects.toThrow();
});

it('validates boardId max length of 32', async () => {
const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId });
await expect(
caller.createTrelloCustomField({
...trelloCredsInput,
boardId: 'a'.repeat(33),
name: 'Cost',
type: 'number',
}),
).rejects.toThrow();
});

it('validates name min length of 1', async () => {
const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId });
await expect(
caller.createTrelloCustomField({
...trelloCredsInput,
boardId: 'boardabc',
name: '',
type: 'number',
}),
).rejects.toThrow();
});

it('validates name max length of 100', async () => {
const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId });
await expect(
caller.createTrelloCustomField({
...trelloCredsInput,
boardId: 'boardabc',
name: 'a'.repeat(101),
type: 'number',
}),
).rejects.toThrow();
});

it('validates type is one of the allowed enum values', async () => {
const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId });
await expect(
caller.createTrelloCustomField({
...trelloCredsInput,
boardId: 'boardabc',
name: 'Cost',
type: 'invalid-type',
}),
).rejects.toThrow();
});

it('wraps API failure in BAD_REQUEST', async () => {
setupDbCredentials([
{ orgId: 'org-1', value: 'api-key' },
{ orgId: 'org-1', value: 'token' },
]);
mockTrelloCreateBoardCustomField.mockRejectedValue(new Error('Board not found'));

const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId });
await expect(
caller.createTrelloCustomField({
...trelloCredsInput,
boardId: 'boardabc',
name: 'Cost',
type: 'number',
}),
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
});
});
});
79 changes: 79 additions & 0 deletions tests/unit/trello/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -705,4 +705,83 @@ describe('trelloClient', () => {
).rejects.toThrow('Trello API error 403');
});
});

describe('createBoardCustomField', () => {
it('POSTs to /customFields with boardId, name, type, and pos', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ id: 'cf-new', name: 'Cost', type: 'number' }), {
status: 200,
}),
);

const result = await withTrelloCredentials(creds, () =>
trelloClient.createBoardCustomField('board-abc', 'Cost', 'number'),
);

expect(result).toEqual({ id: 'cf-new', name: 'Cost', type: 'number' });
const [url, options] = fetchSpy.mock.calls[0];
expect(url).toContain('/1/customFields');
expect(url).toContain('key=test-key');
expect(url).toContain('token=test-token');
expect(options?.method).toBe('POST');
expect(options?.headers).toEqual({ 'Content-Type': 'application/json' });
expect(options?.body).toBe(
JSON.stringify({
idModel: 'board-abc',
modelType: 'board',
name: 'Cost',
type: 'number',
pos: 'bottom',
}),
);
});

it('handles all supported custom field types', async () => {
const types = ['number', 'text', 'checkbox', 'date', 'list'];

for (const type of types) {
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
new Response(JSON.stringify({ id: `cf-${type}`, name: 'Test Field', type }), {
status: 200,
}),
);

const result = await withTrelloCredentials(creds, () =>
trelloClient.createBoardCustomField('board-1', 'Test Field', type),
);

expect(result.type).toBe(type);
const [, options] = fetchSpy.mock.calls[0];
expect(JSON.parse(options?.body as string).type).toBe(type);
}
});

it('normalizes missing response fields to empty strings', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify({}), { status: 200 }),
);

const result = await withTrelloCredentials(creds, () =>
trelloClient.createBoardCustomField('board-abc', 'Cost', 'number'),
);

expect(result).toEqual({ id: '', name: '', type: '' });
});

it('throws on non-OK response', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('Forbidden', { status: 403 }));

await expect(
withTrelloCredentials(creds, () =>
trelloClient.createBoardCustomField('board-abc', 'Cost', 'number'),
),
).rejects.toThrow('Trello API error 403');
});

it('throws when called outside scope', async () => {
await expect(
trelloClient.createBoardCustomField('board-abc', 'Cost', 'number'),
).rejects.toThrow('No Trello credentials in scope');
});
});
});
Loading