diff --git a/packages/opencode/test/altimate/dbt-lineage-helpers.test.ts b/packages/opencode/test/altimate/dbt-lineage-helpers.test.ts new file mode 100644 index 000000000..31bfcd5ec --- /dev/null +++ b/packages/opencode/test/altimate/dbt-lineage-helpers.test.ts @@ -0,0 +1,330 @@ +/** + * Tests for dbt lineage helper functions: findModel, detectDialect, + * buildSchemaContext, and the top-level dbtLineage() error paths. + * + * These pure functions parse manifest data and build schema contexts + * for column-level lineage analysis. Zero tests existed previously. + * A bug in findModel or buildSchemaContext causes lineage to silently + * return empty results, which users see as "no lineage available". + */ + +import { describe, test, expect, afterEach } from "bun:test" +import { dbtLineage } from "../../src/altimate/native/dbt/lineage" +import { writeFileSync, mkdtempSync, rmSync } from "fs" +import { tmpdir } from "os" +import { join } from "path" + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +let tmpDirs: string[] = [] + +function makeTmpDir(): string { + const dir = mkdtempSync(join(tmpdir(), "dbt-lineage-test-")) + tmpDirs.push(dir) + return dir +} + +function writeManifest(dir: string, manifest: Record): string { + const manifestPath = join(dir, "manifest.json") + writeFileSync(manifestPath, JSON.stringify(manifest)) + return manifestPath +} + +afterEach(() => { + for (const dir of tmpDirs) { + rmSync(dir, { recursive: true, force: true }) + } + tmpDirs = [] +}) + +// --------------------------------------------------------------------------- +// Minimal manifest fixtures +// --------------------------------------------------------------------------- + +const BASE_MANIFEST = { + metadata: { adapter_type: "snowflake" }, + nodes: { + "model.proj.orders": { + resource_type: "model", + name: "orders", + schema: "public", + database: "analytics", + config: { materialized: "table" }, + compiled_code: "SELECT c.id, c.name FROM customers c", + depends_on: { nodes: ["source.proj.raw.customers"] }, + columns: { + id: { name: "id", data_type: "INTEGER" }, + name: { name: "name", data_type: "VARCHAR" }, + }, + }, + "model.proj.revenue": { + resource_type: "model", + name: "revenue", + compiled_code: "SELECT SUM(amount) AS total FROM orders", + depends_on: { nodes: ["model.proj.orders"] }, + columns: {}, + }, + "test.proj.not_null": { + resource_type: "test", + name: "not_null", + }, + }, + sources: { + "source.proj.raw.customers": { + name: "customers", + source_name: "raw", + schema: "raw_data", + database: "analytics", + columns: { + id: { name: "id", data_type: "INTEGER" }, + name: { name: "name", data_type: "VARCHAR" }, + email: { name: "email", data_type: "VARCHAR" }, + }, + }, + }, +} + +// --------------------------------------------------------------------------- +// 1. Model lookup (findModel) +// --------------------------------------------------------------------------- + +describe("dbtLineage: model lookup", () => { + test("finds model by unique_id", () => { + const dir = makeTmpDir() + const manifestPath = writeManifest(dir, BASE_MANIFEST) + + const result = dbtLineage({ + manifest_path: manifestPath, + model: "model.proj.orders", + }) + + expect(result.model_name).toBe("orders") + expect(result.model_unique_id).toBe("model.proj.orders") + expect(result.compiled_sql).toContain("SELECT") + }) + + test("finds model by short name", () => { + const dir = makeTmpDir() + const manifestPath = writeManifest(dir, BASE_MANIFEST) + + const result = dbtLineage({ + manifest_path: manifestPath, + model: "orders", + }) + + expect(result.model_name).toBe("orders") + expect(result.model_unique_id).toBe("model.proj.orders") + }) + + test("returns low confidence when model not found", () => { + const dir = makeTmpDir() + const manifestPath = writeManifest(dir, BASE_MANIFEST) + + const result = dbtLineage({ + manifest_path: manifestPath, + model: "nonexistent_model", + }) + + expect(result.confidence).toBe("low") + expect(result.confidence_factors).toContain("Model 'nonexistent_model' not found in manifest") + }) + + test("does not match test or seed nodes by name", () => { + const dir = makeTmpDir() + const manifestPath = writeManifest(dir, BASE_MANIFEST) + + const result = dbtLineage({ + manifest_path: manifestPath, + model: "not_null", + }) + + // "not_null" is a test node, not a model — should not be found + expect(result.confidence).toBe("low") + expect(result.confidence_factors[0]).toContain("not found in manifest") + }) +}) + +// --------------------------------------------------------------------------- +// 2. Dialect detection (detectDialect) +// --------------------------------------------------------------------------- + +describe("dbtLineage: dialect detection", () => { + test("detects dialect from manifest metadata.adapter_type", () => { + const dir = makeTmpDir() + const manifest = { + ...BASE_MANIFEST, + metadata: { adapter_type: "bigquery" }, + } + const manifestPath = writeManifest(dir, manifest) + + const result = dbtLineage({ + manifest_path: manifestPath, + model: "orders", + }) + + // We can't directly check dialect, but the result shouldn't error + // due to dialect mismatch. The model has compiled_code, so confidence + // should be high if lineage succeeds or reflect the actual error. + expect(result.model_name).toBe("orders") + }) + + test("explicit dialect param overrides auto-detection", () => { + const dir = makeTmpDir() + const manifestPath = writeManifest(dir, BASE_MANIFEST) + + const result = dbtLineage({ + manifest_path: manifestPath, + model: "orders", + dialect: "postgres", + }) + + // Should not throw regardless of dialect choice + expect(result.model_name).toBe("orders") + }) + + test("defaults to snowflake when adapter_type is missing", () => { + const dir = makeTmpDir() + const manifest = { + ...BASE_MANIFEST, + metadata: {}, + } + const manifestPath = writeManifest(dir, manifest) + + const result = dbtLineage({ + manifest_path: manifestPath, + model: "orders", + }) + + // Should not throw — defaults to snowflake + expect(result.model_name).toBe("orders") + }) +}) + +// --------------------------------------------------------------------------- +// 3. Schema context building (buildSchemaContext) +// --------------------------------------------------------------------------- + +describe("dbtLineage: schema context from upstream deps", () => { + test("builds context from source with columns", () => { + const dir = makeTmpDir() + const manifestPath = writeManifest(dir, BASE_MANIFEST) + + const result = dbtLineage({ + manifest_path: manifestPath, + model: "orders", + }) + + // The orders model depends on source.proj.raw.customers which has columns. + // If schema context was built correctly, lineage should have non-empty output. + expect(result.model_name).toBe("orders") + // compiled_sql should be present + expect(result.compiled_sql).toBeDefined() + expect(result.compiled_sql).toContain("SELECT") + }) + + test("handles model with no upstream columns gracefully", () => { + const dir = makeTmpDir() + // Revenue depends on orders, but orders has columns — so context should build. + // Create a model that depends on a node with no columns. + const manifest = { + ...BASE_MANIFEST, + nodes: { + ...BASE_MANIFEST.nodes, + "model.proj.bare": { + resource_type: "model", + name: "bare", + compiled_code: "SELECT 1 AS val", + depends_on: { nodes: ["model.proj.no_cols"] }, + columns: {}, + }, + "model.proj.no_cols": { + resource_type: "model", + name: "no_cols", + compiled_code: "SELECT 1", + depends_on: { nodes: [] }, + columns: {}, + }, + }, + } + const manifestPath = writeManifest(dir, manifest) + + const result = dbtLineage({ + manifest_path: manifestPath, + model: "bare", + }) + + // Should not crash — just returns with whatever lineage can determine + expect(result.model_name).toBe("bare") + expect(result.compiled_sql).toBe("SELECT 1 AS val") + }) +}) + +// --------------------------------------------------------------------------- +// 4. Error paths +// --------------------------------------------------------------------------- + +describe("dbtLineage: error handling", () => { + test("returns low confidence for non-existent manifest", () => { + const result = dbtLineage({ + manifest_path: "/tmp/definitely-not-a-manifest.json", + model: "orders", + }) + + expect(result.confidence).toBe("low") + expect(result.confidence_factors).toContain("Manifest file not found") + expect(result.raw_lineage).toEqual({}) + }) + + test("returns low confidence for invalid JSON manifest", () => { + const dir = makeTmpDir() + const manifestPath = join(dir, "manifest.json") + writeFileSync(manifestPath, "not valid json {{{") + + const result = dbtLineage({ + manifest_path: manifestPath, + model: "orders", + }) + + expect(result.confidence).toBe("low") + expect(result.confidence_factors[0]).toContain("Failed to parse manifest") + }) + + test("returns low confidence when model has no compiled SQL", () => { + const dir = makeTmpDir() + const manifest = { + nodes: { + "model.proj.uncompiled": { + resource_type: "model", + name: "uncompiled", + // No compiled_code or compiled_sql + depends_on: { nodes: [] }, + columns: {}, + }, + }, + sources: {}, + } + const manifestPath = writeManifest(dir, manifest) + + const result = dbtLineage({ + manifest_path: manifestPath, + model: "uncompiled", + }) + + expect(result.confidence).toBe("low") + expect(result.confidence_factors).toContain("No compiled SQL found — run `dbt compile` first") + }) + + test("handles manifest with no nodes key at all", () => { + const dir = makeTmpDir() + const manifestPath = writeManifest(dir, { metadata: {} }) + + const result = dbtLineage({ + manifest_path: manifestPath, + model: "orders", + }) + + expect(result.confidence).toBe("low") + }) +}) diff --git a/packages/opencode/test/altimate/registry-env-loading.test.ts b/packages/opencode/test/altimate/registry-env-loading.test.ts new file mode 100644 index 000000000..cd7b6e202 --- /dev/null +++ b/packages/opencode/test/altimate/registry-env-loading.test.ts @@ -0,0 +1,167 @@ +/** + * Tests for ConnectionRegistry's load() function — env var parsing, + * file loading, and merge precedence (global < local < env). + * + * The existing connections.test.ts only uses setConfigs(), which bypasses + * the entire loadFromFile/loadFromEnv pipeline. These tests verify that + * CI/CD users who set ALTIMATE_CODE_CONN_* env vars get correct configs. + */ + +import { describe, test, expect, beforeEach, afterEach } from "bun:test" +import * as Registry from "../../src/altimate/native/connections/registry" +import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "fs" +import { tmpdir } from "os" +import { join } from "path" + +// --------------------------------------------------------------------------- +// Env var cleanup helper +// --------------------------------------------------------------------------- + +const ENV_PREFIX = "ALTIMATE_CODE_CONN_" +const envVarsToClean: string[] = [] + +function setEnvVar(name: string, value: string): void { + const key = `${ENV_PREFIX}${name}` + process.env[key] = value + envVarsToClean.push(key) +} + +function cleanEnvVars(): void { + for (const key of envVarsToClean) { + delete process.env[key] + } + envVarsToClean.length = 0 +} + +// --------------------------------------------------------------------------- +// Setup / Teardown +// --------------------------------------------------------------------------- + +beforeEach(() => { + Registry.reset() + cleanEnvVars() +}) + +afterEach(() => { + Registry.reset() + cleanEnvVars() +}) + +// --------------------------------------------------------------------------- +// 1. Env var loading +// --------------------------------------------------------------------------- + +describe("ConnectionRegistry: env var loading", () => { + test("loads connection from ALTIMATE_CODE_CONN_* env var", () => { + setEnvVar("MYDB", JSON.stringify({ type: "postgres", host: "localhost", port: 5432 })) + + Registry.load() + + const config = Registry.getConfig("mydb") + expect(config).toBeDefined() + expect(config?.type).toBe("postgres") + expect(config?.host).toBe("localhost") + }) + + test("env var name is lowercased to connection name", () => { + setEnvVar("MY_PROD_DB", JSON.stringify({ type: "snowflake", account: "abc123" })) + + Registry.load() + + expect(Registry.getConfig("my_prod_db")).toBeDefined() + expect(Registry.getConfig("MY_PROD_DB")).toBeUndefined() + }) + + test("ignores env vars with empty value", () => { + process.env[`${ENV_PREFIX}EMPTY`] = "" + envVarsToClean.push(`${ENV_PREFIX}EMPTY`) + + Registry.load() + + expect(Registry.getConfig("empty")).toBeUndefined() + }) + + test("ignores env vars with invalid JSON", () => { + setEnvVar("BAD_JSON", "not valid json {{{") + + Registry.load() + + expect(Registry.getConfig("bad_json")).toBeUndefined() + // Should not throw — graceful handling + }) + + test("ignores env vars where parsed value is not an object or has no type", () => { + setEnvVar("STRING_VAL", JSON.stringify("just a string")) + setEnvVar("NUMBER_VAL", JSON.stringify(42)) + setEnvVar("NULL_VAL", JSON.stringify(null)) + setEnvVar("NO_TYPE", JSON.stringify({ host: "localhost", port: 5432 })) + + Registry.load() + + expect(Registry.getConfig("string_val")).toBeUndefined() + expect(Registry.getConfig("number_val")).toBeUndefined() + expect(Registry.getConfig("null_val")).toBeUndefined() + expect(Registry.getConfig("no_type")).toBeUndefined() + }) + + test("loads multiple connections from env vars", () => { + setEnvVar("PG", JSON.stringify({ type: "postgres", host: "pg.local" })) + setEnvVar("SF", JSON.stringify({ type: "snowflake", account: "xyz" })) + setEnvVar("DDB", JSON.stringify({ type: "duckdb", path: ":memory:" })) + + Registry.load() + + const warehouses = Registry.list().warehouses + expect(warehouses).toHaveLength(3) + expect(warehouses.map((w) => w.type).sort()).toEqual(["duckdb", "postgres", "snowflake"]) + }) + +}) + +// --------------------------------------------------------------------------- +// 2. Merge precedence: env overrides file configs +// --------------------------------------------------------------------------- + +describe("ConnectionRegistry: load() replaces prior state", () => { + test("load() replaces setConfigs state entirely with fresh file+env data", () => { + // setConfigs() populates in-memory state without going through load() + Registry.setConfigs({ + mydb: { type: "postgres", host: "file-host", port: 5432 }, + }) + expect(Registry.getConfig("mydb")?.host).toBe("file-host") + + // load() clears configs and rebuilds from files + env. + // Since no connections.json exists at globalConfigPath()/localConfigPath() + // in this test environment, only env vars contribute. + setEnvVar("MYDB", JSON.stringify({ type: "postgres", host: "env-host", port: 5433 })) + Registry.load() + + const config = Registry.getConfig("mydb") + expect(config?.host).toBe("env-host") + expect(config?.port).toBe(5433) + }) +}) + +// --------------------------------------------------------------------------- +// 3. list() reflects env-loaded connections +// --------------------------------------------------------------------------- + +describe("ConnectionRegistry: list() with env-loaded connections", () => { + test("list returns warehouses loaded from env vars", () => { + setEnvVar("CI_WAREHOUSE", JSON.stringify({ + type: "bigquery", + project: "my-project", + database: "analytics", + })) + + Registry.load() + + const { warehouses } = Registry.list() + // Use .some() instead of index-based access to avoid flakiness if the + // host filesystem has a connections.json that also contributes entries. + const ciWarehouse = warehouses.find((w) => w.name === "ci_warehouse") + expect(ciWarehouse).toBeDefined() + expect(ciWarehouse?.type).toBe("bigquery") + expect(ciWarehouse?.database).toBe("analytics") + }) +})