import { AuthMethod, CampaignStatus, EmailSourceType, EmailStatus, Prisma, PrismaClient, Role, TemplateType, TrackingMode, WorkflowExecutionStatus, WorkflowStepType, WorkflowTriggerType } from '@plunk/db'; import {getPrismaClient} from './database'; import bcrypt from 'bcrypt'; /** * Factory helpers for creating test data * These factories create database records with sensible defaults */ let factoryCounter = 0; function uniqueId() { return `${Date.now()}-${factoryCounter++}`; } export interface UserFactoryOptions { email?: string; password?: string; type?: AuthMethod; } export interface ProjectFactoryOptions { name?: string; disabled?: boolean; tracking?: TrackingMode; billingLimitWorkflows?: number | null; billingLimitCampaigns?: number | null; billingLimitTransactional?: number | null; } export interface ContactFactoryOptions { projectId: string; email?: string; data?: Record; subscribed?: boolean; } export interface TemplateFactoryOptions { projectId: string; name?: string; subject?: string; body?: string; from?: string; fromName?: string; type?: TemplateType; } export interface CampaignFactoryOptions { projectId: string; name?: string; subject?: string; body?: string; from?: string; status?: CampaignStatus; scheduledFor?: Date | null; segmentId?: string | null; type?: TemplateType; } export interface WorkflowFactoryOptions { projectId: string; name?: string; enabled?: boolean; triggerType?: WorkflowTriggerType; triggerConfig?: unknown; allowReentry?: boolean; } export interface WorkflowStepFactoryOptions { workflowId: string; type?: WorkflowStepType; name?: string; position?: {x: number; y: number}; config?: unknown; templateId?: string | null; } export interface DomainFactoryOptions { projectId: string; domain?: string; verified?: boolean; dkimTokens?: unknown; } export class TestFactories { private prisma: PrismaClient; constructor() { this.prisma = getPrismaClient(); } /** * Create a test user */ async createUser(options: UserFactoryOptions = {}) { const email = options.email || `user-${uniqueId()}@test.com`; const password = options.password || 'password123'; // Cost factor 4 is the bcrypt minimum — ~100x faster than the production cost of 10. // Test users don't need real-world hash strength. const hashedPassword = await bcrypt.hash(password, 4); return this.prisma.user.create({ data: { email, password: hashedPassword, type: options.type || AuthMethod.PASSWORD, }, }); } /** * Create a test project */ async createProject(options: ProjectFactoryOptions = {}) { return this.prisma.project.create({ data: { name: options.name || `Test Project ${uniqueId()}`, public: `pk_${uniqueId()}`, secret: `sk_${uniqueId()}`, disabled: options.disabled || false, tracking: options.tracking ?? TrackingMode.ENABLED, billingLimitWorkflows: options.billingLimitWorkflows, billingLimitCampaigns: options.billingLimitCampaigns, billingLimitTransactional: options.billingLimitTransactional, }, }); } /** * Create a user with a project */ async createUserWithProject(userOptions: UserFactoryOptions = {}, projectOptions: ProjectFactoryOptions = {}) { const user = await this.createUser(userOptions); const project = await this.createProject(projectOptions); await this.prisma.membership.create({ data: { userId: user.id, projectId: project.id, role: Role.ADMIN, }, }); return {user, project}; } /** * Create a test contact */ async createContact(options: ContactFactoryOptions) { return this.prisma.contact.create({ data: { projectId: options.projectId, email: options.email || `contact-${uniqueId()}@test.com`, data: options.data || {}, subscribed: options.subscribed ?? true, }, }); } /** * Create multiple contacts using bulk insert for better performance and memory efficiency */ async createContacts(projectId: string, count: number, baseOptions: Partial = {}) { // Use createMany for bulk insert to avoid memory issues const contactsData = Array.from({length: count}, (_, i) => ({ projectId, email: `contact-${i}-${uniqueId()}@test.com`, data: baseOptions.data || {}, subscribed: baseOptions.subscribed ?? true, })); await this.prisma.contact.createMany({ data: contactsData, }); // Fetch the created contacts to return them (needed for test assertions) // Use a limited query to avoid loading too many at once return this.prisma.contact.findMany({ where: {projectId}, orderBy: {createdAt: 'desc'}, take: count, }); } /** * Create a test template */ async createTemplate(options: TemplateFactoryOptions) { return this.prisma.template.create({ data: { projectId: options.projectId, name: options.name || `Template ${uniqueId()}`, subject: options.subject || 'Test Subject', body: options.body || '

Hello {{firstName}}, this is a test email.

', from: options.from || 'test@example.com', fromName: options.fromName || 'Test Sender', type: options.type || TemplateType.MARKETING, }, }); } /** * Create a test campaign */ async createCampaign(options: CampaignFactoryOptions) { return this.prisma.campaign.create({ data: { projectId: options.projectId, name: options.name || `Campaign ${uniqueId()}`, subject: options.subject || 'Test Campaign Subject', body: options.body || '

Test campaign body

', from: options.from || 'campaign@example.com', status: options.status || CampaignStatus.DRAFT, scheduledFor: options.scheduledFor, segmentId: options.segmentId, type: options.type || TemplateType.MARKETING, }, }); } /** * Create a scheduled campaign * Supports both old (projectId, scheduledFor, options) and new (options) signatures */ async createScheduledCampaign( projectIdOrOptions: string | CampaignFactoryOptions, scheduledFor?: Date, options?: Partial, ) { // Support both calling conventions let campaignOptions: CampaignFactoryOptions; if (typeof projectIdOrOptions === 'string') { // Old signature: createScheduledCampaign(projectId, scheduledFor, options) const projectId = projectIdOrOptions; campaignOptions = { projectId, ...options, scheduledFor: scheduledFor || new Date(Date.now() + 24 * 60 * 60 * 1000), }; } else { // New signature: createScheduledCampaign(options) campaignOptions = projectIdOrOptions; campaignOptions.scheduledFor = campaignOptions.scheduledFor || new Date(Date.now() + 24 * 60 * 60 * 1000); } return this.createCampaign({ ...campaignOptions, status: CampaignStatus.SCHEDULED, }); } /** * Create an email * Supports both old (projectId, contactId, options) and new (options) signatures */ async createEmail( projectIdOrOptions: | string | { projectId: string; contactId: string; subject?: string; body?: string; from?: string; status?: EmailStatus; sourceType?: EmailSourceType; templateId?: string | null; campaignId?: string | null; sentAt?: Date | null; messageId?: string | null; openedAt?: Date | null; clickedAt?: Date | null; opens?: number; clicks?: number; error?: string | null; }, contactId?: string, additionalOptions?: Partial<{ subject?: string; body?: string; from?: string; status?: EmailStatus; sourceType?: EmailSourceType; templateId?: string | null; campaignId?: string | null; sentAt?: Date | null; messageId?: string | null; openedAt?: Date | null; clickedAt?: Date | null; opens?: number; clicks?: number; error?: string | null; }>, ) { // Support both calling conventions let options: { projectId: string; contactId: string; subject?: string; body?: string; from?: string; status?: EmailStatus; sourceType?: EmailSourceType; templateId?: string | null; campaignId?: string | null; sentAt?: Date | null; messageId?: string | null; openedAt?: Date | null; clickedAt?: Date | null; opens?: number; clicks?: number; error?: string | null; }; if (typeof projectIdOrOptions === 'string') { // Old signature: createEmail(projectId, contactId, options) if (!contactId) { throw new Error('contactId is required when using old signature'); } options = { projectId: projectIdOrOptions, contactId, ...additionalOptions, }; } else { // New signature: createEmail(options) options = projectIdOrOptions; } // Automatically determine sourceType based on context if not provided let sourceType = options.sourceType; if (!sourceType) { if (options.campaignId) { sourceType = EmailSourceType.CAMPAIGN; } else if (options.templateId) { sourceType = EmailSourceType.WORKFLOW; } else { sourceType = EmailSourceType.TRANSACTIONAL; } } return this.prisma.email.create({ data: { projectId: options.projectId, contactId: options.contactId, subject: options.subject || 'Test Email', body: options.body || '

Test email body

', from: options.from || 'test@example.com', status: options.status || EmailStatus.PENDING, sourceType, templateId: options.templateId, campaignId: options.campaignId, sentAt: options.sentAt, messageId: options.messageId, openedAt: options.openedAt, clickedAt: options.clickedAt, opens: options.opens, clicks: options.clicks, error: options.error, }, }); } /** * Create a workflow with trigger step (matches WorkflowService.create behavior) */ async createWorkflow(options: WorkflowFactoryOptions) { return this.prisma.$transaction(async tx => { const workflow = await tx.workflow.create({ data: { projectId: options.projectId, name: options.name || `Workflow ${uniqueId()}`, enabled: options.enabled ?? false, triggerType: options.triggerType || WorkflowTriggerType.EVENT, triggerConfig: options.triggerConfig || {eventName: 'contact.created'}, allowReentry: options.allowReentry ?? false, }, }); // Create trigger step (required for workflow to function) await tx.workflowStep.create({ data: { workflowId: workflow.id, type: WorkflowStepType.TRIGGER, name: 'Trigger', position: {x: 0, y: 0}, config: options.triggerConfig || {eventName: 'contact.created'}, }, }); return workflow; }); } /** * Create a workflow step */ async createWorkflowStep(options: WorkflowStepFactoryOptions) { // Build config based on step type let config = options.config; if (!config) { switch (options.type) { case WorkflowStepType.SEND_EMAIL: config = {templateId: options.templateId || null}; break; case WorkflowStepType.DELAY: config = {amount: 24, unit: 'hours'}; break; case WorkflowStepType.WAIT_FOR_EVENT: config = {eventName: 'test.event', timeout: 3600}; break; default: config = {}; } } return this.prisma.workflowStep.create({ data: { workflowId: options.workflowId, type: options.type || WorkflowStepType.SEND_EMAIL, name: options.name || `Step ${uniqueId()}`, position: options.position || {x: 0, y: 0}, config, templateId: options.templateId, }, }); } /** * Create a workflow execution */ async createWorkflowExecution( workflowId: string, contactId: string, overrides: { status?: WorkflowExecutionStatus; context?: Record; } = {}, ) { return this.prisma.workflowExecution.create({ data: { workflowId, contactId, status: overrides.status || WorkflowExecutionStatus.RUNNING, context: overrides.context || {}, }, }); } /** * Create a complete workflow with steps */ async createWorkflowWithSteps( projectId: string, steps: Array<{ type: WorkflowStepType; delay?: number; timeout?: number; templateId?: string; config?: unknown; }>, ) { const workflow = await this.createWorkflow({projectId}); const createdSteps = []; for (let i = 0; i < steps.length; i++) { const stepData = steps[i]; // Build config based on provided data or step type let config = stepData.config; if (!config) { switch (stepData.type) { case WorkflowStepType.SEND_EMAIL: config = {templateId: stepData.templateId || null}; break; case WorkflowStepType.DELAY: config = {amount: stepData.delay || 24, unit: 'hours'}; break; case WorkflowStepType.WAIT_FOR_EVENT: config = { eventName: 'test.event', timeout: stepData.timeout || 3600, }; break; default: config = {}; } } const step = await this.createWorkflowStep({ workflowId: workflow.id, type: stepData.type, name: `Step ${i + 1}`, position: {x: i * 100, y: 0}, config, templateId: stepData.templateId, }); createdSteps.push(step); } return {workflow, steps: createdSteps}; } /** * Create a segment * Supports both old format (filters array) and new format (condition object) */ async createSegment( projectId: string, overrides: { name?: string; filters?: Array<{field: string; operator: string; value?: unknown; unit?: string}>; condition?: { logic: 'AND' | 'OR'; groups: Array<{filters: Array<{field: string; operator: string; value?: unknown; unit?: string}>}>; }; trackMembership?: boolean; } = {}, ) { // Convert filters array to condition format if condition not provided const condition = overrides.condition || { logic: 'AND' as const, groups: [{filters: overrides.filters || []}], }; return this.prisma.segment.create({ data: { projectId, name: overrides.name || `Segment ${uniqueId()}`, condition: condition as unknown as Prisma.InputJsonValue, trackMembership: overrides.trackMembership ?? false, }, }); } /** * Create an event */ async createEvent( projectId: string, contactId: string, overrides: { event?: string; data?: Record; } = {}, ) { return this.prisma.event.create({ data: { projectId, contactId, event: overrides.event || 'test.event', data: overrides.data || {}, }, }); } /** * Create a domain */ async createDomain(options: DomainFactoryOptions) { return this.prisma.domain.create({ data: { projectId: options.projectId, domain: options.domain || 'example.com', verified: options.verified ?? true, dkimTokens: options.dkimTokens || null, }, }); } } // Export lazy-initialized singleton instance let factoriesInstance: TestFactories | null = null; export const factories = new Proxy({} as TestFactories, { get(target, prop) { if (!factoriesInstance) { factoriesInstance = new TestFactories(); } return factoriesInstance[prop as keyof TestFactories]; }, });