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.
546 lines
17 KiB
TypeScript
546 lines
17 KiB
TypeScript
import { test, expect } from '@playwright/test';
|
|
import { createClient } from '@supabase/supabase-js';
|
|
|
|
/**
|
|
* Schema Validation Tests
|
|
*
|
|
* These tests validate that submission tables, version tables, and main entity tables
|
|
* have consistent schemas to prevent field mismatches during the approval pipeline.
|
|
*
|
|
* Critical validations:
|
|
* 1. Submission tables must have all fields from main tables (except auto-generated)
|
|
* 2. Version tables must have all fields from main tables plus version metadata
|
|
* 3. Critical functions must reference correct column names
|
|
* 4. Required NOT NULL fields must be present in all tables
|
|
*/
|
|
|
|
const supabase = createClient(
|
|
'https://ydvtmnrszybqnbcqbdcy.supabase.co',
|
|
process.env.SUPABASE_SERVICE_ROLE_KEY || ''
|
|
);
|
|
|
|
interface ColumnDefinition {
|
|
column_name: string;
|
|
data_type: string;
|
|
is_nullable: string;
|
|
column_default: string | null;
|
|
}
|
|
|
|
interface TableSchema {
|
|
[columnName: string]: ColumnDefinition;
|
|
}
|
|
|
|
// Fields that are expected to be different or missing in submission tables
|
|
const EXCLUDED_FIELDS = [
|
|
'id', // Submission tables have their own ID
|
|
'created_at', // Managed differently in submissions
|
|
'updated_at', // Managed differently in submissions
|
|
'view_count_all', // Calculated fields not in submissions
|
|
'view_count_30d',
|
|
'view_count_7d',
|
|
'average_rating',
|
|
'review_count',
|
|
'installations_count', // Only for ride_models
|
|
'is_test_data', // Test data flag
|
|
];
|
|
|
|
// Version-specific metadata fields (expected to be extra in version tables)
|
|
const VERSION_METADATA_FIELDS = [
|
|
'version_id',
|
|
'version_number',
|
|
'change_type',
|
|
'change_reason',
|
|
'is_current',
|
|
'created_by',
|
|
'created_at',
|
|
'submission_id',
|
|
'is_test_data',
|
|
];
|
|
|
|
async function getTableSchema(tableName: string): Promise<TableSchema> {
|
|
const { data, error } = await supabase
|
|
.from('information_schema.columns' as any)
|
|
.select('column_name, data_type, is_nullable, column_default')
|
|
.eq('table_schema', 'public')
|
|
.eq('table_name', tableName);
|
|
|
|
if (error) throw error;
|
|
|
|
const schema: TableSchema = {};
|
|
data?.forEach((col: any) => {
|
|
schema[col.column_name] = col;
|
|
});
|
|
|
|
return schema;
|
|
}
|
|
|
|
function normalizeColumnName(name: string): string {
|
|
// Handle known version table variations
|
|
const mapping: { [key: string]: string } = {
|
|
'height_requirement_cm': 'height_requirement',
|
|
'gforce_max': 'max_g_force',
|
|
'inversions_count': 'inversions',
|
|
'height_meters': 'max_height_meters',
|
|
'drop_meters': 'drop_height_meters',
|
|
};
|
|
|
|
return mapping[name] || name;
|
|
}
|
|
|
|
test.describe('Schema Validation - Entity Tables', () => {
|
|
test('parks: submission table matches main table schema', async () => {
|
|
const mainSchema = await getTableSchema('parks');
|
|
const submissionSchema = await getTableSchema('park_submissions');
|
|
|
|
const mismatches: string[] = [];
|
|
const missingFields: string[] = [];
|
|
|
|
// Check each field in main table exists in submission table
|
|
for (const [fieldName, fieldDef] of Object.entries(mainSchema)) {
|
|
if (EXCLUDED_FIELDS.includes(fieldName)) continue;
|
|
|
|
if (!submissionSchema[fieldName]) {
|
|
missingFields.push(fieldName);
|
|
} else {
|
|
// Check data type matches
|
|
const mainType = fieldDef.data_type;
|
|
const submissionType = submissionSchema[fieldName].data_type;
|
|
|
|
if (mainType !== submissionType) {
|
|
mismatches.push(
|
|
`${fieldName}: main=${mainType}, submission=${submissionType}`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
expect(missingFields,
|
|
`park_submissions is missing fields: ${missingFields.join(', ')}`
|
|
).toHaveLength(0);
|
|
|
|
expect(mismatches,
|
|
`park_submissions has type mismatches: ${mismatches.join('; ')}`
|
|
).toHaveLength(0);
|
|
});
|
|
|
|
test('rides: submission table matches main table schema', async () => {
|
|
const mainSchema = await getTableSchema('rides');
|
|
const submissionSchema = await getTableSchema('ride_submissions');
|
|
|
|
const mismatches: string[] = [];
|
|
const missingFields: string[] = [];
|
|
|
|
for (const [fieldName, fieldDef] of Object.entries(mainSchema)) {
|
|
if (EXCLUDED_FIELDS.includes(fieldName)) continue;
|
|
|
|
if (!submissionSchema[fieldName]) {
|
|
missingFields.push(fieldName);
|
|
} else {
|
|
const mainType = fieldDef.data_type;
|
|
const submissionType = submissionSchema[fieldName].data_type;
|
|
|
|
if (mainType !== submissionType) {
|
|
mismatches.push(
|
|
`${fieldName}: main=${mainType}, submission=${submissionType}`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
expect(missingFields,
|
|
`ride_submissions is missing fields: ${missingFields.join(', ')}`
|
|
).toHaveLength(0);
|
|
|
|
expect(mismatches,
|
|
`ride_submissions has type mismatches: ${mismatches.join('; ')}`
|
|
).toHaveLength(0);
|
|
});
|
|
|
|
test('companies: submission table matches main table schema', async () => {
|
|
const mainSchema = await getTableSchema('companies');
|
|
const submissionSchema = await getTableSchema('company_submissions');
|
|
|
|
const mismatches: string[] = [];
|
|
const missingFields: string[] = [];
|
|
|
|
for (const [fieldName, fieldDef] of Object.entries(mainSchema)) {
|
|
if (EXCLUDED_FIELDS.includes(fieldName)) continue;
|
|
|
|
if (!submissionSchema[fieldName]) {
|
|
missingFields.push(fieldName);
|
|
} else {
|
|
const mainType = fieldDef.data_type;
|
|
const submissionType = submissionSchema[fieldName].data_type;
|
|
|
|
if (mainType !== submissionType) {
|
|
mismatches.push(
|
|
`${fieldName}: main=${mainType}, submission=${submissionType}`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
expect(missingFields,
|
|
`company_submissions is missing fields: ${missingFields.join(', ')}`
|
|
).toHaveLength(0);
|
|
|
|
expect(mismatches,
|
|
`company_submissions has type mismatches: ${mismatches.join('; ')}`
|
|
).toHaveLength(0);
|
|
});
|
|
|
|
test('ride_models: submission table matches main table schema', async () => {
|
|
const mainSchema = await getTableSchema('ride_models');
|
|
const submissionSchema = await getTableSchema('ride_model_submissions');
|
|
|
|
const mismatches: string[] = [];
|
|
const missingFields: string[] = [];
|
|
|
|
for (const [fieldName, fieldDef] of Object.entries(mainSchema)) {
|
|
if (EXCLUDED_FIELDS.includes(fieldName)) continue;
|
|
|
|
if (!submissionSchema[fieldName]) {
|
|
missingFields.push(fieldName);
|
|
} else {
|
|
const mainType = fieldDef.data_type;
|
|
const submissionType = submissionSchema[fieldName].data_type;
|
|
|
|
if (mainType !== submissionType) {
|
|
mismatches.push(
|
|
`${fieldName}: main=${mainType}, submission=${submissionType}`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
expect(missingFields,
|
|
`ride_model_submissions is missing fields: ${missingFields.join(', ')}`
|
|
).toHaveLength(0);
|
|
|
|
expect(mismatches,
|
|
`ride_model_submissions has type mismatches: ${mismatches.join('; ')}`
|
|
).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
test.describe('Schema Validation - Version Tables', () => {
|
|
test('park_versions: has all main table fields plus version metadata', async () => {
|
|
const mainSchema = await getTableSchema('parks');
|
|
const versionSchema = await getTableSchema('park_versions');
|
|
|
|
const missingFields: string[] = [];
|
|
|
|
// Check all main table fields exist in version table
|
|
for (const [fieldName] of Object.entries(mainSchema)) {
|
|
if (EXCLUDED_FIELDS.includes(fieldName)) continue;
|
|
|
|
const normalizedName = normalizeColumnName(fieldName);
|
|
if (!versionSchema[fieldName] && !versionSchema[normalizedName]) {
|
|
missingFields.push(fieldName);
|
|
}
|
|
}
|
|
|
|
// Check all version metadata fields exist
|
|
const missingMetadata: string[] = [];
|
|
for (const metaField of VERSION_METADATA_FIELDS) {
|
|
if (!versionSchema[metaField]) {
|
|
missingMetadata.push(metaField);
|
|
}
|
|
}
|
|
|
|
expect(missingFields,
|
|
`park_versions is missing main table fields: ${missingFields.join(', ')}`
|
|
).toHaveLength(0);
|
|
|
|
expect(missingMetadata,
|
|
`park_versions is missing version metadata: ${missingMetadata.join(', ')}`
|
|
).toHaveLength(0);
|
|
});
|
|
|
|
test('ride_versions: has all main table fields plus version metadata', async () => {
|
|
const mainSchema = await getTableSchema('rides');
|
|
const versionSchema = await getTableSchema('ride_versions');
|
|
|
|
const missingFields: string[] = [];
|
|
|
|
for (const [fieldName] of Object.entries(mainSchema)) {
|
|
if (EXCLUDED_FIELDS.includes(fieldName)) continue;
|
|
|
|
const normalizedName = normalizeColumnName(fieldName);
|
|
if (!versionSchema[fieldName] && !versionSchema[normalizedName]) {
|
|
missingFields.push(fieldName);
|
|
}
|
|
}
|
|
|
|
const missingMetadata: string[] = [];
|
|
for (const metaField of VERSION_METADATA_FIELDS) {
|
|
if (!versionSchema[metaField]) {
|
|
missingMetadata.push(metaField);
|
|
}
|
|
}
|
|
|
|
expect(missingFields,
|
|
`ride_versions is missing main table fields: ${missingFields.join(', ')}`
|
|
).toHaveLength(0);
|
|
|
|
expect(missingMetadata,
|
|
`ride_versions is missing version metadata: ${missingMetadata.join(', ')}`
|
|
).toHaveLength(0);
|
|
});
|
|
|
|
test('company_versions: has all main table fields plus version metadata', async () => {
|
|
const mainSchema = await getTableSchema('companies');
|
|
const versionSchema = await getTableSchema('company_versions');
|
|
|
|
const missingFields: string[] = [];
|
|
|
|
for (const [fieldName] of Object.entries(mainSchema)) {
|
|
if (EXCLUDED_FIELDS.includes(fieldName)) continue;
|
|
|
|
const normalizedName = normalizeColumnName(fieldName);
|
|
if (!versionSchema[fieldName] && !versionSchema[normalizedName]) {
|
|
missingFields.push(fieldName);
|
|
}
|
|
}
|
|
|
|
const missingMetadata: string[] = [];
|
|
for (const metaField of VERSION_METADATA_FIELDS) {
|
|
if (!versionSchema[metaField]) {
|
|
missingMetadata.push(metaField);
|
|
}
|
|
}
|
|
|
|
expect(missingFields,
|
|
`company_versions is missing main table fields: ${missingFields.join(', ')}`
|
|
).toHaveLength(0);
|
|
|
|
expect(missingMetadata,
|
|
`company_versions is missing version metadata: ${missingMetadata.join(', ')}`
|
|
).toHaveLength(0);
|
|
});
|
|
|
|
test('ride_model_versions: has all main table fields plus version metadata', async () => {
|
|
const mainSchema = await getTableSchema('ride_models');
|
|
const versionSchema = await getTableSchema('ride_model_versions');
|
|
|
|
const missingFields: string[] = [];
|
|
|
|
for (const [fieldName] of Object.entries(mainSchema)) {
|
|
if (EXCLUDED_FIELDS.includes(fieldName)) continue;
|
|
|
|
const normalizedName = normalizeColumnName(fieldName);
|
|
if (!versionSchema[fieldName] && !versionSchema[normalizedName]) {
|
|
missingFields.push(fieldName);
|
|
}
|
|
}
|
|
|
|
const missingMetadata: string[] = [];
|
|
for (const metaField of VERSION_METADATA_FIELDS) {
|
|
if (!versionSchema[metaField]) {
|
|
missingMetadata.push(metaField);
|
|
}
|
|
}
|
|
|
|
expect(missingFields,
|
|
`ride_model_versions is missing main table fields: ${missingFields.join(', ')}`
|
|
).toHaveLength(0);
|
|
|
|
expect(missingMetadata,
|
|
`ride_model_versions is missing version metadata: ${missingMetadata.join(', ')}`
|
|
).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
test.describe('Schema Validation - Critical Fields', () => {
|
|
test('rides table does NOT have ride_type column', async () => {
|
|
const ridesSchema = await getTableSchema('rides');
|
|
|
|
expect(ridesSchema['ride_type']).toBeUndefined();
|
|
});
|
|
|
|
test('rides table DOES have category column (NOT NULL)', async () => {
|
|
const ridesSchema = await getTableSchema('rides');
|
|
|
|
expect(ridesSchema['category']).toBeDefined();
|
|
expect(ridesSchema['category'].is_nullable).toBe('NO');
|
|
});
|
|
|
|
test('ride_models table DOES have both category and ride_type columns', async () => {
|
|
const rideModelsSchema = await getTableSchema('ride_models');
|
|
|
|
expect(rideModelsSchema['category']).toBeDefined();
|
|
expect(rideModelsSchema['category'].is_nullable).toBe('NO');
|
|
expect(rideModelsSchema['ride_type']).toBeDefined();
|
|
});
|
|
|
|
test('all entity tables have required base fields', async () => {
|
|
const requiredFields = ['id', 'name', 'slug', 'created_at', 'updated_at'];
|
|
const tables = ['parks', 'rides', 'companies', 'ride_models'];
|
|
|
|
for (const table of tables) {
|
|
const schema = await getTableSchema(table);
|
|
|
|
for (const field of requiredFields) {
|
|
expect(schema[field],
|
|
`${table} is missing required field: ${field}`
|
|
).toBeDefined();
|
|
}
|
|
}
|
|
});
|
|
|
|
test('all submission tables have submission_id foreign key', async () => {
|
|
const submissionTables = [
|
|
'park_submissions',
|
|
'ride_submissions',
|
|
'company_submissions',
|
|
'ride_model_submissions',
|
|
'photo_submissions',
|
|
];
|
|
|
|
for (const table of submissionTables) {
|
|
const schema = await getTableSchema(table);
|
|
|
|
expect(schema['submission_id'],
|
|
`${table} is missing submission_id foreign key`
|
|
).toBeDefined();
|
|
expect(schema['submission_id'].is_nullable).toBe('NO');
|
|
}
|
|
});
|
|
|
|
test('all version tables have version metadata fields', async () => {
|
|
const versionTables = [
|
|
'park_versions',
|
|
'ride_versions',
|
|
'company_versions',
|
|
'ride_model_versions',
|
|
];
|
|
|
|
const requiredVersionFields = [
|
|
'version_id',
|
|
'version_number',
|
|
'change_type',
|
|
'is_current',
|
|
];
|
|
|
|
for (const table of versionTables) {
|
|
const schema = await getTableSchema(table);
|
|
|
|
for (const field of requiredVersionFields) {
|
|
expect(schema[field],
|
|
`${table} is missing required version field: ${field}`
|
|
).toBeDefined();
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
test.describe('Schema Validation - Function Parameter Alignment', () => {
|
|
test('verify create_entity_from_submission function exists', async () => {
|
|
const { data, error } = await supabase
|
|
.rpc('pg_get_functiondef', {
|
|
funcid: 'create_entity_from_submission'::any
|
|
} as any)
|
|
.single();
|
|
|
|
// Function should exist (will error if not)
|
|
expect(error).toBeNull();
|
|
});
|
|
|
|
test('verify update_entity_from_submission function exists', async () => {
|
|
const { data, error } = await supabase
|
|
.rpc('pg_get_functiondef', {
|
|
funcid: 'update_entity_from_submission'::any
|
|
} as any)
|
|
.single();
|
|
|
|
// Function should exist (will error if not)
|
|
expect(error).toBeNull();
|
|
});
|
|
|
|
test('verify process_approval_transaction function exists', async () => {
|
|
const { data, error } = await supabase.rpc('pg_catalog.pg_function_is_visible', {
|
|
funcid: 'process_approval_transaction'::any
|
|
} as any);
|
|
|
|
// Function should be visible
|
|
expect(data).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
test.describe('Schema Validation - Known Field Name Variations', () => {
|
|
test('ride_versions uses height_requirement_cm instead of height_requirement', async () => {
|
|
const versionSchema = await getTableSchema('ride_versions');
|
|
|
|
expect(versionSchema['height_requirement_cm']).toBeDefined();
|
|
expect(versionSchema['height_requirement']).toBeUndefined();
|
|
});
|
|
|
|
test('ride_versions uses gforce_max instead of max_g_force', async () => {
|
|
const versionSchema = await getTableSchema('ride_versions');
|
|
|
|
expect(versionSchema['gforce_max']).toBeDefined();
|
|
expect(versionSchema['max_g_force']).toBeUndefined();
|
|
});
|
|
|
|
test('ride_versions uses inversions_count instead of inversions', async () => {
|
|
const versionSchema = await getTableSchema('ride_versions');
|
|
|
|
expect(versionSchema['inversions_count']).toBeDefined();
|
|
expect(versionSchema['inversions']).toBeUndefined();
|
|
});
|
|
|
|
test('ride_versions uses height_meters instead of max_height_meters', async () => {
|
|
const versionSchema = await getTableSchema('ride_versions');
|
|
|
|
expect(versionSchema['height_meters']).toBeDefined();
|
|
expect(versionSchema['max_height_meters']).toBeUndefined();
|
|
});
|
|
|
|
test('ride_versions uses drop_meters instead of drop_height_meters', async () => {
|
|
const versionSchema = await getTableSchema('ride_versions');
|
|
|
|
expect(versionSchema['drop_meters']).toBeDefined();
|
|
expect(versionSchema['drop_height_meters']).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
test.describe('Schema Validation - Submission Items', () => {
|
|
test('submission_items has all required foreign key columns', async () => {
|
|
const schema = await getTableSchema('submission_items');
|
|
|
|
const requiredFKs = [
|
|
'submission_id',
|
|
'park_submission_id',
|
|
'ride_submission_id',
|
|
'company_submission_id',
|
|
'ride_model_submission_id',
|
|
'photo_submission_id',
|
|
'timeline_event_submission_id',
|
|
'depends_on', // For dependency chain
|
|
];
|
|
|
|
for (const fk of requiredFKs) {
|
|
expect(schema[fk],
|
|
`submission_items is missing FK: ${fk}`
|
|
).toBeDefined();
|
|
}
|
|
});
|
|
|
|
test('submission_items has required metadata fields', async () => {
|
|
const schema = await getTableSchema('submission_items');
|
|
|
|
const requiredFields = [
|
|
'item_type',
|
|
'action_type',
|
|
'status',
|
|
'order_index',
|
|
];
|
|
|
|
for (const field of requiredFields) {
|
|
expect(schema[field],
|
|
`submission_items is missing field: ${field}`
|
|
).toBeDefined();
|
|
}
|
|
});
|
|
});
|