From a1bbb6fdc1f4cd98af9a20f45a6f461c5f36940e Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Mon, 23 Feb 2026 07:47:20 +0000 Subject: [PATCH] refactor(router): extract platformClients.ts to eliminate credential resolution duplication --- src/router/acknowledgments.ts | 206 ++------- src/router/notifications.ts | 34 +- src/router/platformClients.ts | 228 ++++++++++ src/router/reactions.ts | 86 +--- tests/unit/router/platformClients.test.ts | 489 ++++++++++++++++++++++ 5 files changed, 775 insertions(+), 268 deletions(-) create mode 100644 src/router/platformClients.ts create mode 100644 tests/unit/router/platformClients.test.ts diff --git a/src/router/acknowledgments.ts b/src/router/acknowledgments.ts index fd1f059f..c120735a 100644 --- a/src/router/acknowledgments.ts +++ b/src/router/acknowledgments.ts @@ -10,15 +10,26 @@ * Errors are always caught and logged — never propagated. */ -import { getProjectGitHubToken } from '../config/projects.js'; -import { - findProjectById, - findProjectByRepo, - getIntegrationCredential, -} from '../config/provider.js'; -import { getJiraConfig } from '../pm/config.js'; import { markdownToAdf } from '../pm/jira/adf.js'; -import type { ProjectConfig } from '../types/index.js'; +import { + _resetJiraBotCache, + _resetTrelloBotCache, + getJiraAuthForProject, + getTrelloCredentialsForProject, + resolveGitHubTokenForAck, + resolveJiraBotAccountId, + resolveTrelloBotMemberId, +} from './platformClients.js'; + +// Re-export bot-identity helpers so callers that import from acknowledgments +// continue to work without changes. +export { + resolveJiraBotAccountId, + resolveTrelloBotMemberId, + resolveGitHubTokenForAck, + _resetJiraBotCache, + _resetTrelloBotCache, +}; // --------------------------------------------------------------------------- // Trello @@ -29,17 +40,13 @@ export async function postTrelloAck( cardId: string, message: string, ): Promise { - let trelloApiKey: string; - let trelloToken: string; - try { - trelloApiKey = await getIntegrationCredential(projectId, 'pm', 'api_key'); - trelloToken = await getIntegrationCredential(projectId, 'pm', 'token'); - } catch { + const creds = await getTrelloCredentialsForProject(projectId); + if (!creds) { console.warn('[Ack] Missing Trello credentials, skipping ack comment'); return null; } - const url = `https://api.trello.com/1/cards/${cardId}/actions/comments?key=${trelloApiKey}&token=${trelloToken}`; + const url = `https://api.trello.com/1/cards/${cardId}/actions/comments?key=${creds.apiKey}&token=${creds.token}`; const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -61,16 +68,10 @@ export async function deleteTrelloAck( cardId: string, commentId: string, ): Promise { - let trelloApiKey: string; - let trelloToken: string; - try { - trelloApiKey = await getIntegrationCredential(projectId, 'pm', 'api_key'); - trelloToken = await getIntegrationCredential(projectId, 'pm', 'token'); - } catch { - return; - } + const creds = await getTrelloCredentialsForProject(projectId); + if (!creds) return; - const url = `https://api.trello.com/1/cards/${cardId}/actions/${commentId}/comments?key=${trelloApiKey}&token=${trelloToken}`; + const url = `https://api.trello.com/1/cards/${cardId}/actions/${commentId}/comments?key=${creds.apiKey}&token=${creds.token}`; try { await fetch(url, { method: 'DELETE' }); console.log('[Ack] Trello orphan ack deleted:', commentId); @@ -141,27 +142,18 @@ export async function postJiraAck( issueKey: string, message: string, ): Promise { - let jiraEmail: string; - let jiraApiToken: string; - let jiraBaseUrl: string; - try { - jiraEmail = await getIntegrationCredential(projectId, 'pm', 'email'); - jiraApiToken = await getIntegrationCredential(projectId, 'pm', 'api_token'); - const project = await findProjectById(projectId); - jiraBaseUrl = (project ? getJiraConfig(project)?.baseUrl : undefined) ?? ''; - if (!jiraBaseUrl) throw new Error('Missing JIRA base URL'); - } catch { + const auth = await getJiraAuthForProject(projectId); + if (!auth) { console.warn('[Ack] Missing JIRA credentials, skipping ack comment'); return null; } - const auth = Buffer.from(`${jiraEmail}:${jiraApiToken}`).toString('base64'); const adfBody = markdownToAdf(message); - const url = `${jiraBaseUrl}/rest/api/3/issue/${issueKey}/comment`; + const url = `${auth.baseUrl}/rest/api/3/issue/${issueKey}/comment`; const response = await fetch(url, { method: 'POST', headers: { - Authorization: `Basic ${auth}`, + Authorization: `Basic ${auth.basicAuth}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ body: adfBody }), @@ -182,26 +174,15 @@ export async function deleteJiraAck( issueKey: string, commentId: string, ): Promise { - let jiraEmail: string; - let jiraApiToken: string; - let jiraBaseUrl: string; - try { - jiraEmail = await getIntegrationCredential(projectId, 'pm', 'email'); - jiraApiToken = await getIntegrationCredential(projectId, 'pm', 'api_token'); - const project = await findProjectById(projectId); - jiraBaseUrl = (project ? getJiraConfig(project)?.baseUrl : undefined) ?? ''; - if (!jiraBaseUrl) throw new Error('Missing JIRA base URL'); - } catch { - return; - } + const auth = await getJiraAuthForProject(projectId); + if (!auth) return; - const auth = Buffer.from(`${jiraEmail}:${jiraApiToken}`).toString('base64'); - const url = `${jiraBaseUrl}/rest/api/2/issue/${issueKey}/comment/${commentId}`; + const url = `${auth.baseUrl}/rest/api/2/issue/${issueKey}/comment/${commentId}`; try { await fetch(url, { method: 'DELETE', headers: { - Authorization: `Basic ${auth}`, + Authorization: `Basic ${auth.basicAuth}`, 'Content-Type': 'application/json', }, }); @@ -210,124 +191,3 @@ export async function deleteJiraAck( console.warn('[Ack] Failed to delete JIRA orphan ack:', String(err)); } } - -// --------------------------------------------------------------------------- -// Bot identity resolution (cached, for self-authored comment detection) -// --------------------------------------------------------------------------- - -const IDENTITY_CACHE_TTL_MS = 60_000; // 60 seconds - -const jiraBotCache = new Map(); - -/** - * Resolve the JIRA account ID for the bot credentials linked to a project. - * Cached per-project with 60s TTL. Returns null on any failure. - */ -export async function resolveJiraBotAccountId(projectId: string): Promise { - const cached = jiraBotCache.get(projectId); - if (cached && Date.now() < cached.expiresAt) return cached.accountId; - - let jiraEmail: string; - let jiraApiToken: string; - let jiraBaseUrl: string; - try { - jiraEmail = await getIntegrationCredential(projectId, 'pm', 'email'); - jiraApiToken = await getIntegrationCredential(projectId, 'pm', 'api_token'); - const project = await findProjectById(projectId); - jiraBaseUrl = (project ? getJiraConfig(project)?.baseUrl : undefined) ?? ''; - if (!jiraBaseUrl) throw new Error('Missing JIRA base URL'); - } catch { - return null; - } - - const auth = Buffer.from(`${jiraEmail}:${jiraApiToken}`).toString('base64'); - try { - const response = await fetch(`${jiraBaseUrl}/rest/api/2/myself`, { - headers: { Authorization: `Basic ${auth}`, Accept: 'application/json' }, - }); - if (!response.ok) return null; - - const data = (await response.json()) as { accountId?: string }; - if (!data.accountId) return null; - - jiraBotCache.set(projectId, { - accountId: data.accountId, - expiresAt: Date.now() + IDENTITY_CACHE_TTL_MS, - }); - return data.accountId; - } catch { - return null; - } -} - -/** @internal Visible for testing only */ -export function _resetJiraBotCache(): void { - jiraBotCache.clear(); -} - -const trelloBotCache = new Map(); - -/** - * Resolve the Trello member ID for the bot credentials linked to a project. - * Cached per-project with 60s TTL. Returns null on any failure. - */ -export async function resolveTrelloBotMemberId(projectId: string): Promise { - const cached = trelloBotCache.get(projectId); - if (cached && Date.now() < cached.expiresAt) return cached.memberId; - - let trelloApiKey: string; - let trelloToken: string; - try { - trelloApiKey = await getIntegrationCredential(projectId, 'pm', 'api_key'); - trelloToken = await getIntegrationCredential(projectId, 'pm', 'token'); - } catch { - return null; - } - - try { - const response = await fetch( - `https://api.trello.com/1/members/me?key=${trelloApiKey}&token=${trelloToken}`, - { headers: { Accept: 'application/json' } }, - ); - if (!response.ok) return null; - - const data = (await response.json()) as { id?: string }; - if (!data.id) return null; - - trelloBotCache.set(projectId, { - memberId: data.id, - expiresAt: Date.now() + IDENTITY_CACHE_TTL_MS, - }); - return data.id; - } catch { - return null; - } -} - -/** @internal Visible for testing only */ -export function _resetTrelloBotCache(): void { - trelloBotCache.clear(); -} - -// --------------------------------------------------------------------------- -// Resolve GitHub token for router-side ack posting -// --------------------------------------------------------------------------- - -/** - * Resolve a GitHub token for posting ack comments from the router. - * Uses the implementer token since ack comments are "from" the bot. - */ -export async function resolveGitHubTokenForAck( - repoFullName: string, -): Promise<{ token: string; project: ProjectConfig } | null> { - const project = await findProjectByRepo(repoFullName); - if (!project) return null; - - try { - const token = await getProjectGitHubToken(project); - return { token, project }; - } catch { - console.warn('[Ack] Missing GitHub token for repo:', repoFullName); - return null; - } -} diff --git a/src/router/notifications.ts b/src/router/notifications.ts index 39bf88c3..3895cc0c 100644 --- a/src/router/notifications.ts +++ b/src/router/notifications.ts @@ -1,9 +1,6 @@ import { getProjectGitHubToken } from '../config/projects.js'; -import { - findProjectById, - findProjectByRepo, - getIntegrationCredential, -} from '../config/provider.js'; +import { findProjectByRepo } from '../config/provider.js'; +import { getJiraAuthForProject, getTrelloCredentialsForProject } from './platformClients.js'; import type { CascadeJob, GitHubJob, JiraJob, TrelloJob } from './queue.js'; /** @@ -76,12 +73,8 @@ interface TimeoutInfo { } async function notifyTrelloTimeout(job: TrelloJob, info: TimeoutInfo): Promise { - let trelloApiKey: string; - let trelloToken: string; - try { - trelloApiKey = await getIntegrationCredential(job.projectId, 'pm', 'api_key'); - trelloToken = await getIntegrationCredential(job.projectId, 'pm', 'token'); - } catch { + const creds = await getTrelloCredentialsForProject(job.projectId); + if (!creds) { console.warn('[Notifications] Missing Trello credentials in DB, skipping timeout notification'); return; } @@ -93,7 +86,7 @@ async function notifyTrelloTimeout(job: TrelloJob, info: TimeoutInfo): Promise { - let jiraEmail: string; - let jiraApiToken: string; - let jiraBaseUrl: string; - try { - jiraEmail = await getIntegrationCredential(job.projectId, 'pm', 'email'); - jiraApiToken = await getIntegrationCredential(job.projectId, 'pm', 'api_token'); - const project = await findProjectById(job.projectId); - jiraBaseUrl = project?.jira?.baseUrl ?? ''; - if (!jiraBaseUrl) throw new Error('Missing JIRA base URL'); - } catch { + const auth = await getJiraAuthForProject(job.projectId); + if (!auth) { console.warn('[Notifications] Missing JIRA credentials in DB, skipping timeout notification'); return; } @@ -182,12 +167,11 @@ async function notifyJiraTimeout(job: JiraJob, info: TimeoutInfo): Promise // Use v2 API which accepts plain text, avoiding the pm/jira/adf dependency // (the router image doesn't include pm/ modules) - const url = `${jiraBaseUrl}/rest/api/2/issue/${job.issueKey}/comment`; - const auth = Buffer.from(`${jiraEmail}:${jiraApiToken}`).toString('base64'); + const url = `${auth.baseUrl}/rest/api/2/issue/${job.issueKey}/comment`; const response = await fetch(url, { method: 'POST', headers: { - Authorization: `Basic ${auth}`, + Authorization: `Basic ${auth.basicAuth}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ body: message }), diff --git a/src/router/platformClients.ts b/src/router/platformClients.ts new file mode 100644 index 00000000..a115d550 --- /dev/null +++ b/src/router/platformClients.ts @@ -0,0 +1,228 @@ +/** + * Centralized platform credential resolution for router-side modules. + * + * Provides lightweight helpers that encapsulate credential resolution per + * platform (Trello, GitHub, JIRA) and cached bot identity lookups. + * All callers (acknowledgments, reactions, notifications) use these instead + * of duplicating the resolve-try/catch pattern themselves. + */ + +import { getProjectGitHubToken } from '../config/projects.js'; +import { + findProjectById, + findProjectByRepo, + getIntegrationCredential, +} from '../config/provider.js'; +import { getJiraConfig } from '../pm/config.js'; +import type { ProjectConfig } from '../types/index.js'; + +// --------------------------------------------------------------------------- +// Trello +// --------------------------------------------------------------------------- + +export interface TrelloCredentials { + apiKey: string; + token: string; +} + +/** + * Resolve Trello credentials (api_key + token) for a project. + * Returns null if either credential is missing. + */ +export async function getTrelloCredentialsForProject( + projectId: string, +): Promise { + try { + const apiKey = await getIntegrationCredential(projectId, 'pm', 'api_key'); + const token = await getIntegrationCredential(projectId, 'pm', 'token'); + return { apiKey, token }; + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// JIRA +// --------------------------------------------------------------------------- + +export interface JiraAuth { + email: string; + apiToken: string; + baseUrl: string; + /** Pre-computed Base64 Basic auth string */ + basicAuth: string; +} + +/** + * Resolve JIRA credentials (email + api_token + baseUrl) for a project. + * Returns null if any piece is missing or the JIRA base URL is unavailable. + */ +export async function getJiraAuthForProject(projectId: string): Promise { + try { + const email = await getIntegrationCredential(projectId, 'pm', 'email'); + const apiToken = await getIntegrationCredential(projectId, 'pm', 'api_token'); + const project = await findProjectById(projectId); + const baseUrl = (project ? getJiraConfig(project)?.baseUrl : undefined) ?? ''; + if (!baseUrl) throw new Error('Missing JIRA base URL'); + const basicAuth = Buffer.from(`${email}:${apiToken}`).toString('base64'); + return { email, apiToken, baseUrl, basicAuth }; + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// GitHub +// --------------------------------------------------------------------------- + +/** + * Resolve a GitHub implementer token for a given repository. + * Returns the token and resolved project, or null on failure. + */ +export async function getGitHubTokenForProject( + repoFullName: string, +): Promise<{ token: string; project: ProjectConfig } | null> { + const project = await findProjectByRepo(repoFullName); + if (!project) return null; + + try { + const token = await getProjectGitHubToken(project); + return { token, project }; + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// Bot identity caches +// --------------------------------------------------------------------------- + +const IDENTITY_CACHE_TTL_MS = 60_000; // 60 seconds + +// Trello bot member ID cache (per project) +const trelloBotCache = new Map(); + +/** + * Resolve the Trello member ID for the bot credentials linked to a project. + * Cached per-project with 60s TTL. Returns null on any failure. + */ +export async function resolveTrelloBotMemberId(projectId: string): Promise { + const cached = trelloBotCache.get(projectId); + if (cached && Date.now() < cached.expiresAt) return cached.memberId; + + const creds = await getTrelloCredentialsForProject(projectId); + if (!creds) return null; + + try { + const response = await fetch( + `https://api.trello.com/1/members/me?key=${creds.apiKey}&token=${creds.token}`, + { headers: { Accept: 'application/json' } }, + ); + if (!response.ok) return null; + + const data = (await response.json()) as { id?: string }; + if (!data.id) return null; + + trelloBotCache.set(projectId, { + memberId: data.id, + expiresAt: Date.now() + IDENTITY_CACHE_TTL_MS, + }); + return data.id; + } catch { + return null; + } +} + +/** @internal Visible for testing only */ +export function _resetTrelloBotCache(): void { + trelloBotCache.clear(); +} + +// JIRA bot account ID cache (per project) +const jiraBotCache = new Map(); + +/** + * Resolve the JIRA account ID for the bot credentials linked to a project. + * Cached per-project with 60s TTL. Returns null on any failure. + */ +export async function resolveJiraBotAccountId(projectId: string): Promise { + const cached = jiraBotCache.get(projectId); + if (cached && Date.now() < cached.expiresAt) return cached.accountId; + + const auth = await getJiraAuthForProject(projectId); + if (!auth) return null; + + try { + const response = await fetch(`${auth.baseUrl}/rest/api/2/myself`, { + headers: { Authorization: `Basic ${auth.basicAuth}`, Accept: 'application/json' }, + }); + if (!response.ok) return null; + + const data = (await response.json()) as { accountId?: string }; + if (!data.accountId) return null; + + jiraBotCache.set(projectId, { + accountId: data.accountId, + expiresAt: Date.now() + IDENTITY_CACHE_TTL_MS, + }); + return data.accountId; + } catch { + return null; + } +} + +/** @internal Visible for testing only */ +export function _resetJiraBotCache(): void { + jiraBotCache.clear(); +} + +// JIRA CloudId cache (per baseUrl) +const jiraCloudIdCache = new Map(); + +/** + * Lightweight JIRA cloudId resolver with in-memory cache. + * Keyed by baseUrl. Returns null on any failure. + */ +export async function getJiraCloudId(auth: JiraAuth): Promise { + const cached = jiraCloudIdCache.get(auth.baseUrl); + if (cached) return cached; + + let response: Response; + try { + response = await fetch(`${auth.baseUrl}/_edge/tenant_info`, { + headers: { Authorization: `Basic ${auth.basicAuth}` }, + }); + } catch (err) { + console.warn('[PlatformClients] Failed to fetch JIRA cloudId:', String(err)); + return null; + } + + if (!response.ok) { + console.warn('[PlatformClients] JIRA tenant_info returned', response.status); + return null; + } + + const data = (await response.json()) as { cloudId?: string }; + if (!data.cloudId) { + console.warn('[PlatformClients] JIRA tenant_info missing cloudId'); + return null; + } + + jiraCloudIdCache.set(auth.baseUrl, data.cloudId); + return data.cloudId; +} + +/** @internal Visible for testing only */ +export function _resetJiraCloudIdCache(): void { + jiraCloudIdCache.clear(); +} + +/** + * Resolve a GitHub implementer token for acknowledgment posting. + * Alias of getGitHubTokenForProject for backward compatibility. + */ +export async function resolveGitHubTokenForAck( + repoFullName: string, +): Promise<{ token: string; project: ProjectConfig } | null> { + return getGitHubTokenForProject(repoFullName); +} diff --git a/src/router/reactions.ts b/src/router/reactions.ts index 976f1757..2a725edc 100644 --- a/src/router/reactions.ts +++ b/src/router/reactions.ts @@ -9,58 +9,19 @@ */ import { getProjectGitHubToken } from '../config/projects.js'; -import { findProjectById, getIntegrationCredential } from '../config/provider.js'; import { type PersonaIdentities, isCascadeBot } from '../github/personas.js'; -import { getJiraConfig } from '../pm/config.js'; import { trelloClient, withTrelloCredentials } from '../trello/client.js'; import type { ProjectConfig } from '../types/index.js'; import { parseRepoFullName } from '../utils/repo.js'; +import { + _resetJiraCloudIdCache, + getJiraAuthForProject, + getJiraCloudId, + getTrelloCredentialsForProject, +} from './platformClients.js'; -// In-memory JIRA CloudId cache keyed by baseUrl -const jiraCloudIdCache = new Map(); - -/** - * Lightweight JIRA cloudId resolver with in-memory cache. - * Mirrors jiraClient.getCloudId() but uses standalone fetch() with explicit credentials. - */ -async function getJiraCloudId( - baseUrl: string, - email: string, - apiToken: string, -): Promise { - const cached = jiraCloudIdCache.get(baseUrl); - if (cached) return cached; - - const auth = Buffer.from(`${email}:${apiToken}`).toString('base64'); - let response: Response; - try { - response = await fetch(`${baseUrl}/_edge/tenant_info`, { - headers: { Authorization: `Basic ${auth}` }, - }); - } catch (err) { - console.warn('[Reactions] Failed to fetch JIRA cloudId:', String(err)); - return null; - } - - if (!response.ok) { - console.warn('[Reactions] JIRA tenant_info returned', response.status); - return null; - } - - const data = (await response.json()) as { cloudId?: string }; - if (!data.cloudId) { - console.warn('[Reactions] JIRA tenant_info missing cloudId'); - return null; - } - - jiraCloudIdCache.set(baseUrl, data.cloudId); - return data.cloudId; -} - -/** @internal Visible for testing only */ -export function _resetJiraCloudIdCache(): void { - jiraCloudIdCache.clear(); -} +// Re-export cache reset for test compatibility +export { _resetJiraCloudIdCache }; // --------------------------------------------------------------------------- // Platform-specific reaction senders @@ -75,12 +36,8 @@ async function sendTrelloReaction(projectId: string, payload: unknown): Promise< const actionId = action.id as string | undefined; if (!actionId) return; - let trelloApiKey: string; - let trelloToken: string; - try { - trelloApiKey = await getIntegrationCredential(projectId, 'pm', 'api_key'); - trelloToken = await getIntegrationCredential(projectId, 'pm', 'token'); - } catch { + const creds = await getTrelloCredentialsForProject(projectId); + if (!creds) { console.warn('[Reactions] Missing Trello credentials, skipping reaction'); return; } @@ -88,7 +45,7 @@ async function sendTrelloReaction(projectId: string, payload: unknown): Promise< const emoji = { shortName: 'eyes', native: '👀', unified: '1f440' }; try { - await withTrelloCredentials({ apiKey: trelloApiKey, token: trelloToken }, async () => { + await withTrelloCredentials({ apiKey: creds.apiKey, token: creds.token }, async () => { await trelloClient.addActionReaction(actionId, emoji); }); console.log('[Reactions] Trello reaction sent for action:', actionId); @@ -203,33 +160,22 @@ async function sendJiraReaction(projectId: string, payload: unknown): Promise ({ + getIntegrationCredential: vi.fn(), + findProjectByRepo: vi.fn(), + findProjectById: vi.fn(), +})); + +// Mock getProjectGitHubToken +vi.mock('../../../src/config/projects.js', () => ({ + getProjectGitHubToken: vi.fn(), +})); + +// Mock config cache (imported transitively) +vi.mock('../../../src/config/configCache.js', () => ({ + configCache: { + getConfig: vi.fn().mockReturnValue(null), + getProjectByBoardId: vi.fn().mockReturnValue(null), + getProjectByRepo: vi.fn().mockReturnValue(null), + setConfig: vi.fn(), + setProjectByBoardId: vi.fn(), + setProjectByRepo: vi.fn(), + invalidate: vi.fn(), + }, +})); + +import { getProjectGitHubToken } from '../../../src/config/projects.js'; +import { + findProjectById, + findProjectByRepo, + getIntegrationCredential, +} from '../../../src/config/provider.js'; +import { + _resetJiraBotCache, + _resetJiraCloudIdCache, + _resetTrelloBotCache, + getGitHubTokenForProject, + getJiraAuthForProject, + getJiraCloudId, + getTrelloCredentialsForProject, + resolveGitHubTokenForAck, + resolveJiraBotAccountId, + resolveTrelloBotMemberId, +} from '../../../src/router/platformClients.js'; + +const mockGetIntegrationCredential = vi.mocked(getIntegrationCredential); +const mockGetProjectGitHubToken = vi.mocked(getProjectGitHubToken); +const mockFindProjectByRepo = vi.mocked(findProjectByRepo); +const mockFindProjectById = vi.mocked(findProjectById); + +const MOCK_CREDENTIALS: Record = { + 'pm/api_key': 'test-trello-key', + 'pm/token': 'test-trello-token', + 'pm/email': 'bot@example.com', + 'pm/api_token': 'test-jira-token', +}; + +// Mock global fetch +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +const MOCK_PROJECT = { + id: 'test', + name: 'Test', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + jira: { + baseUrl: 'https://test.atlassian.net', + projectKey: 'PROJ', + statuses: {}, + labels: {}, + }, +}; + +beforeEach(() => { + mockFetch.mockReset(); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + + mockGetIntegrationCredential.mockImplementation(async (_projectId, category, role) => { + const value = MOCK_CREDENTIALS[`${category}/${role}`]; + if (value) return value; + throw new Error(`Credential '${category}/${role}' not found`); + }); + mockGetProjectGitHubToken.mockResolvedValue('test-github-token'); + mockFindProjectByRepo.mockResolvedValue({ + id: 'test', + name: 'Test', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + }); + mockFindProjectById.mockResolvedValue(MOCK_PROJECT); +}); + +afterEach(() => { + vi.restoreAllMocks(); + _resetJiraBotCache(); + _resetTrelloBotCache(); + _resetJiraCloudIdCache(); +}); + +// --------------------------------------------------------------------------- +// getTrelloCredentialsForProject +// --------------------------------------------------------------------------- + +describe('getTrelloCredentialsForProject', () => { + it('returns credentials when both are present', async () => { + const result = await getTrelloCredentialsForProject('test'); + + expect(result).not.toBeNull(); + expect(result?.apiKey).toBe('test-trello-key'); + expect(result?.token).toBe('test-trello-token'); + }); + + it('returns null when credentials are missing', async () => { + mockGetIntegrationCredential.mockRejectedValue(new Error('not found')); + + const result = await getTrelloCredentialsForProject('test'); + + expect(result).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// getJiraAuthForProject +// --------------------------------------------------------------------------- + +describe('getJiraAuthForProject', () => { + it('returns auth object with basicAuth when all fields present', async () => { + const result = await getJiraAuthForProject('test'); + + expect(result).not.toBeNull(); + expect(result?.email).toBe('bot@example.com'); + expect(result?.apiToken).toBe('test-jira-token'); + expect(result?.baseUrl).toBe('https://test.atlassian.net'); + expect(result?.basicAuth).toMatch(/^[A-Za-z0-9+/]+=*$/); // base64 + }); + + it('returns null when credentials are missing', async () => { + mockGetIntegrationCredential.mockRejectedValue(new Error('not found')); + + const result = await getJiraAuthForProject('test'); + + expect(result).toBeNull(); + }); + + it('returns null when JIRA base URL is missing', async () => { + mockFindProjectById.mockResolvedValue({ + id: 'test', + name: 'Test', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + }); + + const result = await getJiraAuthForProject('test'); + + expect(result).toBeNull(); + }); + + it('computes correct basicAuth value', async () => { + const result = await getJiraAuthForProject('test'); + + const expected = Buffer.from('bot@example.com:test-jira-token').toString('base64'); + expect(result?.basicAuth).toBe(expected); + }); +}); + +// --------------------------------------------------------------------------- +// getGitHubTokenForProject +// --------------------------------------------------------------------------- + +describe('getGitHubTokenForProject', () => { + it('returns token and project when both are found', async () => { + const result = await getGitHubTokenForProject('owner/repo'); + + expect(result).not.toBeNull(); + expect(result?.token).toBe('test-github-token'); + expect(result?.project.id).toBe('test'); + }); + + it('returns null when project is not found', async () => { + mockFindProjectByRepo.mockResolvedValue(undefined); + + const result = await getGitHubTokenForProject('unknown/repo'); + + expect(result).toBeNull(); + }); + + it('returns null when GitHub token is missing', async () => { + mockGetProjectGitHubToken.mockRejectedValue(new Error('Missing token')); + + const result = await getGitHubTokenForProject('owner/repo'); + + expect(result).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// resolveGitHubTokenForAck (alias for getGitHubTokenForProject) +// --------------------------------------------------------------------------- + +describe('resolveGitHubTokenForAck', () => { + it('returns token and project when both are found', async () => { + const result = await resolveGitHubTokenForAck('owner/repo'); + + expect(result).not.toBeNull(); + expect(result?.token).toBe('test-github-token'); + expect(result?.project.id).toBe('test'); + }); + + it('returns null when project is not found', async () => { + mockFindProjectByRepo.mockResolvedValue(undefined); + + const result = await resolveGitHubTokenForAck('unknown/repo'); + + expect(result).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// resolveTrelloBotMemberId +// --------------------------------------------------------------------------- + +describe('resolveTrelloBotMemberId', () => { + it('returns member ID from /1/members/me', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: 'trello-bot-456' }), + }); + + const result = await resolveTrelloBotMemberId('test'); + + expect(result).toBe('trello-bot-456'); + expect(mockFetch).toHaveBeenCalledOnce(); + const [url] = mockFetch.mock.calls[0]; + expect(url).toContain('https://api.trello.com/1/members/me'); + expect(url).toContain('key=test-trello-key'); + expect(url).toContain('token=test-trello-token'); + }); + + it('caches the result for subsequent calls', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: 'trello-bot-456' }), + }); + + const result1 = await resolveTrelloBotMemberId('test'); + const result2 = await resolveTrelloBotMemberId('test'); + + expect(result1).toBe('trello-bot-456'); + expect(result2).toBe('trello-bot-456'); + expect(mockFetch).toHaveBeenCalledOnce(); // Only one API call + }); + + it('returns null when credentials are missing', async () => { + mockGetIntegrationCredential.mockRejectedValue(new Error('not found')); + + const result = await resolveTrelloBotMemberId('test'); + + expect(result).toBeNull(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('returns null on API error', async () => { + mockFetch.mockResolvedValueOnce({ ok: false, status: 401 }); + + const result = await resolveTrelloBotMemberId('test'); + + expect(result).toBeNull(); + }); + + it('returns null when response has no id', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({}), + }); + + const result = await resolveTrelloBotMemberId('test'); + + expect(result).toBeNull(); + }); + + it('cache expires after TTL and re-fetches', async () => { + mockFetch + .mockResolvedValueOnce({ ok: true, json: async () => ({ id: 'bot-1' }) }) + .mockResolvedValueOnce({ ok: true, json: async () => ({ id: 'bot-2' }) }); + + const result1 = await resolveTrelloBotMemberId('test'); + + // Manually manipulate cache TTL by clearing and re-calling + _resetTrelloBotCache(); + + const result2 = await resolveTrelloBotMemberId('test'); + + expect(result1).toBe('bot-1'); + expect(result2).toBe('bot-2'); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); +}); + +// --------------------------------------------------------------------------- +// resolveJiraBotAccountId +// --------------------------------------------------------------------------- + +describe('resolveJiraBotAccountId', () => { + it('returns account ID from /rest/api/2/myself', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ accountId: 'jira-bot-123' }), + }); + + const result = await resolveJiraBotAccountId('test'); + + expect(result).toBe('jira-bot-123'); + expect(mockFetch).toHaveBeenCalledOnce(); + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toBe('https://test.atlassian.net/rest/api/2/myself'); + expect(options.headers.Authorization).toMatch(/^Basic /); + }); + + it('caches the result for subsequent calls', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ accountId: 'jira-bot-123' }), + }); + + const result1 = await resolveJiraBotAccountId('test'); + const result2 = await resolveJiraBotAccountId('test'); + + expect(result1).toBe('jira-bot-123'); + expect(result2).toBe('jira-bot-123'); + expect(mockFetch).toHaveBeenCalledOnce(); + }); + + it('returns null when credentials are missing', async () => { + mockGetIntegrationCredential.mockRejectedValue(new Error('not found')); + + const result = await resolveJiraBotAccountId('test'); + + expect(result).toBeNull(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('returns null when JIRA base URL is missing', async () => { + mockFindProjectById.mockResolvedValue({ + id: 'test', + name: 'Test', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + }); + + const result = await resolveJiraBotAccountId('test'); + + expect(result).toBeNull(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('returns null on API error', async () => { + mockFetch.mockResolvedValueOnce({ ok: false, status: 401 }); + + const result = await resolveJiraBotAccountId('test'); + + expect(result).toBeNull(); + }); + + it('returns null when response has no accountId', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({}), + }); + + const result = await resolveJiraBotAccountId('test'); + + expect(result).toBeNull(); + }); + + it('cache can be cleared and re-fetches', async () => { + mockFetch + .mockResolvedValueOnce({ ok: true, json: async () => ({ accountId: 'acct-1' }) }) + .mockResolvedValueOnce({ ok: true, json: async () => ({ accountId: 'acct-2' }) }); + + const result1 = await resolveJiraBotAccountId('test'); + _resetJiraBotCache(); + const result2 = await resolveJiraBotAccountId('test'); + + expect(result1).toBe('acct-1'); + expect(result2).toBe('acct-2'); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); +}); + +// --------------------------------------------------------------------------- +// getJiraCloudId +// --------------------------------------------------------------------------- + +describe('getJiraCloudId', () => { + const mockAuth = { + email: 'bot@example.com', + apiToken: 'test-jira-token', + baseUrl: 'https://test.atlassian.net', + basicAuth: Buffer.from('bot@example.com:test-jira-token').toString('base64'), + }; + + it('returns cloudId from tenant_info endpoint', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ cloudId: 'cloud-abc-123' }), + }); + + const result = await getJiraCloudId(mockAuth); + + expect(result).toBe('cloud-abc-123'); + expect(mockFetch).toHaveBeenCalledOnce(); + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toBe('https://test.atlassian.net/_edge/tenant_info'); + expect(options.headers.Authorization).toMatch(/^Basic /); + }); + + it('caches the cloudId for subsequent calls with same baseUrl', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ cloudId: 'cloud-abc-123' }), + }); + + const result1 = await getJiraCloudId(mockAuth); + const result2 = await getJiraCloudId(mockAuth); + + expect(result1).toBe('cloud-abc-123'); + expect(result2).toBe('cloud-abc-123'); + expect(mockFetch).toHaveBeenCalledOnce(); + }); + + it('returns null on network error', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network error')); + + const result = await getJiraCloudId(mockAuth); + + expect(result).toBeNull(); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('Failed to fetch JIRA cloudId'), + expect.any(String), + ); + }); + + it('returns null on HTTP error', async () => { + mockFetch.mockResolvedValueOnce({ ok: false, status: 403 }); + + const result = await getJiraCloudId(mockAuth); + + expect(result).toBeNull(); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('JIRA tenant_info returned'), + 403, + ); + }); + + it('returns null when cloudId is missing from response', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({}), + }); + + const result = await getJiraCloudId(mockAuth); + + expect(result).toBeNull(); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('JIRA tenant_info missing cloudId'), + ); + }); + + it('cache can be cleared and re-fetches', async () => { + mockFetch + .mockResolvedValueOnce({ ok: true, json: async () => ({ cloudId: 'cloud-1' }) }) + .mockResolvedValueOnce({ ok: true, json: async () => ({ cloudId: 'cloud-2' }) }); + + const result1 = await getJiraCloudId(mockAuth); + _resetJiraCloudIdCache(); + const result2 = await getJiraCloudId(mockAuth); + + expect(result1).toBe('cloud-1'); + expect(result2).toBe('cloud-2'); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); +});