Files
plunk/test/helpers/database.ts
T

187 lines
4.9 KiB
TypeScript

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
*/
async cleanup() {
if (!this.prisma) return;
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(),
]);
} catch (error) {
console.error('Error cleaning up database:', error);
throw error;
}
}
/**
* 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;
};
})();