From f38908c1d94edaf3e50098c693b229bba70b6552 Mon Sep 17 00:00:00 2001 From: alejandrobailo Date: Mon, 23 Feb 2026 16:50:10 +0100 Subject: [PATCH] refactor(ui): extract account selection utils and tests --- .../org-account-selection.utils.test.ts | 176 ++++++++++++ .../org-account-selection.utils.ts | 272 ++++++++++++++++++ 2 files changed, 448 insertions(+) create mode 100644 ui/components/providers/organizations/org-account-selection.utils.test.ts create mode 100644 ui/components/providers/organizations/org-account-selection.utils.ts diff --git a/ui/components/providers/organizations/org-account-selection.utils.test.ts b/ui/components/providers/organizations/org-account-selection.utils.test.ts new file mode 100644 index 0000000000..04dcca001c --- /dev/null +++ b/ui/components/providers/organizations/org-account-selection.utils.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it, vi } from "vitest"; + +import { CONNECTION_TEST_STATUS } from "@/types/organizations"; + +import { + buildAccountToProviderMap, + canAdvanceToLaunchStep, + getLaunchableProviderIds, + pollConnectionTask, + runWithConcurrencyLimit, +} from "./org-account-selection.utils"; + +describe("buildAccountToProviderMap", () => { + it("uses explicit account-provider mappings when apply response is unordered", async () => { + // Given + const resolveProviderUidById = vi.fn(); + const selectedAccountIds = ["111111111111", "222222222222"]; + const providerIds = ["provider-b", "provider-a"]; + const applyResult = { + data: { + attributes: { + account_provider_mappings: [ + { + account_id: "111111111111", + provider_id: "provider-a", + }, + { + account_id: "222222222222", + provider_id: "provider-b", + }, + ], + }, + }, + }; + + // When + const map = await buildAccountToProviderMap({ + selectedAccountIds, + providerIds, + applyResult, + resolveProviderUidById, + }); + + // Then + expect(map.get("111111111111")).toBe("provider-a"); + expect(map.get("222222222222")).toBe("provider-b"); + expect(resolveProviderUidById).not.toHaveBeenCalled(); + }); + + it("falls back to provider uid matching when explicit mappings are missing", async () => { + // Given + const selectedAccountIds = ["111111111111", "222222222222"]; + const providerIds = ["provider-a", "provider-b", "provider-c"]; + const resolveProviderUidById = vi.fn(async (providerId: string) => { + if (providerId === "provider-a") return "222222222222"; + if (providerId === "provider-c") return "111111111111"; + return "999999999999"; + }); + + // When + const map = await buildAccountToProviderMap({ + selectedAccountIds, + providerIds, + applyResult: {}, + resolveProviderUidById, + }); + + // Then + expect(map.get("111111111111")).toBe("provider-c"); + expect(map.get("222222222222")).toBe("provider-a"); + }); +}); + +describe("runWithConcurrencyLimit", () => { + it("processes work with the configured concurrency cap", async () => { + // Given + const items = Array.from({ length: 8 }, (_, index) => index + 1); + let activeWorkers = 0; + let maxActiveWorkers = 0; + + // When + const results = await runWithConcurrencyLimit(items, 3, async (item) => { + activeWorkers += 1; + maxActiveWorkers = Math.max(maxActiveWorkers, activeWorkers); + await new Promise((resolve) => setTimeout(resolve, 1)); + activeWorkers -= 1; + return item * 2; + }); + + // Then + expect(maxActiveWorkers).toBeLessThanOrEqual(3); + expect(results).toEqual([2, 4, 6, 8, 10, 12, 14, 16]); + }); +}); + +describe("pollConnectionTask", () => { + it("uses progressive delays and returns connection result from the final task payload", async () => { + // Given + const sleeps: number[] = []; + const getTaskById = vi + .fn() + .mockResolvedValueOnce({ + data: { attributes: { state: "executing" } }, + }) + .mockResolvedValueOnce({ + data: { attributes: { state: "executing" } }, + }) + .mockResolvedValueOnce({ + data: { + attributes: { + state: "completed", + result: { connected: false, error: "Role trust policy mismatch." }, + }, + }, + }); + + // When + const result = await pollConnectionTask("task-1", { + getTaskById, + sleep: async (delay) => { + sleeps.push(delay); + }, + maxRetries: 5, + }); + + // Then + expect(sleeps).toEqual([2000, 3000]); + expect(getTaskById).toHaveBeenCalledTimes(3); + expect(result).toEqual({ + success: false, + error: "Role trust policy mismatch.", + }); + }); +}); + +describe("launch gating", () => { + it("blocks advancing when all tested providers failed", () => { + // Given + const providerIds = ["provider-a", "provider-b"]; + const connectionResults = { + "provider-a": CONNECTION_TEST_STATUS.ERROR, + "provider-b": CONNECTION_TEST_STATUS.ERROR, + }; + + // When + const launchableProviderIds = getLaunchableProviderIds( + providerIds, + connectionResults, + ); + const canAdvance = canAdvanceToLaunchStep(providerIds, connectionResults); + + // Then + expect(launchableProviderIds).toEqual([]); + expect(canAdvance).toBe(false); + }); + + it("allows advancing and keeps only successful providers", () => { + // Given + const providerIds = ["provider-a", "provider-b"]; + const connectionResults = { + "provider-a": CONNECTION_TEST_STATUS.SUCCESS, + "provider-b": CONNECTION_TEST_STATUS.ERROR, + }; + + // When + const launchableProviderIds = getLaunchableProviderIds( + providerIds, + connectionResults, + ); + const canAdvance = canAdvanceToLaunchStep(providerIds, connectionResults); + + // Then + expect(launchableProviderIds).toEqual(["provider-a"]); + expect(canAdvance).toBe(true); + }); +}); diff --git a/ui/components/providers/organizations/org-account-selection.utils.ts b/ui/components/providers/organizations/org-account-selection.utils.ts new file mode 100644 index 0000000000..65cd762b58 --- /dev/null +++ b/ui/components/providers/organizations/org-account-selection.utils.ts @@ -0,0 +1,272 @@ +import { + CONNECTION_TEST_STATUS, + ConnectionTestStatus, +} from "@/types/organizations"; + +const DEFAULT_CONCURRENCY_LIMIT = 5; +const DEFAULT_POLL_DELAYS_MS = [2000, 3000, 5000] as const; + +interface AccountProviderMapping { + account_id: string; + provider_id: string; +} + +interface BuildAccountToProviderMapParams { + selectedAccountIds: string[]; + providerIds: string[]; + applyResult: unknown; + resolveProviderUidById: (providerId: string) => Promise; +} + +interface PollConnectionTaskOptions { + getTaskById?: (taskId: string) => Promise; + sleep?: (ms: number) => Promise; + maxRetries?: number; + delaysMs?: number[]; +} + +export interface PollConnectionTaskResult { + success: boolean; + error?: string; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function getPollingDelay(attempt: number, delaysMs: number[]): number { + if (delaysMs.length === 0) { + return DEFAULT_POLL_DELAYS_MS[DEFAULT_POLL_DELAYS_MS.length - 1]; + } + const delayIndex = Math.min(attempt, delaysMs.length - 1); + return delaysMs[delayIndex] ?? delaysMs[delaysMs.length - 1]; +} + +function normalizeAccountProviderMapping( + value: unknown, +): AccountProviderMapping | null { + if (!isRecord(value)) { + return null; + } + + const attributes = isRecord(value.attributes) ? value.attributes : null; + const accountId = + (typeof value.account_id === "string" && value.account_id) || + (typeof attributes?.account_id === "string" && attributes.account_id) || + (typeof value.id === "string" && value.id) || + null; + + const providerId = + (typeof value.provider_id === "string" && value.provider_id) || + (typeof attributes?.provider_id === "string" && attributes.provider_id) || + null; + + if (!accountId || !providerId) { + return null; + } + + return { + account_id: accountId, + provider_id: providerId, + }; +} + +function extractAccountProviderMappings(applyResult: unknown) { + if (!isRecord(applyResult)) { + return []; + } + + const data = isRecord(applyResult.data) ? applyResult.data : null; + if (!data) { + return []; + } + + const attributes = isRecord(data.attributes) ? data.attributes : null; + const relationships = isRecord(data.relationships) + ? data.relationships + : null; + + const attributeMappings = Array.isArray(attributes?.account_provider_mappings) + ? attributes.account_provider_mappings + : []; + const relationshipNode = isRecord(relationships?.account_provider_mappings) + ? relationships.account_provider_mappings + : null; + const relationshipMappings = Array.isArray(relationshipNode?.data) + ? relationshipNode.data + : []; + + return [...attributeMappings, ...relationshipMappings] + .map(normalizeAccountProviderMapping) + .filter((mapping): mapping is AccountProviderMapping => mapping !== null); +} + +export async function runWithConcurrencyLimit( + items: T[], + concurrencyLimit: number, + worker: (item: T, index: number) => Promise, +): Promise { + if (items.length === 0) { + return []; + } + + const normalizedConcurrency = Math.max(1, Math.floor(concurrencyLimit)); + const results = new Array(items.length); + let currentIndex = 0; + + const runWorker = async () => { + while (currentIndex < items.length) { + const assignedIndex = currentIndex; + currentIndex += 1; + results[assignedIndex] = await worker( + items[assignedIndex], + assignedIndex, + ); + } + }; + + const workers = Array.from( + { length: Math.min(normalizedConcurrency, items.length) }, + () => runWorker(), + ); + + await Promise.all(workers); + return results; +} + +export async function buildAccountToProviderMap({ + selectedAccountIds, + providerIds, + applyResult, + resolveProviderUidById, +}: BuildAccountToProviderMapParams): Promise> { + const selectedAccountIdSet = new Set(selectedAccountIds); + + const explicitMappings = extractAccountProviderMappings(applyResult); + if (explicitMappings.length > 0) { + const mappedProviders = new Map(); + + for (const mapping of explicitMappings) { + if (!selectedAccountIdSet.has(mapping.account_id)) { + continue; + } + mappedProviders.set(mapping.account_id, mapping.provider_id); + } + + if (mappedProviders.size > 0) { + return mappedProviders; + } + } + + const fallbackEntries = await runWithConcurrencyLimit( + providerIds, + DEFAULT_CONCURRENCY_LIMIT, + async (providerId) => { + const providerUid = await resolveProviderUidById(providerId); + if (!providerUid || !selectedAccountIdSet.has(providerUid)) { + return null; + } + return { accountId: providerUid, providerId }; + }, + ); + + const fallbackMapping = new Map(); + for (const entry of fallbackEntries) { + if (!entry) { + continue; + } + fallbackMapping.set(entry.accountId, entry.providerId); + } + + return fallbackMapping; +} + +export async function pollConnectionTask( + taskId: string, + { + getTaskById, + sleep = async (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)), + maxRetries = 20, + delaysMs = [...DEFAULT_POLL_DELAYS_MS], + }: PollConnectionTaskOptions = {}, +): Promise { + const inProgressStates = new Set([ + "available", + "scheduled", + "executing", + "pending", + "running", + ]); + const taskFetcher = + getTaskById ?? + (async (currentTaskId: string) => { + const { getTask } = await import("@/actions/task/tasks"); + return getTask(currentTaskId); + }); + + for (let attempt = 0; attempt < maxRetries; attempt += 1) { + const taskResponse = await taskFetcher(taskId); + + if (isRecord(taskResponse) && typeof taskResponse.error === "string") { + return { success: false, error: taskResponse.error }; + } + + const data = + isRecord(taskResponse) && isRecord(taskResponse.data) + ? taskResponse.data + : null; + const attributes = isRecord(data?.attributes) ? data.attributes : null; + const state = + typeof attributes?.state === "string" ? attributes.state : null; + const result = isRecord(attributes?.result) ? attributes.result : null; + + if (state === "completed") { + const connected = + typeof result?.connected === "boolean" ? result.connected : true; + if (connected) { + return { success: true }; + } + return { + success: false, + error: + (typeof result?.error === "string" && result.error) || + "Connection failed for this account.", + }; + } + + if (state === "failed") { + return { + success: false, + error: + (typeof result?.error === "string" && result.error) || + "Connection test task failed.", + }; + } + + if (!state || !inProgressStates.has(state)) { + return { success: false, error: "Unexpected task state." }; + } + + await sleep(getPollingDelay(attempt, delaysMs)); + } + + return { success: false, error: "Connection test timed out." }; +} + +export function getLaunchableProviderIds( + providerIds: string[], + connectionResults: Record, +): string[] { + return providerIds.filter( + (providerId) => + connectionResults[providerId] === CONNECTION_TEST_STATUS.SUCCESS, + ); +} + +export function canAdvanceToLaunchStep( + providerIds: string[], + connectionResults: Record, +): boolean { + return getLaunchableProviderIds(providerIds, connectionResults).length > 0; +}