Repository: openops-cloud/openops
Pull request #552: Add service to save step test output
Author: login: MarceloRGonc
Created: 2025-05-06T12:32:57Z | Merged: 2025-05-06T15:33:30Z


Fixes OPS-1686



Diff:
diff --git a/packages/server/api/src/app/database/database-connection.ts b/packages/server/api/src/app/database/database-connection.ts
index 905af7a732..51d7d1300b 100644
--- a/packages/server/api/src/app/database/database-connection.ts
+++ b/packages/server/api/src/app/database/database-connection.ts
@@ -23,6 +23,7 @@ import { FlowRunEntity } from '../flows/flow-run/flow-run-entity';
 import { FlowVersionEntity } from '../flows/flow-version/flow-version-entity';
 import { FlowEntity } from '../flows/flow/flow.entity';
 import { FolderEntity } from '../flows/folder/folder.entity';
+import { FlowStepTestOutputEntity } from '../flows/step-test-output/flow-step-test-output-entity';
 import { TriggerEventEntity } from '../flows/trigger-events/trigger-event.entity';
 import { OrganizationEntity } from '../organization/organization.entity';
 import { ProjectEntity } from '../project/project-entity';
@@ -57,6 +58,7 @@ function getEntities(): EntitySchema<unknown>[] {
     FlowTemplateEntity,
     UserSettingsEntity,
     AiConfigEntity,
+    FlowStepTestOutputEntity,
   ];
 
   return entities;
diff --git a/packages/server/api/src/app/flows/step-run/step-run-service.ts b/packages/server/api/src/app/flows/step-run/step-run-service.ts
index f104eea063..a9f1bd2c73 100644
--- a/packages/server/api/src/app/flows/step-run/step-run-service.ts
+++ b/packages/server/api/src/app/flows/step-run/step-run-service.ts
@@ -13,6 +13,7 @@ import { engineRunner } from 'server-worker';
 import { accessTokenManager } from '../../authentication/lib/access-token-manager';
 import { sendWorkflowTestBlockEvent } from '../../telemetry/event-models';
 import { flowVersionService } from '../flow-version/flow-version.service';
+import { flowStepTestOutputService } from '../step-test-output/flow-step-test-output.service';
 
 export const stepRunService = {
   async create({
@@ -47,6 +48,14 @@ export const stepRunService = {
       projectId,
     });
 
+    if (step.id) {
+      await flowStepTestOutputService.save({
+        stepId: step.id,
+        flowVersionId: flowVersion.id,
+        output: result.output,
+      });
+    }
+
     const endTime = performance.now();
     const duration = endTime - startTime;
 
diff --git a/packages/server/api/src/app/flows/step-test-output/flow-step-test-output-entity.ts b/packages/server/api/src/app/flows/step-test-output/flow-step-test-output-entity.ts
new file mode 100644
index 0000000000..6775df4895
--- /dev/null
+++ b/packages/server/api/src/app/flows/step-test-output/flow-step-test-output-entity.ts
@@ -0,0 +1,43 @@
+import { FlowStepTestOutput, FlowVersion } from '@openops/shared';
+import { EntitySchema } from 'typeorm';
+import {
+  BaseColumnSchemaPart,
+  BLOB_COLUMN_TYPE,
+  OpenOpsIdSchema,
+} from '../../database/database-common';
+
+type FlowStepTestOutputSchema = {
+  flowVersion: FlowVersion;
+} & FlowStepTestOutput;
+
+export const FlowStepTestOutputEntity =
+  new EntitySchema<FlowStepTestOutputSchema>({
+    name: 'flow_step_test_output',
+    columns: {
+      ...BaseColumnSchemaPart,
+      stepId: OpenOpsIdSchema,
+      flowVersionId: OpenOpsIdSchema,
+      output: {
+        type: BLOB_COLUMN_TYPE,
+        nullable: false,
+      },
+    },
+    indices: [
+      {
+        name: 'UQ_flow_step_test_output_step_id_flow_version_id',
+        columns: ['stepId', 'flowVersionId'],
+        unique: true,
+      },
+    ],
+    relations: {
+      flowVersion: {
+        type: 'many-to-one',
+        target: 'flow_version',
+        joinColumn: {
+          name: 'flowVersionId',
+        },
+        onDelete: 'CASCADE',
+        onUpdate: 'NO ACTION',
+      },
+    },
+  });
diff --git a/packages/server/api/src/app/flows/step-test-output/flow-step-test-output.service.ts b/packages/server/api/src/app/flows/step-test-output/flow-step-test-output.service.ts
new file mode 100644
index 0000000000..6ffd7e0c48
--- /dev/null
+++ b/packages/server/api/src/app/flows/step-test-output/flow-step-test-output.service.ts
@@ -0,0 +1,54 @@
+import { fileCompressor } from '@openops/server-shared';
+import {
+  FileCompression,
+  FlowStepTestOutput,
+  FlowVersionId,
+  OpenOpsId,
+  openOpsId,
+} from '@openops/shared';
+import { repoFactory } from '../../core/db/repo-factory';
+import { encryptUtils } from '../../helper/encryption';
+import { FlowStepTestOutputEntity } from './flow-step-test-output-entity';
+
+export const flowStepTestOutputRepo = repoFactory(FlowStepTestOutputEntity);
+
+export const flowStepTestOutputService = {
+  async save({
+    stepId,
+    flowVersionId,
+    output,
+  }: SaveParams): Promise<FlowStepTestOutput> {
+    const encryptOutput = encryptUtils.encryptObject(output);
+    const binaryOutput = Buffer.from(JSON.stringify(encryptOutput));
+
+    const compressedOutput = await fileCompressor.compress({
+      data: binaryOutput,
+      compression: FileCompression.GZIP,
+    });
+
+    const existing = await flowStepTestOutputRepo().findOneBy({
+      stepId,
+      flowVersionId,
+    });
+
+    let outputId = openOpsId();
+    if (existing) {
+      outputId = existing.id;
+    }
+
+    const stepOutput = {
+      id: outputId,
+      stepId,
+      flowVersionId,
+      output: compressedOutput,
+    };
+
+    return flowStepTestOutputRepo().save(stepOutput);
+  },
+};
+
+type SaveParams = {
+  stepId: OpenOpsId;
+  flowVersionId: FlowVersionId;
+  output: unknown;
+};
diff --git a/packages/server/api/test/integration/ce/flows/flow-step-test-output.test.ts b/packages/server/api/test/integration/ce/flows/flow-step-test-output.test.ts
new file mode 100644
index 0000000000..ff5dc50cab
--- /dev/null
+++ b/packages/server/api/test/integration/ce/flows/flow-step-test-output.test.ts
@@ -0,0 +1,57 @@
+import { FlowVersionState, openOpsId } from '@openops/shared';
+import { FastifyInstance } from 'fastify';
+import { databaseConnection } from '../../../../src/app/database/database-connection';
+import { flowStepTestOutputService } from '../../../../src/app/flows/step-test-output/flow-step-test-output.service';
+import { setupServer } from '../../../../src/app/server';
+import {
+  createMockFlow,
+  createMockFlowVersion,
+  mockBasicSetup,
+} from '../../../helpers/mocks';
+
+let app: FastifyInstance | null = null;
+
+beforeAll(async () => {
+  await databaseConnection().initialize();
+  app = await setupServer();
+});
+
+afterAll(async () => {
+  await databaseConnection().destroy();
+  await app?.close();
+});
+
+describe('Flow Step Test output', () => {
+  it('Should save step test output', async () => {
+    const { mockProject } = await mockBasicSetup();
+
+    const mockFlow = createMockFlow({
+      projectId: mockProject.id,
+    });
+    await databaseConnection().getRepository('flow').save([mockFlow]);
+
+    const mockFlowVersion = createMockFlowVersion({
+      flowId: mockFlow.id,
+      state: FlowVersionState.DRAFT,
+    });
+    await databaseConnection()
+      .getRepository('flow_version')
+      .save([mockFlowVersion]);
+
+    const savedData = await flowStepTestOutputService.save({
+      stepId: openOpsId(),
+      flowVersionId: mockFlowVersion.id,
+      output: {
+        test: 'test',
+      },
+    });
+
+    const stepTestOutput = await databaseConnection()
+      .getRepository('flow_step_test_output')
+      .findOneByOrFail({
+        id: savedData.id,
+      });
+
+    expect(Buffer.isBuffer(stepTestOutput.output)).toBe(true);
+  });
+});
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index ce6af690dd..1264a571d2 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -36,6 +36,7 @@ export * from './lib/flows/dto/list-flows-request';
 export * from './lib/flows/flow';
 export * from './lib/flows/flow-helper';
 export * from './lib/flows/flow-operations';
+export * from './lib/flows/flow-step-test-output';
 export * from './lib/flows/flow-version';
 export * from './lib/flows/folders/folder';
 export * from './lib/flows/folders/folder-requests';
diff --git a/packages/shared/src/lib/flows/actions/action.ts b/packages/shared/src/lib/flows/actions/action.ts
index 37d7f3bc8d..485eaa77fe 100644
--- a/packages/shared/src/lib/flows/actions/action.ts
+++ b/packages/shared/src/lib/flows/actions/action.ts
@@ -19,6 +19,7 @@ export enum RiskLevel {
 }
 
 const commonActionProps = {
+  id: Type.Optional(Type.String()),
   name: Type.String({}),
   valid: Type.Boolean({}),
   displayName: Type.String({}),
diff --git a/packages/shared/src/lib/flows/flow-step-test-output.ts b/packages/shared/src/lib/flows/flow-step-test-output.ts
new file mode 100644
index 0000000000..96e35aa116
--- /dev/null
+++ b/packages/shared/src/lib/flows/flow-step-test-output.ts
@@ -0,0 +1,11 @@
+import { Static, Type } from '@sinclair/typebox';
+import { BaseModelSchema } from '../common';
+
+export const FlowStepTestOutput = Type.Object({
+  ...BaseModelSchema,
+  stepId: Type.String(),
+  flowVersionId: Type.String(),
+  output: Type.Unknown(),
+});
+
+export type FlowStepTestOutput = Static<typeof FlowStepTestOutput>;
diff --git a/packages/shared/src/lib/flows/triggers/trigger.ts b/packages/shared/src/lib/flows/triggers/trigger.ts
index af8cd24225..894a463e80 100644
--- a/packages/shared/src/lib/flows/triggers/trigger.ts
+++ b/packages/shared/src/lib/flows/triggers/trigger.ts
@@ -10,6 +10,7 @@ export enum TriggerType {
 }
 
 const commonProps = {
+  id: Type.Optional(Type.String()),
   name: Type.String({}),
   valid: Type.Boolean({}),
   displayName: Type.String({}),


Reviews:
user: login: bigfluffycookie
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T12:54:49Z
user: login: bigfluffycookie
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T13:04:37Z
user: login: bigfluffycookie
body: Are you gonna add the actual save endpoint in another pr?
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T13:10:13Z
user: login: MarceloRGonc
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T13:18:55Z
user: login: MarceloRGonc
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T14:45:46Z
user: login: bigfluffycookie
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T14:56:29Z
user: login: MarceloRGonc
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T14:59:42Z

Review comments:
diff_hunk: @@ -0,0 +1,46 @@
+import { fileCompressor } from '@openops/server-shared';
+import {
+  FileCompression,
+  FlowStepTestOutput,
+  OpenOpsId,
+  openOpsId,
+} from '@openops/shared';
+import { repoFactory } from '../../core/db/repo-factory';
+import { encryptUtils } from '../../helper/encryption';
+import { FlowStepTestOutputEntity } from './flow-step-test-output-entity';
+
+export const flowStepTestOutputRepo = repoFactory(FlowStepTestOutputEntity);
+
+export const flowStepTestOutputService = {
+  async save({
+    outputId,
+    stepId,
+    flowVersionId,
+    output,
+  }: SaveParams): Promise<FlowStepTestOutput> {
+    const encryptOutput = encryptUtils.encryptObject(output);
+    const binaryOutput = Buffer.from(JSON.stringify(encryptOutput));
+
+    const compressedOutput = await fileCompressor.compress({
+      data: binaryOutput,
+      compression: FileCompression.GZIP,
+    });
+
+    const stepOutput = {
+      id: outputId ?? openOpsId(),
+      stepId,
+      flowVersionId,
+      output: compressedOutput,
+    };
+
+    return flowStepTestOutputRepo().save(stepOutput);
+  },
+};
+
+type SaveParams = {
+  outputId?: OpenOpsId | undefined;
+  // TODO: remove optional
+  stepId?: OpenOpsId | undefined;
+  flowVersionId: OpenOpsId;
path: packages/server/api/src/app/flows/step-test-output/flow-step-test-output.service.ts
user: login: bigfluffycookie
body: this should be type FlowVersionId for consistency 
created_at: 2025-05-06T12:54:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,46 @@
+import { fileCompressor } from '@openops/server-shared';
+import {
+  FileCompression,
+  FlowStepTestOutput,
+  OpenOpsId,
+  openOpsId,
+} from '@openops/shared';
+import { repoFactory } from '../../core/db/repo-factory';
+import { encryptUtils } from '../../helper/encryption';
+import { FlowStepTestOutputEntity } from './flow-step-test-output-entity';
+
+export const flowStepTestOutputRepo = repoFactory(FlowStepTestOutputEntity);
+
+export const flowStepTestOutputService = {
+  async save({
+    outputId,
+    stepId,
+    flowVersionId,
+    output,
+  }: SaveParams): Promise<FlowStepTestOutput> {
+    const encryptOutput = encryptUtils.encryptObject(output);
+    const binaryOutput = Buffer.from(JSON.stringify(encryptOutput));
+
+    const compressedOutput = await fileCompressor.compress({
+      data: binaryOutput,
+      compression: FileCompression.GZIP,
+    });
+
+    const stepOutput = {
+      id: outputId ?? openOpsId(),
+      stepId,
+      flowVersionId,
+      output: compressedOutput,
+    };
+
+    return flowStepTestOutputRepo().save(stepOutput);
+  },
+};
+
+type SaveParams = {
+  outputId?: OpenOpsId | undefined;
+  // TODO: remove optional
+  stepId?: OpenOpsId | undefined;
+  flowVersionId: OpenOpsId;
+  output: unknown;
path: packages/server/api/src/app/flows/step-test-output/flow-step-test-output.service.ts
user: login: bigfluffycookie
body: cant this be maybe Buffer or something? or you later wanna cast it to any?
created_at: 2025-05-06T13:04:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,46 @@
+import { fileCompressor } from '@openops/server-shared';
+import {
+  FileCompression,
+  FlowStepTestOutput,
+  OpenOpsId,
+  openOpsId,
+} from '@openops/shared';
+import { repoFactory } from '../../core/db/repo-factory';
+import { encryptUtils } from '../../helper/encryption';
+import { FlowStepTestOutputEntity } from './flow-step-test-output-entity';
+
+export const flowStepTestOutputRepo = repoFactory(FlowStepTestOutputEntity);
+
+export const flowStepTestOutputService = {
+  async save({
+    outputId,
+    stepId,
+    flowVersionId,
+    output,
+  }: SaveParams): Promise<FlowStepTestOutput> {
+    const encryptOutput = encryptUtils.encryptObject(output);
+    const binaryOutput = Buffer.from(JSON.stringify(encryptOutput));
+
+    const compressedOutput = await fileCompressor.compress({
+      data: binaryOutput,
+      compression: FileCompression.GZIP,
+    });
+
+    const stepOutput = {
+      id: outputId ?? openOpsId(),
+      stepId,
+      flowVersionId,
+      output: compressedOutput,
+    };
+
+    return flowStepTestOutputRepo().save(stepOutput);
+  },
+};
+
+type SaveParams = {
+  outputId?: OpenOpsId | undefined;
+  // TODO: remove optional
+  stepId?: OpenOpsId | undefined;
+  flowVersionId: OpenOpsId;
+  output: unknown;
path: packages/server/api/src/app/flows/step-test-output/flow-step-test-output.service.ts
user: login: MarceloRGonc
body: We don't know the output type of a block
created_at: 2025-05-06T13:18:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -10,6 +10,7 @@ export enum TriggerType {
 }
 
 const commonProps = {
+  id: Type.Optional(Type.String({})),
path: packages/shared/src/lib/flows/triggers/trigger.ts
user: login: MarceloRGonc
body: It will be mandatory in the future
created_at: 2025-05-06T14:45:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -19,6 +19,7 @@ export enum RiskLevel {
 }
 
 const commonActionProps = {
+  id: Type.Optional(Type.String({})),
path: packages/shared/src/lib/flows/actions/action.ts
user: login: MarceloRGonc
body: It will be mandatory in the future
created_at: 2025-05-06T14:45:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -10,6 +10,7 @@ export enum TriggerType {
 }
 
 const commonProps = {
+  id: Type.Optional(Type.String({})),
path: packages/shared/src/lib/flows/triggers/trigger.ts
user: login: bigfluffycookie
body: why Type.String({}) instead of  Type.String() ?
created_at: 2025-05-06T14:56:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -10,6 +10,7 @@ export enum TriggerType {
 }
 
 const commonProps = {
+  id: Type.Optional(Type.String({})),
path: packages/shared/src/lib/flows/triggers/trigger.ts
user: login: MarceloRGonc
body: Basically, it's the same thing. But I changed.
created_at: 2025-05-06T14:59:41Z
author_association: CONTRIBUTOR

PR comments:
user: login: linear[bot]
created_at: 2025-05-06T12:33:00Z
body: <p><a href="https://linear.app/openops/issue/OPS-1686/start-to-save-the-test-data-in-the-new-table">OPS-1686 Start to save the test data in the new table</a></p>
author_association: NONE
user: login: MarceloRGonc
created_at: 2025-05-06T13:19:27Z
body: > Are you gonna add the actual save endpoint in another pr?

We don't need an endpoint. We will call this service on the update action
author_association: CONTRIBUTOR
user: login: sonarqubecloud[bot]
created_at: 2025-05-06T15:14:13Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=openops-cloud_openops&pullRequest=552) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=openops-cloud_openops&pullRequest=552&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=openops-cloud_openops&pullRequest=552&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=openops-cloud_openops&pullRequest=552&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=openops-cloud_openops&pullRequest=552&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=openops-cloud_openops&pullRequest=552&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=openops-cloud_openops&pullRequest=552)


author_association: NONE