Initial push of Plunk Next
This commit is contained in:
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* Example Test File
|
||||
*
|
||||
* This file demonstrates common testing patterns used in the Plunk V2 codebase.
|
||||
* Use this as a reference when writing new tests.
|
||||
*/
|
||||
|
||||
import {describe, it, expect, beforeEach, afterEach} from 'vitest';
|
||||
import {factories, getPrismaClient, createTimeControl, createMockQueues, createServiceMocks} from '../helpers';
|
||||
import {CampaignStatus, WorkflowStepType, EmailStatus, StepExecutionStatus} from '@plunk/db';
|
||||
|
||||
describe('Example Tests - Common Patterns', () => {
|
||||
let projectId: string;
|
||||
const prisma = getPrismaClient();
|
||||
const timeControl = createTimeControl();
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create fresh test data before each test
|
||||
const {project} = await factories.createUserWithProject();
|
||||
projectId = project.id;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore real timers after each test
|
||||
timeControl.restore();
|
||||
});
|
||||
|
||||
describe('Pattern 1: Basic CRUD Testing', () => {
|
||||
it('should create and retrieve an entity', async () => {
|
||||
// Arrange: Set up test data
|
||||
const campaignData = {
|
||||
name: 'Welcome Campaign',
|
||||
subject: 'Welcome to Plunk!',
|
||||
body: '<p>Thanks for signing up</p>',
|
||||
from: '[email protected]',
|
||||
};
|
||||
|
||||
// Act: Create the campaign
|
||||
const campaign = await factories.createCampaign({
|
||||
projectId,
|
||||
...campaignData,
|
||||
});
|
||||
|
||||
// Assert: Verify it was created correctly
|
||||
expect(campaign.id).toBeDefined();
|
||||
expect(campaign.name).toBe('Welcome Campaign');
|
||||
expect(campaign.status).toBe(CampaignStatus.DRAFT);
|
||||
|
||||
// Act: Retrieve it
|
||||
const retrieved = await prisma.campaign.findUnique({
|
||||
where: {id: campaign.id},
|
||||
});
|
||||
|
||||
// Assert: Verify retrieval
|
||||
expect(retrieved).not.toBeNull();
|
||||
expect(retrieved?.name).toBe('Welcome Campaign');
|
||||
});
|
||||
|
||||
it('should update an entity', async () => {
|
||||
const campaign = await factories.createCampaign({
|
||||
projectId,
|
||||
name: 'Original Name',
|
||||
});
|
||||
|
||||
// Update
|
||||
const updated = await prisma.campaign.update({
|
||||
where: {id: campaign.id},
|
||||
data: {name: 'Updated Name'},
|
||||
});
|
||||
|
||||
expect(updated.name).toBe('Updated Name');
|
||||
});
|
||||
|
||||
it('should delete an entity', async () => {
|
||||
const campaign = await factories.createCampaign({projectId});
|
||||
|
||||
// Delete
|
||||
await prisma.campaign.delete({
|
||||
where: {id: campaign.id},
|
||||
});
|
||||
|
||||
// Verify deletion
|
||||
const deleted = await prisma.campaign.findUnique({
|
||||
where: {id: campaign.id},
|
||||
});
|
||||
|
||||
expect(deleted).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pattern 2: Testing Relationships', () => {
|
||||
it('should create entities with relationships', async () => {
|
||||
// Create related entities
|
||||
const segment = await factories.createSegment(projectId, {
|
||||
name: 'Premium Users',
|
||||
});
|
||||
|
||||
const campaign = await factories.createCampaign({
|
||||
projectId,
|
||||
segmentId: segment.id,
|
||||
});
|
||||
|
||||
// Verify relationship
|
||||
const campaignWithSegment = await prisma.campaign.findUnique({
|
||||
where: {id: campaign.id},
|
||||
include: {segment: true},
|
||||
});
|
||||
|
||||
expect(campaignWithSegment?.segment).toBeDefined();
|
||||
expect(campaignWithSegment?.segment?.name).toBe('Premium Users');
|
||||
});
|
||||
|
||||
it('should handle one-to-many relationships', async () => {
|
||||
const contact = await factories.createContact({projectId});
|
||||
|
||||
// Create multiple emails for one contact
|
||||
const email1 = await factories.createEmail(projectId, contact.id);
|
||||
const email2 = await factories.createEmail(projectId, contact.id);
|
||||
const email3 = await factories.createEmail(projectId, contact.id);
|
||||
|
||||
// Verify all emails are associated with the contact
|
||||
const contactWithEmails = await prisma.contact.findUnique({
|
||||
where: {id: contact.id},
|
||||
include: {emails: true},
|
||||
});
|
||||
|
||||
expect(contactWithEmails?.emails).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pattern 3: Testing with Time Control', () => {
|
||||
it('should schedule something for the future', async () => {
|
||||
// Freeze time at a known point
|
||||
const now = timeControl.freeze(new Date('2025-01-20T10:00:00Z'));
|
||||
|
||||
// Schedule for 2 hours from now
|
||||
const scheduledTime = timeControl.helpers.relative(2, 'hour');
|
||||
|
||||
const campaign = await factories.createScheduledCampaign(projectId, scheduledTime, {name: 'Future Campaign'});
|
||||
|
||||
// Verify it's scheduled for the future
|
||||
expect(campaign.scheduledFor).toEqual(scheduledTime);
|
||||
expect(campaign.status).toBe(CampaignStatus.SCHEDULED);
|
||||
|
||||
// Advance time by 1 hour (not yet time to run)
|
||||
timeControl.helpers.advanceHours(1);
|
||||
expect(timeControl.now()).toEqual(new Date('2025-01-20T11:00:00Z'));
|
||||
|
||||
// Advance to scheduled time
|
||||
timeControl.advanceTo(scheduledTime);
|
||||
expect(timeControl.now()).toEqual(scheduledTime);
|
||||
});
|
||||
|
||||
it('should handle timeout scenarios', async () => {
|
||||
timeControl.freeze(new Date('2025-01-20T10:00:00Z'));
|
||||
|
||||
const template = await factories.createTemplate({projectId});
|
||||
const {workflow, steps} = await factories.createWorkflowWithSteps(projectId, [
|
||||
{type: WorkflowStepType.WAIT_FOR_EVENT, timeout: 3600}, // 1 hour timeout
|
||||
]);
|
||||
|
||||
const contact = await factories.createContact({projectId});
|
||||
const execution = await factories.createWorkflowExecution(workflow.id, contact.id);
|
||||
|
||||
const stepExecution = await prisma.workflowStepExecution.create({
|
||||
data: {
|
||||
executionId: execution.id,
|
||||
stepId: steps[0].id,
|
||||
status: StepExecutionStatus.WAITING,
|
||||
startedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Advance past timeout
|
||||
timeControl.helpers.advanceHours(1);
|
||||
|
||||
// Verify we can detect timeout
|
||||
const elapsed = timeControl.now().getTime() - (stepExecution.startedAt?.getTime() || 0);
|
||||
const hasTimedOut = elapsed >= 3600000; // 1 hour in milliseconds
|
||||
|
||||
expect(hasTimedOut).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pattern 4: Testing Collections and Pagination', () => {
|
||||
it('should handle pagination correctly', async () => {
|
||||
// Create 25 campaigns
|
||||
const campaigns = [];
|
||||
for (let i = 0; i < 25; i++) {
|
||||
campaigns.push(
|
||||
await factories.createCampaign({
|
||||
projectId,
|
||||
name: `Campaign ${i}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Test first page
|
||||
const page1 = await prisma.campaign.findMany({
|
||||
where: {projectId},
|
||||
orderBy: {createdAt: 'desc'},
|
||||
take: 10,
|
||||
skip: 0,
|
||||
});
|
||||
|
||||
expect(page1).toHaveLength(10);
|
||||
|
||||
// Test second page
|
||||
const page2 = await prisma.campaign.findMany({
|
||||
where: {projectId},
|
||||
orderBy: {createdAt: 'desc'},
|
||||
take: 10,
|
||||
skip: 10,
|
||||
});
|
||||
|
||||
expect(page2).toHaveLength(10);
|
||||
|
||||
// Verify no overlap
|
||||
const page1Ids = page1.map(c => c.id);
|
||||
const page2Ids = page2.map(c => c.id);
|
||||
const overlap = page1Ids.filter(id => page2Ids.includes(id));
|
||||
|
||||
expect(overlap).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should filter collections', async () => {
|
||||
// Create campaigns with different statuses
|
||||
await factories.createCampaign({projectId, status: CampaignStatus.DRAFT});
|
||||
await factories.createCampaign({projectId, status: CampaignStatus.DRAFT});
|
||||
await factories.createCampaign({projectId, status: CampaignStatus.SENT});
|
||||
await factories.createCampaign({projectId, status: CampaignStatus.SENT});
|
||||
await factories.createCampaign({projectId, status: CampaignStatus.SCHEDULED});
|
||||
|
||||
// Filter by status
|
||||
const drafts = await prisma.campaign.findMany({
|
||||
where: {projectId, status: CampaignStatus.DRAFT},
|
||||
});
|
||||
|
||||
const sent = await prisma.campaign.findMany({
|
||||
where: {projectId, status: CampaignStatus.SENT},
|
||||
});
|
||||
|
||||
expect(drafts).toHaveLength(2);
|
||||
expect(sent).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pattern 5: Testing State Transitions', () => {
|
||||
it('should track status changes', async () => {
|
||||
const contact = await factories.createContact({projectId});
|
||||
const email = await factories.createEmail(projectId, contact.id, {
|
||||
status: EmailStatus.PENDING,
|
||||
});
|
||||
|
||||
// Verify initial state
|
||||
expect(email.status).toBe(EmailStatus.PENDING);
|
||||
|
||||
// Transition to SENDING
|
||||
const sending = await prisma.email.update({
|
||||
where: {id: email.id},
|
||||
data: {status: EmailStatus.SENDING},
|
||||
});
|
||||
|
||||
expect(sending.status).toBe(EmailStatus.SENDING);
|
||||
|
||||
// Transition to SENT
|
||||
const sent = await prisma.email.update({
|
||||
where: {id: email.id},
|
||||
data: {
|
||||
status: EmailStatus.SENT,
|
||||
sentAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(sent.status).toBe(EmailStatus.SENT);
|
||||
expect(sent.sentAt).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pattern 6: Testing Bulk Operations', () => {
|
||||
it('should handle bulk creates efficiently', async () => {
|
||||
// Create many contacts at once
|
||||
const contacts = await factories.createContacts(projectId, 100);
|
||||
|
||||
expect(contacts).toHaveLength(100);
|
||||
|
||||
// Verify they were all created
|
||||
const count = await prisma.contact.count({
|
||||
where: {projectId},
|
||||
});
|
||||
|
||||
expect(count).toBe(100);
|
||||
});
|
||||
|
||||
it('should handle bulk updates', async () => {
|
||||
const contacts = await factories.createContacts(projectId, 50);
|
||||
|
||||
// Bulk update all contacts
|
||||
await prisma.contact.updateMany({
|
||||
where: {projectId},
|
||||
data: {subscribed: false},
|
||||
});
|
||||
|
||||
// Verify all were updated
|
||||
const unsubscribed = await prisma.contact.findMany({
|
||||
where: {projectId, subscribed: false},
|
||||
});
|
||||
|
||||
expect(unsubscribed).toHaveLength(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pattern 7: Testing Error Cases', () => {
|
||||
it('should handle not found errors', async () => {
|
||||
const result = await prisma.campaign.findUnique({
|
||||
where: {id: 'non-existent-id'},
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle validation errors', async () => {
|
||||
// Create a contact first
|
||||
await factories.createContact({
|
||||
projectId,
|
||||
email: '[email protected]',
|
||||
});
|
||||
|
||||
// Try to create another contact with the same email (violates unique constraint)
|
||||
await expect(
|
||||
prisma.contact.create({
|
||||
data: {
|
||||
projectId,
|
||||
email: '[email protected]', // Duplicate email - violates unique constraint on [projectId, email]
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pattern 8: Testing Complex Workflows', () => {
|
||||
it('should execute a multi-step workflow', async () => {
|
||||
const template = await factories.createTemplate({projectId});
|
||||
|
||||
// Create workflow with multiple steps
|
||||
const {workflow, steps} = await factories.createWorkflowWithSteps(projectId, [
|
||||
{type: WorkflowStepType.SEND_EMAIL, templateId: template.id},
|
||||
{type: WorkflowStepType.DELAY, delay: 3600}, // 1 hour delay
|
||||
{type: WorkflowStepType.SEND_EMAIL, templateId: template.id},
|
||||
]);
|
||||
|
||||
expect(workflow).toBeDefined();
|
||||
expect(steps).toHaveLength(3);
|
||||
|
||||
// Verify step order
|
||||
expect(steps[0].type).toBe(WorkflowStepType.SEND_EMAIL);
|
||||
expect(steps[1].type).toBe(WorkflowStepType.DELAY);
|
||||
expect(steps[2].type).toBe(WorkflowStepType.SEND_EMAIL);
|
||||
|
||||
// Verify delay configuration in the config JSON field
|
||||
const delayConfig = steps[1].config as {amount: number; unit: string};
|
||||
expect(delayConfig.amount).toBe(3600);
|
||||
expect(delayConfig.unit).toBe('hours');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
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;
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,560 @@
|
||||
import {
|
||||
PrismaClient,
|
||||
AuthMethod,
|
||||
Role,
|
||||
TemplateType,
|
||||
CampaignStatus,
|
||||
EmailStatus,
|
||||
EmailSourceType,
|
||||
WorkflowTriggerType,
|
||||
WorkflowStepType,
|
||||
WorkflowExecutionStatus,
|
||||
StepExecutionStatus,
|
||||
} 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;
|
||||
trackingEnabled?: boolean;
|
||||
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;
|
||||
}
|
||||
|
||||
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 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,
|
||||
trackingEnabled: options.trackingEnabled ?? true,
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
async createSegment(
|
||||
projectId: string,
|
||||
overrides: {
|
||||
name?: string;
|
||||
filters?: unknown;
|
||||
trackMembership?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
return this.prisma.segment.create({
|
||||
data: {
|
||||
projectId,
|
||||
name: overrides.name || `Segment ${uniqueId()}`,
|
||||
filters: overrides.filters || [],
|
||||
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 || {},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Test helpers export
|
||||
* Centralized export of all test utilities
|
||||
*/
|
||||
|
||||
export * from './database';
|
||||
export * from './factories';
|
||||
export * from './time';
|
||||
export * from './jobs';
|
||||
@@ -0,0 +1,194 @@
|
||||
import { vi } from 'vitest';
|
||||
import { Queue, Worker, Job } from 'bullmq';
|
||||
|
||||
/**
|
||||
* Job testing helper
|
||||
* Provides utilities to test BullMQ job processors without Redis
|
||||
*/
|
||||
|
||||
export interface MockJob<T = any> {
|
||||
id: string;
|
||||
data: T;
|
||||
attemptsMade: number;
|
||||
processedOn?: number;
|
||||
finishedOn?: number;
|
||||
returnvalue?: any;
|
||||
failedReason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock BullMQ job
|
||||
*/
|
||||
export function createMockJob<T>(data: T, options: Partial<MockJob<T>> = {}): Job<T> {
|
||||
const mockJob = {
|
||||
id: options.id || `job-${Date.now()}`,
|
||||
data,
|
||||
attemptsMade: options.attemptsMade || 0,
|
||||
processedOn: options.processedOn,
|
||||
finishedOn: options.finishedOn,
|
||||
returnvalue: options.returnvalue,
|
||||
failedReason: options.failedReason,
|
||||
// Mock methods
|
||||
updateProgress: vi.fn(),
|
||||
log: vi.fn(),
|
||||
moveToCompleted: vi.fn(),
|
||||
moveToFailed: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
retry: vi.fn(),
|
||||
} as unknown as Job<T>;
|
||||
|
||||
return mockJob;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock queue for testing job creation
|
||||
*/
|
||||
export class MockQueue<T = any> {
|
||||
private jobs: Array<{ name?: string; data: T; opts?: any }> = [];
|
||||
|
||||
async add(name: string | T, data?: T | any, opts?: any) {
|
||||
// Handle both queue.add(data) and queue.add(name, data) patterns
|
||||
const jobName = typeof name === 'string' ? name : undefined;
|
||||
const jobData = typeof name === 'string' ? data : name;
|
||||
const jobOpts = typeof name === 'string' ? opts : data;
|
||||
|
||||
this.jobs.push({
|
||||
name: jobName,
|
||||
data: jobData,
|
||||
opts: jobOpts,
|
||||
});
|
||||
|
||||
return createMockJob(jobData, { id: `mock-${this.jobs.length}` });
|
||||
}
|
||||
|
||||
getJobs() {
|
||||
return this.jobs;
|
||||
}
|
||||
|
||||
getJobsByName(name: string) {
|
||||
return this.jobs.filter((job) => job.name === name);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.jobs = [];
|
||||
}
|
||||
|
||||
async close() {
|
||||
// No-op for mock
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create mock queues for testing
|
||||
*/
|
||||
export function createMockQueues() {
|
||||
return {
|
||||
email: new MockQueue(),
|
||||
campaign: new MockQueue(),
|
||||
scheduledCampaign: new MockQueue(),
|
||||
workflow: new MockQueue(),
|
||||
import: new MockQueue(),
|
||||
segmentCount: new MockQueue(),
|
||||
domainVerification: new MockQueue(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Job processor test helper
|
||||
* Allows testing job processors in isolation
|
||||
*/
|
||||
export class JobProcessorTester<T = any> {
|
||||
private processor: (job: Job<T>) => Promise<any>;
|
||||
|
||||
constructor(processor: (job: Job<T>) => Promise<any>) {
|
||||
this.processor = processor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a job with test data
|
||||
*/
|
||||
async process(data: T, options: Partial<MockJob<T>> = {}) {
|
||||
const job = createMockJob(data, options);
|
||||
return this.processor(job);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process multiple jobs
|
||||
*/
|
||||
async processMany(dataArray: T[], options: Partial<MockJob<T>> = {}) {
|
||||
const results = [];
|
||||
for (const data of dataArray) {
|
||||
const result = await this.process(data, options);
|
||||
results.push(result);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a job processor tester
|
||||
*/
|
||||
export function createJobTester<T = any>(processor: (job: Job<T>) => Promise<any>) {
|
||||
return new JobProcessorTester<T>(processor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock external services for job testing
|
||||
*/
|
||||
export function createServiceMocks() {
|
||||
return {
|
||||
// Mock SES (email sending)
|
||||
ses: {
|
||||
sendEmail: vi.fn().mockResolvedValue({ MessageId: 'mock-message-id' }),
|
||||
sendRawEmail: vi.fn().mockResolvedValue({ MessageId: 'mock-message-id' }),
|
||||
},
|
||||
|
||||
// Mock S3 (file storage)
|
||||
s3: {
|
||||
upload: vi.fn().mockResolvedValue({ Location: 'https://mock-s3.com/file.jpg' }),
|
||||
getSignedUrl: vi.fn().mockResolvedValue('https://mock-s3.com/signed-url'),
|
||||
deleteObject: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
|
||||
// Mock Stripe (billing)
|
||||
stripe: {
|
||||
customers: {
|
||||
create: vi.fn().mockResolvedValue({ id: 'cus_mock' }),
|
||||
retrieve: vi.fn().mockResolvedValue({ id: 'cus_mock' }),
|
||||
},
|
||||
subscriptions: {
|
||||
create: vi.fn().mockResolvedValue({ id: 'sub_mock' }),
|
||||
update: vi.fn().mockResolvedValue({ id: 'sub_mock' }),
|
||||
},
|
||||
},
|
||||
|
||||
// Mock Redis
|
||||
redis: {
|
||||
get: vi.fn().mockResolvedValue(null),
|
||||
set: vi.fn().mockResolvedValue('OK'),
|
||||
del: vi.fn().mockResolvedValue(1),
|
||||
incr: vi.fn().mockResolvedValue(1),
|
||||
expire: vi.fn().mockResolvedValue(1),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for jobs to complete (useful in integration tests with real queues)
|
||||
*/
|
||||
export async function waitForJobs(queue: Queue, timeout = 5000): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
const counts = await queue.getJobCounts('waiting', 'active', 'delayed');
|
||||
const pending = counts.waiting + counts.active + counts.delayed;
|
||||
|
||||
if (pending === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
throw new Error(`Jobs did not complete within ${timeout}ms`);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { vi } from 'vitest';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
/**
|
||||
* Time control helper for testing time-dependent functionality
|
||||
* Provides utilities to freeze time, advance time, and control dayjs
|
||||
*/
|
||||
export class TimeControl {
|
||||
private currentTime: Date | null = null;
|
||||
|
||||
/**
|
||||
* Freeze time at a specific date
|
||||
* @param date Date to freeze at (defaults to 2025-01-01 12:00:00 UTC)
|
||||
*/
|
||||
freeze(date?: Date | string) {
|
||||
const freezeDate = date ? new Date(date) : new Date('2025-01-01T12:00:00.000Z');
|
||||
this.currentTime = freezeDate;
|
||||
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(freezeDate);
|
||||
|
||||
return freezeDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance time by specified duration
|
||||
* @param ms Milliseconds to advance
|
||||
*/
|
||||
advance(ms: number) {
|
||||
if (!this.currentTime) {
|
||||
throw new Error('Time not frozen. Call freeze() first.');
|
||||
}
|
||||
|
||||
vi.advanceTimersByTime(ms);
|
||||
this.currentTime = new Date(this.currentTime.getTime() + ms);
|
||||
|
||||
return this.currentTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance time to a specific date
|
||||
* @param date Target date
|
||||
*/
|
||||
advanceTo(date: Date | string) {
|
||||
const targetDate = new Date(date);
|
||||
|
||||
if (!this.currentTime) {
|
||||
throw new Error('Time not frozen. Call freeze() first.');
|
||||
}
|
||||
|
||||
const diff = targetDate.getTime() - this.currentTime.getTime();
|
||||
if (diff < 0) {
|
||||
throw new Error('Cannot advance to a date in the past');
|
||||
}
|
||||
|
||||
return this.advance(diff);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current frozen time
|
||||
*/
|
||||
now(): Date {
|
||||
if (!this.currentTime) {
|
||||
throw new Error('Time not frozen. Call freeze() first.');
|
||||
}
|
||||
return this.currentTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore real time
|
||||
*/
|
||||
restore() {
|
||||
vi.useRealTimers();
|
||||
this.currentTime = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper methods for common time operations
|
||||
*/
|
||||
helpers = {
|
||||
/** Advance time by seconds */
|
||||
advanceSeconds: (seconds: number) => this.advance(seconds * 1000),
|
||||
|
||||
/** Advance time by minutes */
|
||||
advanceMinutes: (minutes: number) => this.advance(minutes * 60 * 1000),
|
||||
|
||||
/** Advance time by hours */
|
||||
advanceHours: (hours: number) => this.advance(hours * 60 * 60 * 1000),
|
||||
|
||||
/** Advance time by days */
|
||||
advanceDays: (days: number) => this.advance(days * 24 * 60 * 60 * 1000),
|
||||
|
||||
/** Get a date relative to frozen time */
|
||||
relative: (amount: number, unit: 'second' | 'minute' | 'hour' | 'day') => {
|
||||
if (!this.currentTime) {
|
||||
throw new Error('Time not frozen. Call freeze() first.');
|
||||
}
|
||||
return dayjs(this.currentTime).add(amount, unit).toDate();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new TimeControl instance for a test
|
||||
*/
|
||||
export function createTimeControl() {
|
||||
return new TimeControl();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to run a test with frozen time
|
||||
*/
|
||||
export async function withFrozenTime<T>(
|
||||
date: Date | string,
|
||||
fn: (timeControl: TimeControl) => T | Promise<T>
|
||||
): Promise<T> {
|
||||
const timeControl = new TimeControl();
|
||||
timeControl.freeze(date);
|
||||
|
||||
try {
|
||||
return await fn(timeControl);
|
||||
} finally {
|
||||
timeControl.restore();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { vi } from 'vitest';
|
||||
import { mockDeep, type DeepMockProxy } from 'vitest-mock-extended';
|
||||
import type { Queue, Job } from 'bullmq';
|
||||
|
||||
/**
|
||||
* In-memory queue implementation for testing
|
||||
* Provides realistic queue behavior without Redis
|
||||
*/
|
||||
export class MockQueueStore<T = any> {
|
||||
private jobs = new Map<string, MockJobData<T>>();
|
||||
private jobCounter = 0;
|
||||
|
||||
async add(name: string | T, data?: T | any, opts?: any): Promise<MockJobData<T>> {
|
||||
const jobName = typeof name === 'string' ? name : undefined;
|
||||
const jobData = typeof name === 'string' ? data : name;
|
||||
const jobOpts = typeof name === 'string' ? opts : data;
|
||||
|
||||
const jobId = jobOpts?.jobId || `job-${++this.jobCounter}`;
|
||||
|
||||
const job: MockJobData<T> = {
|
||||
id: jobId,
|
||||
name: jobName,
|
||||
data: jobData,
|
||||
opts: jobOpts,
|
||||
state: 'waiting',
|
||||
attemptsMade: 0,
|
||||
timestamp: Date.now(),
|
||||
processedOn: undefined,
|
||||
finishedOn: undefined,
|
||||
returnvalue: undefined,
|
||||
failedReason: undefined,
|
||||
};
|
||||
|
||||
this.jobs.set(jobId, job);
|
||||
return job;
|
||||
}
|
||||
|
||||
async getJob(jobId: string): Promise<MockJobData<T> | null> {
|
||||
return this.jobs.get(jobId) || null;
|
||||
}
|
||||
|
||||
async getJobs(states: string[]): Promise<MockJobData<T>[]> {
|
||||
return Array.from(this.jobs.values()).filter((job) => states.includes(job.state));
|
||||
}
|
||||
|
||||
async getJobCounts(...states: string[]): Promise<Record<string, number>> {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const state of states) {
|
||||
counts[state] = Array.from(this.jobs.values()).filter((j) => j.state === state).length;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
async removeJob(jobId: string): Promise<void> {
|
||||
this.jobs.delete(jobId);
|
||||
}
|
||||
|
||||
async pause(): Promise<void> {
|
||||
// No-op for mock
|
||||
}
|
||||
|
||||
async resume(): Promise<void> {
|
||||
// No-op for mock
|
||||
}
|
||||
|
||||
async clean(grace: number, limit: number, type: string): Promise<string[]> {
|
||||
const now = Date.now();
|
||||
const removed: string[] = [];
|
||||
|
||||
for (const [jobId, job] of this.jobs.entries()) {
|
||||
if (job.state === type && job.finishedOn && now - job.finishedOn > grace) {
|
||||
this.jobs.delete(jobId);
|
||||
removed.push(jobId);
|
||||
if (removed.length >= limit) break;
|
||||
}
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.jobs.clear();
|
||||
}
|
||||
|
||||
// Test utilities
|
||||
getAllJobs(): MockJobData<T>[] {
|
||||
return Array.from(this.jobs.values());
|
||||
}
|
||||
|
||||
getJobsByName(name: string): MockJobData<T>[] {
|
||||
return Array.from(this.jobs.values()).filter((job) => job.name === name);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.jobs.clear();
|
||||
this.jobCounter = 0;
|
||||
}
|
||||
|
||||
size(): number {
|
||||
return this.jobs.size;
|
||||
}
|
||||
|
||||
markJobAsCompleted(jobId: string, returnvalue?: any): void {
|
||||
const job = this.jobs.get(jobId);
|
||||
if (job) {
|
||||
job.state = 'completed';
|
||||
job.finishedOn = Date.now();
|
||||
job.returnvalue = returnvalue;
|
||||
}
|
||||
}
|
||||
|
||||
markJobAsFailed(jobId: string, error: string): void {
|
||||
const job = this.jobs.get(jobId);
|
||||
if (job) {
|
||||
job.state = 'failed';
|
||||
job.finishedOn = Date.now();
|
||||
job.failedReason = error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface MockJobData<T = any> {
|
||||
id: string;
|
||||
name?: string;
|
||||
data: T;
|
||||
opts?: any;
|
||||
state: 'waiting' | 'active' | 'completed' | 'failed' | 'delayed';
|
||||
attemptsMade: number;
|
||||
timestamp: number;
|
||||
processedOn?: number;
|
||||
finishedOn?: number;
|
||||
returnvalue?: any;
|
||||
failedReason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create mock BullMQ queues for testing
|
||||
*/
|
||||
export function createMockQueues() {
|
||||
return {
|
||||
email: new MockQueueStore(),
|
||||
campaign: new MockQueueStore(),
|
||||
workflow: new MockQueueStore(),
|
||||
scheduled: new MockQueueStore(),
|
||||
import: new MockQueueStore(),
|
||||
segmentCount: new MockQueueStore(),
|
||||
domainVerification: new MockQueueStore(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a typed mock queue using vitest-mock-extended
|
||||
*/
|
||||
export function createMockQueue<T = any>(): DeepMockProxy<Queue<T>> {
|
||||
const mock = mockDeep<Queue<T>>();
|
||||
|
||||
// Default implementations
|
||||
mock.add.mockImplementation(async (name, data, opts) => {
|
||||
return {
|
||||
id: opts?.jobId || `mock-job-${Date.now()}`,
|
||||
data: (typeof name === 'string' ? data : name) as T,
|
||||
attemptsMade: 0,
|
||||
} as unknown as Job<T>;
|
||||
});
|
||||
|
||||
mock.getJob.mockResolvedValue(null);
|
||||
mock.getJobs.mockResolvedValue([]);
|
||||
mock.getJobCounts.mockResolvedValue({
|
||||
waiting: 0,
|
||||
active: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
delayed: 0,
|
||||
});
|
||||
mock.pause.mockResolvedValue(undefined);
|
||||
mock.resume.mockResolvedValue(undefined);
|
||||
mock.close.mockResolvedValue(undefined);
|
||||
|
||||
return mock;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { vi } from 'vitest';
|
||||
import { mockDeep, mockReset, type DeepMockProxy } from 'vitest-mock-extended';
|
||||
import type { Redis } from 'ioredis';
|
||||
|
||||
/**
|
||||
* Mock Redis instance for testing
|
||||
* Uses in-memory Map to simulate Redis operations
|
||||
*/
|
||||
export class MockRedis {
|
||||
private store = new Map<string, { value: string; expiry?: number }>();
|
||||
|
||||
get(key: string): Promise<string | null> {
|
||||
const item = this.store.get(key);
|
||||
if (!item) return Promise.resolve(null);
|
||||
|
||||
// Check expiry
|
||||
if (item.expiry && Date.now() > item.expiry) {
|
||||
this.store.delete(key);
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
return Promise.resolve(item.value);
|
||||
}
|
||||
|
||||
set(key: string, value: string): Promise<'OK'> {
|
||||
this.store.set(key, { value });
|
||||
return Promise.resolve('OK');
|
||||
}
|
||||
|
||||
setex(key: string, seconds: number, value: string): Promise<'OK'> {
|
||||
const expiry = Date.now() + seconds * 1000;
|
||||
this.store.set(key, { value, expiry });
|
||||
return Promise.resolve('OK');
|
||||
}
|
||||
|
||||
del(...keys: string[]): Promise<number> {
|
||||
let count = 0;
|
||||
for (const key of keys) {
|
||||
if (this.store.delete(key)) count++;
|
||||
}
|
||||
return Promise.resolve(count);
|
||||
}
|
||||
|
||||
incr(key: string): Promise<number> {
|
||||
const item = this.store.get(key);
|
||||
const current = item ? parseInt(item.value, 10) : 0;
|
||||
const newValue = current + 1;
|
||||
this.store.set(key, { value: String(newValue) });
|
||||
return Promise.resolve(newValue);
|
||||
}
|
||||
|
||||
expire(key: string, seconds: number): Promise<number> {
|
||||
const item = this.store.get(key);
|
||||
if (!item) return Promise.resolve(0);
|
||||
|
||||
const expiry = Date.now() + seconds * 1000;
|
||||
this.store.set(key, { ...item, expiry });
|
||||
return Promise.resolve(1);
|
||||
}
|
||||
|
||||
exists(...keys: string[]): Promise<number> {
|
||||
let count = 0;
|
||||
for (const key of keys) {
|
||||
if (this.store.has(key)) count++;
|
||||
}
|
||||
return Promise.resolve(count);
|
||||
}
|
||||
|
||||
keys(pattern: string): Promise<string[]> {
|
||||
const regex = new RegExp(pattern.replace(/\*/g, '.*'));
|
||||
const matchingKeys = Array.from(this.store.keys()).filter((key) => regex.test(key));
|
||||
return Promise.resolve(matchingKeys);
|
||||
}
|
||||
|
||||
flushall(): Promise<'OK'> {
|
||||
this.store.clear();
|
||||
return Promise.resolve('OK');
|
||||
}
|
||||
|
||||
flushdb(): Promise<'OK'> {
|
||||
this.store.clear();
|
||||
return Promise.resolve('OK');
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all stored data (for test cleanup)
|
||||
*/
|
||||
clear(): void {
|
||||
this.store.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all keys (for debugging)
|
||||
*/
|
||||
getAllKeys(): string[] {
|
||||
return Array.from(this.store.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get store size (for assertions)
|
||||
*/
|
||||
size(): number {
|
||||
return this.store.size;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock Redis instance using vitest-mock-extended
|
||||
* This provides full type safety and spy functionality
|
||||
*/
|
||||
export function createMockRedis(): DeepMockProxy<Redis> {
|
||||
const mock = mockDeep<Redis>();
|
||||
|
||||
// Default implementations
|
||||
mock.get.mockResolvedValue(null);
|
||||
mock.set.mockResolvedValue('OK');
|
||||
mock.setex.mockResolvedValue('OK');
|
||||
mock.del.mockResolvedValue(1);
|
||||
mock.incr.mockResolvedValue(1);
|
||||
mock.expire.mockResolvedValue(1);
|
||||
|
||||
return mock;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a functional mock Redis with in-memory storage
|
||||
* Use this when you need Redis behavior (caching, expiry, etc.)
|
||||
*/
|
||||
export function createFunctionalMockRedis(): MockRedis {
|
||||
return new MockRedis();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset a mock Redis instance
|
||||
*/
|
||||
export function resetMockRedis(redis: DeepMockProxy<Redis>): void {
|
||||
mockReset(redis);
|
||||
|
||||
// Restore default implementations
|
||||
redis.get.mockResolvedValue(null);
|
||||
redis.set.mockResolvedValue('OK');
|
||||
redis.setex.mockResolvedValue('OK');
|
||||
redis.del.mockResolvedValue(1);
|
||||
redis.incr.mockResolvedValue(1);
|
||||
redis.expire.mockResolvedValue(1);
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import {describe, it, expect, beforeEach} from 'vitest';
|
||||
import {ContactService} from '../../apps/api/src/services/ContactService';
|
||||
import {SegmentService} from '../../apps/api/src/services/SegmentService';
|
||||
import {factories, getPrismaClient} from '../helpers';
|
||||
|
||||
/**
|
||||
* Performance tests for cursor-based pagination at scale
|
||||
* Critical for CLAUDE.md requirement: "Database Performance: Queries must be optimized for large datasets (1M+ rows)"
|
||||
*/
|
||||
describe('Performance: Cursor Pagination at Scale', () => {
|
||||
let projectId: string;
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
beforeEach(async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
projectId = project.id;
|
||||
});
|
||||
|
||||
describe('ContactService - Large Dataset Pagination', () => {
|
||||
it('should paginate through 10k contacts without loading all into memory', async () => {
|
||||
// Create 10,000 contacts
|
||||
const CONTACT_COUNT = 10000;
|
||||
await factories.createContacts(projectId, CONTACT_COUNT);
|
||||
|
||||
let totalFetched = 0;
|
||||
let cursor: string | undefined;
|
||||
const pageSize = 1000;
|
||||
|
||||
const initialMemory = process.memoryUsage().heapUsed;
|
||||
|
||||
// Paginate through all contacts using cursor
|
||||
while (true) {
|
||||
const result = await ContactService.list(projectId, pageSize, cursor);
|
||||
totalFetched += result.contacts.length;
|
||||
|
||||
if (!result.hasMore) break;
|
||||
cursor = result.cursor;
|
||||
}
|
||||
|
||||
const finalMemory = process.memoryUsage().heapUsed;
|
||||
const memoryIncrease = (finalMemory - initialMemory) / 1024 / 1024; // MB
|
||||
|
||||
expect(totalFetched).toBe(CONTACT_COUNT);
|
||||
expect(memoryIncrease).toBeLessThan(100); // Max 100MB increase
|
||||
}, 60000); // 60s timeout for large dataset
|
||||
|
||||
it('should return correct hasMore flag at end of dataset', async () => {
|
||||
await factories.createContacts(projectId, 25);
|
||||
|
||||
const pageSize = 10;
|
||||
|
||||
const page1 = await ContactService.list(projectId, pageSize);
|
||||
expect(page1.contacts).toHaveLength(10);
|
||||
expect(page1.hasMore).toBe(true);
|
||||
|
||||
const page2 = await ContactService.list(projectId, pageSize, page1.cursor);
|
||||
expect(page2.contacts).toHaveLength(10);
|
||||
expect(page2.hasMore).toBe(true);
|
||||
|
||||
const page3 = await ContactService.list(projectId, pageSize, page2.cursor);
|
||||
expect(page3.contacts).toHaveLength(5);
|
||||
expect(page3.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle pagination with search filter efficiently', async () => {
|
||||
// Create contacts with specific pattern
|
||||
await factories.createContacts(projectId, 1000);
|
||||
|
||||
// Create 100 contacts with searchable email
|
||||
for (let i = 0; i < 100; i++) {
|
||||
await factories.createContact({
|
||||
projectId,
|
||||
email: `vip${i}@example.com`,
|
||||
});
|
||||
}
|
||||
|
||||
let totalFetched = 0;
|
||||
let cursor: string | undefined;
|
||||
const pageSize = 20;
|
||||
|
||||
// Paginate through filtered results
|
||||
while (true) {
|
||||
const result = await ContactService.list(projectId, pageSize, cursor, 'vip');
|
||||
totalFetched += result.contacts.length;
|
||||
|
||||
if (!result.hasMore) break;
|
||||
cursor = result.cursor;
|
||||
}
|
||||
|
||||
expect(totalFetched).toBe(100);
|
||||
}, 30000);
|
||||
|
||||
it('should only count total on first page for performance', async () => {
|
||||
await factories.createContacts(projectId, 1000);
|
||||
|
||||
const page1 = await ContactService.list(projectId, 100);
|
||||
expect(page1.total).toBeGreaterThan(0);
|
||||
|
||||
const page2 = await ContactService.list(projectId, 100, page1.cursor);
|
||||
expect(page2.total).toBe(0);
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
describe('SegmentService - Large Membership Computation', () => {
|
||||
it('should compute membership for 10k contacts using cursor pagination', async () => {
|
||||
// Create 10,000 contacts (half subscribed)
|
||||
const CONTACT_COUNT = 10000;
|
||||
await factories.createContacts(projectId, CONTACT_COUNT);
|
||||
|
||||
// Create segment tracking subscribed users
|
||||
const segment = await factories.createSegment(projectId, {
|
||||
name: 'Subscribed Users',
|
||||
filters: [{field: 'subscribed', operator: 'equals', value: true}],
|
||||
trackMembership: true,
|
||||
});
|
||||
|
||||
const initialMemory = process.memoryUsage().heapUsed;
|
||||
|
||||
const result = await SegmentService.computeMembership(projectId, segment.id);
|
||||
|
||||
const finalMemory = process.memoryUsage().heapUsed;
|
||||
const memoryIncrease = (finalMemory - initialMemory) / 1024 / 1024; // MB
|
||||
|
||||
expect(result.total).toBeGreaterThan(0);
|
||||
expect(result.total).toBeLessThanOrEqual(CONTACT_COUNT);
|
||||
|
||||
// Should not load all contacts into memory
|
||||
expect(memoryIncrease).toBeLessThan(150); // Max 150MB increase
|
||||
}, 120000); // 120s timeout
|
||||
|
||||
it('should batch membership additions in chunks of 500', async () => {
|
||||
// Create 2000 contacts
|
||||
await factories.createContacts(projectId, 2000);
|
||||
|
||||
const segment = await factories.createSegment(projectId, {
|
||||
filters: [{field: 'subscribed', operator: 'equals', value: true}],
|
||||
trackMembership: true,
|
||||
});
|
||||
|
||||
await SegmentService.computeMembership(projectId, segment.id);
|
||||
|
||||
const memberships = await prisma.segmentMembership.count({
|
||||
where: {segmentId: segment.id},
|
||||
});
|
||||
|
||||
expect(memberships).toBeGreaterThan(0);
|
||||
}, 60000);
|
||||
|
||||
it('should handle membership removal efficiently when contacts no longer match', async () => {
|
||||
// Create 1000 subscribed contacts
|
||||
const contacts = [];
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const contact = await factories.createContact({
|
||||
projectId,
|
||||
subscribed: true,
|
||||
});
|
||||
contacts.push(contact);
|
||||
}
|
||||
|
||||
const segment = await factories.createSegment(projectId, {
|
||||
filters: [{field: 'subscribed', operator: 'equals', value: true}],
|
||||
trackMembership: true,
|
||||
});
|
||||
|
||||
// Initial computation
|
||||
const initial = await SegmentService.computeMembership(projectId, segment.id);
|
||||
expect(initial.added).toBe(1000);
|
||||
|
||||
// Unsubscribe half the contacts
|
||||
for (let i = 0; i < 500; i++) {
|
||||
await prisma.contact.update({
|
||||
where: {id: contacts[i].id},
|
||||
data: {subscribed: false},
|
||||
});
|
||||
}
|
||||
|
||||
// Recompute - should remove 500
|
||||
const updated = await SegmentService.computeMembership(projectId, segment.id);
|
||||
expect(updated.removed).toBe(500);
|
||||
expect(updated.total).toBe(500);
|
||||
}, 90000);
|
||||
});
|
||||
|
||||
describe('SegmentService - Query Performance', () => {
|
||||
it('should get contacts in segment under 200ms for 10k dataset', async () => {
|
||||
// Per CLAUDE.md: "API Response Times: Target < 200ms for read operations"
|
||||
await factories.createContacts(projectId, 10000);
|
||||
|
||||
const segment = await factories.createSegment(projectId, {
|
||||
filters: [{field: 'subscribed', operator: 'equals', value: true}],
|
||||
});
|
||||
|
||||
const start = Date.now();
|
||||
const result = await SegmentService.getContacts(projectId, segment.id, 1, 20);
|
||||
const duration = Date.now() - start;
|
||||
|
||||
expect(result.contacts.length).toBeLessThanOrEqual(20);
|
||||
expect(duration).toBeLessThan(200); // < 200ms target
|
||||
}, 30000);
|
||||
|
||||
it('should handle offset pagination without OOM for large results', async () => {
|
||||
await factories.createContacts(projectId, 5000);
|
||||
|
||||
const segment = await factories.createSegment(projectId, {
|
||||
filters: [{field: 'subscribed', operator: 'equals', value: true}],
|
||||
});
|
||||
|
||||
const page1 = await SegmentService.getContacts(projectId, segment.id, 1, 100);
|
||||
const page10 = await SegmentService.getContacts(projectId, segment.id, 10, 100);
|
||||
const page50 = await SegmentService.getContacts(projectId, segment.id, 50, 100);
|
||||
|
||||
expect(page1.contacts.length).toBeLessThanOrEqual(100);
|
||||
expect(page10.contacts.length).toBeLessThanOrEqual(100);
|
||||
expect(page50.contacts.length).toBeLessThanOrEqual(100);
|
||||
}, 45000);
|
||||
});
|
||||
|
||||
describe('Memory Efficiency Validation', () => {
|
||||
it('should not accumulate memory when paginating repeatedly', async () => {
|
||||
await factories.createContacts(projectId, 5000);
|
||||
|
||||
const measureMemory = () => {
|
||||
if (global.gc) global.gc(); // Force GC if available
|
||||
return process.memoryUsage().heapUsed / 1024 / 1024; // MB
|
||||
};
|
||||
|
||||
const initialMemory = measureMemory();
|
||||
|
||||
// Paginate 10 times
|
||||
for (let i = 0; i < 10; i++) {
|
||||
let cursor: string | undefined;
|
||||
while (true) {
|
||||
const result = await ContactService.list(projectId, 500, cursor);
|
||||
if (!result.hasMore) break;
|
||||
cursor = result.cursor;
|
||||
}
|
||||
}
|
||||
|
||||
const finalMemory = measureMemory();
|
||||
const memoryGrowth = finalMemory - initialMemory;
|
||||
|
||||
// Memory should not grow significantly with repeated pagination
|
||||
expect(memoryGrowth).toBeLessThan(50); // Max 50MB growth
|
||||
}, 90000);
|
||||
});
|
||||
|
||||
describe('Pagination Edge Cases', () => {
|
||||
it('should handle empty dataset gracefully', async () => {
|
||||
const result = await ContactService.list(projectId, 20);
|
||||
|
||||
expect(result.contacts).toHaveLength(0);
|
||||
expect(result.hasMore).toBe(false);
|
||||
expect(result.cursor).toBeUndefined();
|
||||
expect(result.total).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle dataset smaller than page size', async () => {
|
||||
await factories.createContacts(projectId, 5);
|
||||
|
||||
const result = await ContactService.list(projectId, 20);
|
||||
|
||||
expect(result.contacts).toHaveLength(5);
|
||||
expect(result.hasMore).toBe(false);
|
||||
expect(result.cursor).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle exact page size boundary', async () => {
|
||||
await factories.createContacts(projectId, 20);
|
||||
|
||||
const result = await ContactService.list(projectId, 20);
|
||||
|
||||
expect(result.contacts).toHaveLength(20);
|
||||
expect(result.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle one item over page size', async () => {
|
||||
await factories.createContacts(projectId, 21);
|
||||
|
||||
const page1 = await ContactService.list(projectId, 20);
|
||||
|
||||
expect(page1.contacts).toHaveLength(20);
|
||||
expect(page1.hasMore).toBe(true);
|
||||
|
||||
const page2 = await ContactService.list(projectId, 20, page1.cursor);
|
||||
|
||||
expect(page2.contacts).toHaveLength(1);
|
||||
expect(page2.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle concurrent pagination requests', async () => {
|
||||
await factories.createContacts(projectId, 1000);
|
||||
|
||||
// Multiple workers paginating simultaneously
|
||||
const promises = Array.from({length: 5}, async () => {
|
||||
let totalFetched = 0;
|
||||
let cursor: string | undefined;
|
||||
|
||||
while (true) {
|
||||
const result = await ContactService.list(projectId, 100, cursor);
|
||||
totalFetched += result.contacts.length;
|
||||
if (!result.hasMore) break;
|
||||
cursor = result.cursor;
|
||||
}
|
||||
|
||||
return totalFetched;
|
||||
});
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
// All should fetch the same total
|
||||
expect(results.every(count => count === results[0])).toBe(true);
|
||||
expect(results[0]).toBe(1000);
|
||||
}, 30000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,528 @@
|
||||
import {describe, it, expect, beforeEach} from 'vitest';
|
||||
import {WorkflowStepType, WorkflowExecutionStatus} from '@plunk/db';
|
||||
import {factories, getPrismaClient} from '../helpers';
|
||||
|
||||
/**
|
||||
* Performance Tests: Workflow Execution at Scale
|
||||
*
|
||||
* These tests verify that workflow operations perform efficiently
|
||||
* even with large numbers of executions and complex workflows.
|
||||
*
|
||||
* Performance Targets (from CLAUDE.md):
|
||||
* - Read operations: < 200ms
|
||||
* - Write operations: < 500ms
|
||||
* - Background jobs for bulk operations
|
||||
*/
|
||||
describe('Performance: Workflow Execution at Scale', () => {
|
||||
let projectId: string;
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
beforeEach(async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
projectId = project.id;
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// WORKFLOW EXECUTION RETRIEVAL
|
||||
// ========================================
|
||||
describe('Workflow Execution Queries', () => {
|
||||
it('should retrieve workflow executions under 200ms for 1000+ executions', async () => {
|
||||
const workflow = await factories.createWorkflow({projectId});
|
||||
const contacts = await factories.createContacts(projectId, 1000);
|
||||
|
||||
// Create 1000 executions
|
||||
await Promise.all(contacts.map(contact => factories.createWorkflowExecution(workflow.id, contact.id)));
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
const executions = await prisma.workflowExecution.findMany({
|
||||
where: {workflowId: workflow.id},
|
||||
take: 20, // Paginated query
|
||||
include: {
|
||||
contact: {
|
||||
select: {email: true},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
expect(executions).toHaveLength(20);
|
||||
expect(duration).toBeLessThan(200); // < 200ms target
|
||||
});
|
||||
|
||||
it('should filter running executions efficiently', async () => {
|
||||
const workflow = await factories.createWorkflow({projectId});
|
||||
const contacts = await factories.createContacts(projectId, 500);
|
||||
|
||||
// Create mix of statuses
|
||||
await Promise.all(
|
||||
contacts.slice(0, 250).map(contact =>
|
||||
factories.createWorkflowExecution(workflow.id, contact.id, {
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
contacts.slice(250).map(contact =>
|
||||
factories.createWorkflowExecution(workflow.id, contact.id, {
|
||||
status: WorkflowExecutionStatus.COMPLETED,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
const running = await prisma.workflowExecution.findMany({
|
||||
where: {
|
||||
workflowId: workflow.id,
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
},
|
||||
take: 20,
|
||||
});
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
expect(running).toHaveLength(20);
|
||||
expect(running.every(e => e.status === WorkflowExecutionStatus.RUNNING)).toBe(true);
|
||||
expect(duration).toBeLessThan(200);
|
||||
});
|
||||
|
||||
it('should count executions efficiently without loading all into memory', async () => {
|
||||
const workflow = await factories.createWorkflow({projectId});
|
||||
const contacts = await factories.createContacts(projectId, 2000);
|
||||
|
||||
await Promise.all(contacts.map(contact => factories.createWorkflowExecution(workflow.id, contact.id)));
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
const count = await prisma.workflowExecution.count({
|
||||
where: {workflowId: workflow.id},
|
||||
});
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
expect(count).toBe(2000);
|
||||
expect(duration).toBeLessThan(200);
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// COMPLEX WORKFLOW QUERIES
|
||||
// ========================================
|
||||
describe('Complex Workflow Structures', () => {
|
||||
it('should handle workflows with many steps efficiently', async () => {
|
||||
const workflow = await factories.createWorkflow({projectId});
|
||||
|
||||
// Create workflow with 20 steps
|
||||
const steps = await Promise.all(
|
||||
Array.from({length: 20}, (_, i) =>
|
||||
factories.createWorkflowStep({
|
||||
workflowId: workflow.id,
|
||||
name: `Step ${i + 1}`,
|
||||
type: i % 2 === 0 ? WorkflowStepType.SEND_EMAIL : WorkflowStepType.DELAY,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// Create transitions (linear chain)
|
||||
for (let i = 0; i < steps.length - 1; i++) {
|
||||
await prisma.workflowTransition.create({
|
||||
data: {
|
||||
fromStepId: steps[i].id,
|
||||
toStepId: steps[i + 1].id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
const retrieved = await prisma.workflow.findUnique({
|
||||
where: {id: workflow.id},
|
||||
include: {
|
||||
steps: {
|
||||
include: {
|
||||
outgoingTransitions: true,
|
||||
incomingTransitions: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
expect(retrieved?.steps.length).toBeGreaterThanOrEqual(20); // At least 20 steps
|
||||
expect(duration).toBeLessThan(200);
|
||||
});
|
||||
|
||||
it('should handle branching workflows (CONDITION steps) efficiently', async () => {
|
||||
const workflow = await factories.createWorkflow({projectId});
|
||||
|
||||
// Create condition step
|
||||
const conditionStep = await factories.createWorkflowStep({
|
||||
workflowId: workflow.id,
|
||||
type: WorkflowStepType.CONDITION,
|
||||
});
|
||||
|
||||
// Create branches (yes/no paths with multiple steps each)
|
||||
const yesSteps = await Promise.all(
|
||||
Array.from({length: 5}, () =>
|
||||
factories.createWorkflowStep({
|
||||
workflowId: workflow.id,
|
||||
type: WorkflowStepType.SEND_EMAIL,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const noSteps = await Promise.all(
|
||||
Array.from({length: 5}, () =>
|
||||
factories.createWorkflowStep({
|
||||
workflowId: workflow.id,
|
||||
type: WorkflowStepType.DELAY,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// Create transitions
|
||||
await prisma.workflowTransition.create({
|
||||
data: {
|
||||
fromStepId: conditionStep.id,
|
||||
toStepId: yesSteps[0].id,
|
||||
condition: {branch: 'yes'},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.workflowTransition.create({
|
||||
data: {
|
||||
fromStepId: conditionStep.id,
|
||||
toStepId: noSteps[0].id,
|
||||
condition: {branch: 'no'},
|
||||
},
|
||||
});
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
const retrieved = await prisma.workflow.findUnique({
|
||||
where: {id: workflow.id},
|
||||
include: {
|
||||
steps: {
|
||||
include: {
|
||||
outgoingTransitions: {
|
||||
include: {
|
||||
toStep: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
expect(retrieved?.steps.length).toBeGreaterThan(10);
|
||||
expect(duration).toBeLessThan(200);
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// RE-ENTRY CHECKS
|
||||
// ========================================
|
||||
describe('Re-Entry Check Performance', () => {
|
||||
it('should check for existing executions efficiently (allowReentry=false)', async () => {
|
||||
const workflow = await factories.createWorkflow({
|
||||
projectId,
|
||||
allowReentry: false,
|
||||
});
|
||||
|
||||
const contacts = await factories.createContacts(projectId, 100);
|
||||
|
||||
// Create executions for all contacts
|
||||
await Promise.all(contacts.map(contact => factories.createWorkflowExecution(workflow.id, contact.id)));
|
||||
|
||||
// Measure time to check for existing execution
|
||||
const testContact = contacts[50];
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
const existingExecution = await prisma.workflowExecution.findFirst({
|
||||
where: {
|
||||
workflowId: workflow.id,
|
||||
contactId: testContact.id,
|
||||
},
|
||||
});
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
expect(existingExecution).toBeDefined();
|
||||
expect(duration).toBeLessThan(50); // Should be very fast with proper index
|
||||
});
|
||||
|
||||
it('should check for running executions efficiently (allowReentry=true)', async () => {
|
||||
const workflow = await factories.createWorkflow({
|
||||
projectId,
|
||||
allowReentry: true,
|
||||
});
|
||||
|
||||
const contacts = await factories.createContacts(projectId, 200);
|
||||
|
||||
// Create mix of running and completed executions
|
||||
await Promise.all(
|
||||
contacts.slice(0, 100).map(contact =>
|
||||
factories.createWorkflowExecution(workflow.id, contact.id, {
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
contacts.slice(100).map(contact =>
|
||||
factories.createWorkflowExecution(workflow.id, contact.id, {
|
||||
status: WorkflowExecutionStatus.COMPLETED,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const testContact = contacts[50];
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
const runningExecution = await prisma.workflowExecution.findFirst({
|
||||
where: {
|
||||
workflowId: workflow.id,
|
||||
contactId: testContact.id,
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
},
|
||||
});
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
expect(runningExecution).toBeDefined();
|
||||
expect(duration).toBeLessThan(50);
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// BULK OPERATIONS
|
||||
// ========================================
|
||||
describe('Bulk Workflow Operations', () => {
|
||||
it('should create multiple executions efficiently', async () => {
|
||||
const workflow = await factories.createWorkflow({projectId});
|
||||
const contacts = await factories.createContacts(projectId, 500);
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
// Get the trigger step (created automatically by factory)
|
||||
const triggerStep = await prisma.workflowStep.findFirst({
|
||||
where: {workflowId: workflow.id, type: WorkflowStepType.TRIGGER},
|
||||
});
|
||||
|
||||
// Batch create executions
|
||||
const executions = await prisma.workflowExecution.createMany({
|
||||
data: contacts.map(contact => ({
|
||||
workflowId: workflow.id,
|
||||
contactId: contact.id,
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
currentStepId: triggerStep!.id,
|
||||
})),
|
||||
});
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
expect(executions.count).toBe(500);
|
||||
expect(duration).toBeLessThan(500); // < 500ms for bulk write
|
||||
});
|
||||
|
||||
it('should cancel multiple executions efficiently', async () => {
|
||||
const workflow = await factories.createWorkflow({projectId});
|
||||
const contacts = await factories.createContacts(projectId, 300);
|
||||
|
||||
await Promise.all(
|
||||
contacts.map(contact =>
|
||||
factories.createWorkflowExecution(workflow.id, contact.id, {
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
// Bulk cancel all running executions
|
||||
const result = await prisma.workflowExecution.updateMany({
|
||||
where: {
|
||||
workflowId: workflow.id,
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
},
|
||||
data: {
|
||||
status: WorkflowExecutionStatus.CANCELLED,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
expect(result.count).toBe(300);
|
||||
expect(duration).toBeLessThan(500);
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// STEP EXECUTION PERFORMANCE
|
||||
// ========================================
|
||||
describe('Step Execution Tracking', () => {
|
||||
it('should create step executions efficiently', async () => {
|
||||
const workflow = await factories.createWorkflow({projectId});
|
||||
const contact = await factories.createContact({projectId});
|
||||
const execution = await factories.createWorkflowExecution(workflow.id, contact.id);
|
||||
|
||||
const steps = await Promise.all(
|
||||
Array.from({length: 10}, () =>
|
||||
factories.createWorkflowStep({
|
||||
workflowId: workflow.id,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
// Create step executions for all steps
|
||||
await prisma.workflowStepExecution.createMany({
|
||||
data: steps.map(step => ({
|
||||
executionId: execution.id,
|
||||
stepId: step.id,
|
||||
status: 'COMPLETED',
|
||||
output: null,
|
||||
})),
|
||||
});
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
expect(duration).toBeLessThan(200);
|
||||
});
|
||||
|
||||
it('should retrieve execution history with steps efficiently', async () => {
|
||||
const workflow = await factories.createWorkflow({projectId});
|
||||
const contact = await factories.createContact({projectId});
|
||||
const execution = await factories.createWorkflowExecution(workflow.id, contact.id);
|
||||
|
||||
// Create 20 step executions
|
||||
const steps = await Promise.all(
|
||||
Array.from({length: 20}, () =>
|
||||
factories.createWorkflowStep({
|
||||
workflowId: workflow.id,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
steps.map(step =>
|
||||
prisma.workflowStepExecution.create({
|
||||
data: {
|
||||
executionId: execution.id,
|
||||
stepId: step.id,
|
||||
status: 'COMPLETED',
|
||||
output: null,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
const retrieved = await prisma.workflowExecution.findUnique({
|
||||
where: {id: execution.id},
|
||||
include: {
|
||||
stepExecutions: {
|
||||
include: {
|
||||
step: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
expect(retrieved?.stepExecutions).toHaveLength(20);
|
||||
expect(duration).toBeLessThan(200);
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// MEMORY EFFICIENCY
|
||||
// ========================================
|
||||
describe('Memory Efficiency', () => {
|
||||
it('should not load all executions when counting', async () => {
|
||||
const workflow = await factories.createWorkflow({projectId});
|
||||
const contacts = await factories.createContacts(projectId, 5000);
|
||||
|
||||
// Create 5000 executions
|
||||
await prisma.workflowExecution.createMany({
|
||||
data: contacts.map(contact => ({
|
||||
workflowId: workflow.id,
|
||||
contactId: contact.id,
|
||||
status: WorkflowExecutionStatus.COMPLETED,
|
||||
})),
|
||||
});
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
// Count without loading
|
||||
const count = await prisma.workflowExecution.count({
|
||||
where: {workflowId: workflow.id},
|
||||
});
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
expect(count).toBe(5000);
|
||||
expect(duration).toBeLessThan(200);
|
||||
});
|
||||
|
||||
it('should paginate large result sets without OOM', async () => {
|
||||
const workflow = await factories.createWorkflow({projectId});
|
||||
const contacts = await factories.createContacts(projectId, 1000);
|
||||
|
||||
await prisma.workflowExecution.createMany({
|
||||
data: contacts.map(contact => ({
|
||||
workflowId: workflow.id,
|
||||
contactId: contact.id,
|
||||
status: WorkflowExecutionStatus.COMPLETED,
|
||||
})),
|
||||
});
|
||||
|
||||
// Paginate through all results
|
||||
let cursor: string | undefined;
|
||||
let totalFetched = 0;
|
||||
const pageSize = 100;
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
while (true) {
|
||||
const page = await prisma.workflowExecution.findMany({
|
||||
where: {workflowId: workflow.id},
|
||||
take: pageSize + 1,
|
||||
...(cursor ? {skip: 1, cursor: {id: cursor}} : {}),
|
||||
orderBy: {createdAt: 'asc'},
|
||||
});
|
||||
|
||||
if (page.length === 0) break;
|
||||
|
||||
const hasMore = page.length > pageSize;
|
||||
const items = hasMore ? page.slice(0, -1) : page;
|
||||
|
||||
totalFetched += items.length;
|
||||
|
||||
if (!hasMore) break;
|
||||
|
||||
cursor = page[page.length - 1].id;
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
expect(totalFetched).toBeGreaterThanOrEqual(1000); // At least 1000, may include other test data
|
||||
expect(duration).toBeLessThan(2000); // Should complete in reasonable time
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { beforeAll, afterAll, afterEach, vi } from 'vitest';
|
||||
import { testDatabase } from './helpers/database';
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
|
||||
// Load environment variables from root .env file
|
||||
dotenv.config({ path: path.resolve(__dirname, '../.env') });
|
||||
|
||||
// Global test setup
|
||||
beforeAll(async () => {
|
||||
// Initialize test database
|
||||
await testDatabase.initialize();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clear all mocks first
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Restore real timers
|
||||
vi.useRealTimers();
|
||||
|
||||
// Clean up database after each test
|
||||
// This must be last to ensure proper cleanup order
|
||||
await testDatabase.cleanup();
|
||||
|
||||
// Force garbage collection hint (if available in test environment)
|
||||
if (global.gc) {
|
||||
global.gc();
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Disconnect from database
|
||||
await testDatabase.disconnect();
|
||||
});
|
||||
|
||||
// Set test environment variables
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-jwt-secret-key-for-testing';
|
||||
Reference in New Issue
Block a user