From 34bedbc53279cd9ce7a9be5c01269721422a39ee Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Fri, 13 Mar 2026 17:59:10 +0000 Subject: [PATCH 1/2] feat(trello): add createBoardCustomField support - Add createBoardCustomField method to trelloClient making POST to /customFields - Method accepts boardId, name, type and returns { id, name, type } - Add createTrelloCustomField tRPC mutation in integrationsDiscoveryRouter - Validate boardId (alphanumeric, max 32), name (min 1, max 100), type (enum) - Add unit tests for client method and tRPC endpoint - Tests cover success, validation, auth, credentials, and error handling --- src/api/routers/integrationsDiscovery.ts | 29 ++++ src/trello/client.ts | 21 +++ .../api/routers/integrationsDiscovery.test.ts | 137 ++++++++++++++++++ tests/unit/trello/client.test.ts | 79 ++++++++++ 4 files changed, 266 insertions(+) diff --git a/src/api/routers/integrationsDiscovery.ts b/src/api/routers/integrationsDiscovery.ts index 6b31b3b9..643ec268 100644 --- a/src/api/routers/integrationsDiscovery.ts +++ b/src/api/routers/integrationsDiscovery.ts @@ -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( diff --git a/src/trello/client.ts b/src/trello/client.ts index 4fb35d62..7fb7cff0 100644 --- a/src/trello/client.ts +++ b/src/trello/client.ts @@ -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 }> { diff --git a/tests/unit/api/routers/integrationsDiscovery.test.ts b/tests/unit/api/routers/integrationsDiscovery.test.ts index e725e52d..16eba353 100644 --- a/tests/unit/api/routers/integrationsDiscovery.test.ts +++ b/tests/unit/api/routers/integrationsDiscovery.test.ts @@ -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[]) => { @@ -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), }, })); @@ -482,3 +484,138 @@ describe('integrationsDiscoveryRouter', () => { }); }); }); + +// ── 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' }); + }); +}); diff --git a/tests/unit/trello/client.test.ts b/tests/unit/trello/client.test.ts index b2acd15f..5fa91dbd 100644 --- a/tests/unit/trello/client.test.ts +++ b/tests/unit/trello/client.test.ts @@ -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'); + }); + }); }); From b93768131bba6b16bc16b5deade30b55eede9b1f Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Fri, 13 Mar 2026 18:10:36 +0000 Subject: [PATCH 2/2] test(trello): move createTrelloCustomField describe inside parent block Move the describe('createTrelloCustomField') block inside the parent describe('integrationsDiscoveryRouter') so the beforeEach DB mock setup runs before these tests, matching the pattern of all other endpoint suites. Co-Authored-By: Claude Opus 4.6 --- .../api/routers/integrationsDiscovery.test.ts | 232 +++++++++--------- 1 file changed, 116 insertions(+), 116 deletions(-) diff --git a/tests/unit/api/routers/integrationsDiscovery.test.ts b/tests/unit/api/routers/integrationsDiscovery.test.ts index 16eba353..dbdad52d 100644 --- a/tests/unit/api/routers/integrationsDiscovery.test.ts +++ b/tests/unit/api/routers/integrationsDiscovery.test.ts @@ -483,139 +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', - }); + // ── createTrelloCustomField ────────────────────────────────────────── - 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', + 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', - }), - ).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({ + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await 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', + expect(result).toEqual({ + id: 'cf-123', name: 'Cost', type: 'number', - }), - ).rejects.toThrow(); - }); + }); + expect(mockTrelloCreateBoardCustomField).toHaveBeenCalledWith('boardabc', 'Cost', 'number'); + }); - 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('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('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('throws NOT_FOUND when credential does not exist', async () => { + mockDbWhere.mockResolvedValueOnce([]); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); - 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(); - }); + await expect( + caller.createTrelloCustomField({ + ...trelloCredsInput, + boardId: 'boardabc', + name: 'Cost', + type: 'number', + }), + ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); - 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('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('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')); + 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(); + }); - const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); - await expect( - caller.createTrelloCustomField({ - ...trelloCredsInput, - boardId: 'boardabc', - name: 'Cost', - type: 'number', - }), - ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + 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' }); + }); }); });