snapshot of https://github.com/twentyhq/twenty.git (main, 2026-09-17; blobs > 131072B omitted)
Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 2s

This commit is contained in:
upstream-mirror
2026-09-17 23:28:13 +05:00
commit e7da97194a
32688 changed files with 4208681 additions and 0 deletions

View File

@@ -0,0 +1,303 @@
import gql from 'graphql-tag';
import request from 'supertest';
import {
destroyWorkflowRun,
runWorkflowVersion,
waitForWorkflowCompletion,
} from 'test/integration/graphql/suites/workflow/utils/workflow-run-test.util';
import { updateWorkflowVersionTrigger } from 'test/integration/graphql/suites/workflow/utils/update-workflow-version-trigger.util';
import { updateLogicFunctionSource } from 'test/integration/metadata/suites/logic-function/utils/update-logic-function-source.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { updateFeatureFlag } from 'test/integration/metadata/suites/utils/update-feature-flag.util';
import { FeatureFlagKey } from 'twenty-shared/types';
import { LogicFunctionExecutionMode } from 'src/engine/metadata-modules/logic-function/logic-function.entity';
const client = request(`http://localhost:${APP_PORT}`);
const EXTERNAL_PACKAGES_FUNCTION_CODE = `import groupBy from 'lodash.groupby';
export const main = async (params: { items: Array<{ category: string; name: string }> }): Promise<object> => {
const grouped = groupBy(params.items, 'category');
return {
grouped,
categories: Object.keys(grouped),
};
};`;
describe('Code step workflow with PREBUILT logic function (e2e)', () => {
let createdWorkflowId: string | null = null;
let createdWorkflowVersionId: string | null = null;
let codeStepId: string | null = null;
let codeStepLogicFunctionId: string | null = null;
let createdWorkflowRunId: string | null = null;
beforeAll(async () => {
await updateFeatureFlag({
featureFlag: FeatureFlagKey.IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED,
value: true,
expectToFail: false,
});
const createWorkflowResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
mutation CreateWorkflow {
createWorkflow(data: {
name: "Code Step PREBUILT Test"
}) {
id
}
}
`,
});
expect(createWorkflowResponse.body.errors).toBeUndefined();
createdWorkflowId = createWorkflowResponse.body.data.createWorkflow.id;
const getWorkflowResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
query GetWorkflow($id: UUID!) {
workflow(filter: { id: { eq: $id } }) {
id
versions {
edges {
node {
id
}
}
}
}
}
`,
variables: { id: createdWorkflowId },
});
expect(getWorkflowResponse.body.errors).toBeUndefined();
createdWorkflowVersionId =
getWorkflowResponse.body.data.workflow.versions.edges[0].node.id;
const manualTrigger = {
name: 'Manual Trigger',
type: 'MANUAL',
settings: {
outputSchema: {
items: { isLeaf: true, type: 'array', value: undefined },
},
},
nextStepIds: [],
position: { x: 0, y: 0 },
};
const updateTriggerResponse = await updateWorkflowVersionTrigger({
workflowVersionId: createdWorkflowVersionId!,
trigger: manualTrigger,
});
expect(updateTriggerResponse.body.errors).toBeUndefined();
const createCodeStepResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
mutation CreateWorkflowVersionStep($input: CreateWorkflowVersionStepInput!) {
createWorkflowVersionStep(input: $input) {
stepsDiff
}
}
`,
variables: {
input: {
workflowVersionId: createdWorkflowVersionId,
stepType: 'CODE',
parentStepId: 'trigger',
position: { x: 200, y: 0 },
},
},
});
expect(createCodeStepResponse.body.errors).toBeUndefined();
const getStepsResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
query GetWorkflowVersion($id: UUID!) {
workflowVersion(filter: { id: { eq: $id } }) {
id
steps
}
}
`,
variables: { id: createdWorkflowVersionId },
});
expect(getStepsResponse.body.errors).toBeUndefined();
const codeStep = getStepsResponse.body.data.workflowVersion.steps.find(
(step: { type: string }) => step.type === 'CODE',
);
expect(codeStep).toBeDefined();
codeStepId = codeStep.id;
const logicFunctionId = codeStep.settings.input.logicFunctionId;
expect(logicFunctionId).toBeDefined();
codeStepLogicFunctionId = logicFunctionId;
const updateStepResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
mutation UpdateWorkflowVersionStep($input: UpdateWorkflowVersionStepInput!) {
updateWorkflowVersionStep(input: $input) {
id
}
}
`,
variables: {
input: {
workflowVersionId: createdWorkflowVersionId,
step: {
...codeStep,
settings: {
...codeStep.settings,
input: {
...codeStep.settings.input,
logicFunctionInput: { items: '{{trigger.items}}' },
},
},
},
},
},
});
expect(updateStepResponse.body.errors).toBeUndefined();
const updateSourceResponse = await updateLogicFunctionSource({
input: {
id: logicFunctionId,
update: {
sourceHandlerCode: EXTERNAL_PACKAGES_FUNCTION_CODE,
},
},
expectToFail: false,
});
expect(updateSourceResponse.errors).toBeUndefined();
const activateResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
mutation ActivateWorkflowVersion($workflowVersionId: UUID!) {
activateWorkflowVersion(workflowVersionId: $workflowVersionId)
}
`,
variables: { workflowVersionId: createdWorkflowVersionId },
});
expect(activateResponse.body.errors).toBeUndefined();
expect(activateResponse.body.data.activateWorkflowVersion).toBe(true);
});
afterAll(async () => {
if (createdWorkflowRunId) {
await destroyWorkflowRun(createdWorkflowRunId);
}
if (createdWorkflowId) {
await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
mutation DestroyWorkflow($id: ID!) {
destroyWorkflow(id: $id) {
id
}
}
`,
variables: { id: createdWorkflowId },
});
}
await updateFeatureFlag({
featureFlag: FeatureFlagKey.IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED,
value: false,
expectToFail: false,
});
});
it('flips the underlying logic function to PREBUILT on workflow activation', async () => {
const findLogicFunctionResponse = await makeMetadataAPIRequest({
query: gql`
query FindOneLogicFunction($input: LogicFunctionIdInput!) {
findOneLogicFunction(input: $input) {
id
executionMode
}
}
`,
variables: { input: { id: codeStepLogicFunctionId } },
});
expect(findLogicFunctionResponse.body.errors).toBeUndefined();
const logicFunction =
findLogicFunctionResponse.body.data.findOneLogicFunction;
expect(logicFunction.executionMode).toBe(
LogicFunctionExecutionMode.PREBUILT,
);
});
it('runs the code step from its prebuilt bundle and resolves bare imports', async () => {
createdWorkflowRunId = await runWorkflowVersion({
workflowVersionId: createdWorkflowVersionId!,
payload: {
items: [
{ category: 'fruit', name: 'apple' },
{ category: 'vegetable', name: 'carrot' },
{ category: 'fruit', name: 'banana' },
],
},
});
const workflowRun = await waitForWorkflowCompletion(createdWorkflowRunId);
expect(workflowRun?.status).toBe('COMPLETED');
expect(workflowRun?.state?.stepInfos?.trigger?.status).toBe('SUCCESS');
expect(workflowRun?.state?.stepInfos?.[codeStepId!]?.status).toBe(
'SUCCESS',
);
const stepResult = workflowRun?.state?.stepInfos?.[codeStepId!]?.result as
| {
grouped?: Record<string, Array<{ category: string; name: string }>>;
categories?: string[];
}
| undefined;
expect(stepResult?.grouped).toMatchObject({
fruit: [
{ category: 'fruit', name: 'apple' },
{ category: 'fruit', name: 'banana' },
],
vegetable: [{ category: 'vegetable', name: 'carrot' }],
});
expect(stepResult?.categories).toEqual(
expect.arrayContaining(['fruit', 'vegetable']),
);
});
});

View File

@@ -0,0 +1,234 @@
import { updateWorkflowVersionTrigger } from 'test/integration/graphql/suites/workflow/utils/update-workflow-version-trigger.util';
import { workflowGraphqlRequest } from 'test/integration/graphql/suites/workflow/utils/workflow-graphql-request.util';
import { isDefined } from 'twenty-shared/utils';
import { getWorkspaceSchemaName } from 'src/engine/workspace-datasource/utils/get-workspace-schema-name.util';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
const WORKSPACE_SCHEMA = getWorkspaceSchemaName(SEED_APPLE_WORKSPACE_ID);
const DISCARD_MUTATION = `
mutation Discard($input: DiscardCoreWorkflowDraftInput!) {
discardCoreWorkflowDraft(input: $input) {
id
statuses
}
}
`;
describe('discardCoreWorkflowDraft (e2e)', () => {
let coreWorkflowId: string;
let workspaceWorkflowId: string;
let initialWorkspaceVersionId: string;
let draftWorkspaceVersionId: string;
beforeAll(async () => {
const createResponse = await workflowGraphqlRequest(`
mutation {
createCoreWorkflow(input: { name: "Discard Draft Target" }) {
id
workspaceWorkflowId
}
}
`);
expect(createResponse.body.errors).toBeUndefined();
coreWorkflowId = createResponse.body.data.createCoreWorkflow.id;
workspaceWorkflowId =
createResponse.body.data.createCoreWorkflow.workspaceWorkflowId;
const versionsResponse = await workflowGraphqlRequest(
`
query GetWorkflow($id: UUID!) {
workflow(filter: { id: { eq: $id } }) {
versions {
edges {
node {
id
}
}
}
}
}
`,
{ id: workspaceWorkflowId },
);
initialWorkspaceVersionId =
versionsResponse.body.data.workflow.versions.edges[0].node.id;
await updateWorkflowVersionTrigger({
workflowVersionId: initialWorkspaceVersionId,
trigger: {
name: 'Manual Trigger',
type: 'MANUAL',
settings: { outputSchema: {} },
nextStepIds: [],
position: { x: 0, y: 0 },
},
});
const stepResponse = await workflowGraphqlRequest(
`
mutation CreateWorkflowVersionStep(
$input: CreateWorkflowVersionStepInput!
) {
createWorkflowVersionStep(input: $input) {
stepsDiff
}
}
`,
{
input: {
workflowVersionId: initialWorkspaceVersionId,
stepType: 'FIND_RECORDS',
parentStepId: 'trigger',
position: { x: 200, y: 0 },
},
},
);
expect(stepResponse.body.errors).toBeUndefined();
const activateResponse = await workflowGraphqlRequest(
`
mutation ActivateWorkflowVersion($workflowVersionId: UUID!) {
activateWorkflowVersion(workflowVersionId: $workflowVersionId)
}
`,
{ workflowVersionId: initialWorkspaceVersionId },
);
expect(activateResponse.body.errors).toBeUndefined();
const draftResponse = await workflowGraphqlRequest(
`
mutation CreateDraft($input: CreateDraftFromWorkflowVersionInput!) {
createDraftFromWorkflowVersion(input: $input) {
id
}
}
`,
{
input: {
workflowId: workspaceWorkflowId,
workflowVersionIdToCopy: initialWorkspaceVersionId,
},
},
);
expect(draftResponse.body.errors).toBeUndefined();
draftWorkspaceVersionId =
draftResponse.body.data.createDraftFromWorkflowVersion.id;
});
afterAll(async () => {
if (isDefined(workspaceWorkflowId)) {
await workflowGraphqlRequest(
`
mutation DestroyWorkflow($id: ID!) {
destroyWorkflow(id: $id) {
id
}
}
`,
{ id: workspaceWorkflowId },
);
}
});
it('soft-deletes the workspace draft, removes its core row and returns the refreshed workflow', async () => {
const discardResponse = await workflowGraphqlRequest(DISCARD_MUTATION, {
input: { workspaceWorkflowVersionId: draftWorkspaceVersionId },
});
expect(discardResponse.body.errors).toBeUndefined();
const refreshedWorkflow =
discardResponse.body.data.discardCoreWorkflowDraft;
expect(refreshedWorkflow.id).toBe(coreWorkflowId);
expect(refreshedWorkflow.statuses).not.toContain('DRAFT');
const workspaceRows = await global.testDataSource.query(
`SELECT "deletedAt" FROM "${WORKSPACE_SCHEMA}"."workflowVersion" WHERE id = $1`,
[draftWorkspaceVersionId],
);
expect(workspaceRows[0]?.deletedAt).not.toBeNull();
const coreVersionRows = await global.testDataSource.query(
`SELECT cv.id FROM core."workflowVersion" cv
JOIN "${WORKSPACE_SCHEMA}"."workflowVersion" wv
ON wv."coreWorkflowVersionId" = cv.id
WHERE cv."workspaceId" = $1 AND wv.id = $2`,
[SEED_APPLE_WORKSPACE_ID, draftWorkspaceVersionId],
);
expect(coreVersionRows).toHaveLength(0);
});
it('returns null when the draft is already discarded', async () => {
const discardResponse = await workflowGraphqlRequest(DISCARD_MUTATION, {
input: { workspaceWorkflowVersionId: draftWorkspaceVersionId },
});
expect(discardResponse.body.errors).toBeUndefined();
expect(discardResponse.body.data.discardCoreWorkflowDraft).toBeNull();
});
it('refuses to discard the only remaining version', async () => {
const createResponse = await workflowGraphqlRequest(`
mutation {
createCoreWorkflow(input: { name: "Only Version Guard" }) {
workspaceWorkflowId
}
}
`);
expect(createResponse.body.errors).toBeUndefined();
const guardedWorkspaceWorkflowId =
createResponse.body.data.createCoreWorkflow.workspaceWorkflowId;
const versionsResponse = await workflowGraphqlRequest(
`
query GetWorkflow($id: UUID!) {
workflow(filter: { id: { eq: $id } }) {
versions {
edges {
node {
id
}
}
}
}
}
`,
{ id: guardedWorkspaceWorkflowId },
);
const onlyVersionId =
versionsResponse.body.data.workflow.versions.edges[0].node.id;
const discardResponse = await workflowGraphqlRequest(DISCARD_MUTATION, {
input: { workspaceWorkflowVersionId: onlyVersionId },
});
expect(discardResponse.body.errors?.[0]?.message).toContain(
'initial version',
);
await workflowGraphqlRequest(
`
mutation DestroyWorkflow($id: ID!) {
destroyWorkflow(id: $id) {
id
}
}
`,
{ id: guardedWorkspaceWorkflowId },
);
});
});

View File

@@ -0,0 +1,320 @@
import request from 'supertest';
import { isDefined } from 'twenty-shared/utils';
const TEST_SCHEMA_NAME = 'workspace_1wgvd1injqtife6y4rvfbu3h5';
const client = request(`http://localhost:${APP_PORT}`);
const graphql = (query: string, variables?: object) =>
client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({ query, variables });
const LIST_CORE_WORKFLOWS_QUERY = `
query CoreWorkflows {
coreWorkflows(first: 200) {
edges {
node {
id
name
statuses
workspaceWorkflowId
}
}
pageInfo {
hasNextPage
}
}
}
`;
type CoreWorkflow = {
id: string;
name: string | null;
statuses: string[];
workspaceWorkflowId: string | null;
};
describe('coreWorkflow mutations (e2e)', () => {
let coreWorkflowId: string;
let workspaceWorkflowId: string;
const findListedCoreWorkflowById = async (
id: string,
): Promise<CoreWorkflow | undefined> => {
const response = await graphql(LIST_CORE_WORKFLOWS_QUERY);
expect(response.body.errors).toBeUndefined();
return (response.body.data.coreWorkflows.edges as { node: CoreWorkflow }[])
.map((edge) => edge.node)
.find((workflow) => workflow.id === id);
};
afterAll(async () => {
if (!isDefined(workspaceWorkflowId)) {
return;
}
await graphql(
`
mutation DestroyWorkflow($id: UUID!) {
destroyWorkflow(id: $id) {
id
}
}
`,
{ id: workspaceWorkflowId },
);
});
it('should create a workflow listed synchronously with a draft version', async () => {
const createResponse = await graphql(`
mutation {
createCoreWorkflow(input: { name: "Core Created Workflow" }) {
id
name
statuses
workspaceWorkflowId
}
}
`);
expect(createResponse.body.errors).toBeUndefined();
const createdCoreWorkflow = createResponse.body.data.createCoreWorkflow;
expect(createdCoreWorkflow.name).toBe('Core Created Workflow');
expect(createdCoreWorkflow.statuses).toEqual(['DRAFT']);
expect(createdCoreWorkflow.workspaceWorkflowId).toBeDefined();
coreWorkflowId = createdCoreWorkflow.id;
workspaceWorkflowId = createdCoreWorkflow.workspaceWorkflowId;
const listedCoreWorkflow = await findListedCoreWorkflowById(coreWorkflowId);
expect(listedCoreWorkflow).toBeDefined();
expect(listedCoreWorkflow?.statuses).toEqual(['DRAFT']);
expect(listedCoreWorkflow?.workspaceWorkflowId).toBe(workspaceWorkflowId);
const storedCoreWorkflowRows = await global.testDataSource.query(
`SELECT "workspaceWorkflowId" FROM core."workflow" WHERE "id" = $1`,
[coreWorkflowId],
);
expect(storedCoreWorkflowRows[0]?.workspaceWorkflowId).toBe(
workspaceWorkflowId,
);
const workspaceWorkflowResponse = await graphql(
`
query GetWorkflow($id: UUID!) {
workflow(filter: { id: { eq: $id } }) {
id
name
versions {
edges {
node {
id
name
status
}
}
}
}
}
`,
{ id: workspaceWorkflowId },
);
expect(workspaceWorkflowResponse.body.errors).toBeUndefined();
const workspaceWorkflow = workspaceWorkflowResponse.body.data.workflow;
expect(workspaceWorkflow.name).toBe('Core Created Workflow');
expect(workspaceWorkflow.versions.edges).toHaveLength(1);
expect(workspaceWorkflow.versions.edges[0].node.name).toBe('v1');
expect(workspaceWorkflow.versions.edges[0].node.status).toBe('DRAFT');
});
it('should delete workflows and unlist them synchronously', async () => {
const deleteResponse = await graphql(
`
mutation DeleteCoreWorkflows($input: DeleteCoreWorkflowsInput!) {
deleteCoreWorkflows(input: $input) {
id
workspaceWorkflowId
}
}
`,
{ input: { coreWorkflowIds: [coreWorkflowId] } },
);
expect(deleteResponse.body.errors).toBeUndefined();
expect(deleteResponse.body.data.deleteCoreWorkflows).toEqual([
{ id: coreWorkflowId, workspaceWorkflowId },
]);
expect(await findListedCoreWorkflowById(coreWorkflowId)).toBeUndefined();
const softDeletedWorkflowResponse = await graphql(
`
query DeletedWorkflow($id: UUID!) {
workflow(
filter: { id: { eq: $id }, not: { deletedAt: { is: "NULL" } } }
) {
id
}
}
`,
{ id: workspaceWorkflowId },
);
expect(softDeletedWorkflowResponse.body.errors).toBeUndefined();
expect(softDeletedWorkflowResponse.body.data.workflow?.id).toBe(
workspaceWorkflowId,
);
});
it('should report nothing when deleting the same workflows again', async () => {
const deleteResponse = await graphql(
`
mutation DeleteCoreWorkflows($input: DeleteCoreWorkflowsInput!) {
deleteCoreWorkflows(input: $input) {
id
workspaceWorkflowId
}
}
`,
{ input: { coreWorkflowIds: [coreWorkflowId] } },
);
expect(deleteResponse.body.errors).toBeUndefined();
expect(deleteResponse.body.data.deleteCoreWorkflows).toEqual([]);
});
it('should create a workflow from an empty input like the index does', async () => {
const createResponse = await graphql(`
mutation {
createCoreWorkflow(input: {}) {
id
name
statuses
workspaceWorkflowId
}
}
`);
expect(createResponse.body.errors).toBeUndefined();
const createdCoreWorkflow = createResponse.body.data.createCoreWorkflow;
expect(createdCoreWorkflow.name).toBeNull();
expect(createdCoreWorkflow.statuses).toEqual(['DRAFT']);
expect(
await findListedCoreWorkflowById(createdCoreWorkflow.id),
).toBeDefined();
await graphql(
`
mutation DestroyWorkflow($id: UUID!) {
destroyWorkflow(id: $id) {
id
}
}
`,
{ id: createdCoreWorkflow.workspaceWorkflowId },
);
});
it('should finish the cleanup on a retry after the core row is gone', async () => {
const createResponse = await graphql(`
mutation {
createCoreWorkflow(input: { name: "Core Retry Delete" }) {
id
workspaceWorkflowId
}
}
`);
expect(createResponse.body.errors).toBeUndefined();
const retryCoreWorkflowId = createResponse.body.data.createCoreWorkflow.id;
const retryWorkspaceWorkflowId =
createResponse.body.data.createCoreWorkflow.workspaceWorkflowId;
const deleteMutation = `
mutation DeleteCoreWorkflows($input: DeleteCoreWorkflowsInput!) {
deleteCoreWorkflows(input: $input) {
id
workspaceWorkflowId
}
}
`;
const firstDeleteResponse = await graphql(deleteMutation, {
input: { coreWorkflowIds: [retryCoreWorkflowId] },
});
expect(firstDeleteResponse.body.errors).toBeUndefined();
expect(firstDeleteResponse.body.data.deleteCoreWorkflows).toEqual([
{
id: retryCoreWorkflowId,
workspaceWorkflowId: retryWorkspaceWorkflowId,
},
]);
await global.testDataSource.query(
`UPDATE "${TEST_SCHEMA_NAME}"."workflowVersion" SET "deletedAt" = NULL WHERE "workflowId" = $1`,
[retryWorkspaceWorkflowId],
);
const retryDeleteResponse = await graphql(deleteMutation, {
input: { coreWorkflowIds: [retryCoreWorkflowId] },
});
expect(retryDeleteResponse.body.errors).toBeUndefined();
expect(retryDeleteResponse.body.data.deleteCoreWorkflows).toEqual([]);
const workflowVersionRows: { deletedAt: Date | null }[] =
await global.testDataSource.query(
`SELECT "deletedAt" FROM "${TEST_SCHEMA_NAME}"."workflowVersion" WHERE "workflowId" = $1`,
[retryWorkspaceWorkflowId],
);
expect(workflowVersionRows).toHaveLength(1);
expect(workflowVersionRows[0].deletedAt).not.toBeNull();
await graphql(
`
mutation DestroyWorkflow($id: UUID!) {
destroyWorkflow(id: $id) {
id
}
}
`,
{ id: retryWorkspaceWorkflowId },
);
});
it('should delete nothing for unknown core workflow ids', async () => {
const deleteResponse = await graphql(
`
mutation DeleteCoreWorkflows($input: DeleteCoreWorkflowsInput!) {
deleteCoreWorkflows(input: $input) {
id
workspaceWorkflowId
}
}
`,
{
input: { coreWorkflowIds: ['00000000-0000-4000-8000-000000000000'] },
},
);
expect(deleteResponse.body.errors).toBeUndefined();
expect(deleteResponse.body.data.deleteCoreWorkflows).toEqual([]);
});
});

View File

@@ -0,0 +1,171 @@
import { isDefined } from 'twenty-shared/utils';
import { pollWorkflowGraphqlRequest } from 'test/integration/graphql/suites/workflow/utils/poll-workflow-graphql-request.util';
import { workflowGraphqlRequest } from 'test/integration/graphql/suites/workflow/utils/workflow-graphql-request.util';
const CORE_WORKFLOW_QUERY = `
query CoreWorkflow($workspaceWorkflowId: UUID!) {
coreWorkflow(workspaceWorkflowId: $workspaceWorkflowId) {
id
name
statuses
lastPublishedVersionId
workspaceWorkflowId
updatedAt
}
}
`;
const CORE_WORKFLOW_VERSIONS_QUERY = `
query CoreWorkflowVersions($workspaceWorkflowId: UUID!) {
coreWorkflowVersions(workspaceWorkflowId: $workspaceWorkflowId) {
id
label
status
workspaceWorkflowVersionId
createdAt
updatedAt
}
}
`;
const CORE_WORKFLOW_VERSION_QUERY = `
query CoreWorkflowVersion($workspaceWorkflowVersionId: UUID!) {
coreWorkflowVersion(
workspaceWorkflowVersionId: $workspaceWorkflowVersionId
) {
id
label
status
workspaceWorkflowVersionId
trigger
steps
createdAt
updatedAt
}
}
`;
type CoreWorkflowResult = {
id: string;
name: string | null;
statuses: string[];
lastPublishedVersionId: string | null;
workspaceWorkflowId: string | null;
updatedAt: string;
};
type CoreWorkflowVersionResult = {
id: string;
label: string;
status: string;
workspaceWorkflowVersionId: string | null;
createdAt: string;
updatedAt: string;
};
describe('coreWorkflow (e2e)', () => {
let workspaceWorkflowId: string;
beforeAll(async () => {
const createResponse = await workflowGraphqlRequest(`
mutation {
createWorkflow(data: { name: "Core Workflow Read" }) {
id
}
}
`);
expect(createResponse.body.errors).toBeUndefined();
workspaceWorkflowId = createResponse.body.data.createWorkflow.id;
});
afterAll(async () => {
if (!isDefined(workspaceWorkflowId)) {
return;
}
await workflowGraphqlRequest(
`
mutation DestroyWorkflow($id: UUID!) {
destroyWorkflow(id: $id) {
id
}
}
`,
{ id: workspaceWorkflowId },
);
});
it('should read one workflow by its workspace id', async () => {
const coreWorkflow = await pollWorkflowGraphqlRequest<
{ coreWorkflow: CoreWorkflowResult | null },
CoreWorkflowResult | null | undefined
>({
query: CORE_WORKFLOW_QUERY,
variables: { workspaceWorkflowId },
extract: (data) => data?.coreWorkflow,
until: isDefined,
});
expect(coreWorkflow).toEqual({
id: expect.any(String),
name: 'Core Workflow Read',
statuses: ['DRAFT'],
lastPublishedVersionId: null,
workspaceWorkflowId,
updatedAt: expect.any(String),
});
});
it('should return null for a workflow that does not exist', async () => {
const response = await workflowGraphqlRequest(CORE_WORKFLOW_QUERY, {
workspaceWorkflowId: '00000000-0000-4000-8000-000000000000',
});
expect(response.body.errors).toBeUndefined();
expect(response.body.data.coreWorkflow).toBeNull();
});
it('should expose the version content the show page renders', async () => {
const versions = await pollWorkflowGraphqlRequest<
{ coreWorkflowVersions: CoreWorkflowVersionResult[] },
CoreWorkflowVersionResult[]
>({
query: CORE_WORKFLOW_VERSIONS_QUERY,
variables: { workspaceWorkflowId },
extract: (data) => data?.coreWorkflowVersions ?? [],
until: (polledVersions) =>
polledVersions.length === 1 &&
isDefined(polledVersions[0].workspaceWorkflowVersionId),
});
expect(versions).toEqual([
{
id: expect.any(String),
label: 'v1',
status: 'DRAFT',
workspaceWorkflowVersionId: expect.any(String),
createdAt: expect.any(String),
updatedAt: expect.any(String),
},
]);
const versionResponse = await workflowGraphqlRequest(
CORE_WORKFLOW_VERSION_QUERY,
{
workspaceWorkflowVersionId: versions[0].workspaceWorkflowVersionId,
},
);
expect(versionResponse.body.errors).toBeUndefined();
expect(versionResponse.body.data.coreWorkflowVersion).toEqual(
expect.objectContaining({
workspaceWorkflowVersionId: versions[0].workspaceWorkflowVersionId,
status: 'DRAFT',
updatedAt: expect.any(String),
}),
);
});
});

View File

@@ -0,0 +1,692 @@
import request from 'supertest';
import { updateWorkflowVersionTrigger } from 'test/integration/graphql/suites/workflow/utils/update-workflow-version-trigger.util';
import { isDefined } from 'twenty-shared/utils';
const POLL_ATTEMPTS = 20;
const POLL_INTERVAL_MS = 250;
const client = request(`http://localhost:${APP_PORT}`);
const graphql = (query: string, variables?: object) =>
client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({ query, variables });
const CORE_WORKFLOWS_QUERY = `
query CoreWorkflows($filter: CoreWorkflowFilterInput) {
coreWorkflows(first: 200, filter: $filter) {
edges {
node {
id
name
statuses
applicationId
workspaceWorkflowId
updatedAt
}
cursor
}
pageInfo {
endCursor
hasNextPage
}
totalCount
}
}
`;
type CoreWorkflow = {
id: string;
name: string | null;
statuses: string[];
applicationId: string | null;
workspaceWorkflowId: string | null;
updatedAt: string;
};
type CoreWorkflowFilterRule = {
fieldKey: 'NAME' | 'STATUSES' | 'UPDATED_AT';
operand:
| 'CONTAINS'
| 'DOES_NOT_CONTAIN'
| 'IS'
| 'IS_NOT'
| 'IS_EMPTY'
| 'IS_NOT_EMPTY'
| 'IS_BEFORE'
| 'IS_AFTER';
value?: string;
};
type CoreWorkflowFilter = {
logicalOperator: 'AND' | 'OR';
rules: CoreWorkflowFilterRule[];
};
describe('coreWorkflows (e2e)', () => {
let workflowId: string;
let firstVersionId: string;
let alreadyDestroyed = false;
let softDeletedWorkflowId: string | undefined;
const listCoreWorkflows = async (
filter?: CoreWorkflowFilter,
): Promise<CoreWorkflow[]> => {
const response = await graphql(CORE_WORKFLOWS_QUERY, { filter });
expect(response.body.errors).toBeUndefined();
const { edges, pageInfo, totalCount } = response.body.data
.coreWorkflows as {
edges: { node: CoreWorkflow }[];
pageInfo: { hasNextPage: boolean };
totalCount: number;
};
if (!pageInfo.hasNextPage) {
expect(totalCount).toBe(edges.length);
}
return edges.map((edge) => edge.node);
};
const findCoreWorkflow = async (
filter?: CoreWorkflowFilter,
): Promise<CoreWorkflow | undefined> =>
(await listCoreWorkflows(filter)).find(
(workflow) => workflow.workspaceWorkflowId === workflowId,
);
const findCoreWorkflowByName = async (
name: string,
): Promise<CoreWorkflow | undefined> =>
(await listCoreWorkflows()).find((workflow) => workflow.name === name);
const waitForCoreWorkflow = async (
predicate: (workflow: CoreWorkflow | undefined) => boolean,
): Promise<CoreWorkflow | undefined> => {
for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt++) {
const coreWorkflow = await findCoreWorkflow();
if (predicate(coreWorkflow)) {
return coreWorkflow;
}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
}
return findCoreWorkflow();
};
beforeAll(async () => {
const createResponse = await graphql(`
mutation {
createWorkflow(data: { name: "Core Workflows List" }) {
id
}
}
`);
expect(createResponse.body.errors).toBeUndefined();
workflowId = createResponse.body.data.createWorkflow.id;
const getResponse = await graphql(
`
query GetWorkflow($id: UUID!) {
workflow(filter: { id: { eq: $id } }) {
versions {
edges {
node {
id
}
}
}
}
}
`,
{ id: workflowId },
);
firstVersionId = getResponse.body.data.workflow.versions.edges[0].node.id;
});
afterAll(async () => {
if (isDefined(softDeletedWorkflowId)) {
await graphql(
`
mutation DestroyWorkflow($id: UUID!) {
destroyWorkflow(id: $id) {
id
}
}
`,
{ id: softDeletedWorkflowId },
);
}
if (alreadyDestroyed) {
return;
}
await graphql(
`
mutation DestroyWorkflow($id: UUID!) {
destroyWorkflow(id: $id) {
id
}
}
`,
{ id: workflowId },
);
});
it('should list the workflow as DRAFT right after creation', async () => {
const coreWorkflow = await waitForCoreWorkflow((workflow) =>
isDefined(workflow),
);
expect(coreWorkflow).toBeDefined();
expect(coreWorkflow?.name).toBe('Core Workflows List');
expect(coreWorkflow?.statuses).toEqual(['DRAFT']);
});
it('should filter by derived statuses', async () => {
await waitForCoreWorkflow(
(workflow) => workflow?.statuses.includes('DRAFT') === true,
);
const draftFiltered = await findCoreWorkflow({
logicalOperator: 'AND',
rules: [
{
fieldKey: 'STATUSES',
operand: 'CONTAINS',
value: JSON.stringify(['DRAFT']),
},
],
});
expect(draftFiltered?.statuses).toEqual(['DRAFT']);
const activeOrDeactivatedFiltered = await findCoreWorkflow({
logicalOperator: 'AND',
rules: [
{
fieldKey: 'STATUSES',
operand: 'CONTAINS',
value: JSON.stringify(['ACTIVE', 'DEACTIVATED']),
},
],
});
expect(activeOrDeactivatedFiltered).toBeUndefined();
});
it('should filter by a case-insensitive name match', async () => {
await waitForCoreWorkflow((workflow) => isDefined(workflow));
const matching = await findCoreWorkflow({
logicalOperator: 'AND',
rules: [
{ fieldKey: 'NAME', operand: 'CONTAINS', value: 'core workflows li' },
],
});
expect(matching?.name).toBe('Core Workflows List');
const notMatching = await findCoreWorkflow({
logicalOperator: 'AND',
rules: [
{
fieldKey: 'NAME',
operand: 'CONTAINS',
value: 'no workflow bears this name',
},
],
});
expect(notMatching).toBeUndefined();
});
it('should compose a status rule and a name rule with AND', async () => {
await waitForCoreWorkflow(
(workflow) => workflow?.statuses.includes('DRAFT') === true,
);
const filtered = await findCoreWorkflow({
logicalOperator: 'AND',
rules: [
{
fieldKey: 'STATUSES',
operand: 'CONTAINS',
value: JSON.stringify(['DRAFT']),
},
{ fieldKey: 'NAME', operand: 'CONTAINS', value: 'CORE WORKFLOWS LIST' },
],
});
expect(filtered?.statuses).toEqual(['DRAFT']);
const filteredOut = await findCoreWorkflow({
logicalOperator: 'AND',
rules: [
{
fieldKey: 'STATUSES',
operand: 'CONTAINS',
value: JSON.stringify(['ACTIVE']),
},
{ fieldKey: 'NAME', operand: 'CONTAINS', value: 'CORE WORKFLOWS LIST' },
],
});
expect(filteredOut).toBeUndefined();
});
it('should compose a name rule and a status rule with OR', async () => {
await waitForCoreWorkflow(
(workflow) => workflow?.statuses.includes('DRAFT') === true,
);
const matchedByStatusOnly = await findCoreWorkflow({
logicalOperator: 'OR',
rules: [
{
fieldKey: 'NAME',
operand: 'CONTAINS',
value: 'no workflow bears this name',
},
{
fieldKey: 'STATUSES',
operand: 'CONTAINS',
value: JSON.stringify(['DRAFT']),
},
],
});
expect(matchedByStatusOnly?.statuses).toEqual(['DRAFT']);
const matchedByNeither = await findCoreWorkflow({
logicalOperator: 'OR',
rules: [
{
fieldKey: 'NAME',
operand: 'CONTAINS',
value: 'no workflow bears this name',
},
{
fieldKey: 'STATUSES',
operand: 'CONTAINS',
value: JSON.stringify(['ACTIVE']),
},
],
});
expect(matchedByNeither).toBeUndefined();
});
it('should filter by update date', async () => {
const coreWorkflow = await waitForCoreWorkflow((workflow) =>
isDefined(workflow),
);
expect(coreWorkflow).toBeDefined();
const updatedAt = new Date(coreWorkflow?.updatedAt ?? '');
const oneHourBefore = new Date(updatedAt.getTime() - 60 * 60 * 1000);
const updatedAfter = await findCoreWorkflow({
logicalOperator: 'AND',
rules: [
{
fieldKey: 'UPDATED_AT',
operand: 'IS_AFTER',
value: JSON.stringify(oneHourBefore.toISOString()),
},
],
});
expect(updatedAfter?.workspaceWorkflowId).toBe(workflowId);
const updatedBefore = await findCoreWorkflow({
logicalOperator: 'AND',
rules: [
{
fieldKey: 'UPDATED_AT',
operand: 'IS_BEFORE',
value: JSON.stringify(oneHourBefore.toISOString()),
},
],
});
expect(updatedBefore).toBeUndefined();
const updatedOnTheSameDay = await findCoreWorkflow({
logicalOperator: 'AND',
rules: [
{
fieldKey: 'UPDATED_AT',
operand: 'IS',
value: JSON.stringify(updatedAt.toISOString()),
},
],
});
expect(updatedOnTheSameDay?.workspaceWorkflowId).toBe(workflowId);
});
it('should reject an operand the field does not support', async () => {
const response = await graphql(CORE_WORKFLOWS_QUERY, {
filter: {
logicalOperator: 'AND',
rules: [{ fieldKey: 'STATUSES', operand: 'IS', value: 'ACTIVE' }],
},
});
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toContain(
'Operand IS is not supported on field STATUSES',
);
});
it('should list the workflow as ACTIVE once its version is activated', async () => {
await updateWorkflowVersionTrigger({
workflowVersionId: firstVersionId,
trigger: {
name: 'Manual Trigger',
type: 'MANUAL',
settings: { outputSchema: {} },
nextStepIds: [],
position: { x: 0, y: 0 },
},
});
const stepResponse = await graphql(
`
mutation CreateWorkflowVersionStep(
$input: CreateWorkflowVersionStepInput!
) {
createWorkflowVersionStep(input: $input) {
stepsDiff
}
}
`,
{
input: {
workflowVersionId: firstVersionId,
stepType: 'FIND_RECORDS',
parentStepId: 'trigger',
position: { x: 200, y: 0 },
},
},
);
expect(stepResponse.body.errors).toBeUndefined();
const activateResponse = await graphql(
`
mutation ActivateWorkflowVersion($workflowVersionId: UUID!) {
activateWorkflowVersion(workflowVersionId: $workflowVersionId)
}
`,
{ workflowVersionId: firstVersionId },
);
expect(activateResponse.body.errors).toBeUndefined();
const coreWorkflow = await waitForCoreWorkflow(
(workflow) => workflow?.statuses[0] === 'ACTIVE',
);
expect(coreWorkflow?.statuses).toEqual(['ACTIVE']);
});
it('should list the workflow as DEACTIVATED once its version is deactivated', async () => {
const deactivateResponse = await graphql(
`
mutation DeactivateWorkflowVersion($workflowVersionId: UUID!) {
deactivateWorkflowVersion(workflowVersionId: $workflowVersionId)
}
`,
{ workflowVersionId: firstVersionId },
);
expect(deactivateResponse.body.errors).toBeUndefined();
const coreWorkflow = await waitForCoreWorkflow(
(workflow) => workflow?.statuses[0] === 'DEACTIVATED',
);
expect(coreWorkflow?.statuses).toEqual(['DEACTIVATED']);
});
it('should paginate with a stable keyset cursor', async () => {
const firstPageResponse = await graphql(`
query {
coreWorkflows(first: 1, orderBy: NAME, orderByDirection: ASC) {
edges {
node {
id
}
cursor
}
pageInfo {
endCursor
hasNextPage
}
totalCount
}
}
`);
expect(firstPageResponse.body.errors).toBeUndefined();
const firstPage = firstPageResponse.body.data.coreWorkflows;
expect(firstPage.edges).toHaveLength(1);
expect(firstPage.totalCount).toBeGreaterThanOrEqual(1);
if (!firstPage.pageInfo.hasNextPage) {
return;
}
const secondPageResponse = await graphql(
`
query SecondPage($after: String!) {
coreWorkflows(
first: 1
after: $after
orderBy: NAME
orderByDirection: ASC
) {
edges {
node {
id
}
cursor
}
}
}
`,
{ after: firstPage.pageInfo.endCursor },
);
expect(secondPageResponse.body.errors).toBeUndefined();
const secondPage = secondPageResponse.body.data.coreWorkflows;
expect(secondPage.edges[0].node.id).not.toBe(firstPage.edges[0].node.id);
});
it('should not list a workflow once it is soft-deleted', async () => {
const softDeletedName = 'Core Workflows Soft Deleted';
const createResponse = await graphql(
`
mutation CreateWorkflow($name: String!) {
createWorkflow(data: { name: $name }) {
id
}
}
`,
{ name: softDeletedName },
);
expect(createResponse.body.errors).toBeUndefined();
softDeletedWorkflowId = createResponse.body.data.createWorkflow.id;
const versionResponse = await graphql(
`
query GetWorkflow($id: UUID!) {
workflow(filter: { id: { eq: $id } }) {
versions {
edges {
node {
id
}
}
}
}
}
`,
{ id: softDeletedWorkflowId },
);
const softDeletedVersionId =
versionResponse.body.data.workflow.versions.edges[0].node.id;
await updateWorkflowVersionTrigger({
workflowVersionId: softDeletedVersionId,
trigger: {
name: 'Manual Trigger',
type: 'MANUAL',
settings: { outputSchema: {} },
nextStepIds: [],
position: { x: 0, y: 0 },
},
});
await graphql(
`
mutation CreateWorkflowVersionStep(
$input: CreateWorkflowVersionStepInput!
) {
createWorkflowVersionStep(input: $input) {
stepsDiff
}
}
`,
{
input: {
workflowVersionId: softDeletedVersionId,
stepType: 'FIND_RECORDS',
parentStepId: 'trigger',
position: { x: 200, y: 0 },
},
},
);
const activateResponse = await graphql(
`
mutation ActivateWorkflowVersion($workflowVersionId: UUID!) {
activateWorkflowVersion(workflowVersionId: $workflowVersionId)
}
`,
{ workflowVersionId: softDeletedVersionId },
);
expect(activateResponse.body.errors).toBeUndefined();
let activated: CoreWorkflow | undefined;
for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt++) {
activated = await findCoreWorkflowByName(softDeletedName);
if (activated?.statuses.includes('ACTIVE')) {
break;
}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
}
expect(activated?.statuses).toEqual(['ACTIVE']);
const deleteResponse = await graphql(
`
mutation DeleteWorkflow($id: UUID!) {
deleteWorkflow(id: $id) {
id
}
}
`,
{ id: softDeletedWorkflowId },
);
expect(deleteResponse.body.errors).toBeUndefined();
let deletedWorkflowStatuses: string[] | undefined;
for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt++) {
const deletedWorkflowResponse = await graphql(
`
query DeletedWorkflow($id: UUID!) {
workflow(
filter: { id: { eq: $id }, not: { deletedAt: { is: "NULL" } } }
) {
id
statuses
}
}
`,
{ id: softDeletedWorkflowId },
);
deletedWorkflowStatuses =
deletedWorkflowResponse.body.data?.workflow?.statuses;
if (deletedWorkflowStatuses?.includes('DEACTIVATED')) {
break;
}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
}
expect(deletedWorkflowStatuses).toEqual(['DEACTIVATED']);
for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt++) {
if (!isDefined(await findCoreWorkflowByName(softDeletedName))) {
break;
}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
}
for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt++) {
expect(await findCoreWorkflowByName(softDeletedName)).toBeUndefined();
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
}
});
it('should not list the workflow once it is destroyed', async () => {
await graphql(
`
mutation DestroyWorkflow($id: UUID!) {
destroyWorkflow(id: $id) {
id
}
}
`,
{ id: workflowId },
);
alreadyDestroyed = true;
const coreWorkflow = await waitForCoreWorkflow(
(workflow) => !isDefined(workflow),
);
expect(coreWorkflow).toBeUndefined();
});
});

View File

@@ -0,0 +1,206 @@
import request from 'supertest';
const client = request(`http://localhost:${APP_PORT}`);
const STEP_FILTER_GROUP_ID = 'a1b2c3d4-1111-4a2b-8c3d-000000000001';
const STEP_FILTER_ID = 'a1b2c3d4-2222-4a2b-8c3d-000000000002';
const FILTER_VALUE = 'trigger-me-co';
type AutomatedTriggerNode = {
type: string;
workflowId: string;
settings: {
eventName?: string;
filter?: {
stepFilters: Array<Record<string, unknown>>;
stepFilterGroups: Array<Record<string, unknown>>;
};
};
};
const graphql = (query: string, variables?: object) =>
client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({ query, variables });
describe('Database event trigger filter (e2e)', () => {
let createdWorkflowId: string | null = null;
let createdWorkflowVersionId: string | null = null;
beforeAll(async () => {
const createWorkflowResponse = await graphql(`
mutation CreateWorkflow {
createWorkflow(data: { name: "DB Event Trigger Filter Test" }) {
id
}
}
`);
expect(createWorkflowResponse.body.errors).toBeUndefined();
createdWorkflowId = createWorkflowResponse.body.data.createWorkflow.id;
const getWorkflowResponse = await graphql(
`
query GetWorkflow($id: UUID!) {
workflow(filter: { id: { eq: $id } }) {
id
versions {
edges {
node {
id
}
}
}
}
}
`,
{ id: createdWorkflowId },
);
expect(getWorkflowResponse.body.errors).toBeUndefined();
createdWorkflowVersionId =
getWorkflowResponse.body.data.workflow.versions.edges[0].node.id;
const databaseEventTrigger = {
name: 'Company is created',
type: 'DATABASE_EVENT',
settings: {
eventName: 'company.created',
outputSchema: {},
filter: {
stepFilterGroups: [
{ id: STEP_FILTER_GROUP_ID, logicalOperator: 'AND' },
],
stepFilters: [
{
id: STEP_FILTER_ID,
type: 'TEXT',
operand: 'CONTAINS',
value: FILTER_VALUE,
stepOutputKey: '{{trigger.properties.after.name}}',
stepFilterGroupId: STEP_FILTER_GROUP_ID,
},
],
},
},
nextStepIds: [],
position: { x: 0, y: 0 },
};
const updateTriggerResponse = await graphql(
`
mutation UpdateWorkflowVersionTrigger(
$input: UpdateWorkflowVersionTriggerInput!
) {
updateWorkflowVersionTrigger(input: $input) {
trigger
}
}
`,
{
input: {
workflowVersionId: createdWorkflowVersionId,
trigger: databaseEventTrigger,
},
},
);
expect(updateTriggerResponse.body.errors).toBeUndefined();
const createStepResponse = await graphql(
`
mutation CreateWorkflowVersionStep(
$input: CreateWorkflowVersionStepInput!
) {
createWorkflowVersionStep(input: $input) {
stepsDiff
}
}
`,
{
input: {
workflowVersionId: createdWorkflowVersionId,
stepType: 'CODE',
parentStepId: 'trigger',
position: { x: 200, y: 0 },
},
},
);
expect(createStepResponse.body.errors).toBeUndefined();
const activateResponse = await graphql(
`
mutation ActivateWorkflowVersion($workflowVersionId: UUID!) {
activateWorkflowVersion(workflowVersionId: $workflowVersionId)
}
`,
{ workflowVersionId: createdWorkflowVersionId },
);
expect(activateResponse.body.errors).toBeUndefined();
expect(activateResponse.body.data.activateWorkflowVersion).toBe(true);
});
afterAll(async () => {
if (createdWorkflowId) {
await graphql(
`
mutation DestroyWorkflow($id: ID!) {
destroyWorkflow(id: $id) {
id
}
}
`,
{ id: createdWorkflowId },
);
}
});
it('syncs the trigger filter onto the workflowAutomatedTrigger row read by the listener', async () => {
const response = await graphql(
`
query WorkflowAutomatedTriggers($workflowId: UUID!) {
workflowAutomatedTriggers(
filter: { workflowId: { eq: $workflowId } }
) {
edges {
node {
type
settings
workflowId
}
}
}
}
`,
{ workflowId: createdWorkflowId },
);
expect(response.body.errors).toBeUndefined();
const automatedTriggers: AutomatedTriggerNode[] =
response.body.data.workflowAutomatedTriggers.edges.map(
(edge: { node: AutomatedTriggerNode }) => edge.node,
);
expect(automatedTriggers).toHaveLength(1);
const automatedTrigger = automatedTriggers[0];
expect(automatedTrigger.type).toBe('DATABASE_EVENT');
expect(automatedTrigger.settings.eventName).toBe('company.created');
const filter = automatedTrigger.settings.filter;
expect(filter).toBeDefined();
expect(filter?.stepFilterGroups).toHaveLength(1);
expect(filter?.stepFilters).toHaveLength(1);
expect(filter?.stepFilters[0]).toMatchObject({
operand: 'CONTAINS',
value: FILTER_VALUE,
stepOutputKey: '{{trigger.properties.after.name}}',
});
});
});

View File

@@ -0,0 +1,149 @@
import request from 'supertest';
import { updateWorkflowVersionTrigger } from 'test/integration/graphql/suites/workflow/utils/update-workflow-version-trigger.util';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
const client = request(`http://localhost:${APP_PORT}`);
const graphql = (query: string, variables?: object) =>
client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({ query, variables });
describe('duplicateWorkflow (e2e)', () => {
let sourceWorkflowId: string;
let sourceVersionId: string;
let duplicatedWorkflowId: string | undefined;
beforeAll(async () => {
const createResponse = await graphql(`
mutation {
createWorkflow(data: { name: "Duplicate Source" }) {
id
}
}
`);
expect(createResponse.body.errors).toBeUndefined();
sourceWorkflowId = createResponse.body.data.createWorkflow.id;
const getResponse = await graphql(
`
query GetWorkflow($id: UUID!) {
workflow(filter: { id: { eq: $id } }) {
id
versions {
edges {
node {
id
}
}
}
}
}
`,
{ id: sourceWorkflowId },
);
sourceVersionId = getResponse.body.data.workflow.versions.edges[0].node.id;
await updateWorkflowVersionTrigger({
workflowVersionId: sourceVersionId,
trigger: {
name: 'Manual Trigger',
type: 'MANUAL',
settings: { outputSchema: {} },
nextStepIds: [],
position: { x: 0, y: 0 },
},
});
const stepResponse = await graphql(
`
mutation CreateWorkflowVersionStep(
$input: CreateWorkflowVersionStepInput!
) {
createWorkflowVersionStep(input: $input) {
stepsDiff
}
}
`,
{
input: {
workflowVersionId: sourceVersionId,
stepType: 'FIND_RECORDS',
parentStepId: 'trigger',
position: { x: 200, y: 0 },
},
},
);
expect(stepResponse.body.errors).toBeUndefined();
});
afterAll(async () => {
for (const id of [duplicatedWorkflowId, sourceWorkflowId]) {
if (id) {
await graphql(
`
mutation DestroyWorkflow($id: ID!) {
destroyWorkflow(id: $id) {
id
}
}
`,
{ id },
);
}
}
});
it('duplicates the workflow and mirrors the new draft version to core', async () => {
const response = await graphql(
`
mutation DuplicateWorkflow($input: DuplicateWorkflowInput!) {
duplicateWorkflow(input: $input) {
id
workflowId
status
trigger
steps
}
}
`,
{
input: {
workflowIdToDuplicate: sourceWorkflowId,
workflowVersionIdToCopy: sourceVersionId,
},
},
);
expect(response.body.errors).toBeUndefined();
const duplicated = response.body.data.duplicateWorkflow;
duplicatedWorkflowId = duplicated?.workflowId;
expect(duplicated.id).not.toBe(sourceVersionId);
expect(duplicated.workflowId).not.toBe(sourceWorkflowId);
expect(duplicated.status).toBe('DRAFT');
expect(duplicated.trigger?.type).toBe('MANUAL');
expect(Array.isArray(duplicated.steps)).toBe(true);
expect(duplicated.steps.length).toBeGreaterThan(0);
const coreRows = await global.testDataSource.query(
`SELECT "id", "steps", "triggers", "status" FROM core."workflowVersion"
WHERE "workspaceId" = $1 AND "workflowId" = $2`,
[SEED_APPLE_WORKSPACE_ID, duplicated.workflowId],
);
expect(coreRows).toHaveLength(1);
expect(coreRows[0].status).toBe('DRAFT');
expect(Array.isArray(coreRows[0].steps)).toBe(true);
expect(coreRows[0].steps.length).toBe(duplicated.steps.length);
expect(Array.isArray(coreRows[0].triggers)).toBe(true);
expect(coreRows[0].triggers[0].type).toBe('MANUAL');
});
});

View File

@@ -0,0 +1,373 @@
import gql from 'graphql-tag';
import request from 'supertest';
import { createManyOperationFactory } from 'test/integration/graphql/utils/create-many-operation-factory.util';
import { deleteManyOperationFactory } from 'test/integration/graphql/utils/delete-many-operation-factory.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { updateWorkflowVersionTrigger } from 'test/integration/graphql/suites/workflow/utils/update-workflow-version-trigger.util';
import {
destroyWorkflowRun,
runWorkflowVersion,
waitForWorkflowCompletion,
} from 'test/integration/graphql/suites/workflow/utils/workflow-run-test.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { isDefined } from 'twenty-shared/utils';
import { v4 as uuidv4 } from 'uuid';
const client = request(`http://localhost:${APP_PORT}`);
const TEST_COMPANY_AIRBNB_ID = '20202020-eeee-4000-8000-000000000001';
const TEST_COMPANY_STRIPE_ID = '20202020-eeee-4000-8000-000000000002';
const TEST_PERSON_AIRBNB_1_ID = '20202020-ffff-4000-8000-000000000001';
const TEST_PERSON_AIRBNB_2_ID = '20202020-ffff-4000-8000-000000000002';
const TEST_PERSON_STRIPE_1_ID = '20202020-ffff-4000-8000-000000000003';
const ALL_TEST_PERSON_IDS = [
TEST_PERSON_AIRBNB_1_ID,
TEST_PERSON_AIRBNB_2_ID,
TEST_PERSON_STRIPE_1_ID,
];
const ALL_TEST_COMPANY_IDS = [TEST_COMPANY_AIRBNB_ID, TEST_COMPANY_STRIPE_ID];
describe('FindRecords workflow action with relation-traversal filter (e2e)', () => {
let createdWorkflowId: string | null = null;
let createdWorkflowVersionId: string | null = null;
let findRecordsStepId: string | null = null;
const createdWorkflowRunIds: string[] = [];
let personCompanyFieldMetadataId: string | null = null;
let companyNameFieldMetadataId: string | null = null;
const lookupFieldMetadataIds = async () => {
const objectsResponse = await makeMetadataAPIRequest({
query: gql`
query Objects($filter: ObjectFilter!, $paging: CursorPaging!) {
objects(filter: $filter, paging: $paging) {
edges {
node {
nameSingular
fieldsList {
id
name
}
}
}
}
}
`,
variables: { paging: { first: 1000 }, filter: {} },
});
expect(objectsResponse.body.errors).toBeUndefined();
const objects: Array<{
nameSingular: string;
fieldsList: Array<{ id: string; name: string }>;
}> = objectsResponse.body.data.objects.edges.map(
(edge: { node: unknown }) => edge.node,
);
const personObject = objects.find((o) => o.nameSingular === 'person');
const companyObject = objects.find((o) => o.nameSingular === 'company');
personCompanyFieldMetadataId =
personObject?.fieldsList.find((f) => f.name === 'company')?.id ?? null;
companyNameFieldMetadataId =
companyObject?.fieldsList.find((f) => f.name === 'name')?.id ?? null;
if (!personCompanyFieldMetadataId || !companyNameFieldMetadataId) {
throw new Error(
`Could not resolve required field metadata ids — person.company=${personCompanyFieldMetadataId}, company.name=${companyNameFieldMetadataId}`,
);
}
};
const seedTestRecords = async () => {
await makeGraphqlAPIRequest(
createManyOperationFactory({
objectMetadataSingularName: 'company',
objectMetadataPluralName: 'companies',
gqlFields: 'id',
data: [
{ id: TEST_COMPANY_AIRBNB_ID, name: 'AirbnbWorkflowTest' },
{ id: TEST_COMPANY_STRIPE_ID, name: 'StripeWorkflowTest' },
],
upsert: true,
}),
);
await makeGraphqlAPIRequest(
createManyOperationFactory({
objectMetadataSingularName: 'person',
objectMetadataPluralName: 'people',
gqlFields: 'id',
data: [
{
id: TEST_PERSON_AIRBNB_1_ID,
companyId: TEST_COMPANY_AIRBNB_ID,
jobTitle: 'workflow-test-airbnb-1',
},
{
id: TEST_PERSON_AIRBNB_2_ID,
companyId: TEST_COMPANY_AIRBNB_ID,
jobTitle: 'workflow-test-airbnb-2',
},
{
id: TEST_PERSON_STRIPE_1_ID,
companyId: TEST_COMPANY_STRIPE_ID,
jobTitle: 'workflow-test-stripe-1',
},
],
upsert: true,
}),
);
};
const buildWorkflow = async () => {
const createWorkflowResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
mutation CreateWorkflow {
createWorkflow(data: { name: "Relation Traversal Find Records Test" }) {
id
}
}
`,
});
expect(createWorkflowResponse.body.errors).toBeUndefined();
createdWorkflowId = createWorkflowResponse.body.data.createWorkflow.id;
const getWorkflowResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
query GetWorkflow($id: UUID!) {
workflow(filter: { id: { eq: $id } }) {
id
versions { edges { node { id } } }
}
}
`,
variables: { id: createdWorkflowId },
});
createdWorkflowVersionId =
getWorkflowResponse.body.data.workflow.versions.edges[0].node.id;
await updateWorkflowVersionTrigger({
workflowVersionId: createdWorkflowVersionId!,
trigger: {
name: 'Manual Trigger',
type: 'MANUAL',
settings: { outputSchema: {} },
nextStepIds: [],
position: { x: 0, y: 0 },
},
});
const createStepResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
mutation CreateWorkflowVersionStep($input: CreateWorkflowVersionStepInput!) {
createWorkflowVersionStep(input: $input) { stepsDiff }
}
`,
variables: {
input: {
workflowVersionId: createdWorkflowVersionId,
stepType: 'FIND_RECORDS',
parentStepId: 'trigger',
position: { x: 200, y: 0 },
},
},
});
expect(createStepResponse.body.errors).toBeUndefined();
const getVersionResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
query GetWorkflowVersion($id: UUID!) {
workflowVersion(filter: { id: { eq: $id } }) { steps }
}
`,
variables: { id: createdWorkflowVersionId },
});
const steps = getVersionResponse.body.data.workflowVersion.steps;
const findRecordsStep = steps.find(
(step: { type: string }) => step.type === 'FIND_RECORDS',
);
expect(findRecordsStep).toBeDefined();
findRecordsStepId = findRecordsStep.id;
const filterGroupId = uuidv4();
const filterId = uuidv4();
const updateStepResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
mutation UpdateWorkflowVersionStep($input: UpdateWorkflowVersionStepInput!) {
updateWorkflowVersionStep(input: $input) { id }
}
`,
variables: {
input: {
workflowVersionId: createdWorkflowVersionId,
step: {
...findRecordsStep,
settings: {
...findRecordsStep.settings,
input: {
...findRecordsStep.settings.input,
objectName: 'person',
limit: 25,
filter: {
recordFilters: [
{
id: filterId,
type: 'TEXT',
label: 'Company → Name',
value: '{{trigger.companyName}}',
operand: 'CONTAINS',
displayValue: '{{trigger.companyName}}',
fieldMetadataId: personCompanyFieldMetadataId,
relationTargetFieldMetadataId:
companyNameFieldMetadataId,
recordFilterGroupId: filterGroupId,
},
],
recordFilterGroups: [
{ id: filterGroupId, logicalOperator: 'AND' },
],
},
},
},
},
},
},
});
expect(updateStepResponse.body.errors).toBeUndefined();
const activateResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
mutation ActivateWorkflowVersion($workflowVersionId: UUID!) {
activateWorkflowVersion(workflowVersionId: $workflowVersionId)
}
`,
variables: { workflowVersionId: createdWorkflowVersionId },
});
expect(activateResponse.body.errors).toBeUndefined();
};
const runWithCompanyName = async (companyName: string | null) => {
const workflowRunId = await runWorkflowVersion({
workflowVersionId: createdWorkflowVersionId!,
payload: { companyName },
});
createdWorkflowRunIds.push(workflowRunId);
return waitForWorkflowCompletion(workflowRunId);
};
beforeAll(async () => {
await lookupFieldMetadataIds();
await seedTestRecords();
await buildWorkflow();
});
afterAll(async () => {
for (const workflowRunId of createdWorkflowRunIds) {
await destroyWorkflowRun(workflowRunId);
}
if (createdWorkflowId) {
await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `mutation DestroyWorkflow($id: ID!) { destroyWorkflow(id: $id) { id } }`,
variables: { id: createdWorkflowId },
});
}
await makeGraphqlAPIRequest(
deleteManyOperationFactory({
objectMetadataSingularName: 'person',
objectMetadataPluralName: 'people',
gqlFields: 'id',
filter: { id: { in: ALL_TEST_PERSON_IDS } },
}),
);
await makeGraphqlAPIRequest(
deleteManyOperationFactory({
objectMetadataSingularName: 'company',
objectMetadataPluralName: 'companies',
gqlFields: 'id',
filter: { id: { in: ALL_TEST_COMPANY_IDS } },
}),
);
});
it('should apply a one-hop relation-traversal filter and return only matching records', async () => {
const workflowRun = await runWithCompanyName('AirbnbWorkflowTest');
expect(workflowRun?.status).toBe('COMPLETED');
expect(workflowRun?.state?.stepInfos?.[findRecordsStepId!]?.status).toBe(
'SUCCESS',
);
const result = workflowRun?.state?.stepInfos?.[findRecordsStepId!]
?.result as
| { all?: Array<{ id: string }>; totalCount?: number }
| undefined;
const returnedIds = (result?.all ?? []).map((record) => record.id);
expect(returnedIds).toContain(TEST_PERSON_AIRBNB_1_ID);
expect(returnedIds).toContain(TEST_PERSON_AIRBNB_2_ID);
expect(returnedIds).not.toContain(TEST_PERSON_STRIPE_1_ID);
});
it('should complete the run when the filter value resolves to empty', async () => {
const workflowRun = await runWithCompanyName(null);
expect(workflowRun?.status).toBe('COMPLETED');
expect(workflowRun?.state?.stepInfos?.[findRecordsStepId!]?.status).toBe(
'SUCCESS',
);
const result = workflowRun?.state?.stepInfos?.[findRecordsStepId!]
?.result as
| {
all?: Array<{ id: string; companyId: string | null }>;
totalCount?: number | string;
}
| undefined;
const returnedRecords = result?.all;
expect(Array.isArray(returnedRecords)).toBe(true);
expect(Number.isFinite(Number(result?.totalCount))).toBe(true);
// An empty filter value can only match people with no company, and other
// specs sharing the database seed those, so assert the shape rather than []
const returnedCompanyIds = (returnedRecords ?? []).map(
(record) => record.companyId,
);
expect(returnedCompanyIds.filter(isDefined)).toEqual([]);
});
});

View File

@@ -0,0 +1,618 @@
import request from 'supertest';
import {
destroyWorkflowRun,
runWorkflowVersion,
waitForWorkflowCompletion,
} from 'test/integration/graphql/suites/workflow/utils/workflow-run-test.util';
import { StepLogicalOperator, ViewFilterOperand } from 'twenty-shared/types';
import { type StepIfElseBranch } from 'twenty-shared/workflow';
import { v4 } from 'uuid';
import { type WorkflowIfElseAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
import { updateWorkflowVersionTrigger } from 'test/integration/graphql/suites/workflow/utils/update-workflow-version-trigger.util';
const client = request(`http://localhost:${APP_PORT}`);
describe('If/Else Workflow (e2e)', () => {
let createdWorkflowId: string | null = null;
let createdWorkflowVersionId: string | null = null;
let ifElseStepId: string | null = null;
let ifBranchEmptyNodeId: string | null = null;
let elseBranchEmptyNodeId: string | null = null;
let elseIfBranchEmptyNodeId: string | null = null;
let elseIfBranchId: string | null = null;
beforeAll(async () => {
const createWorkflowResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
mutation CreateWorkflow {
createWorkflow(data: {
name: "If/Else Test Workflow"
}) {
id
}
}
`,
});
expect(createWorkflowResponse.body.errors).toBeUndefined();
createdWorkflowId = createWorkflowResponse.body.data.createWorkflow.id;
const getWorkflowResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
query GetWorkflow($id: UUID!) {
workflow(filter: { id: { eq: $id } }) {
id
versions {
edges {
node {
id
status
}
}
}
}
}
`,
variables: { id: createdWorkflowId },
});
expect(getWorkflowResponse.body.errors).toBeUndefined();
expect(
getWorkflowResponse.body.data.workflow.versions.edges.length,
).toBeGreaterThan(0);
createdWorkflowVersionId =
getWorkflowResponse.body.data.workflow.versions.edges[0].node.id;
const manualTrigger = {
name: 'Manual Trigger',
type: 'MANUAL',
settings: {
outputSchema: {
number: {
isLeaf: true,
type: 'number',
value: undefined,
},
},
},
nextStepIds: [],
position: { x: 0, y: 0 },
};
const updateWorkflowVersionResponse = await updateWorkflowVersionTrigger({
workflowVersionId: createdWorkflowVersionId!,
trigger: manualTrigger,
});
expect(updateWorkflowVersionResponse.body.errors).toBeUndefined();
const createIfElseStepResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
mutation CreateWorkflowVersionStep($input: CreateWorkflowVersionStepInput!) {
createWorkflowVersionStep(input: $input) {
stepsDiff
}
}
`,
variables: {
input: {
workflowVersionId: createdWorkflowVersionId,
stepType: 'IF_ELSE',
parentStepId: 'trigger',
position: { x: 200, y: 0 },
},
},
});
expect(createIfElseStepResponse.body.errors).toBeUndefined();
const getWorkflowVersionResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
query GetWorkflowVersion($id: UUID!) {
workflowVersion(filter: { id: { eq: $id } }) {
id
steps
}
}
`,
variables: { id: createdWorkflowVersionId },
});
expect(getWorkflowVersionResponse.body.errors).toBeUndefined();
const steps = getWorkflowVersionResponse.body.data.workflowVersion.steps;
const ifElseStep = steps.find(
(step: { type: string }) => step.type === 'IF_ELSE',
);
expect(ifElseStep).toBeDefined();
ifElseStepId = ifElseStep.id;
const branches = ifElseStep.settings.input.branches;
const ifBranch = branches.find(
(branch: StepIfElseBranch) => branch.filterGroupId,
);
const elseBranch = branches.find(
(branch: StepIfElseBranch) => !branch.filterGroupId,
);
expect(ifBranch).toBeDefined();
expect(elseBranch).toBeDefined();
expect(ifBranch.nextStepIds.length).toBeGreaterThan(0);
expect(elseBranch.nextStepIds.length).toBeGreaterThan(0);
ifBranchEmptyNodeId = ifBranch.nextStepIds[0];
elseBranchEmptyNodeId = elseBranch.nextStepIds[0];
expect(ifElseStep.settings.input.stepFilterGroups.length).toBeGreaterThan(
0,
);
expect(ifElseStep.settings.input.stepFilters.length).toBeGreaterThan(0);
expect(ifElseStep.settings.input.branches.length).toBe(2);
const ifFilterGroupId = ifBranch.filterGroupId;
expect(ifFilterGroupId).toBeDefined();
const filterGroup = ifElseStep.settings.input.stepFilterGroups.find(
(g: { id: string }) => g.id === ifFilterGroupId,
);
expect(filterGroup).toBeDefined();
const updateIfElseStepResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
mutation UpdateWorkflowVersionStep($input: UpdateWorkflowVersionStepInput!) {
updateWorkflowVersionStep(input: $input) {
id
type
name
}
}
`,
variables: {
input: {
workflowVersionId: createdWorkflowVersionId,
step: {
...ifElseStep,
settings: {
...ifElseStep.settings,
input: {
...ifElseStep.settings.input,
stepFilters: [
{
id: ifElseStep.settings.input.stepFilters[0].id,
type: 'NUMBER',
stepOutputKey: '{{trigger.number}}',
operand: ViewFilterOperand.IS,
value: '10',
stepFilterGroupId: ifFilterGroupId,
positionInStepFilterGroup: 0,
},
],
},
},
},
},
},
});
expect(updateIfElseStepResponse.body.errors).toBeUndefined();
const createEmptyNodeResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
mutation CreateWorkflowVersionStep($input: CreateWorkflowVersionStepInput!) {
createWorkflowVersionStep(input: $input) {
stepsDiff
}
}
`,
variables: {
input: {
workflowVersionId: createdWorkflowVersionId,
stepType: 'EMPTY',
parentStepId: undefined,
position: { x: 300, y: 100 },
},
},
});
expect(createEmptyNodeResponse.body.errors).toBeUndefined();
const getUpdatedWorkflowVersionResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
query GetWorkflowVersion($id: UUID!) {
workflowVersion(filter: { id: { eq: $id } }) {
id
steps
}
}
`,
variables: { id: createdWorkflowVersionId },
});
const updatedSteps =
getUpdatedWorkflowVersionResponse.body.data.workflowVersion.steps;
const newEmptyNodes = updatedSteps.filter(
(step: { type: string }) => step.type === 'EMPTY',
);
const newElseIfEmptyNode = newEmptyNodes.find(
(node: { id: string }) =>
node.id !== ifBranchEmptyNodeId && node.id !== elseBranchEmptyNodeId,
);
expect(newElseIfEmptyNode).toBeDefined();
elseIfBranchEmptyNodeId = newElseIfEmptyNode.id;
const elseIfFilterGroupId = v4();
const elseIfFilterId = v4();
elseIfBranchId = v4();
const getCurrentIfElseStepResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
query GetWorkflowVersion($id: UUID!) {
workflowVersion(filter: { id: { eq: $id } }) {
id
steps
}
}
`,
variables: { id: createdWorkflowVersionId },
});
const currentSteps =
getCurrentIfElseStepResponse.body.data.workflowVersion.steps;
const currentIfElseStep = currentSteps.find(
(step: { id: string }) => step.id === ifElseStepId,
);
const updatedBranches = [...currentIfElseStep.settings.input.branches];
updatedBranches.splice(
currentIfElseStep.settings.input.branches.length - 1,
0,
{
id: elseIfBranchId,
filterGroupId: elseIfFilterGroupId,
nextStepIds: [elseIfBranchEmptyNodeId],
},
);
const updatedStepFilterGroups = [
...currentIfElseStep.settings.input.stepFilterGroups,
{
id: elseIfFilterGroupId,
logicalOperator: StepLogicalOperator.AND,
positionInStepFilterGroup: 0,
},
];
const updatedStepFilters = [
...currentIfElseStep.settings.input.stepFilters,
{
id: elseIfFilterId,
type: 'NUMBER',
stepOutputKey: '{{trigger.number}}',
operand: ViewFilterOperand.IS,
value: '20',
stepFilterGroupId: elseIfFilterGroupId,
positionInStepFilterGroup: 0,
},
];
const updateIfElseStepWithElseIfResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
mutation UpdateWorkflowVersionStep($input: UpdateWorkflowVersionStepInput!) {
updateWorkflowVersionStep(input: $input) {
id
type
name
}
}
`,
variables: {
input: {
workflowVersionId: createdWorkflowVersionId,
step: {
...currentIfElseStep,
settings: {
...currentIfElseStep.settings,
input: {
...currentIfElseStep.settings.input,
branches: updatedBranches,
stepFilterGroups: updatedStepFilterGroups,
stepFilters: updatedStepFilters,
},
},
},
},
},
});
expect(updateIfElseStepWithElseIfResponse.body.errors).toBeUndefined();
const activateResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
mutation ActivateWorkflowVersion($workflowVersionId: UUID!) {
activateWorkflowVersion(workflowVersionId: $workflowVersionId)
}
`,
variables: { workflowVersionId: createdWorkflowVersionId },
});
expect(activateResponse.body.errors).toBeUndefined();
expect(activateResponse.body.data.activateWorkflowVersion).toBe(true);
});
afterAll(async () => {
if (createdWorkflowId) {
await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
mutation DestroyWorkflow($id: ID!) {
destroyWorkflow(id: $id) {
id
}
}
`,
variables: { id: createdWorkflowId },
});
}
});
const identifyBranches = (branches: StepIfElseBranch[]) => {
const ifBranch = branches[0];
const elseBranch = branches.find((branch) => !branch.filterGroupId);
const elseIfBranches = branches.filter(
(branch, index) =>
index > 0 &&
index < branches.length - 1 &&
branch.filterGroupId !== undefined,
);
return { ifBranch, elseBranch, elseIfBranches };
};
describe('Workflow structure', () => {
it('should verify If/Else workflow exists and is active', async () => {
const response = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
query FindWorkflow($id: UUID!) {
workflow(filter: { id: { eq: $id } }) {
id
name
lastPublishedVersionId
statuses
}
}
`,
variables: { id: createdWorkflowId },
});
expect(response.body.errors).toBeUndefined();
expect(response.body.data.workflow.id).toBe(createdWorkflowId);
expect(response.body.data.workflow.name).toBe('If/Else Test Workflow');
expect(response.body.data.workflow.lastPublishedVersionId).toBe(
createdWorkflowVersionId,
);
expect(response.body.data.workflow.statuses).toContain('ACTIVE');
});
it('should verify If/Else workflow version has correct structure', async () => {
const response = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
query FindWorkflowVersion($id: UUID!) {
workflowVersion(filter: { id: { eq: $id } }) {
id
name
status
trigger
steps
}
}
`,
variables: { id: createdWorkflowVersionId },
});
expect(response.body.errors).toBeUndefined();
const workflowVersion = response.body.data.workflowVersion;
expect(workflowVersion.status).toBe('ACTIVE');
const trigger = workflowVersion.trigger;
expect(trigger.type).toBe('MANUAL');
expect(trigger.nextStepIds).toContain(ifElseStepId);
const steps = workflowVersion.steps;
const ifElseStep = steps.find(
(step: { id: string }) => step.id === ifElseStepId,
);
expect(ifElseStep).toBeDefined();
expect(ifElseStep.type).toBe('IF_ELSE');
expect(ifElseStep.name).toBe('If/Else');
expect(ifElseStep.settings.input.branches.length).toBe(3);
const { ifBranch, elseBranch, elseIfBranches } = identifyBranches(
ifElseStep.settings.input.branches,
);
expect(ifBranch?.filterGroupId).toBeDefined();
expect(elseBranch?.filterGroupId).toBeUndefined();
expect(ifBranch?.nextStepIds).toContain(ifBranchEmptyNodeId);
expect(elseBranch?.nextStepIds).toContain(elseBranchEmptyNodeId);
expect(elseIfBranches.length).toBe(1);
expect(elseIfBranches[0].filterGroupId).toBeDefined();
expect(elseIfBranches[0].nextStepIds).toContain(elseIfBranchEmptyNodeId);
});
});
describe('If/Else branching execution', () => {
const getIfElseStepWithBranches = async (): Promise<
Pick<WorkflowIfElseAction, 'id' | 'settings'>
> => {
const getWorkflowVersionResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
query GetWorkflowVersion($id: UUID!) {
workflowVersion(filter: { id: { eq: $id } }) {
id
steps
}
}
`,
variables: { id: createdWorkflowVersionId },
});
expect(getWorkflowVersionResponse.body.errors).toBeUndefined();
const steps = getWorkflowVersionResponse.body.data.workflowVersion.steps;
const ifElseStep = steps.find(
(step: { id: string }) => step.id === ifElseStepId,
) as WorkflowIfElseAction | undefined;
expect(ifElseStep).toBeDefined();
return ifElseStep!;
};
const verifyBranchExecution = async ({
payload,
expectedBranchType,
}: {
payload: { number: number };
expectedBranchType: 'if' | 'else' | 'else-if';
}) => {
const workflowRunId = await runWorkflowVersion({
workflowVersionId: createdWorkflowVersionId!,
payload,
});
const workflowRun = await waitForWorkflowCompletion(workflowRunId);
expect(workflowRun?.status).toBe('COMPLETED');
expect(workflowRun?.state?.stepInfos?.trigger?.status).toBe('SUCCESS');
expect(workflowRun?.state?.stepInfos?.[ifElseStepId!]?.status).toBe(
'SUCCESS',
);
const ifElseStepResult = workflowRun?.state?.stepInfos?.[ifElseStepId!]
?.result as { matchingBranchId?: string } | undefined;
expect(ifElseStepResult?.matchingBranchId).toBeDefined();
const ifElseStep = await getIfElseStepWithBranches();
const matchedBranch = ifElseStep.settings.input.branches.find(
(branch) => branch.id === ifElseStepResult?.matchingBranchId,
);
if (!matchedBranch) {
const branchIds = ifElseStep.settings.input.branches.map((b) => b.id);
throw new Error(
`Branch with ID ${ifElseStepResult?.matchingBranchId} not found. Available branch IDs: ${branchIds.join(', ')}`,
);
}
const { ifBranch, elseBranch, elseIfBranches } = identifyBranches(
ifElseStep.settings.input.branches,
);
let expectedBranch: StepIfElseBranch | undefined;
let expectedEmptyNodeId: string | null = null;
let otherEmptyNodeIds: (string | null)[] = [];
if (expectedBranchType === 'if') {
expectedBranch = ifBranch;
expectedEmptyNodeId = ifBranchEmptyNodeId;
otherEmptyNodeIds = [elseBranchEmptyNodeId, elseIfBranchEmptyNodeId];
} else if (expectedBranchType === 'else') {
expectedBranch = elseBranch;
expectedEmptyNodeId = elseBranchEmptyNodeId;
otherEmptyNodeIds = [ifBranchEmptyNodeId, elseIfBranchEmptyNodeId];
} else if (expectedBranchType === 'else-if') {
expectedBranch = elseIfBranches.find(
(branch) => branch.id === elseIfBranchId,
);
expectedEmptyNodeId = elseIfBranchEmptyNodeId;
otherEmptyNodeIds = [ifBranchEmptyNodeId, elseBranchEmptyNodeId];
}
expect(matchedBranch.id).toBe(expectedBranch?.id);
expect(matchedBranch.nextStepIds).toContain(expectedEmptyNodeId);
otherEmptyNodeIds.forEach((id) => {
if (id) {
expect(matchedBranch.nextStepIds).not.toContain(id);
}
});
await destroyWorkflowRun(workflowRunId);
};
it('should execute IF branch when condition is true', async () => {
await verifyBranchExecution({
payload: { number: 10 },
expectedBranchType: 'if',
});
});
it('should execute ELSE branch when condition is false', async () => {
await verifyBranchExecution({
payload: { number: 5 },
expectedBranchType: 'else',
});
});
it('should execute ELSE-IF branch when condition is true', async () => {
await verifyBranchExecution({
payload: { number: 20 },
expectedBranchType: 'else-if',
});
});
});
});

View File

@@ -0,0 +1,403 @@
import request from 'supertest';
import { updateWorkflowVersionTrigger } from 'test/integration/graphql/suites/workflow/utils/update-workflow-version-trigger.util';
import {
destroyWorkflowRun,
runWorkflowVersion,
waitForWorkflowCompletion,
} from 'test/integration/graphql/suites/workflow/utils/workflow-run-test.util';
const client = request(`http://localhost:${APP_PORT}`);
const graphql = async (query: string, variables?: Record<string, unknown>) => {
const response = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({ query, variables });
expect(response.body.errors).toBeUndefined();
return response.body.data;
};
describe('Pick Record Workflow - load balanced (e2e)', () => {
let createdWorkflowId: string | null = null;
let createdWorkflowVersionId: string | null = null;
let pickRecordStepId: string | null = null;
let leastLoadedCompanyId: string | null = null;
let mostLoadedCompanyId: string | null = null;
let createdOpportunityId: string | null = null;
beforeAll(async () => {
const createWorkflowData = await graphql(`
mutation CreateWorkflow {
createWorkflow(data: { name: "Pick Record Load Balanced Test" }) {
id
}
}
`);
createdWorkflowId = createWorkflowData.createWorkflow.id;
const getWorkflowData = await graphql(
`
query GetWorkflow($id: UUID!) {
workflow(filter: { id: { eq: $id } }) {
versions {
edges {
node {
id
}
}
}
}
}
`,
{ id: createdWorkflowId },
);
createdWorkflowVersionId =
getWorkflowData.workflow.versions.edges[0].node.id;
await updateWorkflowVersionTrigger({
workflowVersionId: createdWorkflowVersionId!,
trigger: {
name: 'Manual Trigger',
type: 'MANUAL',
settings: { outputSchema: {} },
nextStepIds: [],
position: { x: 0, y: 0 },
},
});
await graphql(
`
mutation CreateWorkflowVersionStep(
$input: CreateWorkflowVersionStepInput!
) {
createWorkflowVersionStep(input: $input) {
stepsDiff
}
}
`,
{
input: {
workflowVersionId: createdWorkflowVersionId,
stepType: 'PICK_RECORD',
parentStepId: 'trigger',
position: { x: 200, y: 0 },
},
},
);
const getStepsData = await graphql(
`
query GetWorkflowVersion($id: UUID!) {
workflowVersion(filter: { id: { eq: $id } }) {
steps
}
}
`,
{ id: createdWorkflowVersionId },
);
const pickRecordStep = getStepsData.workflowVersion.steps.find(
(step: { type: string }) => step.type === 'PICK_RECORD',
);
pickRecordStepId = pickRecordStep.id;
// Two fresh companies start with zero related opportunities; adding one
// opportunity to the second makes the first the least loaded candidate.
const companyOneData = await graphql(`
mutation {
createCompany(data: { name: "Pick Record LB company one" }) {
id
}
}
`);
const companyTwoData = await graphql(`
mutation {
createCompany(data: { name: "Pick Record LB company two" }) {
id
}
}
`);
// The executor orders candidates by id before selecting. Attach the
// opportunity to the id-first company so the least-loaded candidate is the
// id-second one: load balancing must pick it, which also guards against a
// regression where a broken count would fall back to the first candidate.
const [firstSortedCompanyId, secondSortedCompanyId] = [
companyOneData.createCompany.id,
companyTwoData.createCompany.id,
].sort((idA: string, idB: string) => idA.localeCompare(idB));
mostLoadedCompanyId = firstSortedCompanyId;
leastLoadedCompanyId = secondSortedCompanyId;
const opportunityData = await graphql(
`
mutation CreateOpportunity($companyId: UUID!) {
createOpportunity(
data: { name: "Pick Record LB Opportunity", companyId: $companyId }
) {
id
}
}
`,
{ companyId: mostLoadedCompanyId },
);
createdOpportunityId = opportunityData.createOpportunity.id;
await graphql(
`
mutation UpdateWorkflowVersionStep(
$input: UpdateWorkflowVersionStepInput!
) {
updateWorkflowVersionStep(input: $input) {
id
}
}
`,
{
input: {
workflowVersionId: createdWorkflowVersionId,
step: {
...pickRecordStep,
settings: {
...pickRecordStep.settings,
input: {
objectName: 'company',
strategy: 'LOAD_BALANCED',
recordIds: [leastLoadedCompanyId, mostLoadedCompanyId],
loadBalance: {
objectNameSingular: 'opportunity',
fieldName: 'company',
},
},
},
},
},
},
);
const activateData = await graphql(
`
mutation ActivateWorkflowVersion($workflowVersionId: UUID!) {
activateWorkflowVersion(workflowVersionId: $workflowVersionId)
}
`,
{ workflowVersionId: createdWorkflowVersionId },
);
expect(activateData.activateWorkflowVersion).toBe(true);
});
afterAll(async () => {
if (createdOpportunityId) {
await graphql(
`
mutation DeleteOpportunity($id: UUID!) {
deleteOpportunity(id: $id) {
id
}
}
`,
{ id: createdOpportunityId },
);
}
for (const companyId of [leastLoadedCompanyId, mostLoadedCompanyId]) {
if (companyId) {
await graphql(
`
mutation DeleteCompany($id: UUID!) {
deleteCompany(id: $id) {
id
}
}
`,
{ id: companyId },
);
}
}
if (createdWorkflowId) {
await graphql(
`
mutation DestroyWorkflow($id: ID!) {
destroyWorkflow(id: $id) {
id
}
}
`,
{ id: createdWorkflowId },
);
}
});
it('picks the candidate with the fewest related records', async () => {
const workflowRunId = await runWorkflowVersion({
workflowVersionId: createdWorkflowVersionId!,
payload: {},
});
const workflowRun = await waitForWorkflowCompletion(workflowRunId);
expect(workflowRun?.status).toBe('COMPLETED');
const result = workflowRun?.state?.stepInfos?.[pickRecordStepId!]
?.result as { id: string } | undefined;
await destroyWorkflowRun(workflowRunId);
expect(result?.id).toBe(leastLoadedCompanyId);
});
it('rejects activation when the count-by relation points to another object', async () => {
const createData = await graphql(`
mutation CreateWorkflow {
createWorkflow(data: { name: "Pick Record LB misrouted relation" }) {
id
}
}
`);
const misroutedWorkflowId = createData.createWorkflow.id;
try {
const workflowData = await graphql(
`
query GetWorkflow($id: UUID!) {
workflow(filter: { id: { eq: $id } }) {
versions {
edges {
node {
id
}
}
}
}
}
`,
{ id: misroutedWorkflowId },
);
const misroutedWorkflowVersionId =
workflowData.workflow.versions.edges[0].node.id;
await updateWorkflowVersionTrigger({
workflowVersionId: misroutedWorkflowVersionId,
trigger: {
name: 'Manual Trigger',
type: 'MANUAL',
settings: { outputSchema: {} },
nextStepIds: [],
position: { x: 0, y: 0 },
},
});
await graphql(
`
mutation CreateWorkflowVersionStep(
$input: CreateWorkflowVersionStepInput!
) {
createWorkflowVersionStep(input: $input) {
stepsDiff
}
}
`,
{
input: {
workflowVersionId: misroutedWorkflowVersionId,
stepType: 'PICK_RECORD',
parentStepId: 'trigger',
position: { x: 200, y: 0 },
},
},
);
const stepsData = await graphql(
`
query GetWorkflowVersion($id: UUID!) {
workflowVersion(filter: { id: { eq: $id } }) {
steps
}
}
`,
{ id: misroutedWorkflowVersionId },
);
const pickRecordStep = stepsData.workflowVersion.steps.find(
(step: { type: string }) => step.type === 'PICK_RECORD',
);
// pointOfContact is a many-to-one relation on opportunity, but it points
// to person, not the company pool — the silent misrouting the guard blocks.
await graphql(
`
mutation UpdateWorkflowVersionStep(
$input: UpdateWorkflowVersionStepInput!
) {
updateWorkflowVersionStep(input: $input) {
id
}
}
`,
{
input: {
workflowVersionId: misroutedWorkflowVersionId,
step: {
...pickRecordStep,
settings: {
...pickRecordStep.settings,
input: {
objectName: 'company',
strategy: 'LOAD_BALANCED',
recordIds: [],
loadBalance: {
objectNameSingular: 'opportunity',
fieldName: 'pointOfContact',
},
},
},
},
},
},
);
const activateResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
mutation ActivateWorkflowVersion($workflowVersionId: UUID!) {
activateWorkflowVersion(workflowVersionId: $workflowVersionId)
}
`,
variables: { workflowVersionId: misroutedWorkflowVersionId },
});
expect(activateResponse.body.errors).toBeDefined();
expect(activateResponse.body.errors[0].message).toContain(
'many-to-one relation',
);
expect(activateResponse.body.data?.activateWorkflowVersion).not.toBe(
true,
);
} finally {
await graphql(
`
mutation DestroyWorkflow($id: ID!) {
destroyWorkflow(id: $id) {
id
}
}
`,
{ id: misroutedWorkflowId },
);
}
});
});

View File

@@ -0,0 +1,230 @@
import request from 'supertest';
import { updateWorkflowVersionTrigger } from 'test/integration/graphql/suites/workflow/utils/update-workflow-version-trigger.util';
import {
destroyWorkflowRun,
runWorkflowVersion,
waitForWorkflowCompletion,
} from 'test/integration/graphql/suites/workflow/utils/workflow-run-test.util';
const client = request(`http://localhost:${APP_PORT}`);
describe('Pick Record Workflow - round robin (e2e)', () => {
let createdWorkflowId: string | null = null;
let createdWorkflowVersionId: string | null = null;
let pickRecordStepId: string | null = null;
let orderedCandidateRecordIds: string[] = [];
beforeAll(async () => {
const createWorkflowResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
mutation CreateWorkflow {
createWorkflow(data: { name: "Pick Record Round Robin Test" }) {
id
}
}
`,
});
createdWorkflowId = createWorkflowResponse.body.data.createWorkflow.id;
const getWorkflowResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
query GetWorkflow($id: UUID!) {
workflow(filter: { id: { eq: $id } }) {
versions {
edges {
node {
id
}
}
}
}
}
`,
variables: { id: createdWorkflowId },
});
createdWorkflowVersionId =
getWorkflowResponse.body.data.workflow.versions.edges[0].node.id;
await updateWorkflowVersionTrigger({
workflowVersionId: createdWorkflowVersionId!,
trigger: {
name: 'Manual Trigger',
type: 'MANUAL',
settings: { outputSchema: {} },
nextStepIds: [],
position: { x: 0, y: 0 },
},
});
await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
mutation CreateWorkflowVersionStep($input: CreateWorkflowVersionStepInput!) {
createWorkflowVersionStep(input: $input) {
stepsDiff
}
}
`,
variables: {
input: {
workflowVersionId: createdWorkflowVersionId,
stepType: 'PICK_RECORD',
parentStepId: 'trigger',
position: { x: 200, y: 0 },
},
},
});
const getStepsResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
query GetWorkflowVersion($id: UUID!) {
workflowVersion(filter: { id: { eq: $id } }) {
steps
}
}
`,
variables: { id: createdWorkflowVersionId },
});
const pickRecordStep =
getStepsResponse.body.data.workflowVersion.steps.find(
(step: { type: string }) => step.type === 'PICK_RECORD',
);
pickRecordStepId = pickRecordStep.id;
const companiesResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
query Companies {
companies(first: 3) {
edges {
node {
id
}
}
}
}
`,
});
const candidateRecordIds = companiesResponse.body.data.companies.edges.map(
(edge: { node: { id: string } }) => edge.node.id,
);
expect(candidateRecordIds.length).toBe(3);
// The executor sorts the resolved pool deterministically by id before
// applying the round-robin cursor, so the expected cycle order is the
// pool sorted the same way.
orderedCandidateRecordIds = [...candidateRecordIds].sort(
(idA: string, idB: string) => idA.localeCompare(idB),
);
await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
mutation UpdateWorkflowVersionStep($input: UpdateWorkflowVersionStepInput!) {
updateWorkflowVersionStep(input: $input) {
id
}
}
`,
variables: {
input: {
workflowVersionId: createdWorkflowVersionId,
step: {
...pickRecordStep,
settings: {
...pickRecordStep.settings,
input: {
objectName: 'company',
strategy: 'ROUND_ROBIN',
recordIds: candidateRecordIds,
},
},
},
},
},
});
const activateResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
mutation ActivateWorkflowVersion($workflowVersionId: UUID!) {
activateWorkflowVersion(workflowVersionId: $workflowVersionId)
}
`,
variables: { workflowVersionId: createdWorkflowVersionId },
});
expect(activateResponse.body.data.activateWorkflowVersion).toBe(true);
});
afterAll(async () => {
if (createdWorkflowId) {
await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
mutation DestroyWorkflow($id: ID!) {
destroyWorkflow(id: $id) {
id
}
}
`,
variables: { id: createdWorkflowId },
});
}
});
it('cycles through the pool in order on consecutive runs', async () => {
const pickedRecordIds: string[] = [];
for (let runIndex = 0; runIndex < 4; runIndex++) {
const workflowRunId = await runWorkflowVersion({
workflowVersionId: createdWorkflowVersionId!,
payload: {},
});
const workflowRun = await waitForWorkflowCompletion(workflowRunId);
expect(workflowRun?.status).toBe('COMPLETED');
const result = workflowRun?.state?.stepInfos?.[pickRecordStepId!]
?.result as { id: string } | undefined;
expect(result?.id).toBeDefined();
pickedRecordIds.push(result!.id);
await destroyWorkflowRun(workflowRunId);
}
expect(pickedRecordIds).toEqual([
orderedCandidateRecordIds[0],
orderedCandidateRecordIds[1],
orderedCandidateRecordIds[2],
orderedCandidateRecordIds[0],
]);
});
});

View File

@@ -0,0 +1,242 @@
import request from 'supertest';
import { updateWorkflowVersionTrigger } from 'test/integration/graphql/suites/workflow/utils/update-workflow-version-trigger.util';
import {
destroyWorkflowRun,
runWorkflowVersion,
waitForWorkflowCompletion,
} from 'test/integration/graphql/suites/workflow/utils/workflow-run-test.util';
const client = request(`http://localhost:${APP_PORT}`);
describe('Pick Record Workflow (e2e)', () => {
let createdWorkflowId: string | null = null;
let createdWorkflowVersionId: string | null = null;
let pickRecordStepId: string | null = null;
let candidateRecordIds: string[] = [];
beforeAll(async () => {
const createWorkflowResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
mutation CreateWorkflow {
createWorkflow(data: { name: "Pick Record Test Workflow" }) {
id
}
}
`,
});
expect(createWorkflowResponse.body.errors).toBeUndefined();
createdWorkflowId = createWorkflowResponse.body.data.createWorkflow.id;
const getWorkflowResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
query GetWorkflow($id: UUID!) {
workflow(filter: { id: { eq: $id } }) {
id
versions {
edges {
node {
id
}
}
}
}
}
`,
variables: { id: createdWorkflowId },
});
createdWorkflowVersionId =
getWorkflowResponse.body.data.workflow.versions.edges[0].node.id;
const updateTriggerResponse = await updateWorkflowVersionTrigger({
workflowVersionId: createdWorkflowVersionId!,
trigger: {
name: 'Manual Trigger',
type: 'MANUAL',
settings: { outputSchema: {} },
nextStepIds: [],
position: { x: 0, y: 0 },
},
});
expect(updateTriggerResponse.body.errors).toBeUndefined();
const createStepResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
mutation CreateWorkflowVersionStep($input: CreateWorkflowVersionStepInput!) {
createWorkflowVersionStep(input: $input) {
stepsDiff
}
}
`,
variables: {
input: {
workflowVersionId: createdWorkflowVersionId,
stepType: 'PICK_RECORD',
parentStepId: 'trigger',
position: { x: 200, y: 0 },
},
},
});
expect(createStepResponse.body.errors).toBeUndefined();
const getStepsResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
query GetWorkflowVersion($id: UUID!) {
workflowVersion(filter: { id: { eq: $id } }) {
id
steps
}
}
`,
variables: { id: createdWorkflowVersionId },
});
const steps = getStepsResponse.body.data.workflowVersion.steps;
const pickRecordStep = steps.find(
(step: { type: string }) => step.type === 'PICK_RECORD',
);
expect(pickRecordStep).toBeDefined();
pickRecordStepId = pickRecordStep.id;
const companiesResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
query Companies {
companies(first: 2) {
edges {
node {
id
}
}
}
}
`,
});
expect(companiesResponse.body.errors).toBeUndefined();
candidateRecordIds = companiesResponse.body.data.companies.edges.map(
(edge: { node: { id: string } }) => edge.node.id,
);
expect(candidateRecordIds.length).toBe(2);
const updateStepResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
mutation UpdateWorkflowVersionStep($input: UpdateWorkflowVersionStepInput!) {
updateWorkflowVersionStep(input: $input) {
id
}
}
`,
variables: {
input: {
workflowVersionId: createdWorkflowVersionId,
step: {
...pickRecordStep,
settings: {
...pickRecordStep.settings,
input: {
objectName: 'company',
strategy: 'RANDOM',
recordIds: candidateRecordIds,
},
},
},
},
},
});
expect(updateStepResponse.body.errors).toBeUndefined();
const activateResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
mutation ActivateWorkflowVersion($workflowVersionId: UUID!) {
activateWorkflowVersion(workflowVersionId: $workflowVersionId)
}
`,
variables: { workflowVersionId: createdWorkflowVersionId },
});
expect(activateResponse.body.errors).toBeUndefined();
expect(activateResponse.body.data.activateWorkflowVersion).toBe(true);
});
afterAll(async () => {
if (createdWorkflowId) {
await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
mutation DestroyWorkflow($id: ID!) {
destroyWorkflow(id: $id) {
id
}
}
`,
variables: { id: createdWorkflowId },
});
}
});
const pickRecordOnce = async (): Promise<{ id: string }> => {
const workflowRunId = await runWorkflowVersion({
workflowVersionId: createdWorkflowVersionId!,
payload: {},
});
const workflowRun = await waitForWorkflowCompletion(workflowRunId);
expect(workflowRun?.status).toBe('COMPLETED');
expect(workflowRun?.state?.stepInfos?.[pickRecordStepId!]?.status).toBe(
'SUCCESS',
);
const result = workflowRun?.state?.stepInfos?.[pickRecordStepId!]
?.result as { id: string } | undefined;
await destroyWorkflowRun(workflowRunId);
expect(result?.id).toBeDefined();
return result!;
};
it('picks a record and exposes it as the step output', async () => {
const pickedRecord = await pickRecordOnce();
expect(candidateRecordIds).toContain(pickedRecord.id);
});
it('only ever picks records from the configured pool', async () => {
for (let runIndex = 0; runIndex < 5; runIndex++) {
const pickedRecord = await pickRecordOnce();
expect(candidateRecordIds).toContain(pickedRecord.id);
}
});
});

View File

@@ -0,0 +1,382 @@
import request from 'supertest';
import {
destroyWorkflowRun,
getWorkflowRun,
runWorkflowVersion,
waitForWorkflowCompletion,
waitForWorkflowRunStatus,
waitForWorkflowRunStepStatus,
} from 'test/integration/graphql/suites/workflow/utils/workflow-run-test.util';
import { v4 as uuidv4 } from 'uuid';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
import { getWorkflowPrefillIds } from 'src/engine/workspace-manager/standard-objects-prefill-data/utils/prefill-workflows.util';
const client = request(`http://localhost:${APP_PORT}`);
const {
quickLeadWorkflowId: QUICK_LEAD_WORKFLOW_ID,
quickLeadWorkflowVersionId: QUICK_LEAD_WORKFLOW_VERSION_ID,
} = getWorkflowPrefillIds(SEED_APPLE_WORKSPACE_ID);
const FORM_STEP_ID = '6e089bc9-aabd-435f-865f-f31c01c8f4a7';
describe('Quick Lead Workflow (e2e)', () => {
let createdWorkflowRunId: string | null = null;
afterAll(async () => {
if (createdWorkflowRunId) {
await destroyWorkflowRun(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');
const trigger = workflowVersion.trigger;
expect(trigger.type).toBe('MANUAL');
expect(trigger.nextStepIds).toContain(FORM_STEP_ID);
const steps = workflowVersion.steps;
expect(steps).toHaveLength(3);
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');
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');
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 workflowRunId = await runWorkflowVersion({
workflowVersionId: QUICK_LEAD_WORKFLOW_VERSION_ID,
});
createdWorkflowRunId = workflowRunId;
await waitForWorkflowRunStatus(workflowRunId, 'RUNNING');
const workflowRun = await waitForWorkflowRunStepStatus(
workflowRunId,
FORM_STEP_ID,
'PENDING',
);
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 workflowRunId = await runWorkflowVersion({
workflowVersionId: QUICK_LEAD_WORKFLOW_VERSION_ID,
});
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 destroyWorkflowRun(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 destroyWorkflowRun(testWorkflowRunId);
}
});
it('should complete full workflow: trigger → submit form → create Company and Person', async () => {
testWorkflowRunId = await runWorkflowVersion({
workflowVersionId: QUICK_LEAD_WORKFLOW_VERSION_ID,
});
expect(testWorkflowRunId).toBeDefined();
let workflowRun = await waitForWorkflowRunStatus(
testWorkflowRunId as string,
'RUNNING',
);
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 waitForWorkflowCompletion(
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

@@ -0,0 +1,308 @@
import { STEP_RETRY_DELAYS_MS } from 'twenty-shared/workflow';
import request from 'supertest';
import {
destroyWorkflowRun,
runWorkflowVersion,
waitForWorkflowCompletion,
} from 'test/integration/graphql/suites/workflow/utils/workflow-run-test.util';
import {
type WorkflowAction,
type WorkflowHttpRequestAction,
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
import { updateWorkflowVersionTrigger } from 'test/integration/graphql/suites/workflow/utils/update-workflow-version-trigger.util';
const client = request(`http://localhost:${APP_PORT}`);
const BLOCKED_REQUEST_URL = 'http://127.0.0.1:1/';
const TOTAL_RETRY_DELAY_MS = STEP_RETRY_DELAYS_MS.reduce(
(total, delay) => total + delay,
0,
);
const RETRY_TEST_TIMEOUT_MS = TOTAL_RETRY_DELAY_MS + 60_000;
describe('Step error handling workflow (e2e)', () => {
let createdWorkflowId: string | null = null;
let createdWorkflowVersionId: string | null = null;
let httpRequestStepId: string | null = null;
let filterStepId: string | null = null;
const getSteps = async (): Promise<WorkflowAction[]> => {
const response = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
query GetWorkflowVersion($id: UUID!) {
workflowVersion(filter: { id: { eq: $id } }) {
id
steps
}
}
`,
variables: { id: createdWorkflowVersionId },
});
expect(response.body.errors).toBeUndefined();
return response.body.data.workflowVersion.steps;
};
const createStep = async ({
stepType,
parentStepId,
}: {
stepType: string;
parentStepId: string;
}) => {
const response = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
mutation CreateWorkflowVersionStep($input: CreateWorkflowVersionStepInput!) {
createWorkflowVersionStep(input: $input) {
stepsDiff
}
}
`,
variables: {
input: {
workflowVersionId: createdWorkflowVersionId,
stepType,
parentStepId,
position: { x: 200, y: 0 },
},
},
});
expect(response.body.errors).toBeUndefined();
};
const setUpFailingHttpRequestStep = async ({
continueOnFailure,
retryOnFailure = 0,
}: {
continueOnFailure: boolean;
retryOnFailure?: number;
}) => {
const steps = await getSteps();
const httpRequestStep = steps.find(
(step) => step.id === httpRequestStepId,
) as WorkflowHttpRequestAction;
const response = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
mutation UpdateWorkflowVersionStep($input: UpdateWorkflowVersionStepInput!) {
updateWorkflowVersionStep(input: $input) {
id
}
}
`,
variables: {
input: {
workflowVersionId: createdWorkflowVersionId,
step: {
...httpRequestStep,
settings: {
...httpRequestStep.settings,
input: {
...httpRequestStep.settings.input,
url: BLOCKED_REQUEST_URL,
},
errorHandlingOptions: {
...httpRequestStep.settings.errorHandlingOptions,
continueOnFailure: { value: continueOnFailure },
retryOnFailure: { value: retryOnFailure },
},
},
},
},
},
});
expect(response.body.errors).toBeUndefined();
};
beforeAll(async () => {
const createWorkflowResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
mutation CreateWorkflow {
createWorkflow(data: {
name: "Continue On Failure Test Workflow"
}) {
id
}
}
`,
});
expect(createWorkflowResponse.body.errors).toBeUndefined();
createdWorkflowId = createWorkflowResponse.body.data.createWorkflow.id;
const getWorkflowResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
query GetWorkflow($id: UUID!) {
workflow(filter: { id: { eq: $id } }) {
id
versions {
edges {
node {
id
}
}
}
}
}
`,
variables: { id: createdWorkflowId },
});
expect(getWorkflowResponse.body.errors).toBeUndefined();
createdWorkflowVersionId =
getWorkflowResponse.body.data.workflow.versions.edges[0].node.id;
const updateTriggerResponse = await updateWorkflowVersionTrigger({
workflowVersionId: createdWorkflowVersionId!,
trigger: {
name: 'Manual Trigger',
type: 'MANUAL',
settings: { outputSchema: {} },
nextStepIds: [],
position: { x: 0, y: 0 },
},
});
expect(updateTriggerResponse.body.errors).toBeUndefined();
await createStep({ stepType: 'HTTP_REQUEST', parentStepId: 'trigger' });
const stepsAfterHttpRequestCreation = await getSteps();
httpRequestStepId =
stepsAfterHttpRequestCreation.find((step) => step.type === 'HTTP_REQUEST')
?.id ?? null;
expect(httpRequestStepId).not.toBeNull();
await createStep({ stepType: 'FILTER', parentStepId: httpRequestStepId! });
const stepsAfterFilterCreation = await getSteps();
filterStepId =
stepsAfterFilterCreation.find((step) => step.type === 'FILTER')?.id ??
null;
expect(filterStepId).not.toBeNull();
});
afterAll(async () => {
if (createdWorkflowId) {
await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
mutation DestroyWorkflow($id: ID!) {
destroyWorkflow(id: $id) {
id
}
}
`,
variables: { id: createdWorkflowId },
});
}
});
it('should run the next step and complete the run when the failing step continues on failure', async () => {
await setUpFailingHttpRequestStep({ continueOnFailure: true });
const workflowRunId = await runWorkflowVersion({
workflowVersionId: createdWorkflowVersionId!,
});
try {
const workflowRun = await waitForWorkflowCompletion(workflowRunId);
expect(workflowRun?.status).toBe('COMPLETED');
const httpRequestStepInfo =
workflowRun?.state?.stepInfos?.[httpRequestStepId!];
expect(httpRequestStepInfo?.status).toBe('FAILED_SAFELY');
expect(httpRequestStepInfo?.error).toBeDefined();
expect(workflowRun?.state?.stepInfos?.[filterStepId!]?.status).toBe(
'SUCCESS',
);
} finally {
await destroyWorkflowRun(workflowRunId);
}
});
it('should fail the run and leave the next step not started when the failing step does not continue on failure', async () => {
await setUpFailingHttpRequestStep({ continueOnFailure: false });
const workflowRunId = await runWorkflowVersion({
workflowVersionId: createdWorkflowVersionId!,
});
try {
const workflowRun = await waitForWorkflowCompletion(workflowRunId);
expect(workflowRun?.status).toBe('FAILED');
expect(workflowRun?.state?.stepInfos?.[httpRequestStepId!]?.status).toBe(
'FAILED',
);
expect(workflowRun?.state?.stepInfos?.[filterStepId!]?.status).toBe(
'NOT_STARTED',
);
} finally {
await destroyWorkflowRun(workflowRunId);
}
});
it(
'should retry the failing step before failing the run when the step retries on failure',
async () => {
await setUpFailingHttpRequestStep({
continueOnFailure: false,
retryOnFailure: STEP_RETRY_DELAYS_MS.length,
});
const workflowRunId = await runWorkflowVersion({
workflowVersionId: createdWorkflowVersionId!,
});
try {
const workflowRun = await waitForWorkflowCompletion(
workflowRunId,
Math.ceil(TOTAL_RETRY_DELAY_MS / 500) + 60,
);
expect(workflowRun?.status).toBe('FAILED');
const httpRequestStepInfo =
workflowRun?.state?.stepInfos?.[httpRequestStepId!];
expect(httpRequestStepInfo?.status).toBe('FAILED');
expect(httpRequestStepInfo?.history).toHaveLength(
STEP_RETRY_DELAYS_MS.length,
);
} finally {
await destroyWorkflowRun(workflowRunId);
}
},
RETRY_TEST_TIMEOUT_MS,
);
});

View File

@@ -0,0 +1,27 @@
import { gql } from 'graphql-tag';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
export const getWorkflowStepConnectedAccountHandle = async ({
connectedAccountId,
}: {
connectedAccountId: string;
}) => {
const response = await makeGraphqlAPIRequest({
query: gql`
query WorkflowStepConnectedAccountHandle($connectedAccountId: UUID!) {
workflowStepConnectedAccountHandle(
connectedAccountId: $connectedAccountId
) {
id
handle
provider
}
}
`,
variables: { connectedAccountId },
});
expect(response.body.errors).toBeUndefined();
return response.body.data.workflowStepConnectedAccountHandle;
};

View File

@@ -0,0 +1,34 @@
import { workflowGraphqlRequest } from 'test/integration/graphql/suites/workflow/utils/workflow-graphql-request.util';
const POLL_ATTEMPTS = 20;
const POLL_INTERVAL_MS = 250;
export const pollWorkflowGraphqlRequest = async <TData, TResult>({
query,
variables,
extract,
until,
}: {
query: string;
variables?: object;
extract: (data: TData | undefined) => TResult;
until: (result: TResult) => boolean;
}): Promise<TResult> => {
let result: TResult = extract(undefined);
for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt++) {
const response = await workflowGraphqlRequest(query, variables);
expect(response.body.errors).toBeUndefined();
result = extract(response.body.data as TData | undefined);
if (until(result)) {
return result;
}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
}
return result;
};

View File

@@ -0,0 +1,207 @@
import gql from 'graphql-tag';
import request from 'supertest';
import { updateWorkflowVersionTrigger } from 'test/integration/graphql/suites/workflow/utils/update-workflow-version-trigger.util';
import {
destroyWorkflowRun,
waitForWorkflowCompletion,
type WorkflowRunStatusType,
} from 'test/integration/graphql/suites/workflow/utils/workflow-run-test.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
type WorkflowVersionStep = {
id: string;
type: string;
settings: { input: Record<string, unknown> };
};
export type WebhookTriggeredActionStepRun = {
status?: WorkflowRunStatusType;
stepStatus?: string;
stepResult?: Record<string, unknown>;
stepError?: string;
};
export const runWebhookTriggeredActionStep = async ({
name,
stepType,
input,
}: {
name: string;
stepType: 'SEND_EMAIL' | 'DRAFT_EMAIL';
input: Record<string, unknown>;
}): Promise<WebhookTriggeredActionStepRun> => {
const createWorkflowResponse = await makeGraphqlAPIRequest({
query: gql`
mutation CreateWorkflow($name: String!) {
createWorkflow(data: { name: $name }) {
id
}
}
`,
variables: { name },
});
expect(createWorkflowResponse.body.errors).toBeUndefined();
const workflowId = createWorkflowResponse.body.data.createWorkflow.id;
let workflowRunId: string | undefined;
try {
const workflowVersionsResponse = await makeGraphqlAPIRequest({
query: gql`
query FindDraftWorkflowVersion($workflowId: UUID!) {
workflowVersions(
filter: {
workflowId: { eq: $workflowId }
status: { in: ["DRAFT"] }
}
) {
edges {
node {
id
}
}
}
}
`,
variables: { workflowId },
});
expect(workflowVersionsResponse.body.errors).toBeUndefined();
const workflowVersionId =
workflowVersionsResponse.body.data.workflowVersions.edges[0].node.id;
await updateWorkflowVersionTrigger({
workflowVersionId,
trigger: {
name: 'Webhook Trigger',
type: 'WEBHOOK',
settings: {
outputSchema: {},
httpMethod: 'GET',
authentication: null,
},
nextStepIds: [],
position: { x: 0, y: 0 },
},
});
const createStepResponse = await makeGraphqlAPIRequest({
query: gql`
mutation CreateWorkflowVersionStep(
$input: CreateWorkflowVersionStepInput!
) {
createWorkflowVersionStep(input: $input) {
stepsDiff
}
}
`,
variables: {
input: {
workflowVersionId,
stepType,
parentStepId: 'trigger',
position: { x: 200, y: 0 },
},
},
});
expect(createStepResponse.body.errors).toBeUndefined();
const stepsResponse = await makeGraphqlAPIRequest({
query: gql`
query FindWorkflowVersionSteps($workflowVersionId: UUID!) {
workflowVersion(filter: { id: { eq: $workflowVersionId } }) {
steps
}
}
`,
variables: { workflowVersionId },
});
expect(stepsResponse.body.errors).toBeUndefined();
const step: WorkflowVersionStep =
stepsResponse.body.data.workflowVersion.steps.find(
(workflowVersionStep: WorkflowVersionStep) =>
workflowVersionStep.type === stepType,
);
expect(step).toBeDefined();
const updateStepResponse = await makeGraphqlAPIRequest({
query: gql`
mutation UpdateWorkflowVersionStep(
$input: UpdateWorkflowVersionStepInput!
) {
updateWorkflowVersionStep(input: $input) {
id
}
}
`,
variables: {
input: {
workflowVersionId,
step: {
...step,
settings: {
...step.settings,
input: { ...step.settings.input, ...input },
},
},
},
},
});
expect(updateStepResponse.body.errors).toBeUndefined();
const activateResponse = await makeGraphqlAPIRequest({
query: gql`
mutation ActivateWorkflowVersion($workflowVersionId: UUID!) {
activateWorkflowVersion(workflowVersionId: $workflowVersionId)
}
`,
variables: { workflowVersionId },
});
expect(activateResponse.body.errors).toBeUndefined();
const webhookResponse = await request(`http://localhost:${APP_PORT}`).get(
`/webhooks/workflows/${SEED_APPLE_WORKSPACE_ID}/${workflowId}`,
);
expect(webhookResponse.body.success).toBe(true);
workflowRunId = webhookResponse.body.workflowRunId;
const workflowRun = await waitForWorkflowCompletion(workflowRunId!);
const stepInfo = workflowRun?.state?.stepInfos?.[step.id];
return {
status: workflowRun?.status,
stepStatus: stepInfo?.status,
stepResult: stepInfo?.result,
stepError: stepInfo?.error,
};
} finally {
if (workflowRunId !== undefined) {
await destroyWorkflowRun(workflowRunId);
}
await makeGraphqlAPIRequest({
query: gql`
mutation DestroyWorkflow($id: ID!) {
destroyWorkflow(id: $id) {
id
}
}
`,
variables: { id: workflowId },
});
}
};

View File

@@ -0,0 +1,234 @@
import gql from 'graphql-tag';
import { updateWorkflowVersionTrigger } from 'test/integration/graphql/suites/workflow/utils/update-workflow-version-trigger.util';
import { isDefined } from 'twenty-shared/utils';
import {
destroyWorkflowRun,
runWorkflowVersion,
waitForWorkflowCompletion,
type WorkflowRunStatusType,
} from 'test/integration/graphql/suites/workflow/utils/workflow-run-test.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
type WorkflowActionStepType =
| 'SEND_EMAIL'
| 'DRAFT_EMAIL'
| 'CREATE_CALENDAR_EVENT';
type WorkflowVersionStep = {
id: string;
type: string;
settings: { input: Record<string, unknown> };
};
export type WorkflowActionStepRun = {
status?: WorkflowRunStatusType;
stepStatus?: string;
stepResult?: Record<string, unknown>;
stepError?: string;
};
const createWorkflow = async (name: string): Promise<string> => {
const response = await makeGraphqlAPIRequest({
query: gql`
mutation CreateWorkflow($name: String!) {
createWorkflow(data: { name: $name }) {
id
}
}
`,
variables: { name },
});
expect(response.body.errors).toBeUndefined();
return response.body.data.createWorkflow.id;
};
const destroyWorkflow = async (workflowId: string): Promise<void> => {
await makeGraphqlAPIRequest({
query: gql`
mutation DestroyWorkflow($id: ID!) {
destroyWorkflow(id: $id) {
id
}
}
`,
variables: { id: workflowId },
});
};
const findDraftWorkflowVersionId = async (
workflowId: string,
): Promise<string> => {
const response = await makeGraphqlAPIRequest({
query: gql`
query FindDraftWorkflowVersion($workflowId: UUID!) {
workflowVersions(
filter: { workflowId: { eq: $workflowId }, status: { in: ["DRAFT"] } }
) {
edges {
node {
id
}
}
}
}
`,
variables: { workflowId },
});
expect(response.body.errors).toBeUndefined();
return response.body.data.workflowVersions.edges[0].node.id;
};
const createWorkflowVersionStep = async ({
workflowVersionId,
stepType,
}: {
workflowVersionId: string;
stepType: WorkflowActionStepType;
}): Promise<void> => {
const response = await makeGraphqlAPIRequest({
query: gql`
mutation CreateWorkflowVersionStep(
$input: CreateWorkflowVersionStepInput!
) {
createWorkflowVersionStep(input: $input) {
stepsDiff
}
}
`,
variables: {
input: {
workflowVersionId,
stepType,
parentStepId: 'trigger',
position: { x: 200, y: 0 },
},
},
});
expect(response.body.errors).toBeUndefined();
};
const findWorkflowVersionStep = async ({
workflowVersionId,
stepType,
}: {
workflowVersionId: string;
stepType: WorkflowActionStepType;
}): Promise<WorkflowVersionStep> => {
const response = await makeGraphqlAPIRequest({
query: gql`
query FindWorkflowVersionSteps($workflowVersionId: UUID!) {
workflowVersion(filter: { id: { eq: $workflowVersionId } }) {
steps
}
}
`,
variables: { workflowVersionId },
});
expect(response.body.errors).toBeUndefined();
const step = response.body.data.workflowVersion.steps.find(
(workflowVersionStep: WorkflowVersionStep) =>
workflowVersionStep.type === stepType,
);
expect(step).toBeDefined();
return step;
};
const updateWorkflowVersionStepInput = async ({
workflowVersionId,
step,
input,
}: {
workflowVersionId: string;
step: WorkflowVersionStep;
input: Record<string, unknown>;
}): Promise<void> => {
const response = await makeGraphqlAPIRequest({
query: gql`
mutation UpdateWorkflowVersionStep(
$input: UpdateWorkflowVersionStepInput!
) {
updateWorkflowVersionStep(input: $input) {
id
}
}
`,
variables: {
input: {
workflowVersionId,
step: {
...step,
settings: {
...step.settings,
input: { ...step.settings.input, ...input },
},
},
},
},
});
expect(response.body.errors).toBeUndefined();
};
export const runWorkflowActionStep = async ({
name,
stepType,
input,
payload,
}: {
name: string;
stepType: WorkflowActionStepType;
input: Record<string, unknown>;
payload?: object;
}): Promise<WorkflowActionStepRun> => {
const workflowId = await createWorkflow(name);
let workflowRunId: string | undefined;
try {
const workflowVersionId = await findDraftWorkflowVersionId(workflowId);
await updateWorkflowVersionTrigger({
workflowVersionId,
trigger: {
name: 'Manual Trigger',
type: 'MANUAL',
settings: { outputSchema: {} },
nextStepIds: [],
position: { x: 0, y: 0 },
},
});
await createWorkflowVersionStep({ workflowVersionId, stepType });
const step = await findWorkflowVersionStep({ workflowVersionId, stepType });
await updateWorkflowVersionStepInput({ workflowVersionId, step, input });
workflowRunId = await runWorkflowVersion({ workflowVersionId, payload });
const workflowRun = await waitForWorkflowCompletion(workflowRunId);
const stepInfo = workflowRun?.state?.stepInfos?.[step.id];
return {
status: workflowRun?.status,
stepStatus: stepInfo?.status,
stepResult: stepInfo?.result,
stepError: stepInfo?.error,
};
} finally {
if (isDefined(workflowRunId)) {
await destroyWorkflowRun(workflowRunId);
}
await destroyWorkflow(workflowId);
}
};

View File

@@ -0,0 +1,42 @@
import request from 'supertest';
const client = request(`http://localhost:${APP_PORT}`);
export const updateWorkflowVersionTrigger = async ({
workflowVersionId,
trigger,
}: {
workflowVersionId: string;
trigger: object;
}) => {
const response = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
mutation UpdateWorkflowVersionTrigger(
$input: UpdateWorkflowVersionTriggerInput!
) {
updateWorkflowVersionTrigger(input: $input) {
trigger
}
}
`,
variables: {
input: {
workflowVersionId,
trigger,
},
},
});
if (response.body.errors) {
throw new Error(
`Failed to update workflow version trigger: ${JSON.stringify(
response.body.errors,
)}`,
);
}
return response;
};

View File

@@ -0,0 +1,9 @@
import request from 'supertest';
const client = request(`http://localhost:${APP_PORT}`);
export const workflowGraphqlRequest = (query: string, variables?: object) =>
client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({ query, variables });

View File

@@ -0,0 +1,195 @@
import request from 'supertest';
import { WORKFLOW_RUN_GQL_FIELDS } from 'test/integration/constants/workflow-gql-fields.constants';
const client = request(`http://localhost:${APP_PORT}`);
export type WorkflowRunStatusType =
| 'NOT_STARTED'
| 'RUNNING'
| 'COMPLETED'
| 'FAILED'
| 'ENQUEUED'
| 'STOPPING'
| 'STOPPED';
export type WorkflowRunState = {
stepInfos?: Record<
string,
{
status: string;
result?: Record<string, unknown>;
error?: string;
history?: Array<{ status: string; error?: string }>;
}
>;
flow?: {
trigger?: {
type: string;
nextStepIds: string[];
};
steps?: Array<{
id: string;
type: string;
name: string;
}>;
};
};
export type WorkflowRunResponse = {
id: string;
status: WorkflowRunStatusType;
state: WorkflowRunState;
workflowVersionId: string;
};
export 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;
};
export const runWorkflowVersion = async ({
workflowVersionId,
payload,
}: {
workflowVersionId: string;
payload?: object;
}): Promise<string> => {
const response = 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,
payload,
},
},
});
if (response.body.errors || !response.body.data?.runWorkflowVersion) {
throw new Error(
`Failed to run workflow version: ${JSON.stringify(response.body.errors)}`,
);
}
return response.body.data.runWorkflowVersion.workflowRunId;
};
export const destroyWorkflowRun = async (
workflowRunId: string,
): Promise<void> => {
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 },
});
};
const PENDING_WORKFLOW_RUN_STATUSES: WorkflowRunStatusType[] = [
'NOT_STARTED',
'ENQUEUED',
'RUNNING',
'STOPPING',
];
export const waitForWorkflowCompletion = async (
workflowRunId: string,
maxAttempts = 30,
intervalMs = 500,
): Promise<WorkflowRunResponse | null> => {
let workflowRun = await getWorkflowRun(workflowRunId);
let attempts = 0;
while (
workflowRun !== null &&
PENDING_WORKFLOW_RUN_STATUSES.includes(workflowRun.status) &&
attempts < maxAttempts
) {
await new Promise((resolve) => setTimeout(resolve, intervalMs));
workflowRun = await getWorkflowRun(workflowRunId);
attempts++;
}
return workflowRun;
};
export const waitForWorkflowRunStepStatus = async (
workflowRunId: string,
stepId: string,
expectedStatus: string,
maxAttempts = 30,
intervalMs = 500,
): Promise<WorkflowRunResponse | null> => {
let workflowRun = await getWorkflowRun(workflowRunId);
let attempts = 0;
while (
attempts < maxAttempts &&
workflowRun?.state?.stepInfos?.[stepId]?.status !== expectedStatus &&
(workflowRun === null ||
PENDING_WORKFLOW_RUN_STATUSES.includes(workflowRun.status))
) {
await new Promise((resolve) => setTimeout(resolve, intervalMs));
workflowRun = await getWorkflowRun(workflowRunId);
attempts++;
}
return workflowRun;
};
export const waitForWorkflowRunStatus = async (
workflowRunId: string,
expectedStatus: WorkflowRunStatusType,
maxAttempts = 30,
intervalMs = 500,
): Promise<WorkflowRunResponse | null> => {
let workflowRun = await getWorkflowRun(workflowRunId);
let attempts = 0;
while (
attempts < maxAttempts &&
workflowRun?.status !== expectedStatus &&
(workflowRun === null ||
PENDING_WORKFLOW_RUN_STATUSES.includes(workflowRun.status))
) {
await new Promise((resolve) => setTimeout(resolve, intervalMs));
workflowRun = await getWorkflowRun(workflowRunId);
attempts++;
}
return workflowRun;
};

View File

@@ -0,0 +1,131 @@
import { randomUUID } from 'node:crypto';
import request from 'supertest';
import { deleteRecordsByIds } from 'test/integration/utils/delete-records-by-ids';
const baseUrl = `http://localhost:${APP_PORT}`;
type McpToolCallResult = {
content?: Array<{ type: string; text: string }>;
isError?: boolean;
};
const callMcpTool = async (
name: string,
args: Record<string, unknown>,
): Promise<McpToolCallResult> => {
const res = await request(baseUrl)
.post('/mcp')
.set('Authorization', `Bearer ${API_KEY_ACCESS_TOKEN}`)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.send(
JSON.stringify({
jsonrpc: '2.0',
method: 'tools/call',
id: `call-${randomUUID()}`,
params: { name, arguments: args },
}),
)
.expect(200);
expect(res.body.error).toBeUndefined();
return res.body.result as McpToolCallResult;
};
const buildCreateRecordStep = (objectRecord: Record<string, unknown>) => ({
id: randomUUID(),
name: 'Create Record',
type: 'CREATE_RECORD',
valid: true,
nextStepIds: [],
position: { x: 0, y: 100 },
settings: {
input: { objectName: 'note', objectRecord },
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: { value: false },
continueOnFailure: { value: false },
},
},
});
const createCompleteWorkflow = (
step: ReturnType<typeof buildCreateRecordStep>,
) =>
callMcpTool('execute_tool', {
toolName: 'create_complete_workflow',
arguments: {
name: `AI malformed test ${randomUUID().slice(0, 8)}`,
trigger: {
name: 'Manual Trigger',
type: 'MANUAL',
settings: { outputSchema: {} },
position: { x: 0, y: 0 },
nextStepIds: [step.id],
},
steps: [step],
edges: [{ source: 'trigger', target: step.id }],
},
});
type ToolPayload = {
success: boolean;
error?: string;
result?: { workflowId?: string };
};
const parsePayload = (result: McpToolCallResult): ToolPayload =>
JSON.parse(result.content?.[0]?.text ?? '{}') as ToolPayload;
describe('Workflow AI tool validation (e2e)', () => {
const createdWorkflowIds: string[] = [];
afterAll(async () => {
if (createdWorkflowIds.length > 0) {
await deleteRecordsByIds('workflow', createdWorkflowIds);
}
});
it('reports a failure (does not crash) when create_complete_workflow carries a bare-string rich text', async () => {
const result = await createCompleteWorkflow(
buildCreateRecordStep({ bodyV2: 'a plain string' }),
);
expect(result.isError).toBe(true);
const payload = parsePayload(result);
expect(payload.success).toBe(false);
expect(payload.error).toMatch(/rich text/i);
});
it('creates the workflow when the rich text is a valid object', async () => {
const result = await createCompleteWorkflow(
buildCreateRecordStep({ bodyV2: { markdown: 'hello', blocknote: null } }),
);
const payload = parsePayload(result);
expect(payload.success).toBe(true);
expect(payload.result?.workflowId).toBeDefined();
if (payload.result?.workflowId) {
createdWorkflowIds.push(payload.result.workflowId);
}
});
it('creates the workflow when a record step is incomplete (fields not filled in)', async () => {
const result = await createCompleteWorkflow(buildCreateRecordStep({}));
const payload = parsePayload(result);
expect(payload.success).toBe(true);
expect(payload.result?.workflowId).toBeDefined();
if (payload.result?.workflowId) {
createdWorkflowIds.push(payload.result.workflowId);
}
});
});

View File

@@ -0,0 +1,162 @@
import request from 'supertest';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
const client = request(`http://localhost:${APP_PORT}`);
const graphql = (query: string, variables?: object) =>
client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({ query, variables });
const INITIAL_NAME = 'Core Mirror Workflow';
const RENAMED_NAME = 'Core Mirror Workflow Renamed';
const POLL_ATTEMPTS = 20;
const POLL_INTERVAL_MS = 250;
describe('workflow core mirror (e2e)', () => {
let workflowId: string;
let alreadyDestroyed = false;
const fetchWorkspaceWorkflowIdOnCoreRowNamed = async (
name: string,
): Promise<string | null | undefined> => {
const rows = await global.testDataSource.query(
`SELECT "workspaceWorkflowId" FROM core."workflow"
WHERE "workspaceId" = $1 AND "name" = $2`,
[SEED_APPLE_WORKSPACE_ID, name],
);
return rows[0]?.workspaceWorkflowId;
};
const countCoreWorkflowsNamed = async (name: string): Promise<number> => {
const rows = await global.testDataSource.query(
`SELECT "id" FROM core."workflow"
WHERE "workspaceId" = $1 AND "name" = $2`,
[SEED_APPLE_WORKSPACE_ID, name],
);
return rows.length;
};
// the mirror is an async database-event listener, so the core row lands
// shortly after the mutation returns rather than within it
const waitForCoreWorkflowsNamed = async (
name: string,
expected: number,
): Promise<number> => {
for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt++) {
const count = await countCoreWorkflowsNamed(name);
if (count === expected) {
return count;
}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
}
return countCoreWorkflowsNamed(name);
};
afterAll(async () => {
if (workflowId && !alreadyDestroyed) {
await graphql(
`
mutation DestroyWorkflow($id: ID!) {
destroyWorkflow(id: $id) {
id
}
}
`,
{ id: workflowId },
);
}
});
it('mirrors the workflow to core across create, rename, delete, restore and destroy', async () => {
const createResponse = await graphql(
`
mutation CreateWorkflow($name: String!) {
createWorkflow(data: { name: $name }) {
id
}
}
`,
{ name: INITIAL_NAME },
);
expect(createResponse.body.errors).toBeUndefined();
workflowId = createResponse.body.data.createWorkflow.id;
expect(await waitForCoreWorkflowsNamed(INITIAL_NAME, 1)).toBe(1);
expect(await fetchWorkspaceWorkflowIdOnCoreRowNamed(INITIAL_NAME)).toBe(
workflowId,
);
const renameResponse = await graphql(
`
mutation RenameWorkflow($id: UUID!, $name: String!) {
updateWorkflow(id: $id, data: { name: $name }) {
id
}
}
`,
{ id: workflowId, name: RENAMED_NAME },
);
expect(renameResponse.body.errors).toBeUndefined();
expect(await waitForCoreWorkflowsNamed(RENAMED_NAME, 1)).toBe(1);
expect(await waitForCoreWorkflowsNamed(INITIAL_NAME, 0)).toBe(0);
const deleteResponse = await graphql(
`
mutation DeleteWorkflow($id: ID!) {
deleteWorkflow(id: $id) {
id
}
}
`,
{ id: workflowId },
);
expect(deleteResponse.body.errors).toBeUndefined();
// no assertion on removal here: soft-delete does not reliably drop the core
// row. handleDeleted removes it, but the delete cascade then updates the
// workflow record, which emits UPDATED and re-upserts it. That is accepted,
// the row is inert because the core versions are removed by the version
// cascade, so nothing can dispatch. Destroy is what actually cleans it up.
const restoreResponse = await graphql(
`
mutation RestoreWorkflow($id: ID!) {
restoreWorkflow(id: $id) {
id
}
}
`,
{ id: workflowId },
);
expect(restoreResponse.body.errors).toBeUndefined();
expect(await waitForCoreWorkflowsNamed(RENAMED_NAME, 1)).toBe(1);
const destroyResponse = await graphql(
`
mutation DestroyWorkflow($id: ID!) {
destroyWorkflow(id: $id) {
id
}
}
`,
{ id: workflowId },
);
expect(destroyResponse.body.errors).toBeUndefined();
alreadyDestroyed = true;
expect(await waitForCoreWorkflowsNamed(RENAMED_NAME, 0)).toBe(0);
});
});

View File

@@ -0,0 +1,190 @@
import request from 'supertest';
import { updateWorkflowVersionTrigger } from 'test/integration/graphql/suites/workflow/utils/update-workflow-version-trigger.util';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
const client = request(`http://localhost:${APP_PORT}`);
const graphql = (query: string, variables?: object) =>
client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({ query, variables });
describe('workflow lifecycle core mirror with an active version (e2e)', () => {
let workflowId: string;
let alreadyDestroyed = false;
const coreVersionRowCount = async (): Promise<number> => {
const rows = await global.testDataSource.query(
`SELECT "id" FROM core."workflowVersion"
WHERE "workspaceId" = $1 AND "workflowId" = $2`,
[SEED_APPLE_WORKSPACE_ID, workflowId],
);
return rows.length;
};
beforeAll(async () => {
const createResponse = await graphql(`
mutation {
createWorkflow(data: { name: "Lifecycle Mirror" }) {
id
}
}
`);
expect(createResponse.body.errors).toBeUndefined();
workflowId = createResponse.body.data.createWorkflow.id;
const getResponse = await graphql(
`
query GetWorkflow($id: UUID!) {
workflow(filter: { id: { eq: $id } }) {
versions {
edges {
node {
id
}
}
}
}
}
`,
{ id: workflowId },
);
const workflowVersionId =
getResponse.body.data.workflow.versions.edges[0].node.id;
await updateWorkflowVersionTrigger({
workflowVersionId,
trigger: {
name: 'Manual Trigger',
type: 'MANUAL',
settings: { outputSchema: {} },
nextStepIds: [],
position: { x: 0, y: 0 },
},
});
const stepResponse = await graphql(
`
mutation CreateWorkflowVersionStep(
$input: CreateWorkflowVersionStepInput!
) {
createWorkflowVersionStep(input: $input) {
stepsDiff
}
}
`,
{
input: {
workflowVersionId,
stepType: 'FIND_RECORDS',
parentStepId: 'trigger',
position: { x: 200, y: 0 },
},
},
);
expect(stepResponse.body.errors).toBeUndefined();
const activateResponse = await graphql(
`
mutation ActivateWorkflowVersion($workflowVersionId: UUID!) {
activateWorkflowVersion(workflowVersionId: $workflowVersionId)
}
`,
{ workflowVersionId },
);
expect(activateResponse.body.errors).toBeUndefined();
expect(activateResponse.body.data.activateWorkflowVersion).toBe(true);
});
afterAll(async () => {
if (workflowId && !alreadyDestroyed) {
await graphql(
`
mutation DestroyWorkflow($id: ID!) {
destroyWorkflow(id: $id) {
id
}
}
`,
{ id: workflowId },
);
}
});
it('points the core workflow at the core id of the published version', async () => {
const rows = await global.testDataSource.query(
`SELECT cw."lastPublishedCoreWorkflowVersionId" AS "coreVersionId",
published.id IS NOT NULL AS "resolves"
FROM core."workflow" cw
LEFT JOIN core."workflowVersion" published
ON published.id = cw."lastPublishedCoreWorkflowVersionId"
WHERE cw."workspaceId" = $1
AND cw.id IN (
SELECT cv."coreWorkflowId"
FROM core."workflowVersion" cv
WHERE cv."workspaceId" = $1
AND cv."workflowId" = $2
AND cv."coreWorkflowId" IS NOT NULL
)`,
[SEED_APPLE_WORKSPACE_ID, workflowId],
);
expect(typeof rows[0]?.coreVersionId).toBe('string');
expect(rows[0]?.resolves).toBe(true);
});
it('removes the core version row on delete, recreates it on restore, removes it on destroy', async () => {
// the v1 version created with the workflow is mirrored to core
expect(await coreVersionRowCount()).toBeGreaterThan(0);
const deleteResponse = await graphql(
`
mutation DeleteWorkflow($id: ID!) {
deleteWorkflow(id: $id) {
id
}
}
`,
{ id: workflowId },
);
expect(deleteResponse.body.errors).toBeUndefined();
expect(await coreVersionRowCount()).toBe(0);
const restoreResponse = await graphql(
`
mutation RestoreWorkflow($id: ID!) {
restoreWorkflow(id: $id) {
id
}
}
`,
{ id: workflowId },
);
expect(restoreResponse.body.errors).toBeUndefined();
expect(await coreVersionRowCount()).toBeGreaterThan(0);
const destroyResponse = await graphql(
`
mutation DestroyWorkflow($id: ID!) {
destroyWorkflow(id: $id) {
id
}
}
`,
{ id: workflowId },
);
expect(destroyResponse.body.errors).toBeUndefined();
alreadyDestroyed = true;
expect(await coreVersionRowCount()).toBe(0);
});
});

View File

@@ -0,0 +1,426 @@
import request from 'supertest';
import { updateWorkflowVersionTrigger } from 'test/integration/graphql/suites/workflow/utils/update-workflow-version-trigger.util';
import { findCommandMenuItems } from 'test/integration/metadata/suites/command-menu-item/utils/find-command-menu-items.util';
import { type CommandMenuItemDTO } from 'src/engine/metadata-modules/command-menu-item/dtos/command-menu-item.dto';
const client = request(`http://localhost:${APP_PORT}`);
const testWorkflowId = 'd6f9be23-c8e6-42b2-93f5-34ee0f97f1c7';
describe('workflowResolver', () => {
beforeAll(async () => {
const queryData = {
query: `
mutation CreateOneWorkflow {
createWorkflow(
data: {
name: "Custom Test Workflow"
id: "${testWorkflowId}"
}
) {
id
}
}
`,
};
await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(queryData);
});
afterAll(async () => {
const queryData = {
query: `
mutation DestroyOneWorkflow {
destroyWorkflow(id: "${testWorkflowId}") {
id
}
}
`,
};
await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(queryData);
});
it('should create workflow subEntities', async () => {
const queryData = {
query: `
query FindOneWorkflow {
workflow(filter: {id: {eq: "${testWorkflowId}"}}) {
id
deletedAt
versions {
edges {
node {
id
deletedAt
steps
}
}
}
}
}
`,
};
const response = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(queryData);
expect(response.status).toBe(200);
expect(response.body.errors).toBeUndefined();
const workflow = response.body.data.workflow;
expect(workflow.id).toBe(testWorkflowId);
expect(workflow.deletedAt).toBeNull();
expect(workflow.versions.edges.length).toBeGreaterThan(0);
expect(workflow.versions.edges[0].node.deletedAt).toBeNull();
});
it('should delete workflow subEntities', async () => {
const deleteQueryData = {
query: `
mutation DeleteOneWorkflow {
deleteWorkflow(id: "${testWorkflowId}") {
id
}
}
`,
};
const deleteResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(deleteQueryData);
expect(deleteResponse.status).toBe(200);
const queryData = {
query: `
query FindWorkflow {
workflow(filter: {
id: { eq: "${testWorkflowId}" },
not: { deletedAt: { is: NULL } }
}) {
id
deletedAt
}
}
`,
};
const response = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(queryData);
expect(response.status).toBe(200);
expect(response.body.errors).toBeUndefined();
const workflow = response.body.data.workflow;
expect(workflow.id).toBe(testWorkflowId);
expect(workflow.deletedAt).not.toBeNull();
const queryWorkflowVersionsData = {
query: `
query FindManyWorkflowVersions {
workflowVersions(filter: {
workflowId: { eq: "${testWorkflowId}" },
not: { deletedAt: { is: NULL } }
}) {
edges {
node {
id
deletedAt
}
}
}
}
`,
};
const workflowVersionsResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(queryWorkflowVersionsData);
expect(workflowVersionsResponse.status).toBe(200);
expect(workflowVersionsResponse.body.errors).toBeUndefined();
const workflowVersions =
workflowVersionsResponse.body.data.workflowVersions;
expect(workflowVersions.edges.length).toBeGreaterThan(0);
expect(workflowVersions.edges[0].node.deletedAt).not.toBeNull();
});
it('should restore workflow subEntities', async () => {
const restoreQueryData = {
query: `
mutation RestoreOneWorkflow {
restoreWorkflow(id: "${testWorkflowId}") {
id
}
}
`,
};
const restoreResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(restoreQueryData);
expect(restoreResponse.status).toBe(200);
const queryData = {
query: `
query FindOneWorkflow {
workflow(filter: {id: {eq: "${testWorkflowId}"}}) {
id
deletedAt
versions {
edges {
node {
id
deletedAt
steps
}
}
}
}
}
`,
};
const response = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(queryData);
expect(response.status).toBe(200);
expect(response.body.errors).toBeUndefined();
const workflow = response.body.data.workflow;
expect(workflow.id).toBe(testWorkflowId);
expect(workflow.deletedAt).toBeNull();
expect(workflow.versions.edges.length).toBeGreaterThan(0);
expect(workflow.versions.edges[0].node.deletedAt).toBeNull();
});
});
const COMMAND_MENU_ITEM_GQL_FIELDS = `
id
workflowVersionId
engineComponentKey
label
shortLabel
`;
const findCommandMenuItemForWorkflowVersion = async (
workflowVersionId: string,
): Promise<CommandMenuItemDTO | undefined> => {
const { data } = await findCommandMenuItems({
input: undefined,
gqlFields: COMMAND_MENU_ITEM_GQL_FIELDS,
});
return data?.commandMenuItems.find(
(item) => item.workflowVersionId === workflowVersionId,
);
};
describe('workflowResolver command menu item label', () => {
const initialWorkflowName = 'Command Menu Label Sync Test';
let createdWorkflowId: string | null = null;
let createdWorkflowVersionId: string | null = null;
const renameWorkflow = async (name: string) => {
const response = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
mutation UpdateWorkflow($id: ID!, $name: String) {
updateWorkflow(id: $id, data: { name: $name }) {
id
name
}
}
`,
variables: { id: createdWorkflowId, name },
});
expect(response.body.errors).toBeUndefined();
};
beforeAll(async () => {
const createWorkflowResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
mutation CreateWorkflow($name: String!) {
createWorkflow(data: { name: $name }) {
id
}
}
`,
variables: { name: initialWorkflowName },
});
expect(createWorkflowResponse.body.errors).toBeUndefined();
createdWorkflowId = createWorkflowResponse.body.data.createWorkflow.id;
const getWorkflowResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
query GetWorkflow($id: UUID!) {
workflow(filter: { id: { eq: $id } }) {
id
versions {
edges {
node {
id
}
}
}
}
}
`,
variables: { id: createdWorkflowId },
});
createdWorkflowVersionId =
getWorkflowResponse.body.data.workflow.versions.edges[0].node.id;
await updateWorkflowVersionTrigger({
workflowVersionId: createdWorkflowVersionId!,
trigger: {
name: 'Manual Trigger',
type: 'MANUAL',
settings: { outputSchema: {} },
nextStepIds: [],
position: { x: 0, y: 0 },
},
});
const createStepResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
mutation CreateWorkflowVersionStep($input: CreateWorkflowVersionStepInput!) {
createWorkflowVersionStep(input: $input) {
stepsDiff
}
}
`,
variables: {
input: {
workflowVersionId: createdWorkflowVersionId,
stepType: 'FIND_RECORDS',
parentStepId: 'trigger',
position: { x: 200, y: 0 },
},
},
});
expect(createStepResponse.body.errors).toBeUndefined();
const activateResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
mutation ActivateWorkflowVersion($workflowVersionId: UUID!) {
activateWorkflowVersion(workflowVersionId: $workflowVersionId)
}
`,
variables: { workflowVersionId: createdWorkflowVersionId },
});
expect(activateResponse.body.errors).toBeUndefined();
expect(activateResponse.body.data.activateWorkflowVersion).toBe(true);
});
afterAll(async () => {
if (createdWorkflowVersionId) {
await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
mutation DeactivateWorkflowVersion($workflowVersionId: UUID!) {
deactivateWorkflowVersion(workflowVersionId: $workflowVersionId)
}
`,
variables: { workflowVersionId: createdWorkflowVersionId },
});
}
if (createdWorkflowId) {
await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
mutation DestroyWorkflow($id: ID!) {
destroyWorkflow(id: $id) {
id
}
}
`,
variables: { id: createdWorkflowId },
});
}
});
it('labels the command menu item with the workflow name on activation', async () => {
const commandMenuItem = await findCommandMenuItemForWorkflowVersion(
createdWorkflowVersionId as string,
);
expect(commandMenuItem).toBeDefined();
expect(commandMenuItem?.engineComponentKey).toBe(
'TRIGGER_WORKFLOW_VERSION',
);
expect(commandMenuItem?.label).toBe(initialWorkflowName);
expect(commandMenuItem?.shortLabel).toBe(initialWorkflowName);
});
it('updates the command menu item label when the workflow is renamed', async () => {
const renamedWorkflowName = 'Renamed Command Menu Workflow';
await renameWorkflow(renamedWorkflowName);
const commandMenuItem = await findCommandMenuItemForWorkflowVersion(
createdWorkflowVersionId as string,
);
expect(commandMenuItem?.label).toBe(renamedWorkflowName);
expect(commandMenuItem?.shortLabel).toBe(renamedWorkflowName);
});
it('falls back to "Untitled Workflow" when the workflow name is cleared', async () => {
await renameWorkflow('');
const commandMenuItem = await findCommandMenuItemForWorkflowVersion(
createdWorkflowVersionId as string,
);
expect(commandMenuItem?.label).toBe('Untitled Workflow');
expect(commandMenuItem?.shortLabel).toBe('Untitled Workflow');
});
});

View File

@@ -0,0 +1,31 @@
import { getWorkflowStepConnectedAccountHandle } from 'test/integration/graphql/suites/workflow/utils/get-workflow-step-connected-account-handle.util';
import { CONNECTED_ACCOUNT_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/connected-account-data-seeds.constant';
describe('workflowStepConnectedAccountHandle', () => {
it('should return handle for own account', async () => {
const result = await getWorkflowStepConnectedAccountHandle({
connectedAccountId: CONNECTED_ACCOUNT_DATA_SEED_IDS.JANE,
});
expect(result).toBeDefined();
expect(result.handle).toBe('jane.austen@apple.dev');
});
it('should return handle for another user account in the same workspace', async () => {
const result = await getWorkflowStepConnectedAccountHandle({
connectedAccountId: CONNECTED_ACCOUNT_DATA_SEED_IDS.JONY,
});
expect(result).toBeDefined();
expect(result.id).toBe(CONNECTED_ACCOUNT_DATA_SEED_IDS.JONY);
});
it('should return null for non-existent account', async () => {
const result = await getWorkflowStepConnectedAccountHandle({
connectedAccountId: '00000000-0000-0000-0000-000000000000',
});
expect(result).toBeNull();
});
});

View File

@@ -0,0 +1,145 @@
import request from 'supertest';
import { updateWorkflowVersionTrigger } from 'test/integration/graphql/suites/workflow/utils/update-workflow-version-trigger.util';
const client = request(`http://localhost:${APP_PORT}`);
const graphql = async (query: string, variables?: Record<string, unknown>) =>
client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({ query, variables });
const graphqlOrFail = async (
query: string,
variables?: Record<string, unknown>,
) => {
const response = await graphql(query, variables);
expect(response.body.errors).toBeUndefined();
return response.body.data;
};
const validateWorkflowVersion = (workflowVersionId: string) =>
graphql(
`
mutation ValidateWorkflowVersion($workflowVersionId: UUID!) {
validateWorkflowVersion(workflowVersionId: $workflowVersionId)
}
`,
{ workflowVersionId },
);
const activateWorkflowVersion = (workflowVersionId: string) =>
graphql(
`
mutation ActivateWorkflowVersion($workflowVersionId: UUID!) {
activateWorkflowVersion(workflowVersionId: $workflowVersionId)
}
`,
{ workflowVersionId },
);
describe('Workflow version activation validation (e2e)', () => {
let workflowVersionId: string;
beforeAll(async () => {
const createData = await graphqlOrFail(`
mutation CreateWorkflow {
createWorkflow(data: { name: "Activation validation test" }) {
id
}
}
`);
const workflowData = await graphqlOrFail(
`
query GetWorkflow($id: UUID!) {
workflow(filter: { id: { eq: $id } }) {
versions {
edges {
node {
id
}
}
}
}
}
`,
{ id: createData.createWorkflow.id },
);
workflowVersionId = workflowData.workflow.versions.edges[0].node.id;
});
it('refuses to validate an empty draft, with the issues in the error', async () => {
const response = await validateWorkflowVersion(workflowVersionId);
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].extensions.code).toBe('BAD_USER_INPUT');
expect(response.body.errors[0].extensions.subCode).toBe(
'NON_ACTIVABLE_WORKFLOW_VERSION',
);
expect(response.body.errors[0].message).toMatch(/trigger/i);
});
it('refuses activation with the same error as validation', async () => {
const response = await activateWorkflowVersion(workflowVersionId);
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].extensions.subCode).toBe(
'NON_ACTIVABLE_WORKFLOW_VERSION',
);
});
it('validates a version whose trigger and steps are complete without activating it', async () => {
await updateWorkflowVersionTrigger({
workflowVersionId,
trigger: {
name: 'Manual Trigger',
type: 'MANUAL',
settings: { outputSchema: {} },
nextStepIds: [],
position: { x: 0, y: 0 },
},
});
await graphqlOrFail(
`
mutation CreateWorkflowVersionStep(
$input: CreateWorkflowVersionStepInput!
) {
createWorkflowVersionStep(input: $input) {
stepsDiff
}
}
`,
{
input: {
workflowVersionId,
stepType: 'CREATE_RECORD',
parentStepId: 'trigger',
position: { x: 200, y: 0 },
},
},
);
const response = await validateWorkflowVersion(workflowVersionId);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.validateWorkflowVersion).toBe(true);
const statusData = await graphqlOrFail(
`
query GetWorkflowVersion($id: UUID!) {
workflowVersion(filter: { id: { eq: $id } }) {
status
}
}
`,
{ id: workflowVersionId },
);
expect(statusData.workflowVersion.status).toBe('DRAFT');
});
});

View File

@@ -0,0 +1,97 @@
import request from 'supertest';
import { updateWorkflowVersionTrigger } from 'test/integration/graphql/suites/workflow/utils/update-workflow-version-trigger.util';
const client = request(`http://localhost:${APP_PORT}`);
const graphql = (query: string, variables?: object) =>
client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({ query, variables });
describe('workflowVersionContent (e2e)', () => {
let workflowId: string;
let workflowVersionId: string;
beforeAll(async () => {
const createResponse = await graphql(`
mutation {
createWorkflow(data: { name: "Version Content Query" }) {
id
}
}
`);
expect(createResponse.body.errors).toBeUndefined();
workflowId = createResponse.body.data.createWorkflow.id;
const getResponse = await graphql(
`
query GetWorkflow($id: UUID!) {
workflow(filter: { id: { eq: $id } }) {
versions {
edges {
node {
id
}
}
}
}
}
`,
{ id: workflowId },
);
workflowVersionId =
getResponse.body.data.workflow.versions.edges[0].node.id;
await updateWorkflowVersionTrigger({
workflowVersionId,
trigger: {
name: 'Content Query Trigger',
type: 'MANUAL',
settings: { outputSchema: {} },
nextStepIds: [],
position: { x: 0, y: 0 },
},
});
});
afterAll(async () => {
if (workflowId) {
await graphql(
`
mutation DestroyWorkflow($id: ID!) {
destroyWorkflow(id: $id) {
id
}
}
`,
{ id: workflowId },
);
}
});
it('returns the version trigger and steps', async () => {
const contentResponse = await graphql(
`
query GetWorkflowVersionContent($workflowVersionId: UUID!) {
workflowVersionContent(workflowVersionId: $workflowVersionId) {
workflowVersionId
trigger
steps
}
}
`,
{ workflowVersionId },
);
expect(contentResponse.body.errors).toBeUndefined();
const content = contentResponse.body.data.workflowVersionContent;
expect(content.workflowVersionId).toBe(workflowVersionId);
expect(content.trigger.name).toBe('Content Query Trigger');
expect(content.trigger.type).toBe('MANUAL');
});
});

View File

@@ -0,0 +1,151 @@
import request from 'supertest';
import { updateWorkflowVersionTrigger } from 'test/integration/graphql/suites/workflow/utils/update-workflow-version-trigger.util';
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
import { getWorkspaceSchemaName } from 'src/engine/workspace-datasource/utils/get-workspace-schema-name.util';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
const client = request(`http://localhost:${APP_PORT}`);
const graphql = (query: string, variables?: object) =>
client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({ query, variables });
const WORKSPACE_SCHEMA = getWorkspaceSchemaName(SEED_APPLE_WORKSPACE_ID);
const POLL_ATTEMPTS = 20;
const POLL_INTERVAL_MS = 250;
describe('workflow version core workflow id dual write (e2e)', () => {
let workflowId: string;
let workflowVersionId: string;
const fetchWorkspaceCoreWorkflowId = async (): Promise<
string | null | undefined
> => {
const rows = await global.testDataSource.query(
`SELECT "coreWorkflowId" FROM "${WORKSPACE_SCHEMA}"."workflow" WHERE "id" = $1`,
[workflowId],
);
return rows[0]?.coreWorkflowId;
};
const waitForWorkspaceCoreWorkflowId = async (): Promise<
string | null | undefined
> => {
for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt++) {
const coreWorkflowId = await fetchWorkspaceCoreWorkflowId();
if (isDefined(coreWorkflowId)) {
return coreWorkflowId;
}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
}
return fetchWorkspaceCoreWorkflowId();
};
const countCoreVersionRowsPointingAt = async (
coreWorkflowId: string,
): Promise<number> => {
const rows = await global.testDataSource.query(
`SELECT cv."id" FROM core."workflowVersion" cv
JOIN core."workflow" cw ON cw."id" = cv."coreWorkflowId"
WHERE cv."workspaceId" = $1
AND cv."workflowId" = $2
AND cv."coreWorkflowId" = $3`,
[SEED_APPLE_WORKSPACE_ID, workflowId, coreWorkflowId],
);
return rows.length;
};
const waitForCoreVersionRowsPointingAt = async (
coreWorkflowId: string,
): Promise<number> => {
for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt++) {
const count = await countCoreVersionRowsPointingAt(coreWorkflowId);
if (count > 0) {
return count;
}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
}
return countCoreVersionRowsPointingAt(coreWorkflowId);
};
beforeAll(async () => {
const createResponse = await graphql(`
mutation {
createWorkflow(data: { name: "Core Workflow Id Dual Write" }) {
id
}
}
`);
expect(createResponse.body.errors).toBeUndefined();
workflowId = createResponse.body.data.createWorkflow.id;
const getResponse = await graphql(
`
query GetWorkflow($id: UUID!) {
workflow(filter: { id: { eq: $id } }) {
versions {
edges {
node {
id
}
}
}
}
}
`,
{ id: workflowId },
);
workflowVersionId =
getResponse.body.data.workflow.versions.edges[0].node.id;
});
afterAll(async () => {
if (workflowId) {
await graphql(
`
mutation DestroyWorkflow($id: ID!) {
destroyWorkflow(id: $id) {
id
}
}
`,
{ id: workflowId },
);
}
});
it('stamps the core version row with the core workflow id on mirror writes', async () => {
const coreWorkflowId = await waitForWorkspaceCoreWorkflowId();
assertIsDefinedOrThrow(coreWorkflowId);
await updateWorkflowVersionTrigger({
workflowVersionId,
trigger: {
name: 'Manual Trigger',
type: 'MANUAL',
settings: { outputSchema: {} },
nextStepIds: [],
position: { x: 0, y: 0 },
},
});
expect(
await waitForCoreVersionRowsPointingAt(coreWorkflowId),
).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,176 @@
import request from 'supertest';
import { updateWorkflowVersionTrigger } from 'test/integration/graphql/suites/workflow/utils/update-workflow-version-trigger.util';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
const client = request(`http://localhost:${APP_PORT}`);
const graphql = (query: string, variables?: object) =>
client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({ query, variables });
describe('discard draft workflow version core mirror (e2e)', () => {
let workflowId: string;
let firstVersionId: string;
let draftVersionId: string;
const coreVersions = async (): Promise<{ id: string; status: string }[]> => {
return global.testDataSource.query(
`SELECT "id", "status" FROM core."workflowVersion"
WHERE "workspaceId" = $1 AND "workflowId" = $2`,
[SEED_APPLE_WORKSPACE_ID, workflowId],
);
};
beforeAll(async () => {
const createResponse = await graphql(`
mutation {
createWorkflow(data: { name: "Discard Draft Mirror" }) {
id
}
}
`);
expect(createResponse.body.errors).toBeUndefined();
workflowId = createResponse.body.data.createWorkflow.id;
const getResponse = await graphql(
`
query GetWorkflow($id: UUID!) {
workflow(filter: { id: { eq: $id } }) {
versions {
edges {
node {
id
}
}
}
}
}
`,
{ id: workflowId },
);
firstVersionId = getResponse.body.data.workflow.versions.edges[0].node.id;
await updateWorkflowVersionTrigger({
workflowVersionId: firstVersionId,
trigger: {
name: 'Manual Trigger',
type: 'MANUAL',
settings: { outputSchema: {} },
nextStepIds: [],
position: { x: 0, y: 0 },
},
});
const stepResponse = await graphql(
`
mutation CreateWorkflowVersionStep(
$input: CreateWorkflowVersionStepInput!
) {
createWorkflowVersionStep(input: $input) {
stepsDiff
}
}
`,
{
input: {
workflowVersionId: firstVersionId,
stepType: 'FIND_RECORDS',
parentStepId: 'trigger',
position: { x: 200, y: 0 },
},
},
);
expect(stepResponse.body.errors).toBeUndefined();
const activateResponse = await graphql(
`
mutation ActivateWorkflowVersion($workflowVersionId: UUID!) {
activateWorkflowVersion(workflowVersionId: $workflowVersionId)
}
`,
{ workflowVersionId: firstVersionId },
);
expect(activateResponse.body.errors).toBeUndefined();
const draftResponse = await graphql(
`
mutation CreateDraft($input: CreateDraftFromWorkflowVersionInput!) {
createDraftFromWorkflowVersion(input: $input) {
id
}
}
`,
{
input: {
workflowId,
workflowVersionIdToCopy: firstVersionId,
},
},
);
expect(draftResponse.body.errors).toBeUndefined();
draftVersionId = draftResponse.body.data.createDraftFromWorkflowVersion.id;
});
afterAll(async () => {
if (workflowId) {
await graphql(
`
mutation DestroyWorkflow($id: ID!) {
destroyWorkflow(id: $id) {
id
}
}
`,
{ id: workflowId },
);
}
});
it('removes only the discarded draft core row, keeping the active version', async () => {
// the active version and the new draft are both mirrored to core
const versionsBefore = await coreVersions();
expect(versionsBefore.map((version) => version.status).sort()).toEqual([
'ACTIVE',
'DRAFT',
]);
const activeCoreId = versionsBefore.find(
(version) => version.status === 'ACTIVE',
)?.id;
const draftCoreId = versionsBefore.find(
(version) => version.status === 'DRAFT',
)?.id;
expect(activeCoreId).toBeDefined();
expect(draftCoreId).toBeDefined();
const deleteResponse = await graphql(
`
mutation DeleteWorkflowVersion($id: ID!) {
deleteWorkflowVersion(id: $id) {
id
}
}
`,
{ id: draftVersionId },
);
expect(deleteResponse.body.errors).toBeUndefined();
const versionsAfter = await coreVersions();
// only the discarded draft's core row is removed; the active row remains
expect(versionsAfter).toHaveLength(1);
expect(versionsAfter[0].id).toBe(activeCoreId);
expect(versionsAfter[0].id).not.toBe(draftCoreId);
});
});

View File

@@ -0,0 +1,193 @@
import request from 'supertest';
import { updateWorkflowVersionTrigger } from 'test/integration/graphql/suites/workflow/utils/update-workflow-version-trigger.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { updateOneOperationFactory } from 'test/integration/graphql/utils/update-one-operation-factory.util';
const client = request(`http://localhost:${APP_PORT}`);
const graphql = async (query: string, variables?: Record<string, unknown>) => {
const response = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({ query, variables });
expect(response.body.errors).toBeUndefined();
return response.body.data;
};
const updateStepResponse = (
workflowVersionId: string,
step: Record<string, unknown>,
) =>
client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
mutation UpdateWorkflowVersionStep(
$input: UpdateWorkflowVersionStepInput!
) {
updateWorkflowVersionStep(input: $input) {
id
}
}
`,
variables: { input: { workflowVersionId, step } },
});
describe('Workflow version malformed validation (e2e)', () => {
let workflowVersionId: string;
let createRecordStep: {
id: string;
settings: { input: Record<string, unknown> };
};
beforeAll(async () => {
const createData = await graphql(`
mutation CreateWorkflow {
createWorkflow(data: { name: "Malformed validation test" }) {
id
}
}
`);
const workflowData = await graphql(
`
query GetWorkflow($id: UUID!) {
workflow(filter: { id: { eq: $id } }) {
versions {
edges {
node {
id
}
}
}
}
}
`,
{ id: createData.createWorkflow.id },
);
workflowVersionId = workflowData.workflow.versions.edges[0].node.id;
await updateWorkflowVersionTrigger({
workflowVersionId,
trigger: {
name: 'Manual Trigger',
type: 'MANUAL',
settings: { outputSchema: {} },
nextStepIds: [],
position: { x: 0, y: 0 },
},
});
await graphql(
`
mutation CreateWorkflowVersionStep(
$input: CreateWorkflowVersionStepInput!
) {
createWorkflowVersionStep(input: $input) {
stepsDiff
}
}
`,
{
input: {
workflowVersionId,
stepType: 'CREATE_RECORD',
parentStepId: 'trigger',
position: { x: 200, y: 0 },
},
},
);
const stepsData = await graphql(
`
query GetWorkflowVersion($id: UUID!) {
workflowVersion(filter: { id: { eq: $id } }) {
steps
}
}
`,
{ id: workflowVersionId },
);
createRecordStep = stepsData.workflowVersion.steps.find(
(step: { type: string }) => step.type === 'CREATE_RECORD',
);
});
const stepWithInput = (input: Record<string, unknown>) => ({
...createRecordStep,
settings: { ...createRecordStep.settings, input },
});
it('rejects a bare-string rich text value at write time', async () => {
const response = await updateStepResponse(
workflowVersionId,
stepWithInput({
objectName: 'note',
objectRecord: { bodyV2: 'a plain string' },
}),
);
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].extensions.code).toBe('BAD_USER_INPUT');
expect(response.body.errors[0].extensions.subCode).toBe(
'MALFORMED_WORKFLOW_VERSION',
);
expect(response.body.errors[0].message).toMatch(/rich text/i);
});
it('rejects a record step targeting an unknown object', async () => {
const response = await updateStepResponse(
workflowVersionId,
stepWithInput({ objectName: 'ghost', objectRecord: {} }),
);
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].extensions.subCode).toBe(
'MALFORMED_WORKFLOW_VERSION',
);
expect(response.body.errors[0].message).toContain('does not exist');
});
it('accepts a valid rich text object', async () => {
const response = await updateStepResponse(
workflowVersionId,
stepWithInput({
objectName: 'note',
objectRecord: { bodyV2: { markdown: 'hello', blocknote: null } },
}),
);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.updateWorkflowVersionStep.id).toBe(
createRecordStep.id,
);
});
it('accepts an incomplete record step whose fields are not filled in yet', async () => {
const response = await updateStepResponse(
workflowVersionId,
stepWithInput({ objectName: 'note', objectRecord: {} }),
);
expect(response.body.errors).toBeUndefined();
});
it('forbids writing steps through the generic updateWorkflowVersion mutation', async () => {
const operation = updateOneOperationFactory({
objectMetadataSingularName: 'workflowVersion',
gqlFields: 'id',
recordId: workflowVersionId,
data: { steps: [] },
});
const response = await makeGraphqlAPIRequest(operation);
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].extensions.code).toBe('FORBIDDEN');
});
});