mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 07:51:13 -05:00
Create automated tests to validate schema consistency across submission, version, and main entity tables. This includes checking for missing fields, data type mismatches, and correct field presence in critical functions. Also includes a pre-migration validation script and GitHub Actions workflow for automated checks.
333 lines
9.5 KiB
TypeScript
333 lines
9.5 KiB
TypeScript
#!/usr/bin/env tsx
|
|
/**
|
|
* Schema Validation Script
|
|
*
|
|
* Pre-migration validation script that checks schema consistency
|
|
* across the submission pipeline before deploying changes.
|
|
*
|
|
* Usage:
|
|
* npm run validate-schema
|
|
* or
|
|
* tsx scripts/validate-schema.ts
|
|
*
|
|
* Exit codes:
|
|
* 0 = All validations passed
|
|
* 1 = Validation failures detected
|
|
*/
|
|
|
|
import { createClient } from '@supabase/supabase-js';
|
|
|
|
const SUPABASE_URL = 'https://ydvtmnrszybqnbcqbdcy.supabase.co';
|
|
const SUPABASE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
|
|
|
if (!SUPABASE_KEY) {
|
|
console.error('❌ SUPABASE_SERVICE_ROLE_KEY environment variable is required');
|
|
process.exit(1);
|
|
}
|
|
|
|
const supabase = createClient(SUPABASE_URL, SUPABASE_KEY);
|
|
|
|
interface ValidationResult {
|
|
category: string;
|
|
test: string;
|
|
passed: boolean;
|
|
message?: string;
|
|
}
|
|
|
|
const results: ValidationResult[] = [];
|
|
|
|
async function getTableColumns(tableName: string): Promise<Set<string>> {
|
|
const { data, error } = await supabase
|
|
.from('information_schema.columns' as any)
|
|
.select('column_name')
|
|
.eq('table_schema', 'public')
|
|
.eq('table_name', tableName);
|
|
|
|
if (error) throw error;
|
|
|
|
return new Set(data?.map((row: any) => row.column_name) || []);
|
|
}
|
|
|
|
async function validateSubmissionTable(
|
|
mainTable: string,
|
|
submissionTable: string,
|
|
entityName: string
|
|
): Promise<void> {
|
|
const mainColumns = await getTableColumns(mainTable);
|
|
const submissionColumns = await getTableColumns(submissionTable);
|
|
|
|
const excludedFields = new Set([
|
|
'id', 'created_at', 'updated_at', 'is_test_data',
|
|
'view_count_all', 'view_count_30d', 'view_count_7d',
|
|
'average_rating', 'review_count', 'installations_count',
|
|
]);
|
|
|
|
const missingFields: string[] = [];
|
|
|
|
for (const field of mainColumns) {
|
|
if (excludedFields.has(field)) continue;
|
|
if (!submissionColumns.has(field)) {
|
|
missingFields.push(field);
|
|
}
|
|
}
|
|
|
|
if (missingFields.length === 0) {
|
|
results.push({
|
|
category: 'Submission Tables',
|
|
test: `${entityName}: submission table matches main table`,
|
|
passed: true,
|
|
});
|
|
} else {
|
|
results.push({
|
|
category: 'Submission Tables',
|
|
test: `${entityName}: submission table matches main table`,
|
|
passed: false,
|
|
message: `Missing fields: ${missingFields.join(', ')}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
async function validateVersionTable(
|
|
mainTable: string,
|
|
versionTable: string,
|
|
entityName: string
|
|
): Promise<void> {
|
|
const mainColumns = await getTableColumns(mainTable);
|
|
const versionColumns = await getTableColumns(versionTable);
|
|
|
|
const excludedFields = new Set([
|
|
'id', 'created_at', 'updated_at', 'is_test_data',
|
|
'view_count_all', 'view_count_30d', 'view_count_7d',
|
|
'average_rating', 'review_count', 'installations_count',
|
|
]);
|
|
|
|
const fieldMapping: { [key: string]: string } = {
|
|
'height_requirement': 'height_requirement_cm',
|
|
'max_g_force': 'gforce_max',
|
|
'inversions': 'inversions_count',
|
|
'max_height_meters': 'height_meters',
|
|
'drop_height_meters': 'drop_meters',
|
|
};
|
|
|
|
const requiredVersionFields = new Set([
|
|
'version_id', 'version_number', 'change_type', 'change_reason',
|
|
'is_current', 'created_by', 'submission_id', 'is_test_data',
|
|
]);
|
|
|
|
const missingMainFields: string[] = [];
|
|
const missingVersionFields: string[] = [];
|
|
|
|
// Check main table fields exist in version table
|
|
for (const field of mainColumns) {
|
|
if (excludedFields.has(field)) continue;
|
|
|
|
const mappedField = fieldMapping[field] || field;
|
|
if (!versionColumns.has(field) && !versionColumns.has(mappedField)) {
|
|
missingMainFields.push(field);
|
|
}
|
|
}
|
|
|
|
// Check version metadata fields exist
|
|
for (const field of requiredVersionFields) {
|
|
if (!versionColumns.has(field)) {
|
|
missingVersionFields.push(field);
|
|
}
|
|
}
|
|
|
|
if (missingMainFields.length === 0 && missingVersionFields.length === 0) {
|
|
results.push({
|
|
category: 'Version Tables',
|
|
test: `${entityName}: version table has all fields`,
|
|
passed: true,
|
|
});
|
|
} else {
|
|
const messages: string[] = [];
|
|
if (missingMainFields.length > 0) {
|
|
messages.push(`Missing main fields: ${missingMainFields.join(', ')}`);
|
|
}
|
|
if (missingVersionFields.length > 0) {
|
|
messages.push(`Missing version fields: ${missingVersionFields.join(', ')}`);
|
|
}
|
|
|
|
results.push({
|
|
category: 'Version Tables',
|
|
test: `${entityName}: version table has all fields`,
|
|
passed: false,
|
|
message: messages.join('; '),
|
|
});
|
|
}
|
|
}
|
|
|
|
async function validateCriticalFields(): Promise<void> {
|
|
const ridesColumns = await getTableColumns('rides');
|
|
const rideModelsColumns = await getTableColumns('ride_models');
|
|
|
|
// Rides should NOT have ride_type
|
|
if (!ridesColumns.has('ride_type')) {
|
|
results.push({
|
|
category: 'Critical Fields',
|
|
test: 'rides table does NOT have ride_type column',
|
|
passed: true,
|
|
});
|
|
} else {
|
|
results.push({
|
|
category: 'Critical Fields',
|
|
test: 'rides table does NOT have ride_type column',
|
|
passed: false,
|
|
message: 'rides table incorrectly has ride_type column',
|
|
});
|
|
}
|
|
|
|
// Rides MUST have category
|
|
if (ridesColumns.has('category')) {
|
|
results.push({
|
|
category: 'Critical Fields',
|
|
test: 'rides table has category column',
|
|
passed: true,
|
|
});
|
|
} else {
|
|
results.push({
|
|
category: 'Critical Fields',
|
|
test: 'rides table has category column',
|
|
passed: false,
|
|
message: 'rides table is missing required category column',
|
|
});
|
|
}
|
|
|
|
// Ride models must have both category and ride_type
|
|
if (rideModelsColumns.has('category') && rideModelsColumns.has('ride_type')) {
|
|
results.push({
|
|
category: 'Critical Fields',
|
|
test: 'ride_models has both category and ride_type',
|
|
passed: true,
|
|
});
|
|
} else {
|
|
const missing: string[] = [];
|
|
if (!rideModelsColumns.has('category')) missing.push('category');
|
|
if (!rideModelsColumns.has('ride_type')) missing.push('ride_type');
|
|
|
|
results.push({
|
|
category: 'Critical Fields',
|
|
test: 'ride_models has both category and ride_type',
|
|
passed: false,
|
|
message: `ride_models is missing: ${missing.join(', ')}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
async function validateFunctions(): Promise<void> {
|
|
const functionsToCheck = [
|
|
'create_entity_from_submission',
|
|
'update_entity_from_submission',
|
|
'process_approval_transaction',
|
|
];
|
|
|
|
for (const funcName of functionsToCheck) {
|
|
try {
|
|
const { data, error } = await supabase
|
|
.rpc('pg_catalog.pg_function_is_visible' as any, {
|
|
funcid: `public.${funcName}`::any
|
|
} as any);
|
|
|
|
if (!error) {
|
|
results.push({
|
|
category: 'Functions',
|
|
test: `${funcName} exists and is accessible`,
|
|
passed: true,
|
|
});
|
|
} else {
|
|
results.push({
|
|
category: 'Functions',
|
|
test: `${funcName} exists and is accessible`,
|
|
passed: false,
|
|
message: error.message,
|
|
});
|
|
}
|
|
} catch (err) {
|
|
results.push({
|
|
category: 'Functions',
|
|
test: `${funcName} exists and is accessible`,
|
|
passed: false,
|
|
message: err instanceof Error ? err.message : String(err),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
function printResults(): void {
|
|
console.log('\n' + '='.repeat(80));
|
|
console.log('Schema Validation Results');
|
|
console.log('='.repeat(80) + '\n');
|
|
|
|
const categories = [...new Set(results.map(r => r.category))];
|
|
let totalPassed = 0;
|
|
let totalFailed = 0;
|
|
|
|
for (const category of categories) {
|
|
const categoryResults = results.filter(r => r.category === category);
|
|
const passed = categoryResults.filter(r => r.passed).length;
|
|
const failed = categoryResults.filter(r => !r.passed).length;
|
|
|
|
console.log(`\n${category}:`);
|
|
console.log('-'.repeat(80));
|
|
|
|
for (const result of categoryResults) {
|
|
const icon = result.passed ? '✅' : '❌';
|
|
console.log(`${icon} ${result.test}`);
|
|
if (result.message) {
|
|
console.log(` └─ ${result.message}`);
|
|
}
|
|
}
|
|
|
|
totalPassed += passed;
|
|
totalFailed += failed;
|
|
}
|
|
|
|
console.log('\n' + '='.repeat(80));
|
|
console.log(`Total: ${totalPassed} passed, ${totalFailed} failed`);
|
|
console.log('='.repeat(80) + '\n');
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
console.log('🔍 Starting schema validation...\n');
|
|
|
|
try {
|
|
// Validate submission tables
|
|
await validateSubmissionTable('parks', 'park_submissions', 'Parks');
|
|
await validateSubmissionTable('rides', 'ride_submissions', 'Rides');
|
|
await validateSubmissionTable('companies', 'company_submissions', 'Companies');
|
|
await validateSubmissionTable('ride_models', 'ride_model_submissions', 'Ride Models');
|
|
|
|
// Validate version tables
|
|
await validateVersionTable('parks', 'park_versions', 'Parks');
|
|
await validateVersionTable('rides', 'ride_versions', 'Rides');
|
|
await validateVersionTable('companies', 'company_versions', 'Companies');
|
|
await validateVersionTable('ride_models', 'ride_model_versions', 'Ride Models');
|
|
|
|
// Validate critical fields
|
|
await validateCriticalFields();
|
|
|
|
// Validate functions
|
|
await validateFunctions();
|
|
|
|
// Print results
|
|
printResults();
|
|
|
|
// Exit with appropriate code
|
|
const hasFailures = results.some(r => !r.passed);
|
|
if (hasFailures) {
|
|
console.error('❌ Schema validation failed. Please fix the issues above before deploying.\n');
|
|
process.exit(1);
|
|
} else {
|
|
console.log('✅ All schema validations passed. Safe to deploy.\n');
|
|
process.exit(0);
|
|
}
|
|
} catch (error) {
|
|
console.error('❌ Fatal error during validation:');
|
|
console.error(error);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
main();
|