From 106ce161ee419a091696eaf50edf61ce942c8136 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Tue, 12 May 2026 10:25:54 +0200 Subject: [PATCH] fix(cli): support Metabase connection tests (#21) Co-authored-by: Andrey Avtomonov <7889985+andreybavt@users.noreply.github.com> --- packages/cli/src/connection.test.ts | 58 ++++++++++++++++++++++++- packages/cli/src/connection.ts | 67 +++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/connection.test.ts b/packages/cli/src/connection.test.ts index a54280be..ae593805 100644 --- a/packages/cli/src/connection.test.ts +++ b/packages/cli/src/connection.test.ts @@ -1,7 +1,8 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { initKtxProject, parseKtxProjectConfig } from '@ktx/context/project'; +import type { MetabaseRuntimeClient } from '@ktx/context/ingest'; +import { initKtxProject, parseKtxProjectConfig, serializeKtxProjectConfig } from '@ktx/context/project'; import type { KtxConnectionDriver, KtxScanConnector, KtxSchemaSnapshot } from '@ktx/context/scan'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { runKtxConnection } from './connection.js'; @@ -598,6 +599,61 @@ describe('runKtxConnection', () => { expect(io.stdout()).toContain('Tables: 2'); }); + it('tests a configured Metabase connection through the Metabase runtime client', async () => { + const projectDir = join(tempDir, 'project'); + await initKtxProject({ projectDir, projectName: 'warehouse' }); + const projectConfig = parseKtxProjectConfig(await readFile(join(projectDir, 'ktx.yaml'), 'utf-8')); + await writeFile( + join(projectDir, 'ktx.yaml'), + serializeKtxProjectConfig({ + ...projectConfig, + connections: { + ...projectConfig.connections, + prod_metabase: { + driver: 'metabase', + api_url: 'http://metabase.example.test', + api_key: 'mb_test', + }, + }, + }), + 'utf-8', + ); + const testConnection = vi.fn(async () => ({ success: true as const })); + const getDatabases = vi.fn(async () => [ + { id: 1, name: 'Analytics', engine: 'postgres', details: {}, is_sample: false }, + { id: 2, name: 'Sample Database', engine: 'h2', details: {}, is_sample: true }, + ]); + const cleanup = vi.fn(async () => undefined); + const createMetabaseClient = vi.fn( + async (): Promise> => ({ + testConnection, + getDatabases, + cleanup, + }), + ); + const createScanConnector = vi.fn(async () => { + throw new Error('native scanner should not be used for Metabase'); + }); + const io = makeIo(); + + await expect( + runKtxConnection({ command: 'test', projectDir, connectionId: 'prod_metabase' }, io.io, { + createScanConnector, + createMetabaseClient, + }), + ).resolves.toBe(0); + + expect(createScanConnector).not.toHaveBeenCalled(); + expect(createMetabaseClient).toHaveBeenCalledWith(expect.objectContaining({ projectDir }), 'prod_metabase'); + expect(testConnection).toHaveBeenCalledTimes(1); + expect(getDatabases).toHaveBeenCalledTimes(1); + expect(cleanup).toHaveBeenCalledTimes(1); + expect(io.stdout()).toContain('Connection test passed: prod_metabase'); + expect(io.stdout()).toContain('Driver: metabase'); + expect(io.stdout()).toContain('Databases: 1'); + expect(io.stderr()).toBe(''); + }); + it('cleans up the native scan connector when connection testing fails', async () => { const projectDir = join(tempDir, 'project'); await initKtxProject({ projectDir, projectName: 'warehouse' }); diff --git a/packages/cli/src/connection.ts b/packages/cli/src/connection.ts index aa6de7c2..1dde60ac 100644 --- a/packages/cli/src/connection.ts +++ b/packages/cli/src/connection.ts @@ -1,4 +1,10 @@ import { cancel, confirm, isCancel } from '@clack/prompts'; +import { + DEFAULT_METABASE_CLIENT_CONFIG, + DefaultMetabaseConnectionClientFactory, + type MetabaseRuntimeClient, + metabaseRuntimeConfigFromLocalConnection, +} from '@ktx/context/ingest'; import { type KtxLocalProject, loadKtxProject, serializeKtxProjectConfig } from '@ktx/context/project'; import type { KtxScanConnector } from '@ktx/context/scan'; import type { KtxConnectionMappingArgs } from './commands/connection-mapping.js'; @@ -61,6 +67,7 @@ interface KtxConnectionIo extends KtxCliIo { interface KtxConnectionDeps { createScanConnector?: typeof createKtxCliScanConnector; + createMetabaseClient?: typeof createDefaultMetabaseClient; runMapping?: (argv: string[], io: KtxCliIo) => Promise; prompts?: KtxConnectionPromptAdapter; } @@ -104,6 +111,12 @@ async function cleanupConnector(connector: KtxScanConnector | null): Promise> { + const factory = new DefaultMetabaseConnectionClientFactory( + (metabaseConnectionId) => + metabaseRuntimeConfigFromLocalConnection( + metabaseConnectionId, + project.config.connections[metabaseConnectionId], + ), + DEFAULT_METABASE_CLIENT_CONFIG, + ); + return factory.createClient(connectionId); +} + +async function testMetabaseConnection( + project: KtxLocalProject, + connectionId: string, + createMetabaseClient: typeof createDefaultMetabaseClient, +): Promise<{ driver: 'metabase'; databaseCount: number }> { + let client: Pick | null = null; + try { + client = await createMetabaseClient(project, connectionId); + const testResult = await client.testConnection(); + if (!testResult.success) { + throw new Error( + `Metabase connection test failed: ${testResult.error ?? testResult.message ?? 'unknown error'}`, + ); + } + + const databases = await client.getDatabases(); + const databaseCount = databases.filter((database) => database.is_sample !== true).length; + if (databaseCount === 0) { + throw new Error('Metabase auth worked but no usable databases were returned'); + } + + return { driver: 'metabase', databaseCount }; + } finally { + await client?.cleanup(); + } +} + interface BufferedIo extends KtxCliIo { stdoutText(): string; stderrText(): string; @@ -399,6 +454,18 @@ export async function runKtxConnection( return 0; } + if (normalizedConnectionDriver(project, args.connectionId) === 'metabase') { + const result = await testMetabaseConnection( + project, + args.connectionId, + deps.createMetabaseClient ?? createDefaultMetabaseClient, + ); + io.stdout.write(`Connection test passed: ${args.connectionId}\n`); + io.stdout.write(`Driver: ${result.driver}\n`); + io.stdout.write(`Databases: ${result.databaseCount}\n`); + return 0; + } + const result = await testNativeConnection( project, args.connectionId,