Add Quick Lead workflow integration tests (#16862)

## Description

This PR adds integration tests for the Quick Lead workflow, including a
complete end-to-end test with full workflow execution.

### Key Changes

1. **Enabled SyncDriver for integration tests** - Jobs are now processed
synchronously in tests
   - Modified `create-app.ts` to use `SyncDriver` instead of `BullMQ`
- Added `MessageQueueExplorer` to discover and register workflow job
handlers
   - This enables complete workflow execution in integration tests

2. **Added integration tests for Quick Lead workflow**:
   - Verify workflow exists and is active
- Verify workflow version has correct structure (MANUAL trigger, FORM
step, CREATE_RECORD steps)
- Test workflow triggering creates workflow run with correct initial
state
   - Test stop workflow run on a running workflow
- **Full end-to-end test**: trigger → submit form → verify Company and
Person records created

### Test Coverage

The complete end-to-end test verifies:
- Workflow triggers and is in RUNNING status (waiting on FORM step)
- Form submission with test data succeeds
- Workflow completes successfully with all steps in SUCCESS status
- Company record is created with correct name and domain
- Person record is created with correct name and email
- Records are properly cleaned up after test

### How to Run Tests

```bash
npx nx run twenty-server:test:integration -- --testPathPattern="quick-lead-workflow"
```

Or with database reset:

```bash
npx nx run twenty-server:test:integration:with-db-reset -- --testPathPattern="quick-lead-workflow"
```
This commit is contained in:
Félix Malfait
2025-12-30 18:02:04 +01:00
committed by GitHub
parent a2872b02ed
commit fb41b116a4
3 changed files with 544 additions and 2 deletions

View File

@@ -13,3 +13,15 @@ export const WORKFLOW_GQL_FIELDS = `
updatedAt
deletedAt
`;
export const WORKFLOW_RUN_GQL_FIELDS = `
id
name
status
startedAt
endedAt
createdAt
updatedAt
workflowVersionId
state
`;

View File

@@ -0,0 +1,521 @@
import request from 'supertest';
import { WORKFLOW_RUN_GQL_FIELDS } from 'test/integration/constants/workflow-gql-fields.constants';
import { v4 as uuidv4 } from 'uuid';
const client = request(`http://localhost:${APP_PORT}`);
// Quick Lead workflow IDs from prefill-workflows.ts
const QUICK_LEAD_WORKFLOW_ID = '8b213cac-a68b-4ffe-817a-3ec994e9932d';
const QUICK_LEAD_WORKFLOW_VERSION_ID = 'ac67974f-c524-4288-9d88-af8515400b68';
const FORM_STEP_ID = '6e089bc9-aabd-435f-865f-f31c01c8f4a7';
type WorkflowRunStatusType =
| 'NOT_STARTED'
| 'RUNNING'
| 'COMPLETED'
| 'FAILED'
| 'ENQUEUED'
| 'STOPPING'
| 'STOPPED';
type WorkflowRunState = {
stepInfos?: Record<
string,
{
status: string;
result?: Record<string, unknown>;
}
>;
flow?: {
trigger?: {
type: string;
nextStepIds: string[];
};
steps?: Array<{
id: string;
type: string;
name: string;
}>;
};
};
type WorkflowRunResponse = {
id: string;
status: WorkflowRunStatusType;
state: WorkflowRunState;
workflowVersionId: string;
};
const getWorkflowRun = async (
workflowRunId: string,
): Promise<WorkflowRunResponse | null> => {
const response = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
query FindWorkflowRun($id: UUID!) {
workflowRun(filter: { id: { eq: $id } }) {
${WORKFLOW_RUN_GQL_FIELDS}
}
}
`,
variables: { id: workflowRunId },
});
if (response.body.errors || !response.body.data?.workflowRun) {
return null;
}
return response.body.data.workflowRun;
};
describe('Quick Lead Workflow (e2e)', () => {
let createdWorkflowRunId: string | null = null;
afterAll(async () => {
// Clean up workflow run
if (createdWorkflowRunId) {
await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
mutation DestroyWorkflowRun($id: ID!) {
destroyWorkflowRun(id: $id) {
id
}
}
`,
variables: { id: createdWorkflowRunId },
});
}
});
describe('Workflow triggering', () => {
it('should verify Quick Lead workflow exists and is active', async () => {
const response = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
query FindWorkflow {
workflow(filter: { id: { eq: "${QUICK_LEAD_WORKFLOW_ID}" } }) {
id
name
lastPublishedVersionId
statuses
}
}
`,
});
expect(response.status).toBe(200);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.workflow).toBeDefined();
expect(response.body.data.workflow.id).toBe(QUICK_LEAD_WORKFLOW_ID);
expect(response.body.data.workflow.name).toBe('Quick Lead');
expect(response.body.data.workflow.lastPublishedVersionId).toBe(
QUICK_LEAD_WORKFLOW_VERSION_ID,
);
expect(response.body.data.workflow.statuses).toContain('ACTIVE');
});
it('should verify Quick Lead workflow version has correct structure', async () => {
const response = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
query FindWorkflowVersion {
workflowVersion(filter: { id: { eq: "${QUICK_LEAD_WORKFLOW_VERSION_ID}" } }) {
id
name
status
trigger
steps
}
}
`,
});
expect(response.status).toBe(200);
expect(response.body.errors).toBeUndefined();
const workflowVersion = response.body.data.workflowVersion;
expect(workflowVersion).toBeDefined();
expect(workflowVersion.status).toBe('ACTIVE');
// Verify trigger structure
const trigger = workflowVersion.trigger;
expect(trigger.type).toBe('MANUAL');
expect(trigger.nextStepIds).toContain(FORM_STEP_ID);
// Verify steps structure
const steps = workflowVersion.steps;
expect(steps).toHaveLength(3);
// Form step
const formStep = steps.find(
(step: { id: string }) => step.id === FORM_STEP_ID,
);
expect(formStep).toBeDefined();
expect(formStep.type).toBe('FORM');
expect(formStep.name).toBe('Quick Lead Form');
// Create Company step
const createCompanyStep = steps.find(
(step: { id: string }) =>
step.id === '0715b6cd-7cc1-4b98-971b-00f54dfe643b',
);
expect(createCompanyStep).toBeDefined();
expect(createCompanyStep.type).toBe('CREATE_RECORD');
expect(createCompanyStep.name).toBe('Create Company');
// Create Person step
const createPersonStep = steps.find(
(step: { id: string }) =>
step.id === '6f553ea7-b00e-4371-9d88-d8298568a246',
);
expect(createPersonStep).toBeDefined();
expect(createPersonStep.type).toBe('CREATE_RECORD');
expect(createPersonStep.name).toBe('Create Person');
});
it('should trigger Quick Lead workflow and create workflow run', async () => {
const runWorkflowResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
mutation RunWorkflowVersion($input: RunWorkflowVersionInput!) {
runWorkflowVersion(input: $input) {
workflowRunId
}
}
`,
variables: {
input: {
workflowVersionId: QUICK_LEAD_WORKFLOW_VERSION_ID,
},
},
});
expect(runWorkflowResponse.status).toBe(200);
expect(runWorkflowResponse.body.errors).toBeUndefined();
expect(
runWorkflowResponse.body.data.runWorkflowVersion.workflowRunId,
).toBeDefined();
const workflowRunId =
runWorkflowResponse.body.data.runWorkflowVersion.workflowRunId;
createdWorkflowRunId = workflowRunId;
const workflowRun = await getWorkflowRun(workflowRunId);
expect(workflowRun).toBeDefined();
expect(workflowRun?.workflowVersionId).toBe(
QUICK_LEAD_WORKFLOW_VERSION_ID,
);
expect(workflowRun?.status).toBe('RUNNING');
expect(workflowRun?.state).toBeDefined();
expect(workflowRun?.state?.stepInfos).toBeDefined();
expect(workflowRun?.state?.stepInfos?.trigger).toBeDefined();
expect(workflowRun?.state?.stepInfos?.[FORM_STEP_ID]).toBeDefined();
expect(
workflowRun?.state?.stepInfos?.['0715b6cd-7cc1-4b98-971b-00f54dfe643b'],
).toBeDefined();
expect(
workflowRun?.state?.stepInfos?.['6f553ea7-b00e-4371-9d88-d8298568a246'],
).toBeDefined();
expect(workflowRun?.state?.stepInfos?.trigger?.status).toBe('SUCCESS');
expect(workflowRun?.state?.stepInfos?.[FORM_STEP_ID]?.status).toBe(
'PENDING',
);
expect(
workflowRun?.state?.stepInfos?.['0715b6cd-7cc1-4b98-971b-00f54dfe643b']
?.status,
).toBe('NOT_STARTED');
expect(
workflowRun?.state?.stepInfos?.['6f553ea7-b00e-4371-9d88-d8298568a246']
?.status,
).toBe('NOT_STARTED');
});
it('should be able to stop a running workflow run', async () => {
const runWorkflowResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
mutation RunWorkflowVersion($input: RunWorkflowVersionInput!) {
runWorkflowVersion(input: $input) {
workflowRunId
}
}
`,
variables: {
input: {
workflowVersionId: QUICK_LEAD_WORKFLOW_VERSION_ID,
},
},
});
const workflowRunId =
runWorkflowResponse.body.data.runWorkflowVersion.workflowRunId;
const stopResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
mutation StopWorkflowRun($workflowRunId: UUID!) {
stopWorkflowRun(workflowRunId: $workflowRunId) {
id
status
}
}
`,
variables: { workflowRunId },
});
expect(stopResponse.body.errors).toBeUndefined();
expect(stopResponse.body.data.stopWorkflowRun.status).toBe('STOPPED');
const workflowRun = await getWorkflowRun(workflowRunId);
expect(workflowRun?.status).toBe('STOPPED');
await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
mutation DestroyWorkflowRun($id: ID!) {
destroyWorkflowRun(id: $id) {
id
}
}
`,
variables: { id: workflowRunId },
});
});
});
describe('Full workflow execution with form submission', () => {
let testWorkflowRunId: string | null = null;
let createdCompanyId: string | null = null;
let createdPersonId: string | null = null;
afterAll(async () => {
// Clean up created records in reverse order of creation
if (createdPersonId) {
await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
mutation DestroyPerson($id: ID!) {
destroyPerson(id: $id) {
id
}
}
`,
variables: { id: createdPersonId },
});
}
if (createdCompanyId) {
await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
mutation DestroyCompany($id: ID!) {
destroyCompany(id: $id) {
id
}
}
`,
variables: { id: createdCompanyId },
});
}
if (testWorkflowRunId) {
await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
mutation DestroyWorkflowRun($id: ID!) {
destroyWorkflowRun(id: $id) {
id
}
}
`,
variables: { id: testWorkflowRunId },
});
}
});
it('should complete full workflow: trigger → submit form → create Company and Person', async () => {
const runWorkflowResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
mutation RunWorkflowVersion($input: RunWorkflowVersionInput!) {
runWorkflowVersion(input: $input) {
workflowRunId
}
}
`,
variables: {
input: {
workflowVersionId: QUICK_LEAD_WORKFLOW_VERSION_ID,
},
},
});
expect(runWorkflowResponse.body.errors).toBeUndefined();
testWorkflowRunId =
runWorkflowResponse.body.data.runWorkflowVersion.workflowRunId;
expect(testWorkflowRunId).toBeDefined();
let workflowRun = await getWorkflowRun(testWorkflowRunId as string);
expect(workflowRun?.status).toBe('RUNNING');
expect(workflowRun?.state?.stepInfos?.[FORM_STEP_ID]?.status).toBe(
'PENDING',
);
const testId = uuidv4().slice(0, 8);
const testFormData = {
firstName: 'Integration',
lastName: `TestUser-${testId}`,
email: `test-${testId}@example.com`,
jobTitle: 'Test Engineer',
companyName: `Test Company ${testId}`,
companyDomain: `https://test-${testId}.example.com`,
};
const submitFormResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
mutation SubmitFormStep($input: SubmitFormStepInput!) {
submitFormStep(input: $input)
}
`,
variables: {
input: {
stepId: FORM_STEP_ID,
workflowRunId: testWorkflowRunId,
response: testFormData,
},
},
});
expect(submitFormResponse.body.errors).toBeUndefined();
expect(submitFormResponse.body.data.submitFormStep).toBe(true);
workflowRun = await getWorkflowRun(testWorkflowRunId as string);
expect(workflowRun?.status).toBe('COMPLETED');
expect(workflowRun?.state?.stepInfos?.trigger?.status).toBe('SUCCESS');
expect(workflowRun?.state?.stepInfos?.[FORM_STEP_ID]?.status).toBe(
'SUCCESS',
);
expect(
workflowRun?.state?.stepInfos?.['0715b6cd-7cc1-4b98-971b-00f54dfe643b']
?.status,
).toBe('SUCCESS');
expect(
workflowRun?.state?.stepInfos?.['6f553ea7-b00e-4371-9d88-d8298568a246']
?.status,
).toBe('SUCCESS');
const companyStepResult = workflowRun?.state?.stepInfos?.[
'0715b6cd-7cc1-4b98-971b-00f54dfe643b'
]?.result as { id?: string } | undefined;
createdCompanyId = companyStepResult?.id ?? null;
expect(createdCompanyId).toBeDefined();
const companyResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
query FindCompany($id: UUID!) {
company(filter: { id: { eq: $id } }) {
id
name
domainName {
primaryLinkUrl
}
}
}
`,
variables: { id: createdCompanyId },
});
expect(companyResponse.body.errors).toBeUndefined();
expect(companyResponse.body.data.company).toBeDefined();
expect(companyResponse.body.data.company.name).toBe(
testFormData.companyName,
);
expect(
companyResponse.body.data.company.domainName.primaryLinkUrl,
).toContain(`test-${testId}.example.com`);
const personStepResult = workflowRun?.state?.stepInfos?.[
'6f553ea7-b00e-4371-9d88-d8298568a246'
]?.result as { id?: string } | undefined;
createdPersonId = personStepResult?.id ?? null;
expect(createdPersonId).toBeDefined();
const personResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
query FindPerson($id: UUID!) {
person(filter: { id: { eq: $id } }) {
id
name {
firstName
lastName
}
emails {
primaryEmail
}
}
}
`,
variables: { id: createdPersonId },
});
expect(personResponse.body.errors).toBeUndefined();
expect(personResponse.body.data.person).toBeDefined();
expect(personResponse.body.data.person.name.firstName).toBe(
testFormData.firstName,
);
expect(personResponse.body.data.person.name.lastName).toBe(
testFormData.lastName,
);
expect(personResponse.body.data.person.emails.primaryEmail).toBe(
testFormData.email,
);
});
});
});

View File

@@ -14,6 +14,9 @@ import { CAPTCHA_DRIVER } from 'src/engine/core-modules/captcha/constants/captch
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { ExceptionHandlerMockService } from 'src/engine/core-modules/exception-handler/mocks/exception-handler-mock.service';
import { MockedUnhandledExceptionFilter } from 'src/engine/core-modules/exception-handler/mocks/mock-unhandled-exception.filter';
import { SyncDriver } from 'src/engine/core-modules/message-queue/drivers/sync.driver';
import { QUEUE_DRIVER } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueModule } from 'src/engine/core-modules/message-queue/message-queue.module';
interface TestingModuleCreatePreHook {
(moduleBuilder: TestingModuleBuilder): TestingModuleBuilder;
@@ -26,6 +29,10 @@ export type TestingAppCreatePreHook = (
app: NestExpressApplication,
) => Promise<void>;
// Shared SyncDriver instance for all queues in tests
// This enables synchronous processing of jobs during integration tests
const syncDriver = new SyncDriver();
/**
* Sets basic integration testing module of app
*/
@@ -38,7 +45,7 @@ export const createApp = async (
const stripeSDKMockService = new StripeSDKMockService();
const mockExceptionHandlerService = new ExceptionHandlerMockService();
let moduleBuilder: TestingModuleBuilder = Test.createTestingModule({
imports: [AppModule, CommandModule],
imports: [AppModule, CommandModule, MessageQueueModule.registerExplorer()],
providers: [
{
provide: APP_FILTER,
@@ -53,7 +60,9 @@ export const createApp = async (
.overrideProvider(CAPTCHA_DRIVER)
.useValue({
validate: async () => ({ success: true }),
});
})
.overrideProvider(QUEUE_DRIVER)
.useValue(syncDriver);
if (config.moduleBuilderHook) {
moduleBuilder = config.moduleBuilderHook(moduleBuilder);