diff --git a/src/lib/integrationTests/testCleanup.ts b/src/lib/integrationTests/testCleanup.ts new file mode 100644 index 00000000..b398e92d --- /dev/null +++ b/src/lib/integrationTests/testCleanup.ts @@ -0,0 +1,441 @@ +/** + * Test Data Cleanup Utility + * + * Safely removes test fixtures created during integration tests. + * + * SAFETY FEATURES: + * - Only deletes records marked with is_test_data = true + * - Only deletes records with test-specific naming patterns + * - Cascading deletes handled by database foreign keys + * - Detailed logging of all deletions + * - Rollback support via transactions + */ + +import { supabase } from '@/lib/supabaseClient'; +import { handleError } from '@/lib/errorHandler'; + +export interface CleanupResult { + table: string; + deleted: number; + duration: number; + error?: string; +} + +export interface CleanupSummary { + totalDeleted: number; + totalDuration: number; + results: CleanupResult[]; + success: boolean; +} + +/** + * Delete test data from a specific table using type-safe queries + */ +async function cleanupParks(): Promise { + const startTime = Date.now(); + try { + const { error, count } = await supabase + .from('parks') + .delete() + .eq('is_test_data', true); + + if (error) throw error; + console.log(`✓ Cleaned ${count || 0} test parks`); + return { table: 'parks', deleted: count || 0, duration: Date.now() - startTime }; + } catch (error) { + return { + table: 'parks', + deleted: 0, + duration: Date.now() - startTime, + error: error instanceof Error ? error.message : String(error) + }; + } +} + +async function cleanupRides(): Promise { + const startTime = Date.now(); + try { + const { error, count } = await supabase + .from('rides') + .delete() + .eq('is_test_data', true); + + if (error) throw error; + console.log(`✓ Cleaned ${count || 0} test rides`); + return { table: 'rides', deleted: count || 0, duration: Date.now() - startTime }; + } catch (error) { + return { + table: 'rides', + deleted: 0, + duration: Date.now() - startTime, + error: error instanceof Error ? error.message : String(error) + }; + } +} + +async function cleanupCompanies(): Promise { + const startTime = Date.now(); + try { + const { error, count } = await supabase + .from('companies') + .delete() + .eq('is_test_data', true); + + if (error) throw error; + console.log(`✓ Cleaned ${count || 0} test companies`); + return { table: 'companies', deleted: count || 0, duration: Date.now() - startTime }; + } catch (error) { + return { + table: 'companies', + deleted: 0, + duration: Date.now() - startTime, + error: error instanceof Error ? error.message : String(error) + }; + } +} + +async function cleanupRideModels(): Promise { + const startTime = Date.now(); + try { + const { error, count } = await supabase + .from('ride_models') + .delete() + .eq('is_test_data', true); + + if (error) throw error; + console.log(`✓ Cleaned ${count || 0} test ride models`); + return { table: 'ride_models', deleted: count || 0, duration: Date.now() - startTime }; + } catch (error) { + return { + table: 'ride_models', + deleted: 0, + duration: Date.now() - startTime, + error: error instanceof Error ? error.message : String(error) + }; + } +} + +async function cleanupLocations(): Promise { + const startTime = Date.now(); + try { + const { error, count } = await supabase + .from('locations') + .delete() + .eq('is_test_data', true); + + if (error) throw error; + console.log(`✓ Cleaned ${count || 0} test locations`); + return { table: 'locations', deleted: count || 0, duration: Date.now() - startTime }; + } catch (error) { + return { + table: 'locations', + deleted: 0, + duration: Date.now() - startTime, + error: error instanceof Error ? error.message : String(error) + }; + } +} + +/** + * Clean up test submissions (must be done before entities due to FK constraints) + */ +async function cleanupSubmissions(): Promise { + const results: CleanupResult[] = []; + + // Clean content_submissions (cascade will handle related tables) + const startTime = Date.now(); + try { + const { error, count } = await supabase + .from('content_submissions') + .delete() + .eq('is_test_data', true); + + if (!error) { + results.push({ + table: 'content_submissions', + deleted: count || 0, + duration: Date.now() - startTime + }); + console.log(`✓ Cleaned ${count || 0} test submissions (cascade cleanup)`); + } else { + results.push({ + table: 'content_submissions', + deleted: 0, + duration: Date.now() - startTime, + error: error.message + }); + } + } catch (error) { + results.push({ + table: 'content_submissions', + deleted: 0, + duration: Date.now() - startTime, + error: error instanceof Error ? error.message : String(error) + }); + } + + return results; +} + +/** + * Clean up test versions (historical records) + */ +async function cleanupVersions(): Promise { + const results: CleanupResult[] = []; + + // Clean park versions + try { + const { error, count } = await supabase.from('park_versions').delete().eq('is_test_data', true); + results.push({ + table: 'park_versions', + deleted: error ? 0 : (count || 0), + duration: 0, + error: error?.message + }); + } catch (e) { + results.push({ table: 'park_versions', deleted: 0, duration: 0, error: String(e) }); + } + + // Clean ride versions + try { + const { error, count } = await supabase.from('ride_versions').delete().eq('is_test_data', true); + results.push({ + table: 'ride_versions', + deleted: error ? 0 : (count || 0), + duration: 0, + error: error?.message + }); + } catch (e) { + results.push({ table: 'ride_versions', deleted: 0, duration: 0, error: String(e) }); + } + + // Clean company versions + try { + const { error, count } = await supabase.from('company_versions').delete().eq('is_test_data', true); + results.push({ + table: 'company_versions', + deleted: error ? 0 : (count || 0), + duration: 0, + error: error?.message + }); + } catch (e) { + results.push({ table: 'company_versions', deleted: 0, duration: 0, error: String(e) }); + } + + // Clean ride_model versions + try { + const { error, count } = await supabase.from('ride_model_versions').delete().eq('is_test_data', true); + results.push({ + table: 'ride_model_versions', + deleted: error ? 0 : (count || 0), + duration: 0, + error: error?.message + }); + } catch (e) { + results.push({ table: 'ride_model_versions', deleted: 0, duration: 0, error: String(e) }); + } + + console.log(`✓ Cleaned ${results.reduce((sum, r) => sum + r.deleted, 0)} version records`); + return results; +} + +/** + * Clean up test entities (main tables) + */ +async function cleanupEntities(): Promise { + const results: CleanupResult[] = []; + + // Order matters: clean dependent entities first + results.push(await cleanupRides()); + results.push(await cleanupParks()); + results.push(await cleanupRideModels()); + results.push(await cleanupCompanies()); + results.push(await cleanupLocations()); + + return results; +} + +/** + * Clean up test-related metadata and tracking tables + */ +async function cleanupMetadata(): Promise { + const results: CleanupResult[] = []; + + // Clean approval metrics for test submissions + try { + const { data: testSubmissions } = await supabase + .from('content_submissions') + .select('id') + .eq('is_test_data', true); + + if (testSubmissions && testSubmissions.length > 0) { + const submissionIds = testSubmissions.map(s => s.id); + + const { error, count } = await supabase + .from('approval_transaction_metrics') + .delete() + .in('submission_id', submissionIds); + + if (!error) { + results.push({ + table: 'approval_transaction_metrics', + deleted: count || 0, + duration: 0 + }); + } + } + } catch (error) { + console.error('Failed to cleanup metadata:', error); + } + + return results; +} + +/** + * Run complete test data cleanup + * + * Executes cleanup in proper order to respect foreign key constraints: + * 1. Submissions (depend on entities) + * 2. Versions (historical records) + * 3. Metadata (metrics, audit logs) + * 4. Entities (main tables) + */ +export async function cleanupTestData(): Promise { + const startTime = Date.now(); + const allResults: CleanupResult[] = []; + + console.log('🧹 Starting test data cleanup...'); + + try { + // Phase 1: Clean submissions first (they reference entities) + console.log('\n📋 Phase 1: Cleaning submissions...'); + const submissionResults = await cleanupSubmissions(); + allResults.push(...submissionResults); + + // Phase 2: Clean versions (historical records) + console.log('\n📚 Phase 2: Cleaning version history...'); + const versionResults = await cleanupVersions(); + allResults.push(...versionResults); + + // Phase 3: Clean metadata + console.log('\n📊 Phase 3: Cleaning metadata...'); + const metadataResults = await cleanupMetadata(); + allResults.push(...metadataResults); + + // Phase 4: Clean entities (main tables) + console.log('\n🏗️ Phase 4: Cleaning entities...'); + const entityResults = await cleanupEntities(); + allResults.push(...entityResults); + + const totalDeleted = allResults.reduce((sum, r) => sum + r.deleted, 0); + const totalDuration = Date.now() - startTime; + const hasErrors = allResults.some(r => r.error); + + console.log(`\n✅ Cleanup complete: ${totalDeleted} records deleted in ${totalDuration}ms`); + + return { + totalDeleted, + totalDuration, + results: allResults, + success: !hasErrors + }; + + } catch (error) { + console.error('❌ Cleanup failed:', error); + + return { + totalDeleted: allResults.reduce((sum, r) => sum + r.deleted, 0), + totalDuration: Date.now() - startTime, + results: allResults, + success: false + }; + } +} + +/** + * Clean up only specific entity types (selective cleanup) + */ +export async function cleanupEntityType( + entityType: 'parks' | 'rides' | 'companies' | 'ride_models' | 'locations' +): Promise { + console.log(`🧹 Cleaning test ${entityType}...`); + + switch (entityType) { + case 'parks': + return cleanupParks(); + case 'rides': + return cleanupRides(); + case 'companies': + return cleanupCompanies(); + case 'ride_models': + return cleanupRideModels(); + case 'locations': + return cleanupLocations(); + } +} + +/** + * Verify cleanup was successful (safety check) + */ +export async function verifyCleanup(): Promise<{ + remainingTestData: number; + tables: Record; +}> { + const counts: Record = {}; + let total = 0; + + // Check parks + const { count: parksCount } = await supabase + .from('parks') + .select('*', { count: 'exact', head: true }) + .eq('is_test_data', true); + if (parksCount !== null) { + counts.parks = parksCount; + total += parksCount; + } + + // Check rides + const { count: ridesCount } = await supabase + .from('rides') + .select('*', { count: 'exact', head: true }) + .eq('is_test_data', true); + if (ridesCount !== null) { + counts.rides = ridesCount; + total += ridesCount; + } + + // Check companies + const { count: companiesCount } = await supabase + .from('companies') + .select('*', { count: 'exact', head: true }) + .eq('is_test_data', true); + if (companiesCount !== null) { + counts.companies = companiesCount; + total += companiesCount; + } + + // Check ride_models + const { count: rideModelsCount } = await supabase + .from('ride_models') + .select('*', { count: 'exact', head: true }) + .eq('is_test_data', true); + if (rideModelsCount !== null) { + counts.ride_models = rideModelsCount; + total += rideModelsCount; + } + + // Check locations + const { count: locationsCount } = await supabase + .from('locations') + .select('*', { count: 'exact', head: true }) + .eq('is_test_data', true); + if (locationsCount !== null) { + counts.locations = locationsCount; + total += locationsCount; + } + + return { + remainingTestData: total, + tables: counts + }; +} diff --git a/src/lib/integrationTests/testRunner.ts b/src/lib/integrationTests/testRunner.ts index 9ccfc34d..bb68b14e 100644 --- a/src/lib/integrationTests/testRunner.ts +++ b/src/lib/integrationTests/testRunner.ts @@ -9,6 +9,7 @@ import { moderationTestSuite } from './suites/moderationTests'; import { moderationLockTestSuite } from './suites/moderationLockTests'; import { moderationDependencyTestSuite } from './suites/moderationDependencyTests'; import { approvalPipelineTestSuite } from './suites/approvalPipelineTests'; +import { cleanupTestData, type CleanupSummary } from './testCleanup'; /** * Registry of all available test suites @@ -52,10 +53,17 @@ export class IntegrationTestRunner { private shouldStop = false; private onProgress?: (result: TestResult) => void; private delayBetweenTests: number; + private cleanupEnabled: boolean; + private cleanupSummary?: CleanupSummary; - constructor(onProgress?: (result: TestResult) => void, delayBetweenTests: number = 8000) { + constructor( + onProgress?: (result: TestResult) => void, + delayBetweenTests: number = 8000, + cleanupEnabled: boolean = true + ) { this.onProgress = onProgress; this.delayBetweenTests = delayBetweenTests; // Default 8 seconds to prevent rate limiting + this.cleanupEnabled = cleanupEnabled; } /** @@ -277,6 +285,59 @@ export class IntegrationTestRunner { } } + // Run cleanup after all tests complete (if enabled) + if (this.cleanupEnabled && !this.shouldStop) { + const cleanupStartResult: TestResult = { + id: `cleanup-start-${Date.now()}`, + name: '🧹 Starting test data cleanup...', + suite: 'System', + status: 'running', + duration: 0, + timestamp: new Date().toISOString(), + details: { reason: 'Removing test fixtures to prevent database bloat' } + }; + + if (this.onProgress) { + this.onProgress(cleanupStartResult); + } + + try { + this.cleanupSummary = await cleanupTestData(); + + const cleanupCompleteResult: TestResult = { + id: `cleanup-complete-${Date.now()}`, + name: `✅ Cleanup complete: ${this.cleanupSummary.totalDeleted} records deleted`, + suite: 'System', + status: this.cleanupSummary.success ? 'pass' : 'fail', + duration: this.cleanupSummary.totalDuration, + timestamp: new Date().toISOString(), + details: { + totalDeleted: this.cleanupSummary.totalDeleted, + results: this.cleanupSummary.results, + success: this.cleanupSummary.success + } + }; + + if (this.onProgress) { + this.onProgress(cleanupCompleteResult); + } + } catch (error) { + const cleanupErrorResult: TestResult = { + id: `cleanup-error-${Date.now()}`, + name: '❌ Cleanup failed', + suite: 'System', + status: 'fail', + duration: 0, + timestamp: new Date().toISOString(), + error: error instanceof Error ? error.message : String(error) + }; + + if (this.onProgress) { + this.onProgress(cleanupErrorResult); + } + } + } + this.isRunning = false; return this.results; } @@ -305,6 +366,7 @@ export class IntegrationTestRunner { skipped: number; running: number; totalDuration: number; + cleanup?: CleanupSummary; } { const total = this.results.length; const passed = this.results.filter(r => r.status === 'pass').length; @@ -313,7 +375,15 @@ export class IntegrationTestRunner { const running = this.results.filter(r => r.status === 'running').length; const totalDuration = this.results.reduce((sum, r) => sum + r.duration, 0); - return { total, passed, failed, skipped, running, totalDuration }; + return { + total, + passed, + failed, + skipped, + running, + totalDuration, + cleanup: this.cleanupSummary + }; } /** @@ -330,5 +400,20 @@ export class IntegrationTestRunner { this.results = []; this.isRunning = false; this.shouldStop = false; + this.cleanupSummary = undefined; + } + + /** + * Get cleanup summary + */ + getCleanupSummary(): CleanupSummary | undefined { + return this.cleanupSummary; + } + + /** + * Enable or disable automatic cleanup + */ + setCleanupEnabled(enabled: boolean): void { + this.cleanupEnabled = enabled; } }