@ -0,0 +1,417 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { BILLING_FEATURE_USED } from 'src/engine/core-modules/billing/constants/billing-feature-used.constant';
|
||||
import { BillingMeterEventName } from 'src/engine/core-modules/billing/enums/billing-meter-event-names';
|
||||
import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory';
|
||||
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
|
||||
import { WorkflowExecutorFactory } from 'src/modules/workflow/workflow-executor/factories/workflow-executor.factory';
|
||||
import {
|
||||
WorkflowAction,
|
||||
WorkflowActionType,
|
||||
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
import { WorkflowExecutorWorkspaceService } from 'src/modules/workflow/workflow-executor/workspace-services/workflow-executor.workspace-service';
|
||||
import { WorkflowRunWorkspaceService } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run.workspace-service';
|
||||
|
||||
describe('WorkflowExecutorWorkspaceService', () => {
|
||||
let service: WorkflowExecutorWorkspaceService;
|
||||
let workflowExecutorFactory: WorkflowExecutorFactory;
|
||||
let workspaceEventEmitter: WorkspaceEventEmitter;
|
||||
let scopedWorkspaceContextFactory: ScopedWorkspaceContextFactory;
|
||||
let workflowRunWorkspaceService: WorkflowRunWorkspaceService;
|
||||
|
||||
const mockWorkflowExecutor = {
|
||||
execute: jest.fn().mockResolvedValue({ result: { success: true } }),
|
||||
};
|
||||
|
||||
const mockWorkspaceEventEmitter = {
|
||||
emitCustomBatchEvent: jest.fn(),
|
||||
};
|
||||
|
||||
const mockScopedWorkspaceContext = {
|
||||
workspaceId: 'workspace-id',
|
||||
};
|
||||
|
||||
const mockScopedWorkspaceContextFactory = {
|
||||
create: jest.fn().mockReturnValue(mockScopedWorkspaceContext),
|
||||
};
|
||||
|
||||
const mockWorkflowRunWorkspaceService = {
|
||||
saveWorkflowRunState: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
WorkflowExecutorWorkspaceService,
|
||||
{
|
||||
provide: WorkflowExecutorFactory,
|
||||
useValue: {
|
||||
get: jest.fn().mockReturnValue(mockWorkflowExecutor),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: WorkspaceEventEmitter,
|
||||
useValue: mockWorkspaceEventEmitter,
|
||||
},
|
||||
{
|
||||
provide: ScopedWorkspaceContextFactory,
|
||||
useValue: mockScopedWorkspaceContextFactory,
|
||||
},
|
||||
{
|
||||
provide: WorkflowRunWorkspaceService,
|
||||
useValue: mockWorkflowRunWorkspaceService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<WorkflowExecutorWorkspaceService>(
|
||||
WorkflowExecutorWorkspaceService,
|
||||
);
|
||||
workflowExecutorFactory = module.get<WorkflowExecutorFactory>(
|
||||
WorkflowExecutorFactory,
|
||||
);
|
||||
workspaceEventEmitter = module.get<WorkspaceEventEmitter>(
|
||||
WorkspaceEventEmitter,
|
||||
);
|
||||
scopedWorkspaceContextFactory = module.get<ScopedWorkspaceContextFactory>(
|
||||
ScopedWorkspaceContextFactory,
|
||||
);
|
||||
workflowRunWorkspaceService = module.get<WorkflowRunWorkspaceService>(
|
||||
WorkflowRunWorkspaceService,
|
||||
);
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
const mockWorkflowRunId = 'workflow-run-id';
|
||||
const mockContext = { data: 'some-data' };
|
||||
const mockSteps = [
|
||||
{
|
||||
id: 'step-1',
|
||||
type: WorkflowActionType.CODE,
|
||||
settings: {
|
||||
errorHandlingOptions: {
|
||||
continueOnFailure: { value: false },
|
||||
retryOnFailure: { value: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'step-2',
|
||||
type: WorkflowActionType.SEND_EMAIL,
|
||||
settings: {
|
||||
errorHandlingOptions: {
|
||||
continueOnFailure: { value: false },
|
||||
retryOnFailure: { value: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
] as WorkflowAction[];
|
||||
|
||||
it('should return success when all steps are completed', async () => {
|
||||
// No steps to execute
|
||||
const result = await service.execute({
|
||||
workflowRunId: mockWorkflowRunId,
|
||||
currentStepIndex: 2,
|
||||
steps: mockSteps,
|
||||
context: mockContext,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
result: {
|
||||
success: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should execute a step and continue to the next step on success', async () => {
|
||||
const mockStepResult = {
|
||||
result: { stepOutput: 'success' },
|
||||
};
|
||||
|
||||
mockWorkflowExecutor.execute.mockResolvedValueOnce(mockStepResult);
|
||||
|
||||
const result = await service.execute({
|
||||
workflowRunId: mockWorkflowRunId,
|
||||
currentStepIndex: 0,
|
||||
steps: mockSteps,
|
||||
context: mockContext,
|
||||
});
|
||||
|
||||
// execute first step
|
||||
expect(workflowExecutorFactory.get).toHaveBeenCalledWith(
|
||||
WorkflowActionType.CODE,
|
||||
);
|
||||
expect(mockWorkflowExecutor.execute).toHaveBeenCalledWith({
|
||||
workflowRunId: mockWorkflowRunId,
|
||||
currentStepIndex: 0,
|
||||
steps: mockSteps,
|
||||
context: mockContext,
|
||||
attemptCount: 1,
|
||||
});
|
||||
expect(workspaceEventEmitter.emitCustomBatchEvent).toHaveBeenCalledWith(
|
||||
BILLING_FEATURE_USED,
|
||||
[
|
||||
{
|
||||
eventName: BillingMeterEventName.WORKFLOW_NODE_RUN,
|
||||
value: 1,
|
||||
},
|
||||
],
|
||||
'workspace-id',
|
||||
);
|
||||
expect(
|
||||
workflowRunWorkspaceService.saveWorkflowRunState,
|
||||
).toHaveBeenCalledWith({
|
||||
workflowRunId: mockWorkflowRunId,
|
||||
stepOutput: {
|
||||
id: 'step-1',
|
||||
output: mockStepResult,
|
||||
},
|
||||
context: {
|
||||
data: 'some-data',
|
||||
'step-1': { stepOutput: 'success' },
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({ result: { success: true } });
|
||||
|
||||
// execute second step
|
||||
expect(workflowExecutorFactory.get).toHaveBeenCalledWith(
|
||||
WorkflowActionType.SEND_EMAIL,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle step execution errors', async () => {
|
||||
mockWorkflowExecutor.execute.mockRejectedValueOnce(
|
||||
new Error('Step execution failed'),
|
||||
);
|
||||
|
||||
const result = await service.execute({
|
||||
workflowRunId: mockWorkflowRunId,
|
||||
currentStepIndex: 0,
|
||||
steps: mockSteps,
|
||||
context: mockContext,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
error: 'Step execution failed',
|
||||
});
|
||||
expect(workspaceEventEmitter.emitCustomBatchEvent).not.toHaveBeenCalled();
|
||||
expect(
|
||||
workflowRunWorkspaceService.saveWorkflowRunState,
|
||||
).toHaveBeenCalledWith({
|
||||
workflowRunId: mockWorkflowRunId,
|
||||
stepOutput: {
|
||||
id: 'step-1',
|
||||
output: {
|
||||
error: 'Step execution failed',
|
||||
},
|
||||
},
|
||||
context: mockContext,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle pending events', async () => {
|
||||
const mockPendingEvent = {
|
||||
pendingEvent: true,
|
||||
};
|
||||
|
||||
mockWorkflowExecutor.execute.mockResolvedValueOnce(mockPendingEvent);
|
||||
|
||||
const result = await service.execute({
|
||||
workflowRunId: mockWorkflowRunId,
|
||||
currentStepIndex: 0,
|
||||
steps: mockSteps,
|
||||
context: mockContext,
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockPendingEvent);
|
||||
expect(
|
||||
workflowRunWorkspaceService.saveWorkflowRunState,
|
||||
).toHaveBeenCalledWith({
|
||||
workflowRunId: mockWorkflowRunId,
|
||||
stepOutput: {
|
||||
id: 'step-1',
|
||||
output: mockPendingEvent,
|
||||
},
|
||||
context: mockContext,
|
||||
});
|
||||
|
||||
// No recursive call to execute should happen
|
||||
expect(workflowExecutorFactory.get).not.toHaveBeenCalledWith(
|
||||
WorkflowActionType.SEND_EMAIL,
|
||||
);
|
||||
});
|
||||
|
||||
it('should continue to next step if continueOnFailure is true', async () => {
|
||||
const stepsWithContinueOnFailure = [
|
||||
{
|
||||
id: 'step-1',
|
||||
type: WorkflowActionType.CODE,
|
||||
settings: {
|
||||
errorHandlingOptions: {
|
||||
continueOnFailure: { value: true },
|
||||
retryOnFailure: { value: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'step-2',
|
||||
type: WorkflowActionType.SEND_EMAIL,
|
||||
settings: {
|
||||
errorHandlingOptions: {
|
||||
continueOnFailure: { value: false },
|
||||
retryOnFailure: { value: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
] as WorkflowAction[];
|
||||
|
||||
mockWorkflowExecutor.execute.mockResolvedValueOnce({
|
||||
error: 'Step execution failed but continue',
|
||||
});
|
||||
|
||||
const result = await service.execute({
|
||||
workflowRunId: mockWorkflowRunId,
|
||||
currentStepIndex: 0,
|
||||
steps: stepsWithContinueOnFailure,
|
||||
context: mockContext,
|
||||
});
|
||||
|
||||
// execute first step
|
||||
expect(
|
||||
workflowRunWorkspaceService.saveWorkflowRunState,
|
||||
).toHaveBeenCalledWith({
|
||||
workflowRunId: mockWorkflowRunId,
|
||||
stepOutput: {
|
||||
id: 'step-1',
|
||||
output: {
|
||||
error: 'Step execution failed but continue',
|
||||
},
|
||||
},
|
||||
context: mockContext,
|
||||
});
|
||||
expect(result).toEqual({ result: { success: true } });
|
||||
|
||||
// execute second step
|
||||
expect(workflowExecutorFactory.get).toHaveBeenCalledWith(
|
||||
WorkflowActionType.SEND_EMAIL,
|
||||
);
|
||||
});
|
||||
|
||||
it('should retry on failure if retryOnFailure is true', async () => {
|
||||
const stepsWithRetryOnFailure = [
|
||||
{
|
||||
id: 'step-1',
|
||||
type: WorkflowActionType.CODE,
|
||||
settings: {
|
||||
errorHandlingOptions: {
|
||||
continueOnFailure: { value: false },
|
||||
retryOnFailure: { value: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
] as WorkflowAction[];
|
||||
|
||||
mockWorkflowExecutor.execute.mockResolvedValueOnce({
|
||||
error: 'Step execution failed, will retry',
|
||||
});
|
||||
|
||||
await service.execute({
|
||||
workflowRunId: mockWorkflowRunId,
|
||||
currentStepIndex: 0,
|
||||
steps: stepsWithRetryOnFailure,
|
||||
context: mockContext,
|
||||
});
|
||||
|
||||
// Should call execute again with increased attemptCount
|
||||
expect(workflowExecutorFactory.get).toHaveBeenCalledWith(
|
||||
WorkflowActionType.CODE,
|
||||
);
|
||||
expect(workflowExecutorFactory.get).not.toHaveBeenCalledWith(
|
||||
WorkflowActionType.SEND_EMAIL,
|
||||
);
|
||||
expect(workflowExecutorFactory.get).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should stop retrying after MAX_RETRIES_ON_FAILURE', async () => {
|
||||
const stepsWithRetryOnFailure = [
|
||||
{
|
||||
id: 'step-1',
|
||||
type: WorkflowActionType.CODE,
|
||||
settings: {
|
||||
errorHandlingOptions: {
|
||||
continueOnFailure: { value: false },
|
||||
retryOnFailure: { value: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
] as WorkflowAction[];
|
||||
|
||||
const errorOutput = {
|
||||
error: 'Step execution failed, max retries reached',
|
||||
};
|
||||
|
||||
mockWorkflowExecutor.execute.mockResolvedValueOnce(errorOutput);
|
||||
|
||||
const result = await service.execute({
|
||||
workflowRunId: mockWorkflowRunId,
|
||||
currentStepIndex: 0,
|
||||
steps: stepsWithRetryOnFailure,
|
||||
context: mockContext,
|
||||
attemptCount: 3, // MAX_RETRIES_ON_FAILURE is 3
|
||||
});
|
||||
|
||||
// Should not retry anymore
|
||||
expect(workflowExecutorFactory.get).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
workflowRunWorkspaceService.saveWorkflowRunState,
|
||||
).toHaveBeenCalledWith({
|
||||
workflowRunId: mockWorkflowRunId,
|
||||
stepOutput: {
|
||||
id: 'step-1',
|
||||
output: errorOutput,
|
||||
},
|
||||
context: mockContext,
|
||||
});
|
||||
expect(result).toEqual(errorOutput);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendWorkflowNodeRunEvent', () => {
|
||||
it('should emit a billing event', () => {
|
||||
service['sendWorkflowNodeRunEvent']();
|
||||
|
||||
expect(scopedWorkspaceContextFactory.create).toHaveBeenCalled();
|
||||
expect(workspaceEventEmitter.emitCustomBatchEvent).toHaveBeenCalledWith(
|
||||
BILLING_FEATURE_USED,
|
||||
[
|
||||
{
|
||||
eventName: BillingMeterEventName.WORKFLOW_NODE_RUN,
|
||||
value: 1,
|
||||
},
|
||||
],
|
||||
'workspace-id',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle missing workspace ID', () => {
|
||||
mockScopedWorkspaceContextFactory.create.mockReturnValueOnce({
|
||||
workspaceId: null,
|
||||
});
|
||||
|
||||
service['sendWorkflowNodeRunEvent']();
|
||||
|
||||
expect(workspaceEventEmitter.emitCustomBatchEvent).toHaveBeenCalledWith(
|
||||
BILLING_FEATURE_USED,
|
||||
[
|
||||
{
|
||||
eventName: BillingMeterEventName.WORKFLOW_NODE_RUN,
|
||||
value: 1,
|
||||
},
|
||||
],
|
||||
'',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user