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 { createKtxEntityDetailsService } from '../../../src/context/scan/entity-details.js'; import type { KtxConnectionDriver, KtxScanReport, KtxSchemaTable } from '../../../src/context/scan/types.js'; describe('createKtxEntityDetailsService', () => { let tempDir: string; let project: KtxLocalProject; beforeEach(async () => { tempDir = await mkdtemp(join(tmpdir(), 'ktx-entity-details-service-')); project = await initKtxProject({ projectDir: join(tempDir, 'project') }); }); afterEach(async () => { await rm(tempDir, { recursive: true, force: true }); }); function scanReport(input: { connectionId: string; syncId: string; runId: string; driver?: KtxConnectionDriver; createdAt?: string; }): KtxScanReport { const rawSourcesDir = `raw-sources/${input.connectionId}/live-database/${input.syncId}`; return { connectionId: input.connectionId, driver: input.driver ?? 'postgres', syncId: input.syncId, runId: input.runId, trigger: 'mcp', mode: 'structural', dryRun: false, artifactPaths: { rawSourcesDir, reportPath: `${rawSourcesDir}/scan-report.json`, manifestShards: [], enrichmentArtifacts: [], }, diffSummary: { tablesAdded: 0, tablesModified: 0, tablesDeleted: 0, tablesUnchanged: 1, columnsAdded: 0, columnsModified: 0, columnsDeleted: 0, }, manifestShardsWritten: 0, structuralSyncStats: { tablesCreated: 1, tablesUpdated: 0, tablesDeleted: 0, columnsCreated: 0, columnsUpdated: 0, columnsDeleted: 0, }, enrichment: { dataDictionary: 'skipped', tableDescriptions: 'skipped', columnDescriptions: 'skipped', embeddings: 'skipped', deterministicRelationships: 'skipped', llmRelationshipValidation: 'skipped', statisticalValidation: 'skipped', }, capabilityGaps: [], warnings: [], relationships: { accepted: 0, review: 0, rejected: 0, skipped: 0 }, enrichmentState: { resumedStages: [], completedStages: [], failedStages: [] }, createdAt: input.createdAt ?? '2026-05-14T09:00:00.000Z', }; } function ordersTable(input: { db?: string | null; estimatedRows?: number | null } = {}): KtxSchemaTable { return { catalog: null, db: input.db ?? 'public', name: 'orders', kind: 'table', comment: 'Customer orders', estimatedRows: input.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: [ { fromColumn: 'customer_id', toCatalog: null, toDb: 'public', toTable: 'customers', toColumn: 'id', constraintName: 'orders_customer_id_fkey', }, ], }; } async function seedScan(input: { connectionId?: string; syncId: string; runId: string; driver?: KtxConnectionDriver; extractedAt?: string; tables?: KtxSchemaTable[]; }): Promise { const connectionId = input.connectionId ?? 'warehouse'; const report = scanReport({ connectionId, syncId: input.syncId, runId: input.runId, driver: input.driver, createdAt: input.extractedAt, }); const root = report.artifactPaths.rawSourcesDir; await project.fileStore.writeFile( `${root}/connection.json`, JSON.stringify( { connectionId, driver: report.driver, extractedAt: input.extractedAt ?? report.createdAt, scope: { schemas: ['public'] }, }, null, 2, ), 'ktx', 'ktx@example.com', 'seed connection', ); for (const table of input.tables ?? [ordersTable()]) { await project.fileStore.writeFile( `${root}/tables/${table.db ?? 'default'}-${table.name}.json`, JSON.stringify(table, null, 2), 'ktx', 'ktx@example.com', `seed ${table.name}`, ); } await project.fileStore.writeFile( `${root}/scan-report.json`, JSON.stringify(report, null, 2), 'ktx', 'ktx@example.com', 'seed scan report', ); } it('returns the latest scan snapshot table details for a display string', async () => { await seedScan({ syncId: 'sync-1', runId: 'scan-old', extractedAt: '2026-05-14T08:00:00.000Z' }); await seedScan({ syncId: 'sync-2', runId: 'scan-new', extractedAt: '2026-05-14T09:00:00.000Z', tables: [ordersTable({ estimatedRows: 99 })], }); const service = createKtxEntityDetailsService(project); const result = await service.read({ connectionId: 'warehouse', entities: [{ table: 'public.orders' }], }); expect(result.results).toHaveLength(1); expect(result.results[0]).toMatchObject({ ok: true, connectionId: 'warehouse', display: 'public.orders', estimatedRows: 99, snapshot: { syncId: 'sync-2', scanRunId: 'scan-new', extractedAt: '2026-05-14T09:00:00.000Z', }, columns: [ { name: 'id', nativeType: 'integer', primaryKey: true }, { name: 'status', nativeType: 'text', nullable: false }, ], }); }); it('resolves quoted qualified display strings through the dialect parser', async () => { await seedScan({ syncId: 'sync-1', runId: 'scan-1' }); const service = createKtxEntityDetailsService(project); const result = await service.read({ connectionId: 'warehouse', entities: [{ table: '"public"."orders"' }], }); expect(result.results[0]).toMatchObject({ ok: true, display: 'public.orders', tableRef: { catalog: null, db: 'public', name: 'orders' }, }); }); it('filters requested columns while keeping full-table foreign keys', async () => { await seedScan({ syncId: 'sync-1', runId: 'scan-1' }); const service = createKtxEntityDetailsService(project); const result = await service.read({ connectionId: 'warehouse', entities: [{ table: { catalog: null, db: 'public', name: 'orders' }, columns: ['status'] }], }); expect(result.results[0]).toMatchObject({ ok: true, columns: [{ name: 'status' }], foreignKeys: [ { fromColumn: 'customer_id', toDb: 'public', toTable: 'customers', toColumn: 'id', }, ], }); }); it('returns a structured missing-scan error', async () => { const service = createKtxEntityDetailsService(project); const result = await service.read({ connectionId: 'warehouse', entities: [{ table: 'public.orders' }], }); expect(result.results).toEqual([ { ok: false, connectionId: 'warehouse', table: 'public.orders', error: { code: 'scan_missing', message: 'No live-database scan found for connection "warehouse"; run `ktx ingest warehouse` or `ktx scan warehouse`.', }, }, ]); }); it('reports ambiguous bare table names across schemas', async () => { await seedScan({ syncId: 'sync-1', runId: 'scan-1', tables: [ordersTable({ db: 'public' }), ordersTable({ db: 'archive' })], }); const service = createKtxEntityDetailsService(project); const result = await service.read({ connectionId: 'warehouse', entities: [{ table: 'orders' }], }); expect(result.results[0]).toMatchObject({ ok: false, error: { code: 'ambiguous_table', candidates: [ { tableRef: { catalog: null, db: 'archive', name: 'orders' }, display: 'archive.orders' }, { tableRef: { catalog: null, db: 'public', name: 'orders' }, display: 'public.orders' }, ], }, }); }); it('reports missing requested columns with available column candidates', async () => { await seedScan({ syncId: 'sync-1', runId: 'scan-1' }); const service = createKtxEntityDetailsService(project); const result = await service.read({ connectionId: 'warehouse', entities: [{ table: 'public.orders', columns: ['status', 'plan_tier'] }], }); expect(result.results[0]).toMatchObject({ ok: false, error: { code: 'column_not_found', message: 'Column(s) not found on public.orders: plan_tier', candidates: ['id', 'status'], }, }); }); });