durabull
Version:
A durable workflow engine built on top of BullMQ and Redis
261 lines (212 loc) • 8.17 kB
text/typescript
/*
* Integration tests for production features using real Redis and workers
* These tests require a running Redis instance
*/
import {
Durabull,
WorkflowStub,
ActivityStub,
Workflow,
Activity,
startWorkflowWorker,
startActivityWorker,
} from '../../src';
import { closeQueues } from '../../src/queues';
import { closeStorage } from '../../src/runtime/storage';
import { Worker } from 'bullmq';
jest.setTimeout(60000);
let workflowWorker: Worker;
let activityWorker: Worker;
let durabull: Durabull;
const testQueuePrefix = `test-integration-${Date.now()}`;
const lifecycleEvents: Array<{ type: string; id: string; name: string }> = [];
const noop = () => {};
beforeAll(async () => {
const redisUrl = process.env.REDIS_URL || 'redis://redis:6379';
durabull = new Durabull({
redisUrl,
queues: {
workflow: `${testQueuePrefix}-workflow`,
activity: `${testQueuePrefix}-activity`,
},
logger: {
info: noop,
warn: noop,
error: noop,
debug: noop,
},
lifecycleHooks: {
workflow: {
onStart: async (id, name) => {
lifecycleEvents.push({ type: 'workflow:start', id, name });
},
onComplete: async (id, name) => {
lifecycleEvents.push({ type: 'workflow:complete', id, name });
},
onFailed: async (id, name) => {
lifecycleEvents.push({ type: 'workflow:failed', id, name });
},
},
activity: {
onStart: async (_workflowId, activityId, activityName) => {
lifecycleEvents.push({ type: 'activity:start', id: activityId, name: activityName });
},
onComplete: async (_workflowId, activityId, activityName) => {
lifecycleEvents.push({ type: 'activity:complete', id: activityId, name: activityName });
},
onFailed: async (_workflowId, activityId, activityName, _error) => {
lifecycleEvents.push({ type: 'activity:failed', id: activityId, name: activityName });
},
},
},
});
durabull.setActive();
workflowWorker = startWorkflowWorker(durabull);
activityWorker = startActivityWorker(durabull);
await new Promise((resolve) => setTimeout(resolve, 1000));
});
afterAll(async () => {
if (workflowWorker) {
await workflowWorker.close();
}
if (activityWorker) {
await activityWorker.close();
}
await new Promise((resolve) => setTimeout(resolve, 1000));
await closeQueues();
await closeStorage();
await new Promise((resolve) => setTimeout(resolve, 1000));
});
describe('Integration: String-Based Workflow Dispatch with Workers', () => {
class IntegrationTestWorkflow extends Workflow<[string], string> {
async *execute(input: string): AsyncGenerator<unknown, string, unknown> {
const result = yield ActivityStub.make('IntegrationTestActivity', input);
return `workflow: ${result}`;
}
}
class IntegrationTestActivity extends Activity<[string], string> {
tries = 2;
async execute(input: string): Promise<string> {
return `activity: ${input}`;
}
}
beforeAll(() => {
durabull.registerWorkflow('IntegrationTestWorkflow', IntegrationTestWorkflow);
durabull.registerActivity('IntegrationTestActivity', IntegrationTestActivity);
});
it('should execute workflow end-to-end with string-based dispatch', async () => {
const wf = await WorkflowStub.make('IntegrationTestWorkflow');
await wf.start('test-input');
let attempts = 0;
while ((await wf.status()) !== 'completed' && (await wf.status()) !== 'failed' && attempts < 50) {
await new Promise((resolve) => setTimeout(resolve, 200));
attempts++;
}
expect(await wf.status()).toBe('completed');
expect(await wf.output()).toBe('workflow: activity: test-input');
});
it('should support loading workflow by ID', async () => {
const wf = await WorkflowStub.make('IntegrationTestWorkflow', { id: 'test-load-123' });
await wf.start('load-test');
let attempts = 0;
while ((await wf.status()) !== 'completed' && attempts < 50) {
await new Promise((resolve) => setTimeout(resolve, 200));
attempts++;
}
const loaded = await WorkflowStub.load('test-load-123');
expect(loaded.id()).toBe('test-load-123');
expect(await loaded.status()).toBe('completed');
expect(await loaded.output()).toBe('workflow: activity: load-test');
});
});
describe('Integration: Lifecycle Hooks with Workers', () => {
class HookTestWorkflow extends Workflow<[string], string> {
async *execute(input: string): AsyncGenerator<unknown, string, unknown> {
if (input === 'fail') {
throw new Error('Intentional failure');
}
const result = yield ActivityStub.make('HookTestActivity', input);
return result as string;
}
}
class HookTestActivity extends Activity<[string], string> {
async execute(input: string): Promise<string> {
return `activity: ${input}`;
}
}
beforeAll(() => {
durabull.registerWorkflow('HookTestWorkflow', HookTestWorkflow);
durabull.registerActivity('HookTestActivity', HookTestActivity);
});
it('should trigger lifecycle hooks for successful workflow', async () => {
const initialEvents = lifecycleEvents.length;
const wf = await WorkflowStub.make('HookTestWorkflow');
await wf.start('success');
let attempts = 0;
while ((await wf.status()) !== 'completed' && attempts < 50) {
await new Promise((resolve) => setTimeout(resolve, 200));
attempts++;
}
expect(await wf.status()).toBe('completed');
await new Promise((resolve) => setTimeout(resolve, 1000));
const newEvents = lifecycleEvents.slice(initialEvents);
expect(newEvents.some((e) => e.type === 'workflow:start')).toBe(true);
expect(newEvents.some((e) => e.type === 'activity:start')).toBe(true);
expect(newEvents.some((e) => e.type === 'activity:complete')).toBe(true);
expect(newEvents.some((e) => e.type === 'workflow:complete')).toBe(true);
});
it('should trigger onFailed hook for failed workflow', async () => {
const initialEvents = lifecycleEvents.length;
const wf = await WorkflowStub.make('HookTestWorkflow');
await wf.start('fail');
let attempts = 0;
while ((await wf.status()) !== 'failed' && attempts < 50) {
await new Promise((resolve) => setTimeout(resolve, 200));
attempts++;
}
expect(await wf.status()).toBe('failed');
await new Promise((resolve) => setTimeout(resolve, 500));
const newEvents = lifecycleEvents.slice(initialEvents);
expect(newEvents.some((e) => e.type === 'workflow:start')).toBe(true);
expect(newEvents.some((e) => e.type === 'workflow:failed')).toBe(true);
});
});
describe('Integration: Per-Invocation Retry Overrides', () => {
let attemptCount = 0;
class RetryOverrideWorkflow extends Workflow<[number], string> {
async *execute(maxRetries: number): AsyncGenerator<unknown, string, unknown> {
const result = yield ActivityStub.make('RetryOverrideActivity', maxRetries, {
__options: {
tries: maxRetries,
}
});
return result as string;
}
}
class RetryOverrideActivity extends Activity<[number], string> {
tries = 1;
async execute(maxRetries: number): Promise<string> {
attemptCount++;
if (attemptCount < maxRetries) {
throw new Error('Retry needed');
}
return `succeeded after ${attemptCount} attempts`;
}
}
beforeAll(() => {
durabull.registerWorkflow('RetryOverrideWorkflow', RetryOverrideWorkflow);
durabull.registerActivity('RetryOverrideActivity', RetryOverrideActivity);
});
it('should respect per-invocation retry override', async () => {
attemptCount = 0;
const wf = await WorkflowStub.make('RetryOverrideWorkflow');
await wf.start(3);
let attempts = 0;
while ((await wf.status()) !== 'completed' && attempts < 100) {
await new Promise((resolve) => setTimeout(resolve, 200));
attempts++;
}
expect(await wf.status()).toBe('completed');
expect(await wf.output()).toBe('succeeded after 3 attempts');
});
});