import {PrismaClient} from '@plunk/db'; import {execSync} from 'child_process'; /** * Test database helper * Manages test database isolation and cleanup */ class TestDatabase { private prisma: PrismaClient | null = null; async initialize() { // Use test database URL if provided, otherwise use main database const databaseUrl = process.env.TEST_DATABASE_URL || process.env.DATABASE_URL; if (!databaseUrl) { throw new Error('DATABASE_URL or TEST_DATABASE_URL must be set for testing'); } // Create Prisma client with connection pool limits this.prisma = new PrismaClient({ datasources: { db: { url: databaseUrl, }, }, // Limit connection pool to prevent memory issues in tests // @ts-ignore - These options exist but may not be in types __internal: { engine: { connection_limit: 5, }, }, }); // Connect to database await this.prisma.$connect(); // Run migrations (only once per test suite) try { execSync('yarn workspace @plunk/db migrate:dev', { env: { ...process.env, DATABASE_URL: databaseUrl, }, stdio: 'ignore', }); } catch (error) { console.warn('Migration warning (may already be up to date):', error); } } /** * Get Prisma client instance */ getClient(): PrismaClient { if (!this.prisma) { throw new Error('Database not initialized. Call initialize() first.'); } return this.prisma; } /** * Clean up database after each test * Deletes all records in reverse order of dependencies * Uses batched deletes to prevent memory issues with large datasets * Retries on deadlock to handle race conditions with background event tracking */ async cleanup() { if (!this.prisma) return; const maxRetries = 3; let lastError: Error | null = null; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { // Use a transaction to ensure all deletes happen atomically // This prevents foreign key constraint violations and race conditions await this.prisma.$transaction([ // Level 1: Delete deepest dependencies first this.prisma.event.deleteMany(), this.prisma.workflowStepExecution.deleteMany(), // Level 2: Delete entities that depend on Level 1 this.prisma.email.deleteMany(), this.prisma.workflowExecution.deleteMany(), // Level 3: Delete workflow structure this.prisma.workflowTransition.deleteMany(), this.prisma.workflowStep.deleteMany(), this.prisma.workflow.deleteMany(), // Level 4: Delete campaigns and templates this.prisma.campaign.deleteMany(), this.prisma.template.deleteMany(), // Level 5: Delete segment relationships this.prisma.segmentMembership.deleteMany(), this.prisma.segment.deleteMany(), // Level 6: Delete contacts this.prisma.contact.deleteMany(), // Level 7: Delete domains this.prisma.domain.deleteMany(), // Level 8: Delete memberships (has FK to both user and project) this.prisma.membership.deleteMany(), // Level 9: Delete projects this.prisma.project.deleteMany(), // Level 10: Delete users last this.prisma.user.deleteMany(), ]); // Success - exit retry loop return; } catch (error) { lastError = error as Error; // Check if this is a deadlock error (PostgreSQL error code 40P01) const isDeadlock = error instanceof Error && error.message?.includes('deadlock detected'); if (isDeadlock && attempt < maxRetries) { // Wait before retrying (exponential backoff) const delay = Math.pow(2, attempt) * 50; // 100ms, 200ms, 400ms await new Promise(resolve => setTimeout(resolve, delay)); continue; } // Not a deadlock or out of retries break; } } // If we get here, all retries failed console.error(`Error cleaning up database after ${maxRetries} attempts:`, lastError); throw lastError; } /** * Disconnect from database */ async disconnect() { if (this.prisma) { await this.prisma.$disconnect(); this.prisma = null; } } /** * Execute raw SQL (useful for advanced test setup) */ async executeRaw(sql: string) { if (!this.prisma) { throw new Error('Database not initialized'); } return this.prisma.$executeRawUnsafe(sql); } /** * Reset database sequences (useful for predictable IDs in tests) */ async resetSequences() { if (!this.prisma) return; // Get all tables with sequences const tables = [ 'User', 'Project', 'Contact', 'Campaign', 'Email', 'Workflow', 'WorkflowExecution', 'Template', 'Segment', ]; for (const table of tables) { try { await this.prisma.$executeRawUnsafe(`ALTER SEQUENCE "${table}_id_seq" RESTART WITH 1`); } catch (error) { // Sequence might not exist, ignore } } } } // Export singleton instance export const testDatabase = new TestDatabase(); // Export helper to get Prisma client in tests // Returns a Proxy that lazily initializes the database on first property access export const getPrismaClient = (() => { let clientProxy: PrismaClient | null = null; return () => { if (!clientProxy) { clientProxy = new Proxy({} as PrismaClient, { get(target, prop) { const client = testDatabase.getClient(); const value = client[prop as keyof PrismaClient]; // Bind methods to the actual client return typeof value === 'function' ? value.bind(client) : value; }, }); } return clientProxy; }; })();