mirror of
https://github.com/Kaelio/ktx.git
synced 2026-07-07 11:02:11 +02:00
test: split cli tests from source tree (#216)
* feat(cli): define full warehouse dialect contract
* test(cli): keep dialect edge tests focused
* fix(cli): stabilize dialect contract foundation
* refactor(connectors): own read-only query preparation
* refactor(connectors): resolve dialects through registry
* refactor(connectors): keep concrete dialect classes internal
* chore(workspace): enforce dialect import boundary
* refactor(cli): resolve relationship dialect at scan boundary
* refactor(cli): use dialect display parsing for entity details
* refactor(cli): use dialect display parsing for warehouse catalog
* refactor(cli): use dialect SQL in relationship workflows
* test(cli): verify solid dialect scan workflow closure
* test: split cli tests from source tree
* refactor(cli): standardize BigQuery scope listing
* feat(sqlite): implement connector scope listing
* test(connectors): cover required table listing
* feat(cli): add warehouse driver registry
* refactor(setup): route scope discovery through driver registry
* refactor(cli): route local query execution through driver registry
* refactor(historic-sql): route dialect support through driver registry
* refactor(cli): test warehouse connections through driver registry
* fix(cli): close driver registry type export gaps
* Improve setup daemon diagnostics
* refactor(setup): centralize rail-prefixed diagnostics + query-history fallback
Extract errorMessage, writePrefixedLines, and flushPrefixedBufferedCommandOutput
into clack.ts so the setup wizard, managed daemons, and embedding/agent steps
share one rail-formatted writer. setup-databases.ts also adds a
"disable query history and retry" option when the schema-context build fails
and query history is the likely culprit, surfaced via a new
failed-query-history-unavailable status.
* fix(cli): carry catalog through the picker so BigQuery/Snowflake/SQL Server scope filters match
The setup picker's KtxTableListEntry was a 2-level { schema, name }, so
qualifiedTableId always wrote db.name into enabled_tables. When BigQuery,
Snowflake, or SQL Server later ran fast ingest, their introspect step filtered
the scope set with scopedTableNames(scope, { catalog: projectId|database, db })
— catalog was non-null on the introspect side but null in the scope refs, so
every entry was rejected, the live-database adapter staged zero table files,
and detect() failed with 'Adapter "live-database" did not recognize fetched
source output'.
Align the picker boundary with the canonical 3-level KtxTableRef:
- Add catalog: string | null to KtxTableListEntry.
- BigQuery/Snowflake/SQL Server listTables populate catalog from the
resolved projectId / database; Postgres/MySQL/ClickHouse/SQLite set null.
- qualifiedTableId emits catalog.schema.name when catalog is non-null
(resolveEnabledTables already accepts the 3-part shape) and
schemasFromEnabledTables now goes through parseDottedTableEntry so it
recovers the schema correctly from both 2-part and 3-part entries.
- Export parseDottedTableEntry from enabled-tables.ts (@internal) for picker
reuse.
Update listTables expectations in all seven connector tests and the setup /
picker test fixtures. Add a picker regression test that covers the
catalog-bearing round-trip (save + refine).
* fix(cli): allow debug telemetry under opt-out env
This commit is contained in:
parent
924868841d
commit
56985b7e09
548 changed files with 5048 additions and 2228 deletions
|
|
@ -0,0 +1,275 @@
|
|||
import type { Tool } from 'ai';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { StageIndex } from '../../../../src/context/ingest/stages/stage-index.types.js';
|
||||
import { createEmitArtifactResolutionTool } from '../../../../src/context/ingest/tools/emit-artifact-resolution.tool.js';
|
||||
import { createEmitConflictResolutionTool } from '../../../../src/context/ingest/tools/emit-conflict-resolution.tool.js';
|
||||
import { createEmitEvictionDecisionTool } from '../../../../src/context/ingest/tools/emit-eviction-decision.tool.js';
|
||||
import { createEmitUnmappedFallbackTool } from '../../../../src/context/ingest/tools/emit-unmapped-fallback.tool.js';
|
||||
|
||||
function makeStageIndex(): StageIndex {
|
||||
return {
|
||||
jobId: 'job-1',
|
||||
connectionId: 'c1',
|
||||
workUnits: [],
|
||||
conflictsResolved: [],
|
||||
evictionsApplied: [],
|
||||
unmappedFallbacks: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function executeTool<Input>(tool: Tool<Input, string>, input: NoInfer<Input>) {
|
||||
if (!tool.execute) {
|
||||
throw new Error('tool is not executable');
|
||||
}
|
||||
return (await tool.execute(input, { toolCallId: 'tool-call-1', messages: [] })) as string;
|
||||
}
|
||||
|
||||
describe('reconciliation emit tools', () => {
|
||||
it('records conflict resolutions on the shared stage index', async () => {
|
||||
const stageIndex = makeStageIndex();
|
||||
const tool = createEmitConflictResolutionTool({ stageIndex });
|
||||
|
||||
const output = await executeTool(tool, {
|
||||
unitKey: 'wu-orders',
|
||||
kind: 'near_duplicate',
|
||||
contestedKey: 'gross_revenue',
|
||||
artifactKey: 'sl:orders.gross_revenue',
|
||||
detail: 'orders and order_facts compute the same revenue metric; retained orders as canonical',
|
||||
flaggedForHuman: true,
|
||||
});
|
||||
|
||||
expect(stageIndex.conflictsResolved).toEqual([
|
||||
{
|
||||
unitKey: 'wu-orders',
|
||||
kind: 'near_duplicate',
|
||||
contestedKey: 'gross_revenue',
|
||||
artifactKey: 'sl:orders.gross_revenue',
|
||||
detail: 'orders and order_facts compute the same revenue metric; retained orders as canonical',
|
||||
flaggedForHuman: true,
|
||||
},
|
||||
]);
|
||||
expect(output).toBe('recorded conflict resolution for sl:orders.gross_revenue');
|
||||
});
|
||||
|
||||
it('records eviction decisions only for deleted raw paths in the current eviction set', async () => {
|
||||
const stageIndex = makeStageIndex();
|
||||
const tool = createEmitEvictionDecisionTool({
|
||||
stageIndex,
|
||||
deletedRawPaths: ['views/old_orders.view.lkml'],
|
||||
});
|
||||
|
||||
const output = await executeTool(tool, {
|
||||
rawPath: 'views/old_orders.view.lkml',
|
||||
artifactKind: 'sl',
|
||||
artifactKey: 'old_orders',
|
||||
action: 'removed',
|
||||
reason: 'source raw file was deleted and no retained artifacts are required',
|
||||
});
|
||||
|
||||
expect(output).toContain('recorded eviction decision for views/old_orders.view.lkml');
|
||||
expect(stageIndex.evictionsApplied).toEqual([
|
||||
{
|
||||
rawPath: 'views/old_orders.view.lkml',
|
||||
artifactKind: 'sl',
|
||||
artifactKey: 'old_orders',
|
||||
action: 'removed',
|
||||
reason: 'source raw file was deleted and no retained artifacts are required',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('updates an existing eviction decision for the same raw path and artifact', async () => {
|
||||
const stageIndex = makeStageIndex();
|
||||
const tool = createEmitEvictionDecisionTool({
|
||||
stageIndex,
|
||||
deletedRawPaths: ['views/old_orders.view.lkml'],
|
||||
});
|
||||
|
||||
await executeTool(tool, {
|
||||
rawPath: 'views/old_orders.view.lkml',
|
||||
artifactKind: 'wiki',
|
||||
artifactKey: 'orders/old',
|
||||
action: 'removed',
|
||||
reason: 'first pass',
|
||||
});
|
||||
await executeTool(tool, {
|
||||
rawPath: 'views/old_orders.view.lkml',
|
||||
artifactKind: 'wiki',
|
||||
artifactKey: 'orders/old',
|
||||
action: 'removed',
|
||||
reason: 'second pass after checking references',
|
||||
});
|
||||
|
||||
expect(stageIndex.evictionsApplied).toEqual([
|
||||
{
|
||||
rawPath: 'views/old_orders.view.lkml',
|
||||
artifactKind: 'wiki',
|
||||
artifactKey: 'orders/old',
|
||||
action: 'removed',
|
||||
reason: 'second pass after checking references',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects eviction decisions for raw paths outside the current eviction set', async () => {
|
||||
const stageIndex = makeStageIndex();
|
||||
const tool = createEmitEvictionDecisionTool({
|
||||
stageIndex,
|
||||
deletedRawPaths: ['views/old_orders.view.lkml'],
|
||||
});
|
||||
|
||||
const output = await executeTool(tool, {
|
||||
rawPath: 'views/not_deleted.view.lkml',
|
||||
artifactKind: 'sl',
|
||||
artifactKey: 'not_deleted',
|
||||
action: 'removed',
|
||||
reason: 'bad input',
|
||||
});
|
||||
|
||||
expect(output).toContain('Error: rawPath "views/not_deleted.view.lkml" is not in the current eviction set');
|
||||
expect(stageIndex.evictionsApplied).toEqual([]);
|
||||
});
|
||||
|
||||
it('records unmapped fallback decisions for allowed raw paths', async () => {
|
||||
const stageIndex = makeStageIndex();
|
||||
const tool = createEmitUnmappedFallbackTool({
|
||||
stageIndex,
|
||||
allowedPaths: new Set(['metrics/conversion.yml']),
|
||||
});
|
||||
|
||||
const output = await executeTool(tool, {
|
||||
rawPath: 'metrics/conversion.yml',
|
||||
reason: 'no_physical_table',
|
||||
fallback: 'flagged',
|
||||
});
|
||||
|
||||
expect(output).toContain('recorded unmapped fallback for metrics/conversion.yml');
|
||||
expect(stageIndex.unmappedFallbacks).toEqual([
|
||||
{
|
||||
rawPath: 'metrics/conversion.yml',
|
||||
reason: 'no_physical_table',
|
||||
detail: expect.stringContaining('not present as a source'),
|
||||
fallback: 'flagged',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('deduplicates identical unmapped fallback decisions', async () => {
|
||||
const stageIndex = makeStageIndex();
|
||||
const tool = createEmitUnmappedFallbackTool({
|
||||
stageIndex,
|
||||
allowedPaths: new Set(['metrics/conversion.yml']),
|
||||
});
|
||||
|
||||
await executeTool(tool, {
|
||||
rawPath: 'metrics/conversion.yml',
|
||||
reason: 'no_physical_table',
|
||||
fallback: 'flagged',
|
||||
});
|
||||
await executeTool(tool, {
|
||||
rawPath: 'metrics/conversion.yml',
|
||||
reason: 'no_physical_table',
|
||||
fallback: 'flagged',
|
||||
});
|
||||
|
||||
expect(stageIndex.unmappedFallbacks).toEqual([
|
||||
{
|
||||
rawPath: 'metrics/conversion.yml',
|
||||
reason: 'no_physical_table',
|
||||
detail: expect.stringContaining('not present as a source'),
|
||||
fallback: 'flagged',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('records MetricFlow-specific unsupported fallback reasons', async () => {
|
||||
const stageIndex = makeStageIndex();
|
||||
const tool = createEmitUnmappedFallbackTool({
|
||||
stageIndex,
|
||||
allowedPaths: new Set(['metrics/conversion.yml']),
|
||||
});
|
||||
|
||||
const output = await executeTool(tool, {
|
||||
rawPath: 'metrics/conversion.yml',
|
||||
reason: 'conversion_metric_unsupported',
|
||||
fallback: 'flagged',
|
||||
});
|
||||
|
||||
expect(output).toContain('conversion metric');
|
||||
expect(stageIndex.unmappedFallbacks).toEqual([
|
||||
{
|
||||
rawPath: 'metrics/conversion.yml',
|
||||
reason: 'conversion_metric_unsupported',
|
||||
detail: expect.stringContaining('conversion metric'),
|
||||
fallback: 'flagged',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects unmapped fallback decisions for raw paths outside the allowed set', async () => {
|
||||
const stageIndex = makeStageIndex();
|
||||
const tool = createEmitUnmappedFallbackTool({
|
||||
stageIndex,
|
||||
allowedPaths: new Set(['metrics/conversion.yml']),
|
||||
});
|
||||
|
||||
const output = await executeTool(tool, {
|
||||
rawPath: 'metrics/not-in-this-work-unit.yml',
|
||||
reason: 'no_physical_table',
|
||||
fallback: 'flagged',
|
||||
});
|
||||
|
||||
expect(output).toContain(
|
||||
'Error: rawPath "metrics/not-in-this-work-unit.yml" is not available to this ingest stage',
|
||||
);
|
||||
expect(stageIndex.unmappedFallbacks).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects missing-table fallback decisions when the table resolves to an existing semantic source', async () => {
|
||||
const stageIndex = makeStageIndex();
|
||||
const tool = createEmitUnmappedFallbackTool({
|
||||
stageIndex,
|
||||
allowedPaths: new Set(['cards/revenue.json']),
|
||||
tableRefExists: async (tableRef) => tableRef === 'orbit_analytics.mart_revenue_daily',
|
||||
});
|
||||
|
||||
const output = await executeTool(tool, {
|
||||
rawPath: 'cards/revenue.json',
|
||||
reason: 'no_physical_table',
|
||||
tableRef: 'orbit_analytics.mart_revenue_daily',
|
||||
fallback: 'wiki_only',
|
||||
});
|
||||
|
||||
expect(output).toContain(
|
||||
'Error: tableRef "orbit_analytics.mart_revenue_daily" already resolves to a semantic source',
|
||||
);
|
||||
expect(stageIndex.unmappedFallbacks).toEqual([]);
|
||||
});
|
||||
|
||||
it('records explicit artifact resolutions for provenance rows', async () => {
|
||||
const stageIndex = makeStageIndex();
|
||||
const tool = createEmitArtifactResolutionTool({
|
||||
stageIndex,
|
||||
allowedPaths: new Set(['explores/b2b/sales_pipeline.json']),
|
||||
});
|
||||
|
||||
const output = await executeTool(tool, {
|
||||
rawPath: 'explores/b2b/sales_pipeline.json',
|
||||
artifactKind: 'sl',
|
||||
artifactKey: 'looker__b2b__sales_pipeline',
|
||||
actionType: 'subsumed',
|
||||
reason: 'File-adapter source b2b__sales_pipeline is canonical for this explore.',
|
||||
});
|
||||
|
||||
expect(output).toBe('recorded artifact resolution for sl:looker__b2b__sales_pipeline');
|
||||
expect(stageIndex.artifactResolutions).toEqual([
|
||||
{
|
||||
rawPath: 'explores/b2b/sales_pipeline.json',
|
||||
artifactKind: 'sl',
|
||||
artifactKey: 'looker__b2b__sales_pipeline',
|
||||
actionType: 'subsumed',
|
||||
reason: 'File-adapter source b2b__sales_pipeline is canonical for this explore.',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createEvictionListTool } from '../../../../src/context/ingest/tools/eviction-list.tool.js';
|
||||
|
||||
describe('eviction_list tool', () => {
|
||||
it('returns artifacts produced for each deleted raw path', async () => {
|
||||
const provenance = {
|
||||
findLatestArtifactsForRawPaths: vi.fn().mockResolvedValue(
|
||||
new Map([
|
||||
[
|
||||
'views/old.lkml',
|
||||
[{ artifact_kind: 'sl', artifact_key: 'old_metric', action_type: 'source_created' } as any],
|
||||
],
|
||||
['views/gone.lkml', []],
|
||||
]),
|
||||
),
|
||||
};
|
||||
const tool = createEvictionListTool({
|
||||
provenance: provenance as any,
|
||||
connectionId: 'c1',
|
||||
sourceKey: 'lookml',
|
||||
deletedRawPaths: ['views/old.lkml', 'views/gone.lkml'],
|
||||
});
|
||||
const out = (await (tool.execute as (...args: unknown[]) => unknown)(
|
||||
{},
|
||||
{ toolCallId: 't', messages: [] },
|
||||
)) as string;
|
||||
expect(out).toContain('views/old.lkml');
|
||||
expect(out).toContain('old_metric');
|
||||
expect(out).toContain('views/gone.lkml');
|
||||
});
|
||||
|
||||
it('returns empty string when no deletions', async () => {
|
||||
const tool = createEvictionListTool({
|
||||
provenance: {} as any,
|
||||
connectionId: 'c1',
|
||||
sourceKey: 'lookml',
|
||||
deletedRawPaths: [],
|
||||
});
|
||||
const out = (await (tool.execute as (...args: unknown[]) => unknown)(
|
||||
{},
|
||||
{ toolCallId: 't', messages: [] },
|
||||
)) as string;
|
||||
expect(out).toMatch(/empty/i);
|
||||
});
|
||||
|
||||
it('tells curators to record decisions', () => {
|
||||
const tool = createEvictionListTool({
|
||||
provenance: {} as any,
|
||||
connectionId: 'c1',
|
||||
sourceKey: 'lookml',
|
||||
deletedRawPaths: [],
|
||||
});
|
||||
|
||||
expect(tool.description).toContain('emit_eviction_decision');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { createReadRawFileTool } from '../../../../src/context/ingest/tools/read-raw-file.tool.js';
|
||||
|
||||
describe('read_raw_file tool', () => {
|
||||
let stagedDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
stagedDir = await mkdtemp(join(tmpdir(), 'readraw-'));
|
||||
await mkdir(join(stagedDir, 'views'), { recursive: true });
|
||||
await writeFile(join(stagedDir, 'views', 'a.yml'), 'line1\nline2\nline3\n', 'utf-8');
|
||||
await writeFile(join(stagedDir, 'peer.yml'), 'secret', 'utf-8');
|
||||
});
|
||||
|
||||
afterEach(async () => rm(stagedDir, { recursive: true, force: true }));
|
||||
|
||||
it('returns content for an allowed path', async () => {
|
||||
const tool = createReadRawFileTool({ stagedDir, allowedPaths: new Set(['views/a.yml']) });
|
||||
const result = await (tool.execute as (...args: unknown[]) => unknown)(
|
||||
{ path: 'views/a.yml' },
|
||||
{ toolCallId: 't1', messages: [] },
|
||||
);
|
||||
expect(result).toContain('line1');
|
||||
expect(result).toContain('line2');
|
||||
});
|
||||
|
||||
it('refuses to return oversized files and directs callers to read spans', async () => {
|
||||
await writeFile(join(stagedDir, 'views', 'huge.yml'), `${'x'.repeat(160_000)}\n`, 'utf-8');
|
||||
const tool = createReadRawFileTool({ stagedDir, allowedPaths: new Set(['views/huge.yml']) });
|
||||
const result = await (tool.execute as (...args: unknown[]) => unknown)(
|
||||
{ path: 'views/huge.yml' },
|
||||
{ toolCallId: 't1', messages: [] },
|
||||
);
|
||||
|
||||
expect(result).toMatch(/too large/i);
|
||||
expect(result).toMatch(/read_raw_span/i);
|
||||
expect(String(result).length).toBeLessThan(1000);
|
||||
});
|
||||
|
||||
it('rejects a path not in the allow-list', async () => {
|
||||
const tool = createReadRawFileTool({ stagedDir, allowedPaths: new Set(['views/a.yml']) });
|
||||
const result = await (tool.execute as (...args: unknown[]) => unknown)(
|
||||
{ path: 'peer.yml' },
|
||||
{ toolCallId: 't1', messages: [] },
|
||||
);
|
||||
expect(result).toMatch(/not accessible/i);
|
||||
expect(result).not.toContain('secret');
|
||||
});
|
||||
|
||||
it('rejects directory traversal attempts', async () => {
|
||||
const tool = createReadRawFileTool({ stagedDir, allowedPaths: new Set(['views/a.yml']) });
|
||||
const result = await (tool.execute as (...args: unknown[]) => unknown)(
|
||||
{ path: '../outside.yml' },
|
||||
{ toolCallId: 't1', messages: [] },
|
||||
);
|
||||
expect(result).toMatch(/not accessible/i);
|
||||
});
|
||||
|
||||
it('returns a clear error when the file is missing despite being allowed', async () => {
|
||||
const tool = createReadRawFileTool({ stagedDir, allowedPaths: new Set(['views/missing.yml']) });
|
||||
const result = await (tool.execute as (...args: unknown[]) => unknown)(
|
||||
{ path: 'views/missing.yml' },
|
||||
{ toolCallId: 't1', messages: [] },
|
||||
);
|
||||
expect(result).toMatch(/not found/i);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { createReadRawSpanTool } from '../../../../src/context/ingest/tools/read-raw-span.tool.js';
|
||||
|
||||
describe('read_raw_span tool', () => {
|
||||
let stagedDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
stagedDir = await mkdtemp(join(tmpdir(), 'readspan-'));
|
||||
await mkdir(join(stagedDir, 'v'), { recursive: true });
|
||||
await writeFile(join(stagedDir, 'v', 'a.yml'), 'line1\nline2\nline3\nline4\nline5\n', 'utf-8');
|
||||
});
|
||||
|
||||
afterEach(async () => rm(stagedDir, { recursive: true, force: true }));
|
||||
|
||||
it('returns the requested 1-based inclusive line range', async () => {
|
||||
const tool = createReadRawSpanTool({ stagedDir, allowedPaths: new Set(['v/a.yml']) });
|
||||
const result = await (tool.execute as (...args: unknown[]) => unknown)(
|
||||
{ path: 'v/a.yml', startLine: 2, endLine: 4 },
|
||||
{ toolCallId: 't1', messages: [] },
|
||||
);
|
||||
expect(result).toBe('line2\nline3\nline4');
|
||||
});
|
||||
|
||||
it('clamps endLine to the end of the file', async () => {
|
||||
const tool = createReadRawSpanTool({ stagedDir, allowedPaths: new Set(['v/a.yml']) });
|
||||
const result = await (tool.execute as (...args: unknown[]) => unknown)(
|
||||
{ path: 'v/a.yml', startLine: 4, endLine: 99 },
|
||||
{ toolCallId: 't1', messages: [] },
|
||||
);
|
||||
expect(result).toBe('line4\nline5');
|
||||
});
|
||||
|
||||
it('rejects start > end', async () => {
|
||||
const tool = createReadRawSpanTool({ stagedDir, allowedPaths: new Set(['v/a.yml']) });
|
||||
const result = await (tool.execute as (...args: unknown[]) => unknown)(
|
||||
{ path: 'v/a.yml', startLine: 5, endLine: 2 },
|
||||
{ toolCallId: 't1', messages: [] },
|
||||
);
|
||||
expect(result).toMatch(/startLine must be/i);
|
||||
});
|
||||
|
||||
it('rejects paths not in the allow-list', async () => {
|
||||
const tool = createReadRawSpanTool({ stagedDir, allowedPaths: new Set([]) });
|
||||
const result = await (tool.execute as (...args: unknown[]) => unknown)(
|
||||
{ path: 'v/a.yml', startLine: 1, endLine: 1 },
|
||||
{ toolCallId: 't1', messages: [] },
|
||||
);
|
||||
expect(result).toMatch(/not accessible/i);
|
||||
});
|
||||
});
|
||||
131
packages/cli/test/context/ingest/tools/stage-diff.tool.test.ts
Normal file
131
packages/cli/test/context/ingest/tools/stage-diff.tool.test.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { createStageDiffTool } from '../../../../src/context/ingest/tools/stage-diff.tool.js';
|
||||
|
||||
describe('stage_diff tool', () => {
|
||||
const stageIndex = {
|
||||
jobId: 'j',
|
||||
connectionId: 'c1',
|
||||
workUnits: [
|
||||
{
|
||||
unitKey: 'u1',
|
||||
rawFiles: [],
|
||||
status: 'success' as const,
|
||||
actions: [{ target: 'sl' as const, type: 'created' as const, key: 'churn_risk_score', detail: 'customers' }],
|
||||
touchedSlSources: [{ connectionId: 'c1', sourceName: 'customers' }],
|
||||
},
|
||||
{
|
||||
unitKey: 'u2',
|
||||
rawFiles: [],
|
||||
status: 'success' as const,
|
||||
actions: [{ target: 'sl' as const, type: 'created' as const, key: 'churn_risk_score', detail: 'billing' }],
|
||||
touchedSlSources: [{ connectionId: 'c1', sourceName: 'billing' }],
|
||||
},
|
||||
],
|
||||
conflictsResolved: [],
|
||||
evictionsApplied: [],
|
||||
unmappedFallbacks: [],
|
||||
};
|
||||
|
||||
it('finds overlapping artifact keys between two WUs', async () => {
|
||||
const tool = createStageDiffTool({ stageIndex });
|
||||
const out = (await (tool.execute as (...args: unknown[]) => unknown)(
|
||||
{ unitKeyA: 'u1', unitKeyB: 'u2' },
|
||||
{ toolCallId: 't', messages: [] },
|
||||
)) as string;
|
||||
expect(out).toContain('churn_risk_score');
|
||||
expect(out).toMatch(/overlap/i);
|
||||
});
|
||||
|
||||
it('says no overlap when keys are disjoint', async () => {
|
||||
const tool = createStageDiffTool({
|
||||
stageIndex: {
|
||||
jobId: 'j',
|
||||
connectionId: 'c1',
|
||||
workUnits: [
|
||||
{
|
||||
unitKey: 'u1',
|
||||
rawFiles: [],
|
||||
status: 'success',
|
||||
actions: [{ target: 'sl', type: 'created', key: 'a', detail: '' }],
|
||||
touchedSlSources: [{ connectionId: 'c1', sourceName: 'a' }],
|
||||
},
|
||||
{
|
||||
unitKey: 'u2',
|
||||
rawFiles: [],
|
||||
status: 'success',
|
||||
actions: [{ target: 'sl', type: 'created', key: 'b', detail: '' }],
|
||||
touchedSlSources: [{ connectionId: 'c1', sourceName: 'b' }],
|
||||
},
|
||||
],
|
||||
conflictsResolved: [],
|
||||
evictionsApplied: [],
|
||||
unmappedFallbacks: [],
|
||||
},
|
||||
});
|
||||
const out = (await (tool.execute as (...args: unknown[]) => unknown)(
|
||||
{ unitKeyA: 'u1', unitKeyB: 'u2' },
|
||||
{ toolCallId: 't', messages: [] },
|
||||
)) as string;
|
||||
expect(out).toMatch(/no overlap/i);
|
||||
});
|
||||
|
||||
it('does not overlap same-named SL actions on different target connections', async () => {
|
||||
const tool = createStageDiffTool({
|
||||
stageIndex: {
|
||||
jobId: 'j',
|
||||
connectionId: 'looker-run',
|
||||
workUnits: [
|
||||
{
|
||||
unitKey: 'u1',
|
||||
rawFiles: [],
|
||||
status: 'success',
|
||||
actions: [
|
||||
{
|
||||
target: 'sl',
|
||||
type: 'created',
|
||||
key: 'looker__b2b__sales_pipeline',
|
||||
detail: 'W1',
|
||||
targetConnectionId: 'W1',
|
||||
},
|
||||
],
|
||||
touchedSlSources: [{ connectionId: 'W1', sourceName: 'looker__b2b__sales_pipeline' }],
|
||||
},
|
||||
{
|
||||
unitKey: 'u2',
|
||||
rawFiles: [],
|
||||
status: 'success',
|
||||
actions: [
|
||||
{
|
||||
target: 'sl',
|
||||
type: 'created',
|
||||
key: 'looker__b2b__sales_pipeline',
|
||||
detail: 'W2',
|
||||
targetConnectionId: 'W2',
|
||||
},
|
||||
],
|
||||
touchedSlSources: [{ connectionId: 'W2', sourceName: 'looker__b2b__sales_pipeline' }],
|
||||
},
|
||||
],
|
||||
conflictsResolved: [],
|
||||
evictionsApplied: [],
|
||||
unmappedFallbacks: [],
|
||||
},
|
||||
});
|
||||
|
||||
const out = (await (tool.execute as (...args: unknown[]) => unknown)(
|
||||
{ unitKeyA: 'u1', unitKeyB: 'u2' },
|
||||
{ toolCallId: 't', messages: [] },
|
||||
)) as string;
|
||||
|
||||
expect(out).toMatch(/no overlap/i);
|
||||
});
|
||||
|
||||
it('returns an error when a unitKey is unknown', async () => {
|
||||
const tool = createStageDiffTool({ stageIndex });
|
||||
const out = (await (tool.execute as (...args: unknown[]) => unknown)(
|
||||
{ unitKeyA: 'u1', unitKeyB: 'nope' },
|
||||
{ toolCallId: 't', messages: [] },
|
||||
)) as string;
|
||||
expect(out).toMatch(/unknown/i);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { createStageListTool } from '../../../../src/context/ingest/tools/stage-list.tool.js';
|
||||
|
||||
describe('stage_list tool', () => {
|
||||
it('returns a compact summary of the stage index', async () => {
|
||||
const tool = createStageListTool({
|
||||
stageIndex: {
|
||||
jobId: 'j1',
|
||||
connectionId: 'c1',
|
||||
workUnits: [
|
||||
{
|
||||
unitKey: 'u1',
|
||||
rawFiles: ['a.yml'],
|
||||
status: 'success',
|
||||
actions: [{ target: 'sl', type: 'created', key: 'src_a', detail: '' }],
|
||||
touchedSlSources: [{ connectionId: 'c1', sourceName: 'src_a' }],
|
||||
},
|
||||
{
|
||||
unitKey: 'u2',
|
||||
rawFiles: ['b.yml'],
|
||||
status: 'success',
|
||||
actions: [
|
||||
{
|
||||
target: 'wiki',
|
||||
type: 'created',
|
||||
key: 'page_b',
|
||||
detail: 'tables: orbit_analytics.customer',
|
||||
},
|
||||
],
|
||||
touchedSlSources: [],
|
||||
},
|
||||
],
|
||||
conflictsResolved: [],
|
||||
evictionsApplied: [],
|
||||
unmappedFallbacks: [],
|
||||
},
|
||||
});
|
||||
const out = (await (tool.execute as (...args: unknown[]) => unknown)(
|
||||
{},
|
||||
{ toolCallId: 't', messages: [] },
|
||||
)) as string;
|
||||
expect(out).toContain('u1');
|
||||
expect(out).toContain('src_a');
|
||||
expect(out).toContain('u2');
|
||||
expect(out).toContain('page_b');
|
||||
expect(out).toContain('tables: orbit_analytics.customer');
|
||||
});
|
||||
|
||||
it('says empty when no writes', async () => {
|
||||
const tool = createStageListTool({
|
||||
stageIndex: {
|
||||
jobId: 'j',
|
||||
connectionId: 'c1',
|
||||
workUnits: [],
|
||||
conflictsResolved: [],
|
||||
evictionsApplied: [],
|
||||
unmappedFallbacks: [],
|
||||
},
|
||||
});
|
||||
const out = (await (tool.execute as (...args: unknown[]) => unknown)(
|
||||
{},
|
||||
{ toolCallId: 't', messages: [] },
|
||||
)) as string;
|
||||
expect(out).toMatch(/empty/i);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,218 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import type { ToolCallLogEntry } from '../../../../src/context/ingest/tools/tool-call-logger.js';
|
||||
import { createMutableToolTranscriptSummary, recordToolTranscriptEntry } from '../../../../src/context/ingest/tools/tool-transcript-summary.js';
|
||||
|
||||
function entry(overrides: Partial<ToolCallLogEntry>): ToolCallLogEntry {
|
||||
return {
|
||||
ts: '2026-05-11T00:00:00.000Z',
|
||||
wuKey: 'wu-1',
|
||||
toolName: 'wiki_write',
|
||||
durationMs: 1,
|
||||
input: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('tool transcript summaries', () => {
|
||||
it('keeps recovered wiki_write structured failures out of fatal failures', () => {
|
||||
const summary = createMutableToolTranscriptSummary('wu-1', '/tmp/wu-1.jsonl');
|
||||
|
||||
recordToolTranscriptEntry(
|
||||
summary,
|
||||
entry({
|
||||
input: { key: 'orbit-customers' },
|
||||
output: { structured: { success: false, key: 'orbit-customers' } },
|
||||
}),
|
||||
);
|
||||
recordToolTranscriptEntry(
|
||||
summary,
|
||||
entry({
|
||||
input: { key: 'orbit-customers' },
|
||||
output: { structured: { success: true, key: 'orbit-customers' } },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(summary.errorCount).toBe(1);
|
||||
expect(summary.fatalErrorCount).toBe(0);
|
||||
});
|
||||
|
||||
it('treats a suggested flat wiki key retry as recovery for an invalid nested key', () => {
|
||||
const summary = createMutableToolTranscriptSummary('wu-1', '/tmp/wu-1.jsonl');
|
||||
|
||||
recordToolTranscriptEntry(
|
||||
summary,
|
||||
entry({
|
||||
input: { key: 'historic-sql/top-accounts-by-contract-arr' },
|
||||
output: { structured: { success: false, key: 'historic-sql/top-accounts-by-contract-arr' } },
|
||||
}),
|
||||
);
|
||||
recordToolTranscriptEntry(
|
||||
summary,
|
||||
entry({
|
||||
input: { key: 'historic-sql-top-accounts-by-contract-arr' },
|
||||
output: { structured: { success: true, key: 'historic-sql-top-accounts-by-contract-arr' } },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(summary.errorCount).toBe(1);
|
||||
expect(summary.fatalErrorCount).toBe(0);
|
||||
});
|
||||
|
||||
it('counts unrecovered wiki_remove structured failures as fatal transcript errors', () => {
|
||||
const summary = createMutableToolTranscriptSummary('reconcile', '/tmp/reconcile.jsonl');
|
||||
|
||||
recordToolTranscriptEntry(summary, {
|
||||
ts: '2026-05-11T00:00:00.000Z',
|
||||
wuKey: 'reconcile',
|
||||
toolCallId: 'remove-1',
|
||||
toolName: 'wiki_remove',
|
||||
durationMs: 1,
|
||||
input: { key: 'duplicate-page' },
|
||||
output: { structured: { success: false, key: 'duplicate-page' } },
|
||||
});
|
||||
|
||||
expect(summary.errorCount).toBe(1);
|
||||
expect(summary.fatalErrorCount).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps unrecovered structured write failures fatal', () => {
|
||||
const summary = createMutableToolTranscriptSummary('wu-1', '/tmp/wu-1.jsonl');
|
||||
|
||||
recordToolTranscriptEntry(
|
||||
summary,
|
||||
entry({
|
||||
input: { key: 'orbit-customers' },
|
||||
output: { structured: { success: false, key: 'orbit-customers' } },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(summary.errorCount).toBe(1);
|
||||
expect(summary.fatalErrorCount).toBe(1);
|
||||
});
|
||||
|
||||
it('treats a later sl_edit_source success as recovery for the same SL source', () => {
|
||||
const summary = createMutableToolTranscriptSummary('wu-1', '/tmp/wu-1.jsonl');
|
||||
|
||||
recordToolTranscriptEntry(
|
||||
summary,
|
||||
entry({
|
||||
toolName: 'sl_write_source',
|
||||
input: { connectionId: 'warehouse', sourceName: 'orbit_customers' },
|
||||
output: { structured: { success: false, sourceName: 'orbit_customers' } },
|
||||
}),
|
||||
);
|
||||
recordToolTranscriptEntry(
|
||||
summary,
|
||||
entry({
|
||||
toolName: 'sl_edit_source',
|
||||
input: { connectionId: 'warehouse', sourceName: 'orbit_customers' },
|
||||
output: { structured: { success: true, sourceName: 'orbit_customers' } },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(summary.errorCount).toBe(1);
|
||||
expect(summary.fatalErrorCount).toBe(0);
|
||||
});
|
||||
|
||||
it('treats explicit unmapped fallback as recovery for guarded SL write failures', () => {
|
||||
const summary = createMutableToolTranscriptSummary('wu-1', '/tmp/wu-1.jsonl');
|
||||
|
||||
recordToolTranscriptEntry(
|
||||
summary,
|
||||
entry({
|
||||
toolName: 'sl_write_source',
|
||||
input: { connectionId: 'dbt-main', sourceName: 'stg_accounts' },
|
||||
output: { structured: { success: false, sourceName: 'stg_accounts' } },
|
||||
}),
|
||||
);
|
||||
recordToolTranscriptEntry(
|
||||
summary,
|
||||
entry({
|
||||
toolName: 'emit_unmapped_fallback',
|
||||
input: { rawPath: 'models/schema.yml', reason: 'no_physical_table', tableRef: 'stg_accounts', fallback: 'wiki_only' },
|
||||
output: 'recorded unmapped fallback for models/schema.yml (wiki_only)',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(summary.errorCount).toBe(1);
|
||||
expect(summary.fatalErrorCount).toBe(0);
|
||||
});
|
||||
|
||||
it('treats an untargeted unmapped fallback as recovery when there is only one pending SL failure', () => {
|
||||
const summary = createMutableToolTranscriptSummary('wu-1', '/tmp/wu-1.jsonl');
|
||||
|
||||
recordToolTranscriptEntry(
|
||||
summary,
|
||||
entry({
|
||||
toolName: 'sl_write_source',
|
||||
input: { connectionId: 'dbt-main', sourceName: 'stg_accounts' },
|
||||
output: { structured: { success: false, sourceName: 'stg_accounts' } },
|
||||
}),
|
||||
);
|
||||
recordToolTranscriptEntry(
|
||||
summary,
|
||||
entry({
|
||||
toolName: 'emit_unmapped_fallback',
|
||||
input: { rawPath: 'models/schema.yml', reason: 'no_physical_table', fallback: 'wiki_only' },
|
||||
output: 'recorded unmapped fallback for models/schema.yml (wiki_only)',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(summary.errorCount).toBe(1);
|
||||
expect(summary.fatalErrorCount).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps unrelated SL write failures fatal when one source gets an unmapped fallback', () => {
|
||||
const summary = createMutableToolTranscriptSummary('wu-1', '/tmp/wu-1.jsonl');
|
||||
|
||||
recordToolTranscriptEntry(
|
||||
summary,
|
||||
entry({
|
||||
toolName: 'sl_write_source',
|
||||
input: { connectionId: 'dbt-main', sourceName: 'stg_accounts' },
|
||||
output: { structured: { success: false, sourceName: 'stg_accounts' } },
|
||||
}),
|
||||
);
|
||||
recordToolTranscriptEntry(
|
||||
summary,
|
||||
entry({
|
||||
toolName: 'sl_write_source',
|
||||
input: { connectionId: 'dbt-main', sourceName: 'stg_orders' },
|
||||
output: { structured: { success: false, sourceName: 'stg_orders' } },
|
||||
}),
|
||||
);
|
||||
recordToolTranscriptEntry(
|
||||
summary,
|
||||
entry({
|
||||
toolName: 'emit_unmapped_fallback',
|
||||
input: { rawPath: 'models/schema.yml', reason: 'no_physical_table', tableRef: 'stg_accounts', fallback: 'wiki_only' },
|
||||
output: 'recorded unmapped fallback for models/schema.yml (wiki_only)',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(summary.errorCount).toBe(2);
|
||||
expect(summary.fatalErrorCount).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps thrown tool errors fatal even after a successful write', () => {
|
||||
const summary = createMutableToolTranscriptSummary('wu-1', '/tmp/wu-1.jsonl');
|
||||
|
||||
recordToolTranscriptEntry(
|
||||
summary,
|
||||
entry({
|
||||
input: { key: 'orbit-customers' },
|
||||
error: { message: 'tool crashed' },
|
||||
}),
|
||||
);
|
||||
recordToolTranscriptEntry(
|
||||
summary,
|
||||
entry({
|
||||
input: { key: 'orbit-customers' },
|
||||
output: { structured: { success: true, key: 'orbit-customers' } },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(summary.errorCount).toBe(1);
|
||||
expect(summary.fatalErrorCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { WarehouseCatalogService } from '../../../../../src/context/scan/warehouse-catalog.js';
|
||||
import type { BaseTool, ToolContext } from '../../../../../src/context/tools/base-tool.js';
|
||||
import { DiscoverDataTool } from '../../../../../src/context/ingest/tools/warehouse-verification/discover-data.tool.js';
|
||||
|
||||
describe('DiscoverDataTool', () => {
|
||||
const wikiSearchTool = { call: vi.fn() } as unknown as BaseTool & { call: ReturnType<typeof vi.fn> };
|
||||
const slDiscoverTool = { call: vi.fn() } as unknown as BaseTool & { call: ReturnType<typeof vi.fn> };
|
||||
const catalog = { searchByName: vi.fn() } as unknown as WarehouseCatalogService & {
|
||||
searchByName: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
const context: ToolContext = {
|
||||
sourceId: 'ingest',
|
||||
messageId: 'm1',
|
||||
userId: 'system',
|
||||
session: { allowedConnectionNames: new Set(['warehouse']) } as any,
|
||||
};
|
||||
const tool = new DiscoverDataTool({
|
||||
wikiSearchTool,
|
||||
slDiscoverTool,
|
||||
catalogFactory: () => catalog,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
wikiSearchTool.call.mockReset();
|
||||
slDiscoverTool.call.mockReset();
|
||||
catalog.searchByName.mockReset();
|
||||
wikiSearchTool.call.mockResolvedValue({
|
||||
markdown: '- orders wiki',
|
||||
structured: { totalFound: 1, results: [{ key: 'orders' }] },
|
||||
});
|
||||
slDiscoverTool.call.mockResolvedValue({
|
||||
markdown: '- orders source',
|
||||
structured: { totalSources: 1, sources: [{ sourceName: 'orders' }] },
|
||||
});
|
||||
catalog.searchByName.mockResolvedValue([
|
||||
{
|
||||
kind: 'table',
|
||||
connectionId: 'warehouse',
|
||||
ref: { catalog: null, db: 'public', name: 'orders' },
|
||||
display: 'public.orders',
|
||||
matchedOn: 'name',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('groups wiki, semantic layer, and raw schema hits with routing hints', async () => {
|
||||
const result = await tool.call({ query: 'orders', connectionId: 'warehouse', limit: 5 }, context);
|
||||
|
||||
expect(result.markdown).toContain('## Wiki Pages');
|
||||
expect(result.markdown).toContain('use `wiki_read(blockKey)` for full content');
|
||||
expect(result.markdown).toContain('## Semantic Layer Sources');
|
||||
expect(result.markdown).toContain('use `sl_read_source(sourceName)` for the YAML');
|
||||
expect(result.markdown).toContain('## Raw Warehouse Schema');
|
||||
expect(result.markdown).toContain('use `entity_details({connectionId, targets: [{display}]})`');
|
||||
expect(result.structured.raw?.hits).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('includes connectionId on raw schema hits so entity_details can follow up', async () => {
|
||||
const multiConnectionContext: ToolContext = {
|
||||
...context,
|
||||
session: { allowedConnectionNames: new Set(['warehouse', 'analytics']) } as any,
|
||||
};
|
||||
catalog.searchByName.mockImplementation(async (connectionId: string, query: string) => [
|
||||
{
|
||||
kind: 'table',
|
||||
connectionId,
|
||||
ref: { catalog: null, db: 'public', name: `${connectionId}_${query}` },
|
||||
display: `public.${connectionId}_${query}`,
|
||||
matchedOn: 'name',
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await tool.call({ query: 'orders', limit: 10 }, multiConnectionContext);
|
||||
|
||||
expect(catalog.searchByName).toHaveBeenCalledWith('analytics', 'orders', 10);
|
||||
expect(catalog.searchByName).toHaveBeenCalledWith('warehouse', 'orders', 10);
|
||||
expect(result.markdown).toContain('connectionId=analytics');
|
||||
expect(result.markdown).toContain('connectionId=warehouse');
|
||||
expect(result.markdown).toContain(
|
||||
'entity_details({connectionId: "analytics", targets: [{display: "public.analytics_orders"}]})',
|
||||
);
|
||||
expect(result.structured.raw?.hits.map((hit) => hit.connectionId)).toEqual(['analytics', 'warehouse']);
|
||||
});
|
||||
|
||||
it('refuses explicit out-of-scope connection names', async () => {
|
||||
const result = await tool.call({ query: 'orders', connectionId: 'billing' }, context);
|
||||
|
||||
expect(result.markdown).toContain('Connection "billing" is not available to this ingest stage.');
|
||||
expect(result.structured).toEqual({ wiki: null, sl: null, raw: null });
|
||||
expect(wikiSearchTool.call).not.toHaveBeenCalled();
|
||||
expect(slDiscoverTool.call).not.toHaveBeenCalled();
|
||||
expect(catalog.searchByName).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('delegates sourceName inspect mode to sl_discover only', async () => {
|
||||
slDiscoverTool.call.mockResolvedValueOnce({
|
||||
markdown: 'source detail',
|
||||
structured: { sourceName: 'orders' },
|
||||
});
|
||||
|
||||
const result = await tool.call({ sourceName: 'orders', connectionId: 'warehouse' }, context);
|
||||
|
||||
expect(slDiscoverTool.call).toHaveBeenCalledWith({ sourceName: 'orders', connectionId: 'warehouse' }, context);
|
||||
expect(wikiSearchTool.call).not.toHaveBeenCalled();
|
||||
expect(catalog.searchByName).not.toHaveBeenCalled();
|
||||
expect(result.markdown).toContain('source detail');
|
||||
});
|
||||
|
||||
it('returns the empty-state message when all sections are empty', async () => {
|
||||
wikiSearchTool.call.mockResolvedValueOnce({ markdown: '', structured: { totalFound: 0, results: [] } });
|
||||
slDiscoverTool.call.mockResolvedValueOnce({ markdown: '', structured: { totalSources: 0, sources: [] } });
|
||||
catalog.searchByName.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await tool.call({ query: 'customer source', connectionId: 'warehouse' }, context);
|
||||
|
||||
expect(result.markdown).toContain('No matches for "customer source" across wiki, semantic layer, or raw warehouse schema.');
|
||||
});
|
||||
|
||||
it('uses connectionId as the optional connection filter', () => {
|
||||
const legacyConnectionField = ['connection', 'Name'].join('');
|
||||
|
||||
expect(tool.parseInput({ query: 'orders', connectionId: 'warehouse', limit: 5 })).toEqual({
|
||||
query: 'orders',
|
||||
connectionId: 'warehouse',
|
||||
limit: 5,
|
||||
});
|
||||
|
||||
expect(() => tool.parseInput({ query: 'orders', [legacyConnectionField]: 'warehouse', limit: 5 })).toThrow();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,213 @@
|
|||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { initKtxProject, type KtxLocalProject } from '../../../../../src/context/project/project.js';
|
||||
import { WarehouseCatalogService } from '../../../../../src/context/scan/warehouse-catalog.js';
|
||||
import type { ToolContext } from '../../../../../src/context/tools/base-tool.js';
|
||||
import { EntityDetailsTool } from '../../../../../src/context/ingest/tools/warehouse-verification/entity-details.tool.js';
|
||||
|
||||
describe('EntityDetailsTool', () => {
|
||||
let tempDir: string;
|
||||
let project: KtxLocalProject;
|
||||
let tool: EntityDetailsTool;
|
||||
let context: ToolContext;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'ktx-entity-details-'));
|
||||
project = await initKtxProject({ projectDir: join(tempDir, 'project') });
|
||||
await seedLiveDatabaseScan();
|
||||
tool = new EntityDetailsTool(() => new WarehouseCatalogService({ fileStore: project.fileStore }));
|
||||
context = {
|
||||
sourceId: 'ingest',
|
||||
messageId: 'm1',
|
||||
userId: 'system',
|
||||
session: {
|
||||
allowedConnectionNames: new Set(['warehouse']),
|
||||
} as any,
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function seedLiveDatabaseScan(connectionId = 'warehouse', syncId = 'sync-1') {
|
||||
const root = `raw-sources/${connectionId}/live-database/${syncId}`;
|
||||
await project.fileStore.writeFile(
|
||||
`${root}/connection.json`,
|
||||
JSON.stringify({ connectionId, driver: 'postgres', extractedAt: '2026-05-12T00:00:00.000Z' }, null, 2),
|
||||
'ktx',
|
||||
'ktx@example.com',
|
||||
'seed connection',
|
||||
);
|
||||
await project.fileStore.writeFile(
|
||||
`${root}/tables/orders.json`,
|
||||
JSON.stringify(
|
||||
{
|
||||
catalog: null,
|
||||
db: 'public',
|
||||
name: 'orders',
|
||||
kind: 'table',
|
||||
comment: 'Customer orders',
|
||||
estimatedRows: 12,
|
||||
columns: [
|
||||
{
|
||||
name: 'id',
|
||||
nativeType: 'integer',
|
||||
normalizedType: 'integer',
|
||||
dimensionType: 'number',
|
||||
nullable: false,
|
||||
primaryKey: true,
|
||||
comment: 'Order id',
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
nativeType: 'text',
|
||||
normalizedType: 'text',
|
||||
dimensionType: 'string',
|
||||
nullable: false,
|
||||
primaryKey: false,
|
||||
comment: 'Order status',
|
||||
},
|
||||
],
|
||||
foreignKeys: [],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
'ktx',
|
||||
'ktx@example.com',
|
||||
'seed orders',
|
||||
);
|
||||
await project.fileStore.writeFile(
|
||||
`${root}/enrichment/relationship-profile.json`,
|
||||
JSON.stringify(
|
||||
{
|
||||
connectionId,
|
||||
driver: 'postgres',
|
||||
tables: [{ table: { catalog: null, db: 'public', name: 'orders' }, rowCount: 12 }],
|
||||
columns: {
|
||||
'orders.status': {
|
||||
table: { catalog: null, db: 'public', name: 'orders' },
|
||||
column: 'status',
|
||||
rowCount: 12,
|
||||
nullCount: 0,
|
||||
distinctCount: 2,
|
||||
nullRate: 0,
|
||||
sampleValues: ['paid', 'refunded'],
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
'ktx',
|
||||
'ktx@example.com',
|
||||
'seed profile',
|
||||
);
|
||||
}
|
||||
|
||||
it('returns scoped table detail for a display target', async () => {
|
||||
const result = await tool.call({ connectionId: 'warehouse', targets: [{ display: 'public.orders' }] }, context);
|
||||
|
||||
expect(result.markdown).toContain('### public.orders');
|
||||
expect(result.markdown).toContain('- status (text, nullable=false)');
|
||||
expect(result.markdown).toContain('sample: ["paid","refunded"]');
|
||||
expect(result.structured.scanAvailable).toBe(true);
|
||||
expect(result.structured.resolved).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('resolves display targets that include a column name', async () => {
|
||||
const result = await tool.call(
|
||||
{ connectionId: 'warehouse', targets: [{ display: 'public.orders.status' }] },
|
||||
context,
|
||||
);
|
||||
|
||||
expect(result.markdown).toContain('### public.orders');
|
||||
expect(result.markdown).toContain('- status (text, nullable=false)');
|
||||
expect(result.markdown).not.toContain('- id (integer');
|
||||
expect(result.structured.resolved).toHaveLength(1);
|
||||
expect(result.structured.resolved[0]?.columns.map((column) => column.name)).toEqual(['status']);
|
||||
});
|
||||
|
||||
it('reports missing explicit columns instead of returning an empty column list', async () => {
|
||||
const result = await tool.call(
|
||||
{ connectionId: 'warehouse', targets: [{ display: 'public.orders.plan_tier' }] },
|
||||
context,
|
||||
);
|
||||
|
||||
expect(result.markdown).toContain('Column not found in scan: public.orders.plan_tier');
|
||||
expect(result.markdown).toContain('Available columns: id, status');
|
||||
expect(result.structured.resolved).toHaveLength(0);
|
||||
expect(result.structured.missing).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reports missing structured table targets in model-visible markdown', async () => {
|
||||
const result = await tool.call(
|
||||
{
|
||||
connectionId: 'warehouse',
|
||||
targets: [{ catalog: null, db: 'public', name: 'orderz' }],
|
||||
},
|
||||
context,
|
||||
);
|
||||
|
||||
expect(result.markdown).toContain('Not found in scan: public.orderz');
|
||||
expect(result.markdown).toContain('Closest matches: orders');
|
||||
expect(result.structured.resolved).toHaveLength(0);
|
||||
expect(result.structured.missing).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reports missing structured column targets in model-visible markdown', async () => {
|
||||
const result = await tool.call(
|
||||
{
|
||||
connectionId: 'warehouse',
|
||||
targets: [{ catalog: null, db: 'public', name: 'orders', column: 'plan_tier' }],
|
||||
},
|
||||
context,
|
||||
);
|
||||
|
||||
expect(result.markdown).toContain('Column not found in scan: public.orders.plan_tier');
|
||||
expect(result.markdown).toContain('Available columns: id, status');
|
||||
expect(result.structured.resolved).toHaveLength(0);
|
||||
expect(result.structured.missing).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns a no-scan state distinct from not found', async () => {
|
||||
const result = await tool.call(
|
||||
{ connectionId: 'empty', targets: [{ display: 'public.orders' }] },
|
||||
{ ...context, session: { ...context.session!, allowedConnectionNames: new Set(['empty']) } },
|
||||
);
|
||||
|
||||
expect(result.markdown).toContain('No live-database scan available for connection "empty"; run `ktx scan` first.');
|
||||
expect(result.structured.scanAvailable).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses out-of-scope connections', async () => {
|
||||
const result = await tool.call({ connectionId: 'billing', targets: [{ display: 'public.orders' }] }, context);
|
||||
|
||||
expect(result.markdown).toContain('Connection "billing" is not available to this ingest stage.');
|
||||
expect(result.structured.scanAvailable).toBe(false);
|
||||
});
|
||||
|
||||
it('uses connectionId as the public input field', async () => {
|
||||
const legacyConnectionField = ['connection', 'Name'].join('');
|
||||
|
||||
expect(
|
||||
tool.parseInput({
|
||||
connectionId: 'warehouse',
|
||||
targets: [{ display: 'public.orders' }],
|
||||
}),
|
||||
).toEqual({
|
||||
connectionId: 'warehouse',
|
||||
targets: [{ display: 'public.orders' }],
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
tool.parseInput({
|
||||
[legacyConnectionField]: 'warehouse',
|
||||
targets: [{ display: 'public.orders' }],
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { SlConnectionCatalogPort } from '../../../../../src/context/sl/ports.js';
|
||||
import type { ToolContext } from '../../../../../src/context/tools/base-tool.js';
|
||||
import { SqlExecutionTool } from '../../../../../src/context/ingest/tools/warehouse-verification/sql-execution.tool.js';
|
||||
|
||||
describe('SqlExecutionTool', () => {
|
||||
const connections = {
|
||||
executeQuery: vi.fn(),
|
||||
} as unknown as SlConnectionCatalogPort & { executeQuery: ReturnType<typeof vi.fn> };
|
||||
const tool = new SqlExecutionTool(connections);
|
||||
const context: ToolContext = {
|
||||
sourceId: 'ingest',
|
||||
messageId: 'm1',
|
||||
userId: 'system',
|
||||
session: { allowedConnectionNames: new Set(['warehouse']) } as any,
|
||||
};
|
||||
|
||||
it('wraps read-only SQL with a capped row limit', async () => {
|
||||
connections.executeQuery.mockResolvedValue({ headers: ['status'], rows: [['paid']], totalRows: 1 });
|
||||
|
||||
const result = await tool.call(
|
||||
{ connectionId: 'warehouse', sql: 'select status from public.orders', rowLimit: 5 },
|
||||
context,
|
||||
);
|
||||
|
||||
expect(connections.executeQuery).toHaveBeenCalledWith(
|
||||
'warehouse',
|
||||
'select * from (select status from public.orders) as ktx_query_result limit 5',
|
||||
);
|
||||
expect(result.markdown).toContain('| status |');
|
||||
expect(result.structured.wrappedSql).toContain('limit 5');
|
||||
});
|
||||
|
||||
it.each(['insert into x values (1)', 'drop table x', 'vacuum'])('rejects mutating SQL: %s', async (sql) => {
|
||||
connections.executeQuery.mockClear();
|
||||
|
||||
const result = await tool.call({ connectionId: 'warehouse', sql }, context);
|
||||
|
||||
expect(result.markdown).toContain('Only read-only SELECT/WITH queries can be executed locally.');
|
||||
expect(connections.executeQuery).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('surfaces connector errors verbatim', async () => {
|
||||
connections.executeQuery.mockRejectedValue(new Error('relation "orbit_analytics.customer" does not exist'));
|
||||
|
||||
const result = await tool.call(
|
||||
{ connectionId: 'warehouse', sql: 'select 1 from orbit_analytics.customer', rowLimit: 1 },
|
||||
context,
|
||||
);
|
||||
|
||||
expect(result.markdown).toContain('relation "orbit_analytics.customer" does not exist');
|
||||
expect(result.structured.error).toContain('relation "orbit_analytics.customer" does not exist');
|
||||
});
|
||||
|
||||
it('uses connectionId as the public input field', () => {
|
||||
const legacyConnectionField = ['connection', 'Name'].join('');
|
||||
|
||||
expect(
|
||||
tool.parseInput({
|
||||
connectionId: 'warehouse',
|
||||
sql: 'select 1',
|
||||
rowLimit: 5,
|
||||
}),
|
||||
).toEqual({
|
||||
connectionId: 'warehouse',
|
||||
sql: 'select 1',
|
||||
rowLimit: 5,
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
tool.parseInput({
|
||||
[legacyConnectionField]: 'warehouse',
|
||||
sql: 'select 1',
|
||||
rowLimit: 5,
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue