Skip to content

Commit 0166434

Browse files
committed
test: dbt helpers — loadRawManifest caching, getUniqueId, extractColumns, listModelNames
These four exported helper functions in src/altimate/native/dbt/helpers.ts had zero test coverage. They are shared foundations used by dbtLineage(), generateDbtUnitTests(), and parseManifest(). A cache bug in loadRawManifest would silently serve stale manifest data to every dbt tool; a fallback-chain bug in extractColumns would produce wrong column types in generated YAML. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> https://claude.ai/code/session_01627CWGKN6jHiJvoTRQVj1e
1 parent f030bf8 commit 0166434

1 file changed

Lines changed: 278 additions & 0 deletions

File tree

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
/**
2+
* Unit tests for dbt helper functions: loadRawManifest, getUniqueId,
3+
* extractColumns, and listModelNames.
4+
*
5+
* These pure/cached functions are shared foundations used by dbtLineage(),
6+
* generateDbtUnitTests(), and parseManifest(). Zero tests existed.
7+
*
8+
* Risk: a bug in loadRawManifest's mtime cache silently serves stale
9+
* manifest data to every dbt tool (lineage, unit-test gen, manifest).
10+
* A bug in extractColumns produces wrong column types in generated YAML.
11+
* A bug in getUniqueId means model lookup fails silently.
12+
*/
13+
14+
import { describe, test, expect, afterEach } from "bun:test"
15+
import { writeFileSync, mkdtempSync, rmSync, symlinkSync, utimesSync } from "fs"
16+
import { tmpdir } from "os"
17+
import { join } from "path"
18+
import {
19+
loadRawManifest,
20+
getUniqueId,
21+
extractColumns,
22+
listModelNames,
23+
} from "../../src/altimate/native/dbt/helpers"
24+
25+
// ---------------------------------------------------------------------------
26+
// Fixtures
27+
// ---------------------------------------------------------------------------
28+
29+
let tmpDirs: string[] = []
30+
31+
function makeTmpDir(): string {
32+
const dir = mkdtempSync(join(tmpdir(), "dbt-helpers-test-"))
33+
tmpDirs.push(dir)
34+
return dir
35+
}
36+
37+
afterEach(() => {
38+
for (const dir of tmpDirs) {
39+
rmSync(dir, { recursive: true, force: true })
40+
}
41+
tmpDirs = []
42+
})
43+
44+
const NODES: Record<string, any> = {
45+
"model.proj.orders": {
46+
resource_type: "model",
47+
name: "orders",
48+
},
49+
"model.proj.users": {
50+
resource_type: "model",
51+
name: "users",
52+
},
53+
"test.proj.not_null": {
54+
resource_type: "test",
55+
name: "not_null",
56+
},
57+
"seed.proj.country_codes": {
58+
resource_type: "seed",
59+
name: "country_codes",
60+
},
61+
}
62+
63+
// ---------------------------------------------------------------------------
64+
// loadRawManifest
65+
//
66+
// NOTE: loadRawManifest uses a module-level cache keyed by (resolved path,
67+
// mtime). Every test uses a unique file name within its own tmpdir to avoid
68+
// cross-test cache contamination. Assertions use content equality (not object
69+
// identity) unless the test is explicitly verifying cache behaviour.
70+
// ---------------------------------------------------------------------------
71+
72+
describe("loadRawManifest", () => {
73+
test("returns null for non-existent file", () => {
74+
const result = loadRawManifest("/tmp/does-not-exist-manifest-" + Date.now() + ".json")
75+
expect(result).toBeNull()
76+
})
77+
78+
test("parses valid JSON manifest and returns object", () => {
79+
const dir = makeTmpDir()
80+
const manifestPath = join(dir, "valid-manifest.json")
81+
const data = { metadata: { adapter_type: "snowflake" }, nodes: {} }
82+
writeFileSync(manifestPath, JSON.stringify(data))
83+
84+
const result = loadRawManifest(manifestPath)
85+
expect(result).toEqual(data)
86+
})
87+
88+
test("throws on invalid JSON", () => {
89+
const dir = makeTmpDir()
90+
const manifestPath = join(dir, "bad-json.json")
91+
writeFileSync(manifestPath, "not json {{{")
92+
93+
expect(() => loadRawManifest(manifestPath)).toThrow()
94+
})
95+
96+
test("does not throw for JSON array (typeof [] is 'object')", () => {
97+
// Note: the guard in loadRawManifest checks `typeof parsed !== "object"`.
98+
// In JavaScript, `typeof [] === "object"`, so arrays pass through.
99+
// This is a known edge case — a manifest that is a bare array is invalid
100+
// but the guard won't catch it. The callers handle this gracefully since
101+
// they access .nodes/.sources which will be undefined on an array.
102+
const dir = makeTmpDir()
103+
const manifestPath = join(dir, "array-manifest.json")
104+
writeFileSync(manifestPath, "[1, 2, 3]")
105+
106+
const result = loadRawManifest(manifestPath)
107+
expect(Array.isArray(result)).toBe(true)
108+
})
109+
110+
test("throws when manifest is a JSON primitive (string)", () => {
111+
const dir = makeTmpDir()
112+
const manifestPath = join(dir, "string-manifest.json")
113+
writeFileSync(manifestPath, '"just a string"')
114+
115+
expect(() => loadRawManifest(manifestPath)).toThrow("Manifest is not a JSON object")
116+
})
117+
118+
test("returns cached result when file unchanged (same ref)", () => {
119+
const dir = makeTmpDir()
120+
const manifestPath = join(dir, "cache-hit.json")
121+
const data = { metadata: {}, nodes: { a: 1 } }
122+
writeFileSync(manifestPath, JSON.stringify(data))
123+
124+
const first = loadRawManifest(manifestPath)
125+
const second = loadRawManifest(manifestPath)
126+
// Same object reference means cache was used (same path + same mtime)
127+
expect(first).toBe(second)
128+
})
129+
130+
test("re-reads file when content and mtime change", () => {
131+
const dir = makeTmpDir()
132+
const manifestPath = join(dir, "cache-miss.json")
133+
const data1 = { metadata: {}, nodes: { version: 1 } }
134+
writeFileSync(manifestPath, JSON.stringify(data1))
135+
136+
const first = loadRawManifest(manifestPath)
137+
expect(first.nodes.version).toBe(1)
138+
139+
// Write new content, then bump mtime to guarantee it differs from the
140+
// cached mtime (some filesystems have 1-second granularity).
141+
const data2 = { metadata: {}, nodes: { version: 2 } }
142+
writeFileSync(manifestPath, JSON.stringify(data2))
143+
const futureMs = Date.now() / 1000 + 5
144+
utimesSync(manifestPath, futureMs, futureMs)
145+
146+
const second = loadRawManifest(manifestPath)
147+
expect(second.nodes.version).toBe(2)
148+
})
149+
150+
test("resolves symlinks before caching", () => {
151+
const dir = makeTmpDir()
152+
const realPath = join(dir, "real-for-symlink.json")
153+
const symPath = join(dir, "link-for-symlink.json")
154+
const data = { metadata: {}, nodes: { sym: true } }
155+
writeFileSync(realPath, JSON.stringify(data))
156+
symlinkSync(realPath, symPath)
157+
158+
const viaReal = loadRawManifest(realPath)
159+
const viaSym = loadRawManifest(symPath)
160+
// Both resolve to the same real path + same mtime → content must match
161+
expect(viaSym).toEqual(viaReal)
162+
expect(viaSym.nodes.sym).toBe(true)
163+
})
164+
})
165+
166+
// ---------------------------------------------------------------------------
167+
// getUniqueId
168+
// ---------------------------------------------------------------------------
169+
170+
describe("getUniqueId", () => {
171+
test("returns unique_id when passed a unique_id key directly", () => {
172+
expect(getUniqueId(NODES, "model.proj.orders")).toBe("model.proj.orders")
173+
})
174+
175+
test("returns unique_id when passed a model name", () => {
176+
expect(getUniqueId(NODES, "orders")).toBe("model.proj.orders")
177+
})
178+
179+
test("returns undefined for non-existent model", () => {
180+
expect(getUniqueId(NODES, "nonexistent")).toBeUndefined()
181+
})
182+
183+
test("does not match test nodes by name", () => {
184+
expect(getUniqueId(NODES, "not_null")).toBeUndefined()
185+
})
186+
187+
test("does not match seed nodes by name", () => {
188+
expect(getUniqueId(NODES, "country_codes")).toBeUndefined()
189+
})
190+
191+
test("does not match by unique_id if resource_type is not model", () => {
192+
expect(getUniqueId(NODES, "test.proj.not_null")).toBeUndefined()
193+
expect(getUniqueId(NODES, "seed.proj.country_codes")).toBeUndefined()
194+
})
195+
})
196+
197+
// ---------------------------------------------------------------------------
198+
// extractColumns
199+
// ---------------------------------------------------------------------------
200+
201+
describe("extractColumns", () => {
202+
test("extracts columns with name and data_type", () => {
203+
const dict = {
204+
id: { name: "id", data_type: "INTEGER" },
205+
email: { name: "email", data_type: "VARCHAR" },
206+
}
207+
const result = extractColumns(dict)
208+
expect(result).toHaveLength(2)
209+
expect(result).toContainEqual({ name: "id", data_type: "INTEGER", description: undefined })
210+
expect(result).toContainEqual({ name: "email", data_type: "VARCHAR", description: undefined })
211+
})
212+
213+
test("falls back to dict key when col.name is missing", () => {
214+
const dict = {
215+
user_id: { data_type: "BIGINT" },
216+
}
217+
const result = extractColumns(dict)
218+
expect(result[0].name).toBe("user_id")
219+
expect(result[0].data_type).toBe("BIGINT")
220+
})
221+
222+
test("falls back to col.type when data_type is missing", () => {
223+
const dict = {
224+
amount: { name: "amount", type: "DECIMAL(10,2)" },
225+
}
226+
const result = extractColumns(dict)
227+
expect(result[0].data_type).toBe("DECIMAL(10,2)")
228+
})
229+
230+
test("includes description when present", () => {
231+
const dict = {
232+
status: { name: "status", data_type: "VARCHAR", description: "Order status" },
233+
}
234+
const result = extractColumns(dict)
235+
expect(result[0].description).toBe("Order status")
236+
})
237+
238+
test("returns empty array for empty dict", () => {
239+
expect(extractColumns({})).toEqual([])
240+
})
241+
242+
test("handles both name and type fallbacks simultaneously", () => {
243+
const dict = {
244+
my_col: { type: "TEXT" },
245+
}
246+
const result = extractColumns(dict)
247+
expect(result[0].name).toBe("my_col")
248+
expect(result[0].data_type).toBe("TEXT")
249+
expect(result[0].description).toBeUndefined()
250+
})
251+
})
252+
253+
// ---------------------------------------------------------------------------
254+
// listModelNames
255+
// ---------------------------------------------------------------------------
256+
257+
describe("listModelNames", () => {
258+
test("returns only model names, not tests or seeds", () => {
259+
const result = listModelNames(NODES)
260+
expect(result).toContain("orders")
261+
expect(result).toContain("users")
262+
expect(result).not.toContain("not_null")
263+
expect(result).not.toContain("country_codes")
264+
expect(result).toHaveLength(2)
265+
})
266+
267+
test("returns empty for nodes with no models", () => {
268+
const result = listModelNames({
269+
"test.proj.x": { resource_type: "test", name: "x" },
270+
"seed.proj.y": { resource_type: "seed", name: "y" },
271+
})
272+
expect(result).toEqual([])
273+
})
274+
275+
test("returns empty for empty nodes", () => {
276+
expect(listModelNames({})).toEqual([])
277+
})
278+
})

0 commit comments

Comments
 (0)