diff --git a/src/config/provider.ts b/src/config/provider.ts index 175a5a38..fd853809 100644 --- a/src/config/provider.ts +++ b/src/config/provider.ts @@ -106,33 +106,51 @@ async function getOrgIdForProject(projectId: string): Promise { } // ============================================================================ -// Integration credentials — direct by category + role +// Internal: 3-step env/worker/DB resolution helper // ============================================================================ /** - * Resolve an integration credential for a project by category and role. - * Throws if the credential is not found. + * Resolve a credential value using the standard 3-step pattern: + * 1. Check process.env (populated at worker startup from router-supplied credentials) + * 2. If in worker context (CASCADE_CREDENTIAL_KEYS set), credential is absent → return notFoundValue + * 3. Otherwise resolve from DB via the provided async lookup */ -export async function getIntegrationCredential( - projectId: string, - category: string, - role: string, -): Promise { +async function resolveFromEnvOrDb( + envKey: string | undefined, + notFoundValue: T, + dbLookup: () => Promise, +): Promise { // Check process.env first (populated at worker startup from router-supplied credentials) - const envKey = roleToEnvVarKey(category, role); if (envKey && process.env[envKey]) { - return process.env[envKey]; + return process.env[envKey] as T; } // Worker context: all credentials set by router, this one doesn't exist if (process.env.CASCADE_CREDENTIAL_KEYS) { - throw new Error( - `Integration credential '${category}/${role}' not found for project '${projectId}'`, - ); + return notFoundValue; } // Router/dashboard context: resolve from DB - const value = await resolveIntegrationCredential(projectId, category, role); + return dbLookup(); +} + +// ============================================================================ +// Integration credentials — direct by category + role +// ============================================================================ + +/** + * Resolve an integration credential for a project by category and role. + * Throws if the credential is not found. + */ +export async function getIntegrationCredential( + projectId: string, + category: string, + role: string, +): Promise { + const envKey = roleToEnvVarKey(category, role); + const value = await resolveFromEnvOrDb(envKey, null, () => + resolveIntegrationCredential(projectId, category, role), + ); if (value) return value; throw new Error( @@ -148,19 +166,10 @@ export async function getIntegrationCredentialOrNull( category: string, role: string, ): Promise { - // Check process.env first (populated at worker startup from router-supplied credentials) const envKey = roleToEnvVarKey(category, role); - if (envKey && process.env[envKey]) { - return process.env[envKey]; - } - - // Worker context: all credentials set by router, this one doesn't exist - if (process.env.CASCADE_CREDENTIAL_KEYS) { - return null; - } - - // Router/dashboard context: resolve from DB - return resolveIntegrationCredential(projectId, category, role); + return resolveFromEnvOrDb(envKey, null, () => + resolveIntegrationCredential(projectId, category, role), + ); } // ============================================================================ @@ -175,19 +184,10 @@ export async function getOrgCredential( projectId: string, envVarKey: string, ): Promise { - // Check process.env first (populated at worker startup from router-supplied credentials) - if (process.env[envVarKey]) { - return process.env[envVarKey]; - } - - // Worker context: all credentials set by router, this one doesn't exist - if (process.env.CASCADE_CREDENTIAL_KEYS) { - return null; - } - - // Router/dashboard context: resolve from DB - const orgId = await getOrgIdForProject(projectId); - return resolveOrgCredential(orgId, envVarKey); + return resolveFromEnvOrDb(envVarKey, null, async () => { + const orgId = await getOrgIdForProject(projectId); + return resolveOrgCredential(orgId, envVarKey); + }); } // ============================================================================ diff --git a/src/trello/client.ts b/src/trello/client.ts index 325b72ab..98a926e6 100644 --- a/src/trello/client.ts +++ b/src/trello/client.ts @@ -31,6 +31,52 @@ function getClient(): TrelloJsClient { return new TrelloJsClient({ key: creds.apiKey, token: creds.token }); } +/** + * Make an authenticated request to the Trello REST API. + * Handles credential injection, URL construction, error checking, and JSON parsing. + * + * @param path - The API path, e.g. `/cards/${cardId}/attachments`. Query params may be + * included in the path itself (e.g. `?filter=open`). + * @param opts - Optional method, headers, and body for non-GET requests. + */ +async function trelloFetch( + path: string, + opts?: { method?: string; headers?: Record; body?: unknown }, +): Promise { + const { apiKey, token } = getTrelloCredentials(); + const separator = path.includes('?') ? '&' : '?'; + const url = `https://api.trello.com/1${path}${separator}key=${apiKey}&token=${token}`; + + const fetchOpts: RequestInit = {}; + if (opts?.method) fetchOpts.method = opts.method; + if (opts?.headers) fetchOpts.headers = opts.headers; + if (opts?.body !== undefined) fetchOpts.body = JSON.stringify(opts.body); + + const response = await fetch(url, fetchOpts); + if (!response.ok) { + throw new Error(`Trello API error ${response.status} for ${path.split('?')[0]}`); + } + return response.json() as Promise; +} + +// ============================================================================ +// Shared utilities +// ============================================================================ + +function mapLabels( + labels: Array<{ id?: string; name?: string; color?: string }> | undefined, +): Array<{ id: string; name: string; color: string }> { + return (labels || []).map((l) => ({ + id: l.id || '', + name: l.name || '', + color: l.color || '', + })); +} + +// ============================================================================ +// Types +// ============================================================================ + export interface TrelloCard { id: string; name: string; @@ -94,7 +140,13 @@ export interface TrelloAttachment { date: string; } +// ============================================================================ +// Trello client +// ============================================================================ + export const trelloClient = { + // ===== Card Ops ===== + async getCard(cardId: string): Promise { logger.debug('Fetching Trello card', { cardId }); const card = await getClient().cards.getCard({ id: cardId }); @@ -106,14 +158,72 @@ export const trelloClient = { url: card.url || '', shortUrl: card.shortUrl || '', idList: card.idList || '', - labels: (labels || []).map((l) => ({ - id: l.id || '', - name: l.name || '', - color: l.color || '', - })), + labels: mapLabels(labels), + }; + }, + + async updateCard(cardId: string, updates: { name?: string; desc?: string }): Promise { + logger.debug('Updating card', { cardId, hasName: !!updates.name, hasDesc: !!updates.desc }); + await getClient().cards.updateCard({ + id: cardId, + name: updates.name, + desc: updates.desc, + }); + }, + + async moveCardToList(cardId: string, listId: string): Promise { + logger.debug('Moving card to list', { cardId, listId }); + await getClient().cards.updateCard({ + id: cardId, + idList: listId, + }); + }, + + async createCard( + listId: string, + data: { name: string; desc?: string; idLabels?: string[] }, + ): Promise { + logger.debug('Creating card', { listId, name: data.name }); + const card = await getClient().cards.createCard({ + idList: listId, + name: data.name, + desc: data.desc, + idLabels: data.idLabels, + pos: 'bottom', + }); + const labels = card.labels as Array<{ id?: string; name?: string; color?: string }> | undefined; + return { + id: card.id, + name: card.name || '', + desc: card.desc || '', + url: card.url || '', + shortUrl: card.shortUrl || '', + idList: card.idList || '', + labels: mapLabels(labels), }; }, + async getListCards(listId: string): Promise { + logger.debug('Fetching cards from list', { listId }); + const cards = await getClient().lists.getListCards({ id: listId }); + return cards.map((card) => { + const labels = card.labels as + | Array<{ id?: string; name?: string; color?: string }> + | undefined; + return { + id: card.id, + name: card.name || '', + desc: card.desc || '', + url: card.url || '', + shortUrl: card.shortUrl || '', + idList: card.idList || '', + labels: mapLabels(labels), + }; + }); + }, + + // ===== Comments ===== + async getCardComments(cardId: string): Promise { logger.debug('Fetching card comments', { cardId }); const actions = await getClient().cards.getCardActions({ @@ -135,15 +245,6 @@ export const trelloClient = { })); }, - async updateCard(cardId: string, updates: { name?: string; desc?: string }): Promise { - logger.debug('Updating card', { cardId, hasName: !!updates.name, hasDesc: !!updates.desc }); - await getClient().cards.updateCard({ - id: cardId, - name: updates.name, - desc: updates.desc, - }); - }, - async addComment(cardId: string, text: string): Promise { logger.debug('Adding comment', { cardId, textLength: text.length }); const result = (await getClient().cards.addCardComment({ @@ -155,20 +256,15 @@ export const trelloClient = { async updateComment(actionId: string, text: string): Promise { logger.debug('Updating comment', { actionId, textLength: text.length }); - const { apiKey, token } = getTrelloCredentials(); - const response = await fetch( - `https://api.trello.com/1/actions/${actionId}?key=${apiKey}&token=${token}`, - { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ text }), - }, - ); - if (!response.ok) { - throw new Error(`Failed to update comment: ${response.status}`); - } + await trelloFetch(`/actions/${actionId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: { text }, + }); }, + // ===== Labels ===== + async addLabelToCard(cardId: string, labelId: string): Promise { logger.debug('Adding label to card', { cardId, labelId }); await getClient().cards.addCardLabel({ @@ -185,13 +281,7 @@ export const trelloClient = { }); }, - async moveCardToList(cardId: string, listId: string): Promise { - logger.debug('Moving card to list', { cardId, listId }); - await getClient().cards.updateCard({ - id: cardId, - idList: listId, - }); - }, + // ===== Attachments ===== async addAttachment(cardId: string, url: string, name: string): Promise { logger.debug('Adding attachment', { cardId, name }); @@ -218,127 +308,29 @@ export const trelloClient = { }); }, - async getMyActions(limit = 20): Promise { - logger.debug('Fetching my recent actions', { limit }); - // Use raw fetch since trello.js types don't expose 'limit' parameter - const { apiKey, token } = getTrelloCredentials(); - const response = await fetch( - `https://api.trello.com/1/members/me/actions?key=${apiKey}&token=${token}&limit=${limit}`, - ); - if (!response.ok) { - throw new Error(`Failed to fetch actions: ${response.status}`); - } - const actions = (await response.json()) as Array<{ - id?: string; - type?: string; - date?: string; - data?: { - card?: { id?: string; name?: string; shortLink?: string }; - list?: { id?: string; name?: string }; - board?: { id?: string; name?: string }; - text?: string; - }; - }>; - return actions.map((a) => ({ + async getCardAttachments(cardId: string): Promise { + logger.debug('Fetching card attachments', { cardId }); + const attachments = await trelloFetch< + Array<{ + id?: string; + name?: string; + url?: string; + mimeType?: string; + bytes?: number; + date?: string; + }> + >(`/cards/${cardId}/attachments`); + return attachments.map((a) => ({ id: a.id || '', - type: a.type || '', + name: a.name || '', + url: a.url || '', + mimeType: a.mimeType || '', + bytes: a.bytes || 0, date: a.date || '', - data: { - card: a.data?.card - ? { - id: a.data.card.id || '', - name: a.data.card.name || '', - shortLink: a.data.card.shortLink, - } - : undefined, - list: a.data?.list - ? { - id: a.data.list.id || '', - name: a.data.list.name || '', - } - : undefined, - board: a.data?.board - ? { - id: a.data.board.id || '', - name: a.data.board.name || '', - } - : undefined, - text: a.data?.text, - }, })); }, - async getMe(): Promise<{ id: string; fullName: string; username: string }> { - logger.debug('Fetching authenticated member info'); - const { apiKey, token } = getTrelloCredentials(); - const response = await fetch( - `https://api.trello.com/1/members/me?key=${apiKey}&token=${token}`, - ); - if (!response.ok) { - throw new Error(`Failed to fetch member: ${response.status}`); - } - const member = (await response.json()) as { - id?: string; - fullName?: string; - username?: string; - }; - return { - id: member.id || '', - fullName: member.fullName || '', - username: member.username || '', - }; - }, - - async getListCards(listId: string): Promise { - logger.debug('Fetching cards from list', { listId }); - const cards = await getClient().lists.getListCards({ id: listId }); - return cards.map((card) => { - const labels = card.labels as - | Array<{ id?: string; name?: string; color?: string }> - | undefined; - return { - id: card.id, - name: card.name || '', - desc: card.desc || '', - url: card.url || '', - shortUrl: card.shortUrl || '', - idList: card.idList || '', - labels: (labels || []).map((l) => ({ - id: l.id || '', - name: l.name || '', - color: l.color || '', - })), - }; - }); - }, - - async createCard( - listId: string, - data: { name: string; desc?: string; idLabels?: string[] }, - ): Promise { - logger.debug('Creating card', { listId, name: data.name }); - const card = await getClient().cards.createCard({ - idList: listId, - name: data.name, - desc: data.desc, - idLabels: data.idLabels, - pos: 'bottom', - }); - const labels = card.labels as Array<{ id?: string; name?: string; color?: string }> | undefined; - return { - id: card.id, - name: card.name || '', - desc: card.desc || '', - url: card.url || '', - shortUrl: card.shortUrl || '', - idList: card.idList || '', - labels: (labels || []).map((l) => ({ - id: l.id || '', - name: l.name || '', - color: l.color || '', - })), - }; - }, + // ===== Checklists ===== async createChecklist(cardId: string, name: string): Promise { logger.debug('Creating checklist', { cardId, name }); @@ -413,39 +405,17 @@ export const trelloClient = { }); }, - async addActionReaction( - actionId: string, - emoji: { shortName: string; native: string; unified: string }, - ): Promise { - logger.debug('Adding reaction to Trello action', { actionId, emoji: emoji.shortName }); - const { apiKey, token } = getTrelloCredentials(); - const response = await fetch( - `https://api.trello.com/1/actions/${actionId}/reactions?key=${apiKey}&token=${token}`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ emoji }), - }, - ); - if (!response.ok) { - throw new Error(`Failed to add reaction to action: ${response.status}`); - } - }, + // ===== Custom Fields ===== async getCardCustomFieldItems(cardId: string): Promise { logger.debug('Fetching card custom field items', { cardId }); - const { apiKey, token } = getTrelloCredentials(); - const response = await fetch( - `https://api.trello.com/1/cards/${cardId}/customFieldItems?key=${apiKey}&token=${token}`, - ); - if (!response.ok) { - throw new Error(`Failed to get custom fields: ${response.status}`); - } - const items = (await response.json()) as Array<{ - id?: string; - idCustomField?: string; - value?: { number?: string; text?: string; checked?: string }; - }>; + const items = await trelloFetch< + Array<{ + id?: string; + idCustomField?: string; + value?: { number?: string; text?: string; checked?: string }; + }> + >(`/cards/${cardId}/customFieldItems`); return items.map((item) => ({ id: item.id || '', idCustomField: item.idCustomField || '', @@ -453,47 +423,26 @@ export const trelloClient = { })); }, - async getCardAttachments(cardId: string): Promise { - logger.debug('Fetching card attachments', { cardId }); - const { apiKey, token } = getTrelloCredentials(); - const response = await fetch( - `https://api.trello.com/1/cards/${cardId}/attachments?key=${apiKey}&token=${token}`, - ); - if (!response.ok) { - throw new Error(`Failed to get attachments: ${response.status}`); - } - const attachments = (await response.json()) as Array<{ - id?: string; - name?: string; - url?: string; - mimeType?: string; - bytes?: number; - date?: string; - }>; - return attachments.map((a) => ({ - id: a.id || '', - name: a.name || '', - url: a.url || '', - mimeType: a.mimeType || '', - bytes: a.bytes || 0, - date: a.date || '', - })); + async updateCardCustomFieldNumber( + cardId: string, + customFieldId: string, + value: number, + ): Promise { + logger.debug('Updating card custom field', { cardId, customFieldId, value }); + await trelloFetch(`/cards/${cardId}/customField/${customFieldId}/item`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: { value: { number: value.toString() } }, + }); }, + // ===== Board Ops ===== + async getBoards(): Promise> { logger.debug('Fetching boards for authenticated member'); - const { apiKey, token } = getTrelloCredentials(); - const response = await fetch( - `https://api.trello.com/1/members/me/boards?filter=open&fields=id,name,url&key=${apiKey}&token=${token}`, + const boards = await trelloFetch>( + '/members/me/boards?filter=open&fields=id,name,url', ); - if (!response.ok) { - throw new Error(`Failed to fetch boards: ${response.status}`); - } - const boards = (await response.json()) as Array<{ - id?: string; - name?: string; - url?: string; - }>; return boards.map((b) => ({ id: b.id || '', name: b.name || '', @@ -503,17 +452,9 @@ export const trelloClient = { async getBoardLists(boardId: string): Promise> { logger.debug('Fetching board lists', { boardId }); - const { apiKey, token } = getTrelloCredentials(); - const response = await fetch( - `https://api.trello.com/1/boards/${boardId}/lists?filter=open&key=${apiKey}&token=${token}`, + const lists = await trelloFetch>( + `/boards/${boardId}/lists?filter=open`, ); - if (!response.ok) { - throw new Error(`Failed to fetch board lists: ${response.status}`); - } - const lists = (await response.json()) as Array<{ - id?: string; - name?: string; - }>; return lists.map((l) => ({ id: l.id || '', name: l.name || '', @@ -524,18 +465,9 @@ export const trelloClient = { boardId: string, ): Promise> { logger.debug('Fetching board labels', { boardId }); - const { apiKey, token } = getTrelloCredentials(); - const response = await fetch( - `https://api.trello.com/1/boards/${boardId}/labels?key=${apiKey}&token=${token}`, + const labels = await trelloFetch>( + `/boards/${boardId}/labels`, ); - if (!response.ok) { - throw new Error(`Failed to fetch board labels: ${response.status}`); - } - const labels = (await response.json()) as Array<{ - id?: string; - name?: string; - color?: string; - }>; return labels.map((l) => ({ id: l.id || '', name: l.name || '', @@ -547,18 +479,9 @@ export const trelloClient = { boardId: string, ): Promise> { logger.debug('Fetching board custom fields', { boardId }); - const { apiKey, token } = getTrelloCredentials(); - const response = await fetch( - `https://api.trello.com/1/boards/${boardId}/customFields?key=${apiKey}&token=${token}`, + const fields = await trelloFetch>( + `/boards/${boardId}/customFields`, ); - if (!response.ok) { - throw new Error(`Failed to fetch board custom fields: ${response.status}`); - } - const fields = (await response.json()) as Array<{ - id?: string; - name?: string; - type?: string; - }>; return fields.map((f) => ({ id: f.id || '', name: f.name || '', @@ -566,23 +489,74 @@ export const trelloClient = { })); }, - async updateCardCustomFieldNumber( - cardId: string, - customFieldId: string, - value: number, - ): Promise { - logger.debug('Updating card custom field', { cardId, customFieldId, value }); - const { apiKey, token } = getTrelloCredentials(); - const response = await fetch( - `https://api.trello.com/1/cards/${cardId}/customField/${customFieldId}/item?key=${apiKey}&token=${token}`, - { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ value: { number: value.toString() } }), + // ===== Member / Actions ===== + + async getMyActions(limit = 20): Promise { + logger.debug('Fetching my recent actions', { limit }); + // Use raw fetch since trello.js types don't expose 'limit' parameter + const actions = await trelloFetch< + Array<{ + id?: string; + type?: string; + date?: string; + data?: { + card?: { id?: string; name?: string; shortLink?: string }; + list?: { id?: string; name?: string }; + board?: { id?: string; name?: string }; + text?: string; + }; + }> + >(`/members/me/actions?limit=${limit}`); + return actions.map((a) => ({ + id: a.id || '', + type: a.type || '', + date: a.date || '', + data: { + card: a.data?.card + ? { + id: a.data.card.id || '', + name: a.data.card.name || '', + shortLink: a.data.card.shortLink, + } + : undefined, + list: a.data?.list + ? { + id: a.data.list.id || '', + name: a.data.list.name || '', + } + : undefined, + board: a.data?.board + ? { + id: a.data.board.id || '', + name: a.data.board.name || '', + } + : undefined, + text: a.data?.text, }, + })); + }, + + async getMe(): Promise<{ id: string; fullName: string; username: string }> { + logger.debug('Fetching authenticated member info'); + const member = await trelloFetch<{ id?: string; fullName?: string; username?: string }>( + '/members/me', ); - if (!response.ok) { - throw new Error(`Failed to update custom field: ${response.status}`); - } + return { + id: member.id || '', + fullName: member.fullName || '', + username: member.username || '', + }; + }, + + async addActionReaction( + actionId: string, + emoji: { shortName: string; native: string; unified: string }, + ): Promise { + logger.debug('Adding reaction to Trello action', { actionId, emoji: emoji.shortName }); + await trelloFetch(`/actions/${actionId}/reactions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: { emoji }, + }); }, }; diff --git a/tests/unit/trello/client.test.ts b/tests/unit/trello/client.test.ts index 142630a6..dfb34005 100644 --- a/tests/unit/trello/client.test.ts +++ b/tests/unit/trello/client.test.ts @@ -63,6 +63,135 @@ describe('trelloClient', () => { vi.clearAllMocks(); }); + // ===== trelloFetch helper ===== + + describe('trelloFetch (via public methods)', () => { + it('appends key and token to a path without existing query params', async () => { + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response(JSON.stringify({}), { status: 200 })); + + await withTrelloCredentials(creds, () => trelloClient.getMe()); + + const [url] = fetchSpy.mock.calls[0]; + expect(url).toContain('key=test-key'); + expect(url).toContain('token=test-token'); + // Uses ? separator when no existing query params + expect(url).toMatch(/\/members\/me\?/); + }); + + it('appends key and token with & when path already has query params', async () => { + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response(JSON.stringify([]), { status: 200 })); + + await withTrelloCredentials(creds, () => trelloClient.getBoards()); + + const [url] = fetchSpy.mock.calls[0]; + // Path already has ?filter=open, so credentials should be appended with & + expect(url).toMatch(/filter=open.*key=test-key.*token=test-token/); + }); + + it('throws a Trello API error with status code on non-OK response', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('Not Found', { status: 404 })); + + await expect(withTrelloCredentials(creds, () => trelloClient.getMe())).rejects.toThrow( + 'Trello API error 404', + ); + }); + + it('throws when called outside withTrelloCredentials scope', async () => { + await expect(trelloClient.getMe()).rejects.toThrow('No Trello credentials in scope'); + }); + + it('sends PUT request with JSON body for write operations', async () => { + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response(JSON.stringify({}), { status: 200 })); + + await withTrelloCredentials(creds, () => + trelloClient.updateComment('action-123', 'Updated text'), + ); + + const [, options] = fetchSpy.mock.calls[0]; + expect(options?.method).toBe('PUT'); + expect(options?.headers).toEqual({ 'Content-Type': 'application/json' }); + expect(options?.body).toBe(JSON.stringify({ text: 'Updated text' })); + }); + }); + + // ===== mapLabels utility (tested via card methods) ===== + + describe('mapLabels (via getCard / createCard / getListCards)', () => { + it('maps labels with all fields present', async () => { + mockCards.getCard.mockResolvedValue({ + id: 'card-1', + labels: [{ id: 'lbl-1', name: 'Bug', color: 'red' }], + }); + + const result = await withTrelloCredentials(creds, () => trelloClient.getCard('card-1')); + + expect(result.labels).toEqual([{ id: 'lbl-1', name: 'Bug', color: 'red' }]); + }); + + it('returns empty array when labels is undefined', async () => { + mockCards.getCard.mockResolvedValue({ id: 'card-1' }); + + const result = await withTrelloCredentials(creds, () => trelloClient.getCard('card-1')); + + expect(result.labels).toEqual([]); + }); + + it('defaults missing label fields to empty strings', async () => { + mockCards.getCard.mockResolvedValue({ + id: 'card-1', + labels: [{}], + }); + + const result = await withTrelloCredentials(creds, () => trelloClient.getCard('card-1')); + + expect(result.labels).toEqual([{ id: '', name: '', color: '' }]); + }); + + it('applies mapLabels consistently across createCard', async () => { + mockCards.createCard.mockResolvedValue({ + id: 'new-card', + name: 'New', + desc: '', + url: '', + shortUrl: '', + idList: 'list-1', + labels: [{ id: 'lbl-2', name: 'Feature', color: 'green' }], + }); + + const result = await withTrelloCredentials(creds, () => + trelloClient.createCard('list-1', { name: 'New' }), + ); + + expect(result.labels).toEqual([{ id: 'lbl-2', name: 'Feature', color: 'green' }]); + }); + + it('applies mapLabels consistently across getListCards', async () => { + mockLists.getListCards.mockResolvedValue([ + { + id: 'card-1', + name: 'Card', + desc: '', + url: '', + shortUrl: '', + idList: 'list-1', + labels: [{ id: 'lbl-3', name: 'High Priority', color: 'orange' }], + }, + ]); + + const results = await withTrelloCredentials(creds, () => trelloClient.getListCards('list-1')); + + expect(results[0].labels).toEqual([{ id: 'lbl-3', name: 'High Priority', color: 'orange' }]); + }); + }); + + // ===== Existing tests (unchanged behavior) ===== + describe('addComment', () => { it('returns the comment action ID from API response', async () => { mockCards.addCardComment.mockResolvedValue({ id: 'action-abc123' }); @@ -111,7 +240,7 @@ describe('trelloClient', () => { await expect( withTrelloCredentials(creds, () => trelloClient.updateComment('action-123', 'text')), - ).rejects.toThrow('Failed to update comment: 404'); + ).rejects.toThrow('Trello API error 404'); }); it('throws when called outside withTrelloCredentials scope', async () => { @@ -150,7 +279,7 @@ describe('trelloClient', () => { await expect( withTrelloCredentials(creds, () => trelloClient.addActionReaction('action-123', emoji)), - ).rejects.toThrow('Failed to add reaction to action: 400'); + ).rejects.toThrow('Trello API error 400'); }); it('throws when called outside withTrelloCredentials scope', async () => { @@ -365,7 +494,7 @@ describe('trelloClient', () => { ); await expect(withTrelloCredentials(creds, () => trelloClient.getBoards())).rejects.toThrow( - 'Failed to fetch boards: 401', + 'Trello API error 401', ); }); @@ -408,7 +537,7 @@ describe('trelloClient', () => { await expect( withTrelloCredentials(creds, () => trelloClient.getBoardLists('board-1')), - ).rejects.toThrow('Failed to fetch board lists: 404'); + ).rejects.toThrow('Trello API error 404'); }); }); @@ -436,7 +565,7 @@ describe('trelloClient', () => { await expect( withTrelloCredentials(creds, () => trelloClient.getBoardLabels('board-1')), - ).rejects.toThrow('Failed to fetch board labels: 500'); + ).rejects.toThrow('Trello API error 500'); }); }); @@ -464,7 +593,7 @@ describe('trelloClient', () => { await expect( withTrelloCredentials(creds, () => trelloClient.getBoardCustomFields('board-1')), - ).rejects.toThrow('Failed to fetch board custom fields: 403'); + ).rejects.toThrow('Trello API error 403'); }); it('handles missing fields gracefully', async () => { @@ -525,7 +654,7 @@ describe('trelloClient', () => { await expect( withTrelloCredentials(creds, () => trelloClient.getCardAttachments('card-1')), - ).rejects.toThrow('Failed to get attachments: 401'); + ).rejects.toThrow('Trello API error 401'); }); }); });