Files

598 lines
16 KiB
TypeScript

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<string, unknown>;
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<ContactFactoryOptions> = {}) {
// 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 || '<p>Hello {{firstName}}, this is a test email.</p>',
from: options.from || '[email protected]',
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 || '<p>Test campaign body</p>',
from: options.from || '[email protected]',
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<CampaignFactoryOptions>,
) {
// 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 || '<p>Test email body</p>',
from: options.from || '[email protected]',
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<string, unknown>;
} = {},
) {
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<string, unknown>;
} = {},
) {
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];
},
});