mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-22 08:38:08 +02:00
* refactor: remove legacy ktx compatibility shims * fix: restore overlay collision guidance
385 lines
12 KiB
TypeScript
385 lines
12 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
import type { StagedExploreFile, StagedLookmlModelsFile } from './types.js';
|
|
import {
|
|
buildLookerPullConfigFromInputs,
|
|
collectExploreParseItems,
|
|
computeLookerMappingDrift,
|
|
discoverLookerConnections,
|
|
lookerDialectToConnectionType,
|
|
projectParsedIdentifier,
|
|
refreshLookerMappingPlaceholders,
|
|
sqlglotDialectForConnectionType,
|
|
suggestKtxConnectionForLookerConnection,
|
|
validateLookerMappings,
|
|
validateLookerWarehouseTarget,
|
|
} from './mapping.js';
|
|
|
|
const liveConnections = [
|
|
{
|
|
name: 'b2b_sandbox_bq',
|
|
host: 'warehouse.example.com',
|
|
database: 'analytics',
|
|
schema: null,
|
|
dialect: 'bigquery_standard_sql',
|
|
},
|
|
{
|
|
name: 'pg_runtime',
|
|
host: 'pg.internal:5432',
|
|
database: 'app',
|
|
schema: 'public',
|
|
dialect: 'postgres',
|
|
},
|
|
];
|
|
|
|
const mappedExplore: StagedExploreFile = {
|
|
modelName: 'b2b',
|
|
exploreName: 'sales_pipeline',
|
|
label: 'Sales Pipeline',
|
|
description: null,
|
|
rawSqlTableName: 'proj.analytics.opportunities AS opportunities',
|
|
connectionName: 'b2b_sandbox_bq',
|
|
viewName: 'opportunities',
|
|
fields: { dimensions: [], measures: [] },
|
|
joins: [
|
|
{
|
|
name: 'accounts',
|
|
type: 'left_outer',
|
|
relationship: 'many_to_one',
|
|
rawSqlTableName: 'proj.analytics.accounts',
|
|
sqlOn: null,
|
|
from: null,
|
|
targetTable: null,
|
|
},
|
|
],
|
|
targetWarehouseConnectionId: null,
|
|
targetTable: null,
|
|
};
|
|
|
|
const models: StagedLookmlModelsFile = {
|
|
models: [{ name: 'b2b', label: 'B2B', explores: [{ name: 'sales_pipeline', label: 'Sales Pipeline' }] }],
|
|
};
|
|
|
|
describe('discoverLookerConnections', () => {
|
|
it('delegates to the runtime client connection discovery method', async () => {
|
|
const client = { listLookerConnections: vi.fn().mockResolvedValue(liveConnections) };
|
|
|
|
await expect(discoverLookerConnections(client)).resolves.toEqual(liveConnections);
|
|
expect(client.listLookerConnections).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe('looker dialect and target validation helpers', () => {
|
|
it('maps Looker dialect names to KTX connection types', () => {
|
|
expect(lookerDialectToConnectionType('bigquery_standard_sql')).toBe('BIGQUERY');
|
|
expect(lookerDialectToConnectionType('postgres')).toBe('POSTGRESQL');
|
|
expect(lookerDialectToConnectionType('mssql')).toBeNull();
|
|
expect(lookerDialectToConnectionType('tsql')).toBeNull();
|
|
expect(lookerDialectToConnectionType('unknown')).toBeNull();
|
|
});
|
|
|
|
it('maps supported warehouse connection types to sqlglot dialects', () => {
|
|
expect(sqlglotDialectForConnectionType('BIGQUERY')).toBe('bigquery');
|
|
expect(sqlglotDialectForConnectionType('POSTGRESQL')).toBe('postgres');
|
|
expect(sqlglotDialectForConnectionType('LOOKER')).toBeNull();
|
|
});
|
|
|
|
it('returns a structured failure for unsupported Looker warehouse targets', () => {
|
|
expect(validateLookerWarehouseTarget('LOOKER')).toEqual({
|
|
ok: false,
|
|
reason: 'Connection type LOOKER cannot be used as a Looker warehouse mapping target',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('suggestKtxConnectionForLookerConnection', () => {
|
|
it('returns the single deterministic target with matching type, host, and database', () => {
|
|
expect(
|
|
suggestKtxConnectionForLookerConnection({
|
|
lookerConnection: liveConnections[1],
|
|
candidateConnections: [
|
|
{
|
|
id: 'wrong-type',
|
|
connection_type: 'MYSQL',
|
|
connection_params: { host: 'pg.internal', database: 'app' },
|
|
},
|
|
{
|
|
id: 'pg-target',
|
|
connection_type: 'POSTGRESQL',
|
|
connection_params: { host: 'PG.INTERNAL', database: 'APP' },
|
|
},
|
|
],
|
|
}),
|
|
).toBe('pg-target');
|
|
});
|
|
|
|
it('returns null when more than one target matches', () => {
|
|
expect(
|
|
suggestKtxConnectionForLookerConnection({
|
|
lookerConnection: liveConnections[1],
|
|
candidateConnections: [
|
|
{
|
|
id: 'first',
|
|
connection_type: 'POSTGRESQL',
|
|
connection_params: { host: 'pg.internal', database: 'app' },
|
|
},
|
|
{
|
|
id: 'second',
|
|
connection_type: 'POSTGRESQL',
|
|
connection_params: { host: 'pg.internal:5432', database: 'APP' },
|
|
},
|
|
],
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('refreshLookerMappingPlaceholders', () => {
|
|
it('adds newly discovered placeholders and refreshes live metadata without dropping saved targets', () => {
|
|
expect(
|
|
refreshLookerMappingPlaceholders({
|
|
stored: [
|
|
{
|
|
lookerConnectionName: 'b2b_sandbox_bq',
|
|
ktxConnectionId: 'warehouse',
|
|
lookerHost: null,
|
|
lookerDatabase: null,
|
|
lookerDialect: null,
|
|
},
|
|
],
|
|
live: liveConnections,
|
|
}),
|
|
).toEqual({
|
|
changed: true,
|
|
mappings: [
|
|
{
|
|
lookerConnectionName: 'b2b_sandbox_bq',
|
|
ktxConnectionId: 'warehouse',
|
|
lookerHost: 'warehouse.example.com',
|
|
lookerDatabase: 'analytics',
|
|
lookerDialect: 'bigquery_standard_sql',
|
|
},
|
|
{
|
|
lookerConnectionName: 'pg_runtime',
|
|
ktxConnectionId: null,
|
|
lookerHost: 'pg.internal:5432',
|
|
lookerDatabase: 'app',
|
|
lookerDialect: 'postgres',
|
|
},
|
|
],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('computeLookerMappingDrift and validateLookerMappings', () => {
|
|
it('reports unmapped live connections, stale stored mappings, and in-sync mappings', () => {
|
|
expect(
|
|
computeLookerMappingDrift({
|
|
storedMappings: [
|
|
{
|
|
lookerConnectionName: 'b2b_sandbox_bq',
|
|
ktxConnectionId: 'warehouse',
|
|
lookerHost: null,
|
|
lookerDatabase: null,
|
|
lookerDialect: null,
|
|
},
|
|
{
|
|
lookerConnectionName: 'stale_runtime',
|
|
ktxConnectionId: 'warehouse',
|
|
lookerHost: null,
|
|
lookerDatabase: null,
|
|
lookerDialect: null,
|
|
},
|
|
],
|
|
discovered: liveConnections,
|
|
}),
|
|
).toEqual({
|
|
unmappedDiscovered: [liveConnections[1]],
|
|
staleMappings: [{ lookerConnectionName: 'stale_runtime', reason: 'looker_connection_not_found' }],
|
|
inSync: [{ lookerConnectionName: 'b2b_sandbox_bq', ktxConnectionId: 'warehouse' }],
|
|
});
|
|
});
|
|
|
|
it('validates missing and unsupported target connection ids', () => {
|
|
expect(
|
|
validateLookerMappings({
|
|
mappings: [
|
|
{
|
|
lookerConnectionName: 'b2b_sandbox_bq',
|
|
ktxConnectionId: 'missing',
|
|
lookerHost: null,
|
|
lookerDatabase: null,
|
|
lookerDialect: null,
|
|
},
|
|
{
|
|
lookerConnectionName: 'pg_runtime',
|
|
ktxConnectionId: 'looker-target',
|
|
lookerHost: null,
|
|
lookerDatabase: null,
|
|
lookerDialect: null,
|
|
},
|
|
],
|
|
knownKtxConnectionIds: new Set(['looker-target']),
|
|
knownConnectionTypes: new Map([['looker-target', 'LOOKER']]),
|
|
}),
|
|
).toEqual({
|
|
ok: false,
|
|
errors: [
|
|
{ key: 'b2b_sandbox_bq', reason: 'KTX connection missing does not exist' },
|
|
{
|
|
key: 'pg_runtime',
|
|
reason: 'Connection type LOOKER cannot be used as a Looker warehouse mapping target',
|
|
},
|
|
],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('collectExploreParseItems and projectParsedIdentifier', () => {
|
|
it('collects base explore and join parser inputs for mapped explores', () => {
|
|
expect(
|
|
collectExploreParseItems({
|
|
explore: mappedExplore,
|
|
connectionMappings: { b2b_sandbox_bq: 'warehouse' },
|
|
targetConnections: new Map([['warehouse', { id: 'warehouse', connection_type: 'BIGQUERY' }]]),
|
|
}),
|
|
).toEqual({
|
|
parsedTargetTables: {},
|
|
parseItems: [
|
|
{
|
|
key: 'b2b.sales_pipeline',
|
|
sql_table_name: 'proj.analytics.opportunities AS opportunities',
|
|
dialect: 'bigquery',
|
|
},
|
|
{
|
|
key: 'b2b.sales_pipeline.accounts',
|
|
sql_table_name: 'proj.analytics.accounts',
|
|
dialect: 'bigquery',
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it('projects successful and failed parser rows into KTX parsed target tables', () => {
|
|
expect(
|
|
projectParsedIdentifier({
|
|
ok: true,
|
|
catalog: 'proj',
|
|
schema: 'analytics',
|
|
name: 'accounts',
|
|
canonical_table: 'proj.analytics.accounts',
|
|
}),
|
|
).toEqual({
|
|
ok: true,
|
|
catalog: 'proj',
|
|
schema: 'analytics',
|
|
name: 'accounts',
|
|
canonicalTable: 'proj.analytics.accounts',
|
|
});
|
|
|
|
expect(projectParsedIdentifier({ ok: false, reason: 'derived_table_not_supported' })).toEqual({
|
|
ok: false,
|
|
reason: 'derived_table_not_supported',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('buildLookerPullConfigFromInputs', () => {
|
|
it('builds the hosted-equivalent Looker pull config from caller-loaded inputs', async () => {
|
|
const parser = {
|
|
parse: vi.fn().mockResolvedValue({
|
|
'b2b.sales_pipeline': {
|
|
ok: true,
|
|
catalog: 'proj',
|
|
schema: 'analytics',
|
|
name: 'opportunities',
|
|
canonical_table: 'proj.analytics.opportunities',
|
|
},
|
|
'b2b.sales_pipeline.accounts': {
|
|
ok: true,
|
|
catalog: 'proj',
|
|
schema: 'analytics',
|
|
name: 'accounts',
|
|
canonical_table: 'proj.analytics.accounts',
|
|
},
|
|
}),
|
|
};
|
|
const client = {
|
|
listLookmlModels: vi.fn().mockResolvedValue(models),
|
|
getExplore: vi.fn().mockResolvedValue(mappedExplore),
|
|
};
|
|
|
|
await expect(
|
|
buildLookerPullConfigFromInputs({
|
|
lookerConnectionId: 'prod-looker',
|
|
cursors: {
|
|
dashboardsLastSyncedAt: '2026-05-01T00:00:00.000Z',
|
|
looksLastSyncedAt: null,
|
|
},
|
|
refreshedMappings: [
|
|
{
|
|
lookerConnectionName: 'b2b_sandbox_bq',
|
|
ktxConnectionId: 'warehouse',
|
|
lookerHost: 'warehouse.example.com',
|
|
lookerDatabase: 'analytics',
|
|
lookerDialect: 'bigquery_standard_sql',
|
|
},
|
|
],
|
|
targetConnections: new Map([['warehouse', { id: 'warehouse', connection_type: 'BIGQUERY' }]]),
|
|
client,
|
|
parser,
|
|
}),
|
|
).resolves.toEqual({
|
|
lookerConnectionId: 'prod-looker',
|
|
dashboardUpdatedSince: '2026-05-01T00:00:00.000Z',
|
|
lookUpdatedSince: null,
|
|
connectionMappings: { b2b_sandbox_bq: 'warehouse' },
|
|
connectionTypes: { b2b_sandbox_bq: 'BIGQUERY' },
|
|
parsedTargetTables: {
|
|
'b2b.sales_pipeline': {
|
|
ok: true,
|
|
catalog: 'proj',
|
|
schema: 'analytics',
|
|
name: 'opportunities',
|
|
canonicalTable: 'proj.analytics.opportunities',
|
|
},
|
|
'b2b.sales_pipeline.accounts': {
|
|
ok: true,
|
|
catalog: 'proj',
|
|
schema: 'analytics',
|
|
name: 'accounts',
|
|
canonicalTable: 'proj.analytics.accounts',
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it('marks parser failures as parse_error without blocking pull-config construction', async () => {
|
|
const parser = { parse: vi.fn().mockRejectedValue(new Error('python unavailable')) };
|
|
const client = {
|
|
listLookmlModels: vi.fn().mockResolvedValue(models),
|
|
getExplore: vi.fn().mockResolvedValue(mappedExplore),
|
|
};
|
|
|
|
const config = await buildLookerPullConfigFromInputs({
|
|
lookerConnectionId: 'prod-looker',
|
|
cursors: { dashboardsLastSyncedAt: null, looksLastSyncedAt: null },
|
|
refreshedMappings: [
|
|
{
|
|
lookerConnectionName: 'b2b_sandbox_bq',
|
|
ktxConnectionId: 'warehouse',
|
|
lookerHost: null,
|
|
lookerDatabase: null,
|
|
lookerDialect: null,
|
|
},
|
|
],
|
|
targetConnections: new Map([['warehouse', { id: 'warehouse', connection_type: 'BIGQUERY' }]]),
|
|
client,
|
|
parser,
|
|
});
|
|
|
|
expect(config.parsedTargetTables).toMatchObject({
|
|
'b2b.sales_pipeline': { ok: false, reason: 'parse_error' },
|
|
'b2b.sales_pipeline.accounts': { ok: false, reason: 'parse_error' },
|
|
});
|
|
});
|
|
});
|