2025-09-04 05:17:10 +08:00
|
|
|
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
|
|
|
import { tmpdir } from 'node:os';
|
|
|
|
import { join } from 'node:path';
|
|
|
|
import yaml from 'js-yaml';
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
2025-09-06 01:39:43 +08:00
|
|
|
import type { BusterConfig } from '../schemas';
|
2025-09-04 05:17:10 +08:00
|
|
|
import { loadBusterConfig, resolveConfiguration } from './config-loader';
|
|
|
|
|
|
|
|
describe('config-loader', () => {
|
|
|
|
let testDir: string;
|
|
|
|
let testId: string;
|
|
|
|
|
|
|
|
beforeEach(async () => {
|
|
|
|
testId = Math.random().toString(36).substring(7);
|
|
|
|
testDir = join(tmpdir(), `buster-cli-test-${testId}`);
|
|
|
|
await mkdir(testDir, { recursive: true });
|
|
|
|
});
|
|
|
|
|
|
|
|
afterEach(async () => {
|
|
|
|
await rm(testDir, { recursive: true, force: true });
|
|
|
|
vi.clearAllMocks();
|
|
|
|
});
|
|
|
|
|
|
|
|
describe('loadBusterConfig', () => {
|
|
|
|
describe('when no buster.yml file exists', () => {
|
|
|
|
it('should throw error when no config files are found', async () => {
|
|
|
|
await expect(loadBusterConfig(testDir)).rejects.toThrow(
|
2025-09-04 21:22:35 +08:00
|
|
|
`No buster.yml found in ${testDir} or any of its subdirectories`
|
2025-09-04 05:17:10 +08:00
|
|
|
);
|
|
|
|
});
|
|
|
|
|
|
|
|
it('should throw error for non-existent path', async () => {
|
|
|
|
const nonExistentPath = join(testDir, 'does-not-exist');
|
|
|
|
await expect(loadBusterConfig(nonExistentPath)).rejects.toThrow(
|
|
|
|
`Path does not exist: ${nonExistentPath}`
|
|
|
|
);
|
|
|
|
});
|
|
|
|
|
2025-09-04 21:22:35 +08:00
|
|
|
it('should return null when given a non-buster file', async () => {
|
2025-09-04 05:17:10 +08:00
|
|
|
const filePath = join(testDir, 'file.txt');
|
|
|
|
await writeFile(filePath, 'content');
|
2025-09-04 21:22:35 +08:00
|
|
|
// When given a file that's not buster.yml, it should return null
|
2025-09-04 05:17:10 +08:00
|
|
|
await expect(loadBusterConfig(filePath)).rejects.toThrow(
|
2025-09-04 21:22:35 +08:00
|
|
|
`No buster.yml found in ${filePath} or any of its subdirectories`
|
2025-09-04 05:17:10 +08:00
|
|
|
);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
describe('when single buster.yml exists', () => {
|
|
|
|
it('should load a valid buster.yml file', async () => {
|
|
|
|
const config: BusterConfig = {
|
|
|
|
projects: [
|
|
|
|
{
|
|
|
|
name: 'test-project',
|
|
|
|
data_source: 'postgres',
|
|
|
|
database: 'test_db',
|
|
|
|
schema: 'public',
|
|
|
|
},
|
|
|
|
],
|
|
|
|
};
|
|
|
|
|
|
|
|
await writeFile(join(testDir, 'buster.yml'), yaml.dump(config));
|
|
|
|
|
2025-09-04 21:22:35 +08:00
|
|
|
const { config: result, configPath } = await loadBusterConfig(testDir);
|
2025-09-04 05:17:10 +08:00
|
|
|
expect(result.projects).toHaveLength(1);
|
|
|
|
expect(result.projects[0].name).toBe('test-project');
|
|
|
|
expect(result.projects[0].data_source).toBe('postgres');
|
2025-09-04 21:22:35 +08:00
|
|
|
expect(configPath).toBe(join(testDir, 'buster.yml'));
|
2025-09-04 05:17:10 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
it('should handle buster.yaml (with .yaml extension)', async () => {
|
|
|
|
const config: BusterConfig = {
|
|
|
|
projects: [
|
|
|
|
{
|
|
|
|
name: 'yaml-project',
|
|
|
|
data_source: 'mysql',
|
|
|
|
database: 'yaml_db',
|
|
|
|
schema: 'default',
|
|
|
|
},
|
|
|
|
],
|
|
|
|
};
|
|
|
|
|
|
|
|
await writeFile(join(testDir, 'buster.yaml'), yaml.dump(config));
|
|
|
|
|
2025-09-04 21:22:35 +08:00
|
|
|
const { config: result, configPath } = await loadBusterConfig(testDir);
|
2025-09-04 05:17:10 +08:00
|
|
|
expect(result.projects).toHaveLength(1);
|
|
|
|
expect(result.projects[0].name).toBe('yaml-project');
|
2025-09-04 21:22:35 +08:00
|
|
|
expect(configPath).toBe(join(testDir, 'buster.yaml'));
|
2025-09-04 05:17:10 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
it('should skip invalid yaml files with warning', async () => {
|
|
|
|
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
|
|
|
|
|
|
await writeFile(join(testDir, 'buster.yml'), 'invalid: yaml: content:');
|
|
|
|
|
2025-09-04 21:22:35 +08:00
|
|
|
await expect(loadBusterConfig(testDir)).rejects.toThrow('Failed to parse buster.yml');
|
2025-09-04 05:17:10 +08:00
|
|
|
|
|
|
|
expect(consoleSpy).toHaveBeenCalled();
|
|
|
|
});
|
|
|
|
|
|
|
|
it('should skip files with invalid schema', async () => {
|
|
|
|
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
|
|
|
|
|
|
const invalidConfig = {
|
|
|
|
projects: [
|
|
|
|
{
|
|
|
|
// Missing required fields
|
|
|
|
name: 'invalid-project',
|
|
|
|
},
|
|
|
|
],
|
|
|
|
};
|
|
|
|
|
|
|
|
await writeFile(join(testDir, 'buster.yml'), yaml.dump(invalidConfig));
|
|
|
|
|
2025-09-04 21:22:35 +08:00
|
|
|
await expect(loadBusterConfig(testDir)).rejects.toThrow('Failed to parse buster.yml');
|
2025-09-04 05:17:10 +08:00
|
|
|
|
|
|
|
expect(consoleSpy).toHaveBeenCalled();
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
2025-09-04 21:22:35 +08:00
|
|
|
describe('when searching for buster.yml in subdirectories', () => {
|
|
|
|
it('should find buster.yml in subdirectory when searching from parent', async () => {
|
|
|
|
// Create subdirectory
|
|
|
|
const subDir = join(testDir, 'subdirectory');
|
|
|
|
await mkdir(subDir, { recursive: true });
|
2025-09-04 05:17:10 +08:00
|
|
|
|
|
|
|
const config: BusterConfig = {
|
|
|
|
projects: [
|
|
|
|
{
|
2025-09-04 21:22:35 +08:00
|
|
|
name: 'sub-project',
|
2025-09-04 05:17:10 +08:00
|
|
|
data_source: 'postgres',
|
2025-09-04 21:22:35 +08:00
|
|
|
database: 'db1',
|
2025-09-04 05:17:10 +08:00
|
|
|
schema: 'public',
|
|
|
|
},
|
|
|
|
],
|
|
|
|
};
|
|
|
|
|
2025-09-04 21:22:35 +08:00
|
|
|
// Place buster.yml in the subdirectory
|
|
|
|
await writeFile(join(subDir, 'buster.yml'), yaml.dump(config));
|
2025-09-04 05:17:10 +08:00
|
|
|
|
2025-09-04 21:22:35 +08:00
|
|
|
// Search from parent should find subdirectory's buster.yml
|
|
|
|
const { config: result, configPath } = await loadBusterConfig(testDir);
|
2025-09-04 05:17:10 +08:00
|
|
|
expect(result.projects).toHaveLength(1);
|
2025-09-04 21:22:35 +08:00
|
|
|
expect(result.projects[0].name).toBe('sub-project');
|
|
|
|
expect(configPath).toBe(join(subDir, 'buster.yml'));
|
2025-09-04 05:17:10 +08:00
|
|
|
});
|
|
|
|
|
2025-09-04 21:22:35 +08:00
|
|
|
it('should find first buster.yml when multiple exist in different subdirectories', async () => {
|
|
|
|
const subDir1 = join(testDir, 'a');
|
|
|
|
const subDir2 = join(testDir, 'b');
|
2025-09-04 05:17:10 +08:00
|
|
|
await mkdir(subDir1, { recursive: true });
|
|
|
|
await mkdir(subDir2, { recursive: true });
|
|
|
|
|
|
|
|
const config1: BusterConfig = {
|
|
|
|
projects: [
|
|
|
|
{
|
|
|
|
name: 'project-a',
|
2025-09-04 21:22:35 +08:00
|
|
|
data_source: 'bigquery',
|
2025-09-04 05:17:10 +08:00
|
|
|
database: 'db_a',
|
2025-09-04 21:22:35 +08:00
|
|
|
schema: 'dataset',
|
2025-09-04 05:17:10 +08:00
|
|
|
},
|
|
|
|
],
|
|
|
|
};
|
|
|
|
|
|
|
|
const config2: BusterConfig = {
|
|
|
|
projects: [
|
|
|
|
{
|
2025-09-04 21:22:35 +08:00
|
|
|
name: 'project-b',
|
|
|
|
data_source: 'postgres',
|
2025-09-04 05:17:10 +08:00
|
|
|
database: 'db_b',
|
2025-09-04 21:22:35 +08:00
|
|
|
schema: 'public',
|
2025-09-04 05:17:10 +08:00
|
|
|
},
|
|
|
|
],
|
|
|
|
};
|
|
|
|
|
2025-09-04 21:22:35 +08:00
|
|
|
// Place configs in different subdirectories
|
2025-09-04 05:17:10 +08:00
|
|
|
await writeFile(join(subDir1, 'buster.yml'), yaml.dump(config1));
|
|
|
|
await writeFile(join(subDir2, 'buster.yml'), yaml.dump(config2));
|
|
|
|
|
2025-09-04 21:22:35 +08:00
|
|
|
// Should find the first one (deterministic based on directory order)
|
|
|
|
const { config: result } = await loadBusterConfig(testDir);
|
|
|
|
expect(result.projects).toHaveLength(1);
|
|
|
|
// The actual project found depends on directory traversal order
|
|
|
|
expect(['project-a', 'project-b']).toContain(result.projects[0].name);
|
2025-09-04 05:17:10 +08:00
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
describe('when handling edge cases', () => {
|
|
|
|
it('should handle empty projects array', async () => {
|
|
|
|
const config: BusterConfig = {
|
|
|
|
projects: [],
|
|
|
|
};
|
|
|
|
|
|
|
|
await writeFile(join(testDir, 'buster.yml'), yaml.dump(config));
|
|
|
|
|
|
|
|
await expect(loadBusterConfig(testDir)).rejects.toThrow(
|
2025-09-04 21:22:35 +08:00
|
|
|
'No projects defined in buster.yml'
|
2025-09-04 05:17:10 +08:00
|
|
|
);
|
|
|
|
});
|
|
|
|
|
|
|
|
it('should provide informative console output', async () => {
|
|
|
|
const consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation(() => {});
|
|
|
|
|
2025-09-04 21:22:35 +08:00
|
|
|
const config: BusterConfig = {
|
2025-09-04 05:17:10 +08:00
|
|
|
projects: [
|
|
|
|
{
|
|
|
|
name: 'project1',
|
|
|
|
data_source: 'postgres',
|
|
|
|
database: 'db',
|
|
|
|
schema: 'public',
|
|
|
|
},
|
|
|
|
],
|
|
|
|
};
|
|
|
|
|
2025-09-04 21:22:35 +08:00
|
|
|
await writeFile(join(testDir, 'buster.yml'), yaml.dump(config));
|
2025-09-04 05:17:10 +08:00
|
|
|
|
2025-09-05 01:21:47 +08:00
|
|
|
const result = await loadBusterConfig(testDir);
|
2025-09-04 05:17:10 +08:00
|
|
|
|
2025-09-05 01:21:47 +08:00
|
|
|
// Should return the config without logging (console.info calls were removed)
|
|
|
|
expect(result.config).toBeDefined();
|
|
|
|
expect(result.configPath).toContain('buster.yml');
|
|
|
|
expect(result.config.projects).toHaveLength(1);
|
2025-09-04 05:17:10 +08:00
|
|
|
});
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
describe('resolveConfiguration', () => {
|
2025-09-04 21:22:35 +08:00
|
|
|
const defaultConfig: BusterConfig = {
|
|
|
|
projects: [
|
2025-09-04 05:17:10 +08:00
|
|
|
{
|
2025-09-04 21:22:35 +08:00
|
|
|
name: 'test-project',
|
2025-09-04 05:17:10 +08:00
|
|
|
data_source: 'postgres',
|
2025-09-04 21:22:35 +08:00
|
|
|
database: 'test_db',
|
2025-09-04 05:17:10 +08:00
|
|
|
schema: 'public',
|
2025-09-04 21:22:35 +08:00
|
|
|
include: ['**/*.yml'],
|
|
|
|
exclude: ['**/temp/**'],
|
2025-09-04 05:17:10 +08:00
|
|
|
},
|
2025-09-04 21:22:35 +08:00
|
|
|
],
|
|
|
|
};
|
2025-09-04 05:17:10 +08:00
|
|
|
|
2025-09-04 21:22:35 +08:00
|
|
|
it('should resolve configuration with project name', () => {
|
|
|
|
const resolved = resolveConfiguration(defaultConfig, { dryRun: false }, 'test-project');
|
2025-09-04 05:17:10 +08:00
|
|
|
|
|
|
|
expect(resolved.data_source_name).toBe('postgres');
|
2025-09-04 21:22:35 +08:00
|
|
|
expect(resolved.database).toBe('test_db');
|
2025-09-04 05:17:10 +08:00
|
|
|
expect(resolved.schema).toBe('public');
|
2025-09-04 21:22:35 +08:00
|
|
|
expect(resolved.include).toEqual(['**/*.yml']);
|
|
|
|
expect(resolved.exclude).toEqual(['**/temp/**']);
|
2025-09-04 05:17:10 +08:00
|
|
|
});
|
|
|
|
|
2025-09-04 21:22:35 +08:00
|
|
|
it('should use first project when no name specified', () => {
|
|
|
|
const resolved = resolveConfiguration(defaultConfig, { dryRun: false });
|
2025-09-04 05:17:10 +08:00
|
|
|
|
2025-09-04 21:22:35 +08:00
|
|
|
expect(resolved.data_source_name).toBe('postgres');
|
2025-09-04 05:17:10 +08:00
|
|
|
});
|
|
|
|
|
2025-09-04 21:22:35 +08:00
|
|
|
it('should use default include patterns when not specified', () => {
|
|
|
|
const config: BusterConfig = {
|
|
|
|
projects: [
|
|
|
|
{
|
|
|
|
name: 'test-project',
|
|
|
|
data_source: 'postgres',
|
|
|
|
database: 'test_db',
|
|
|
|
schema: 'public',
|
|
|
|
},
|
|
|
|
],
|
|
|
|
};
|
|
|
|
|
|
|
|
const resolved = resolveConfiguration(config, { dryRun: false });
|
2025-09-04 05:17:10 +08:00
|
|
|
|
2025-09-04 21:22:35 +08:00
|
|
|
expect(resolved.include).toEqual(['**/*.yml', '**/*.yaml']);
|
|
|
|
expect(resolved.exclude).toEqual([]);
|
2025-09-04 05:17:10 +08:00
|
|
|
});
|
|
|
|
|
2025-09-04 21:22:35 +08:00
|
|
|
it('should throw error for non-existent project name', () => {
|
|
|
|
expect(() => resolveConfiguration(defaultConfig, { dryRun: false }, 'non-existent')).toThrow(
|
|
|
|
"Project 'non-existent' not found in buster.yml"
|
2025-09-04 05:17:10 +08:00
|
|
|
);
|
|
|
|
});
|
|
|
|
|
2025-09-04 21:22:35 +08:00
|
|
|
it('should throw error when no projects defined', () => {
|
|
|
|
const config: BusterConfig = {
|
|
|
|
projects: [],
|
|
|
|
};
|
2025-09-04 05:17:10 +08:00
|
|
|
|
2025-09-04 21:22:35 +08:00
|
|
|
expect(() => resolveConfiguration(config, { dryRun: false })).toThrow(
|
|
|
|
'No projects defined in buster.yml'
|
|
|
|
);
|
2025-09-04 05:17:10 +08:00
|
|
|
});
|
|
|
|
});
|
|
|
|
});
|