mirror of https://github.com/buster-so/buster.git
607 lines
20 KiB
TypeScript
607 lines
20 KiB
TypeScript
import { describe, expect, test } from 'vitest';
|
|
import * as yaml from 'yaml';
|
|
import { z } from 'zod';
|
|
import { validateArrayAccess } from '../../../src/utils/validation-helpers';
|
|
|
|
// Import the schemas we want to test (extracted from the tool file)
|
|
const columnLabelFormatSchema = z.object({
|
|
columnType: z.enum(['number', 'string', 'date']),
|
|
style: z.enum(['currency', 'percent', 'number', 'date', 'string']),
|
|
multiplier: z.number().optional(),
|
|
displayName: z.string().optional(),
|
|
numberSeparatorStyle: z.string().nullable().optional(),
|
|
minimumFractionDigits: z.number().optional(),
|
|
maximumFractionDigits: z.number().optional(),
|
|
prefix: z.string().optional(),
|
|
suffix: z.string().optional(),
|
|
replaceMissingDataWith: z.number().nullable().optional(),
|
|
compactNumbers: z.boolean().optional(),
|
|
currency: z.string().optional(),
|
|
dateFormat: z.string().optional(),
|
|
useRelativeTime: z.boolean().optional(),
|
|
isUtc: z.boolean().optional(),
|
|
convertNumberTo: z.enum(['day_of_week', 'month_of_year', 'quarter']).optional(),
|
|
});
|
|
|
|
const baseChartConfigSchema = z.object({
|
|
selectedChartType: z.enum(['bar', 'line', 'scatter', 'pie', 'combo', 'metric', 'table']),
|
|
columnLabelFormats: z.record(columnLabelFormatSchema),
|
|
columnSettings: z.record(z.any()).optional(),
|
|
colors: z.array(z.string()).optional(),
|
|
showLegend: z.boolean().optional(),
|
|
gridLines: z.boolean().optional(),
|
|
showLegendHeadline: z.union([z.boolean(), z.string()]).optional(),
|
|
goalLines: z.array(z.any()).optional(),
|
|
trendlines: z.array(z.any()).optional(),
|
|
disableTooltip: z.boolean().optional(),
|
|
});
|
|
|
|
const tableChartConfigSchema = baseChartConfigSchema.extend({
|
|
selectedChartType: z.literal('table'),
|
|
tableColumnOrder: z.array(z.string()).optional(),
|
|
});
|
|
|
|
const metricChartConfigSchema = baseChartConfigSchema.extend({
|
|
selectedChartType: z.literal('metric'),
|
|
metricColumnId: z.string(),
|
|
});
|
|
|
|
const barChartConfigSchema = baseChartConfigSchema.extend({
|
|
selectedChartType: z.literal('bar'),
|
|
});
|
|
|
|
const lineChartConfigSchema = baseChartConfigSchema.extend({
|
|
selectedChartType: z.literal('line'),
|
|
});
|
|
|
|
const scatterChartConfigSchema = baseChartConfigSchema.extend({
|
|
selectedChartType: z.literal('scatter'),
|
|
});
|
|
|
|
const pieChartConfigSchema = baseChartConfigSchema.extend({
|
|
selectedChartType: z.literal('pie'),
|
|
});
|
|
|
|
const comboChartConfigSchema = baseChartConfigSchema.extend({
|
|
selectedChartType: z.literal('combo'),
|
|
});
|
|
|
|
const chartConfigSchema = z.discriminatedUnion('selectedChartType', [
|
|
tableChartConfigSchema,
|
|
metricChartConfigSchema,
|
|
barChartConfigSchema,
|
|
lineChartConfigSchema,
|
|
scatterChartConfigSchema,
|
|
pieChartConfigSchema,
|
|
comboChartConfigSchema,
|
|
]);
|
|
|
|
const metricYmlSchema = z.object({
|
|
name: z.string().min(1),
|
|
description: z.string().min(1),
|
|
timeFrame: z.string().min(1),
|
|
sql: z.string().min(1),
|
|
chartConfig: chartConfigSchema,
|
|
});
|
|
|
|
// Test the core validation functions
|
|
function validateSqlBasic(sqlQuery: string): { success: boolean; error?: string } {
|
|
if (!sqlQuery.trim()) {
|
|
return { success: false, error: 'SQL query cannot be empty' };
|
|
}
|
|
|
|
if (!sqlQuery.toLowerCase().includes('select')) {
|
|
return { success: false, error: 'SQL query must contain SELECT statement' };
|
|
}
|
|
|
|
if (!sqlQuery.toLowerCase().includes('from')) {
|
|
return { success: false, error: 'SQL query must contain FROM clause' };
|
|
}
|
|
|
|
return { success: true };
|
|
}
|
|
|
|
function parseAndValidateYaml(ymlContent: string): {
|
|
success: boolean;
|
|
error?: string;
|
|
data?: z.infer<typeof metricYmlSchema>;
|
|
} {
|
|
try {
|
|
const parsedYml = yaml.parse(ymlContent);
|
|
const validationResult = metricYmlSchema.safeParse(parsedYml);
|
|
|
|
if (!validationResult.success) {
|
|
return {
|
|
success: false,
|
|
error: `Invalid YAML structure: ${validationResult.error.errors.map((e) => `${e.path.join('.')}: ${e.message}`).join(', ')}`,
|
|
};
|
|
}
|
|
|
|
return { success: true, data: validationResult.data };
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : 'YAML parsing failed',
|
|
};
|
|
}
|
|
}
|
|
|
|
describe('Modify Metrics File Tool Unit Tests', () => {
|
|
describe('YAML Schema Validation for Updates', () => {
|
|
test('should validate correct updated table chart YAML', () => {
|
|
const updatedTableYaml = `
|
|
name: Updated Sales Summary
|
|
description: Updated summary of sales data
|
|
timeFrame: Last 60 days
|
|
sql: |
|
|
SELECT
|
|
product_name,
|
|
order_date,
|
|
SUM(amount) as total_amount,
|
|
COUNT(*) as order_count
|
|
FROM sales
|
|
WHERE order_date >= CURRENT_DATE - INTERVAL '60 days'
|
|
GROUP BY product_name, order_date
|
|
ORDER BY order_date DESC
|
|
chartConfig:
|
|
selectedChartType: table
|
|
tableColumnOrder:
|
|
- product_name
|
|
- order_date
|
|
- total_amount
|
|
- order_count
|
|
columnLabelFormats:
|
|
product_name:
|
|
columnType: string
|
|
style: string
|
|
numberSeparatorStyle: null
|
|
replaceMissingDataWith: null
|
|
order_date:
|
|
columnType: date
|
|
style: date
|
|
dateFormat: "MMM D, YYYY"
|
|
numberSeparatorStyle: null
|
|
replaceMissingDataWith: null
|
|
total_amount:
|
|
columnType: number
|
|
style: currency
|
|
currency: "USD"
|
|
numberSeparatorStyle: ","
|
|
replaceMissingDataWith: 0
|
|
order_count:
|
|
columnType: number
|
|
style: number
|
|
numberSeparatorStyle: ","
|
|
replaceMissingDataWith: 0
|
|
`;
|
|
|
|
const result = parseAndValidateYaml(updatedTableYaml);
|
|
expect(result.success).toBe(true);
|
|
if (result.success && result.data) {
|
|
expect(result.data.name).toBe('Updated Sales Summary');
|
|
expect(result.data.timeFrame).toBe('Last 60 days');
|
|
expect(result.data.chartConfig.selectedChartType).toBe('table');
|
|
}
|
|
});
|
|
|
|
test('should validate correct updated metric chart YAML', () => {
|
|
const updatedMetricYaml = `
|
|
name: Updated Total Revenue
|
|
description: Updated total revenue amount
|
|
timeFrame: Year to Date
|
|
sql: |
|
|
SELECT SUM(amount) as total_revenue
|
|
FROM sales
|
|
WHERE EXTRACT(YEAR FROM order_date) = EXTRACT(YEAR FROM CURRENT_DATE)
|
|
chartConfig:
|
|
selectedChartType: metric
|
|
metricColumnId: total_revenue
|
|
columnLabelFormats:
|
|
total_revenue:
|
|
columnType: number
|
|
style: currency
|
|
currency: "USD"
|
|
numberSeparatorStyle: ","
|
|
replaceMissingDataWith: 0
|
|
compactNumbers: true
|
|
`;
|
|
|
|
const result = parseAndValidateYaml(updatedMetricYaml);
|
|
expect(result.success).toBe(true);
|
|
if (result.success && result.data) {
|
|
expect(result.data.name).toBe('Updated Total Revenue');
|
|
expect(result.data.timeFrame).toBe('Year to Date');
|
|
expect(result.data.chartConfig.selectedChartType).toBe('metric');
|
|
if (result.data.chartConfig.selectedChartType === 'metric') {
|
|
expect(result.data.chartConfig.metricColumnId).toBe('total_revenue');
|
|
}
|
|
}
|
|
});
|
|
|
|
test('should reject updated YAML with missing required fields', () => {
|
|
const incompleteYaml = `
|
|
name: Incomplete Update
|
|
description: Missing timeFrame and sql
|
|
chartConfig:
|
|
selectedChartType: table
|
|
columnLabelFormats:
|
|
test:
|
|
columnType: string
|
|
style: string
|
|
numberSeparatorStyle: null
|
|
replaceMissingDataWith: null
|
|
`;
|
|
|
|
const result = parseAndValidateYaml(incompleteYaml);
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('Invalid YAML structure');
|
|
});
|
|
|
|
test('should reject updated YAML with invalid chart type', () => {
|
|
const invalidChartTypeYaml = `
|
|
name: Invalid Chart Update
|
|
description: Testing invalid chart type
|
|
timeFrame: Today
|
|
sql: SELECT * FROM test
|
|
chartConfig:
|
|
selectedChartType: invalid_type
|
|
columnLabelFormats:
|
|
test:
|
|
columnType: string
|
|
style: string
|
|
numberSeparatorStyle: null
|
|
replaceMissingDataWith: null
|
|
`;
|
|
|
|
const result = parseAndValidateYaml(invalidChartTypeYaml);
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('Invalid YAML structure');
|
|
});
|
|
|
|
test('should reject updated metric chart without required metricColumnId', () => {
|
|
const invalidMetricUpdateYaml = `
|
|
name: Invalid Metric Update
|
|
description: Missing metricColumnId
|
|
timeFrame: Today
|
|
sql: SELECT * FROM test
|
|
chartConfig:
|
|
selectedChartType: metric
|
|
# Missing metricColumnId
|
|
columnLabelFormats:
|
|
test:
|
|
columnType: number
|
|
style: number
|
|
numberSeparatorStyle: null
|
|
replaceMissingDataWith: null
|
|
`;
|
|
|
|
const result = parseAndValidateYaml(invalidMetricUpdateYaml);
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('Invalid YAML structure');
|
|
});
|
|
|
|
test('should validate complex chart configuration updates', () => {
|
|
const complexUpdateYaml = `
|
|
name: Complex Chart Update
|
|
description: Testing complex chart configuration
|
|
timeFrame: Last Quarter
|
|
sql: |
|
|
SELECT
|
|
DATE_TRUNC('month', order_date) as month,
|
|
product_category,
|
|
SUM(amount) as revenue,
|
|
COUNT(*) as orders
|
|
FROM sales
|
|
WHERE order_date >= DATE_TRUNC('quarter', CURRENT_DATE) - INTERVAL '3 months'
|
|
GROUP BY DATE_TRUNC('month', order_date), product_category
|
|
ORDER BY month, product_category
|
|
chartConfig:
|
|
selectedChartType: bar
|
|
showLegend: true
|
|
gridLines: true
|
|
colors: ["#FF5733", "#33FF57", "#3357FF"]
|
|
columnLabelFormats:
|
|
month:
|
|
columnType: date
|
|
style: date
|
|
dateFormat: "MMM YYYY"
|
|
numberSeparatorStyle: null
|
|
replaceMissingDataWith: null
|
|
product_category:
|
|
columnType: string
|
|
style: string
|
|
numberSeparatorStyle: null
|
|
replaceMissingDataWith: null
|
|
revenue:
|
|
columnType: number
|
|
style: currency
|
|
currency: "USD"
|
|
numberSeparatorStyle: ","
|
|
replaceMissingDataWith: 0
|
|
compactNumbers: true
|
|
orders:
|
|
columnType: number
|
|
style: number
|
|
numberSeparatorStyle: ","
|
|
replaceMissingDataWith: 0
|
|
`;
|
|
|
|
const result = parseAndValidateYaml(complexUpdateYaml);
|
|
expect(result.success).toBe(true);
|
|
if (result.success && result.data) {
|
|
expect(result.data.chartConfig.showLegend).toBe(true);
|
|
expect(result.data.chartConfig.colors).toEqual(['#FF5733', '#33FF57', '#3357FF']);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('SQL Validation for Updates', () => {
|
|
test('should accept valid updated SELECT SQL', () => {
|
|
const updatedSql =
|
|
"SELECT id, name, amount, created_at FROM updated_sales WHERE created_at > NOW() - INTERVAL '7 days'";
|
|
const result = validateSqlBasic(updatedSql);
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
test('should reject empty SQL in updates', () => {
|
|
const result = validateSqlBasic('');
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toBe('SQL query cannot be empty');
|
|
});
|
|
|
|
test('should reject SQL without SELECT in updates', () => {
|
|
const noSelectSql = 'UPDATE test SET value = 1 WHERE id = 1';
|
|
const result = validateSqlBasic(noSelectSql);
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toBe('SQL query must contain SELECT statement');
|
|
});
|
|
|
|
test('should reject SQL without FROM in updates', () => {
|
|
const noFromSql = 'SELECT NOW()';
|
|
const result = validateSqlBasic(noFromSql);
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toBe('SQL query must contain FROM clause');
|
|
});
|
|
|
|
test('should handle complex updated SQL queries', () => {
|
|
const complexUpdatedSql = `
|
|
SELECT
|
|
c.customer_name,
|
|
p.product_name,
|
|
COUNT(o.id) as order_count,
|
|
SUM(o.total_amount) as total_spent,
|
|
AVG(o.total_amount) as avg_order_value
|
|
FROM orders o
|
|
JOIN customers c ON o.customer_id = c.id
|
|
JOIN products p ON o.product_id = p.id
|
|
WHERE o.created_at >= DATE_SUB(NOW(), INTERVAL 90 DAY)
|
|
AND o.status = 'completed'
|
|
GROUP BY c.customer_name, p.product_name
|
|
HAVING order_count >= 2
|
|
ORDER BY total_spent DESC
|
|
LIMIT 50
|
|
`;
|
|
|
|
const result = validateSqlBasic(complexUpdatedSql);
|
|
expect(result.success).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('Input Schema Validation for Updates', () => {
|
|
test('should validate correct update input format', () => {
|
|
const validUpdateInput = {
|
|
files: [
|
|
{
|
|
id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
|
|
yml_content:
|
|
'name: Updated Metric\ndescription: Updated description\ntimeFrame: Today\nsql: SELECT * FROM updated_test\nchartConfig:\n selectedChartType: table\n columnLabelFormats:\n test:\n columnType: string\n style: string\n numberSeparatorStyle: null\n replaceMissingDataWith: null',
|
|
},
|
|
],
|
|
};
|
|
|
|
// Basic validation that files array exists and has proper structure
|
|
expect(validUpdateInput.files).toHaveLength(1);
|
|
const firstFile = validateArrayAccess(validUpdateInput.files, 0, 'files');
|
|
expect(firstFile?.id).toBe('f47ac10b-58cc-4372-a567-0e02b2c3d479');
|
|
expect(typeof firstFile?.yml_content).toBe('string');
|
|
});
|
|
|
|
test('should reject update input without file ID', () => {
|
|
const invalidInput = {
|
|
files: [
|
|
{
|
|
// Missing id
|
|
yml_content: 'name: Test',
|
|
},
|
|
],
|
|
};
|
|
|
|
// This would fail our ID validation requirement
|
|
expect(invalidInput.files?.[0]).not.toHaveProperty('id');
|
|
});
|
|
|
|
test('should reject update input with invalid UUID', () => {
|
|
const invalidUuidInput = {
|
|
files: [
|
|
{
|
|
id: 'not-a-valid-uuid',
|
|
yml_content: 'name: Test',
|
|
},
|
|
],
|
|
};
|
|
|
|
// This would fail our UUID validation
|
|
expect(invalidUuidInput.files[0].id).toBe('not-a-valid-uuid');
|
|
// In real validation, this would be rejected as not a valid UUID
|
|
});
|
|
|
|
test('should validate bulk update input', () => {
|
|
const bulkUpdateInput = {
|
|
files: [
|
|
{
|
|
id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
|
|
yml_content:
|
|
'name: First Updated Metric\ndescription: First update\ntimeFrame: Today\nsql: SELECT * FROM test1\nchartConfig:\n selectedChartType: table\n columnLabelFormats:\n test:\n columnType: string\n style: string\n numberSeparatorStyle: null\n replaceMissingDataWith: null',
|
|
},
|
|
{
|
|
id: 'a47ac10b-58cc-4372-a567-0e02b2c3d480',
|
|
yml_content:
|
|
'name: Second Updated Metric\ndescription: Second update\ntimeFrame: Last 7 days\nsql: SELECT * FROM test2\nchartConfig:\n selectedChartType: metric\n metricColumnId: test\n columnLabelFormats:\n test:\n columnType: number\n style: number\n numberSeparatorStyle: null\n replaceMissingDataWith: null',
|
|
},
|
|
],
|
|
};
|
|
|
|
expect(bulkUpdateInput.files).toHaveLength(2);
|
|
expect(bulkUpdateInput.files.every((f) => f.id && f.yml_content)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('Column Label Format Validation for Updates', () => {
|
|
test('should validate updated number column format', () => {
|
|
const updatedNumberFormat = {
|
|
columnType: 'number' as const,
|
|
style: 'percent' as const,
|
|
numberSeparatorStyle: ',',
|
|
replaceMissingDataWith: 0,
|
|
minimumFractionDigits: 2,
|
|
maximumFractionDigits: 4,
|
|
};
|
|
|
|
const result = columnLabelFormatSchema.safeParse(updatedNumberFormat);
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
test('should validate updated date column format', () => {
|
|
const updatedDateFormat = {
|
|
columnType: 'date' as const,
|
|
style: 'date' as const,
|
|
dateFormat: 'YYYY-MM-DD',
|
|
useRelativeTime: true,
|
|
isUtc: false,
|
|
numberSeparatorStyle: null,
|
|
replaceMissingDataWith: null,
|
|
};
|
|
|
|
const result = columnLabelFormatSchema.safeParse(updatedDateFormat);
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
test('should validate updated string column format with display name', () => {
|
|
const updatedStringFormat = {
|
|
columnType: 'string' as const,
|
|
style: 'string' as const,
|
|
displayName: 'Updated Column Name',
|
|
prefix: 'Updated: ',
|
|
suffix: ' (modified)',
|
|
numberSeparatorStyle: null,
|
|
replaceMissingDataWith: null,
|
|
};
|
|
|
|
const result = columnLabelFormatSchema.safeParse(updatedStringFormat);
|
|
expect(result.success).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('Error Message Generation for Updates', () => {
|
|
test('should generate appropriate error message for invalid YAML in updates', () => {
|
|
const invalidUpdateYaml = 'invalid: yaml: [structure for update';
|
|
const result = parseAndValidateYaml(invalidUpdateYaml);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toBeDefined();
|
|
expect(typeof result.error).toBe('string');
|
|
});
|
|
|
|
test('should generate appropriate error message for SQL validation in updates', () => {
|
|
const invalidSql = 'DELETE FROM test WHERE id = 1';
|
|
const result = validateSqlBasic(invalidSql);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toBe('SQL query must contain SELECT statement');
|
|
});
|
|
|
|
test('should handle nested validation errors in updates', () => {
|
|
const complexInvalidYaml = `
|
|
name: Test Update
|
|
description: Test
|
|
timeFrame: Today
|
|
sql: SELECT * FROM test
|
|
chartConfig:
|
|
selectedChartType: metric
|
|
# Missing required metricColumnId for metric type
|
|
columnLabelFormats:
|
|
test:
|
|
columnType: invalid_type # Invalid column type
|
|
style: string
|
|
numberSeparatorStyle: null
|
|
replaceMissingDataWith: null
|
|
`;
|
|
|
|
const result = parseAndValidateYaml(complexInvalidYaml);
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('Invalid YAML structure');
|
|
});
|
|
});
|
|
|
|
describe('Version Management Concepts', () => {
|
|
test('should understand version increment logic', () => {
|
|
// Test the concept of version management
|
|
const currentVersion = 3;
|
|
const nextVersion = currentVersion + 1;
|
|
|
|
expect(nextVersion).toBe(4);
|
|
});
|
|
|
|
test('should handle version history concepts', () => {
|
|
// Mock version history structure
|
|
const mockVersionHistory = {
|
|
versions: [
|
|
{ versionNumber: 1, content: { name: 'Original' } },
|
|
{ versionNumber: 2, content: { name: 'First Update' } },
|
|
{ versionNumber: 3, content: { name: 'Second Update' } },
|
|
],
|
|
getLatestVersion: function () {
|
|
return this.versions[this.versions.length - 1];
|
|
},
|
|
};
|
|
|
|
const latestVersion = mockVersionHistory.getLatestVersion();
|
|
expect(latestVersion.versionNumber).toBe(3);
|
|
expect(latestVersion.content.name).toBe('Second Update');
|
|
});
|
|
});
|
|
|
|
describe('Modification Result Tracking', () => {
|
|
test('should track successful modifications', () => {
|
|
const successResult = {
|
|
file_id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
|
|
file_name: 'Updated Test Metric',
|
|
success: true,
|
|
modification_type: 'content',
|
|
timestamp: new Date().toISOString(),
|
|
duration: 150,
|
|
};
|
|
|
|
expect(successResult.success).toBe(true);
|
|
expect(successResult.modification_type).toBe('content');
|
|
expect(typeof successResult.timestamp).toBe('string');
|
|
});
|
|
|
|
test('should track failed modifications', () => {
|
|
const failureResult = {
|
|
file_id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
|
|
file_name: 'Failed Update Metric',
|
|
success: false,
|
|
error: 'Invalid YAML structure',
|
|
modification_type: 'validation',
|
|
timestamp: new Date().toISOString(),
|
|
duration: 75,
|
|
};
|
|
|
|
expect(failureResult.success).toBe(false);
|
|
expect(failureResult.error).toBe('Invalid YAML structure');
|
|
expect(failureResult.modification_type).toBe('validation');
|
|
});
|
|
});
|
|
});
|