596 lines
16 KiB
TypeScript
596 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';
|
|
const hashedPassword = await bcrypt.hash(password, 10);
|
|
|
|
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];
|
|
},
|
|
});
|