Files
automatisch/packages/backend/src/models/step.test.js

505 lines
15 KiB
JavaScript

import { beforeEach, describe, it, expect, vi } from 'vitest';
import appConfig from '../config/app.js';
import App from './app.js';
import Base from './base.js';
import Step from './step.js';
import Flow from './flow.js';
import Connection from './connection.js';
import ExecutionStep from './execution-step.js';
import Telemetry from '../helpers/telemetry/index.js';
import * as testRunModule from '../services/test-run.js';
import { createFlow } from '../../test/factories/flow.js';
import { createUser } from '../../test/factories/user.js';
import { createRole } from '../../test/factories/role.js';
import { createPermission } from '../../test/factories/permission.js';
import { createConnection } from '../../test/factories/connection.js';
import { createStep } from '../../test/factories/step.js';
import { createExecutionStep } from '../../test/factories/execution-step.js';
describe('Step model', () => {
it('tableName should return correct name', () => {
expect(Step.tableName).toBe('steps');
});
it('jsonSchema should have correct validations', () => {
expect(Step.jsonSchema).toMatchSnapshot();
});
it('virtualAttributes should return correct attributes', () => {
const virtualAttributes = Step.virtualAttributes;
const expectedAttributes = ['iconUrl', 'webhookUrl'];
expect(virtualAttributes).toStrictEqual(expectedAttributes);
});
describe('relationMappings', () => {
it('should return correct associations', () => {
const relationMappings = Step.relationMappings();
const expectedRelations = {
flow: {
relation: Base.BelongsToOneRelation,
modelClass: Flow,
join: {
from: 'steps.flow_id',
to: 'flows.id',
},
},
connection: {
relation: Base.HasOneRelation,
modelClass: Connection,
join: {
from: 'steps.connection_id',
to: 'connections.id',
},
},
lastExecutionStep: {
relation: Base.HasOneRelation,
modelClass: ExecutionStep,
join: {
from: 'steps.id',
to: 'execution_steps.step_id',
},
filter: expect.any(Function),
},
executionSteps: {
relation: Base.HasManyRelation,
modelClass: ExecutionStep,
join: {
from: 'steps.id',
to: 'execution_steps.step_id',
},
},
};
expect(relationMappings).toStrictEqual(expectedRelations);
});
it('lastExecutionStep should return the trigger step', () => {
const relations = Step.relationMappings();
const firstSpy = vi.fn();
const limitSpy = vi.fn().mockImplementation(() => ({
first: firstSpy,
}));
const orderBySpy = vi.fn().mockImplementation(() => ({
limit: limitSpy,
}));
relations.lastExecutionStep.filter({ orderBy: orderBySpy });
expect(orderBySpy).toHaveBeenCalledWith('created_at', 'desc');
expect(limitSpy).toHaveBeenCalledWith(1);
expect(firstSpy).toHaveBeenCalledOnce();
});
});
describe('webhookUrl', () => {
it('should return it along with appConfig.webhookUrl when exists', () => {
vi.spyOn(appConfig, 'webhookUrl', 'get').mockReturnValue(
'https://automatisch.io'
);
const step = new Step();
step.webhookPath = '/webhook-path';
expect(step.webhookUrl).toBe('https://automatisch.io/webhook-path');
});
it('should return null when webhookUrl does not exist', () => {
const step = new Step();
expect(step.webhookUrl).toBe(null);
});
});
describe('iconUrl', () => {
it('should return step app icon absolute URL when app is set', () => {
vi.spyOn(appConfig, 'baseUrl', 'get').mockReturnValue(
'https://automatisch.io'
);
const step = new Step();
step.appKey = 'gitlab';
expect(step.iconUrl).toBe(
'https://automatisch.io/apps/gitlab/assets/favicon.svg'
);
});
it('should return null when appKey is not set', () => {
const step = new Step();
expect(step.iconUrl).toBe(null);
});
});
it('isTrigger should return true when step type is trigger', () => {
const step = new Step();
step.type = 'trigger';
expect(step.isTrigger).toBe(true);
});
it('isAction should return true when step type is action', () => {
const step = new Step();
step.type = 'action';
expect(step.isAction).toBe(true);
});
describe.todo('computeWebhookPath');
describe('getWebhookUrl', () => {
it('should return absolute webhook URL when step type is trigger', async () => {
const step = new Step();
step.type = 'trigger';
vi.spyOn(step, 'computeWebhookPath').mockResolvedValue('/webhook-path');
vi.spyOn(appConfig, 'webhookUrl', 'get').mockReturnValue(
'https://automatisch.io'
);
expect(await step.getWebhookUrl()).toBe(
'https://automatisch.io/webhook-path'
);
});
it('should return undefined when step type is action', async () => {
const step = new Step();
step.type = 'action';
expect(await step.getWebhookUrl()).toBe(undefined);
});
});
describe('getApp', () => {
it('should return app with the given appKey', async () => {
const step = new Step();
step.appKey = 'gitlab';
const findOneByKeySpy = vi.spyOn(App, 'findOneByKey').mockResolvedValue();
await step.getApp();
expect(findOneByKeySpy).toHaveBeenCalledWith('gitlab');
});
it('should return null with no appKey', async () => {
const step = new Step();
const findOneByKeySpy = vi.spyOn(App, 'findOneByKey').mockResolvedValue();
expect(await step.getApp()).toBe(null);
expect(findOneByKeySpy).not.toHaveBeenCalled();
});
});
it('test should execute the flow and mark the step as completed', async () => {
const step = await createStep({ status: 'incomplete' });
const testRunSpy = vi.spyOn(testRunModule, 'default').mockResolvedValue();
const updatedStep = await step.test();
expect(testRunSpy).toHaveBeenCalledWith({ stepId: step.id });
expect(updatedStep.status).toBe('completed');
});
it('getLastExecutionStep should return last execution step', async () => {
const step = await createStep();
await createExecutionStep({ stepId: step.id });
const secondExecutionStep = await createExecutionStep({ stepId: step.id });
expect(await step.getLastExecutionStep()).toStrictEqual(
secondExecutionStep
);
});
it('getNextStep should return the next step', async () => {
const firstStep = await createStep();
const secondStep = await createStep({ flowId: firstStep.flowId });
const thirdStep = await createStep({ flowId: firstStep.flowId });
expect(await secondStep.getNextStep()).toStrictEqual(thirdStep);
});
describe('getTriggerCommand', () => {
it('should return trigger command when app key and key are defined in trigger step', async () => {
const step = new Step();
step.type = 'trigger';
step.appKey = 'webhook';
step.key = 'catchRawWebhook';
const findOneByKeySpy = vi.spyOn(App, 'findOneByKey');
const triggerCommand = await step.getTriggerCommand();
expect(findOneByKeySpy).toHaveBeenCalledWith(step.appKey);
expect(triggerCommand.key).toBe(step.key);
});
it('should return null when key is not defined', async () => {
const step = new Step();
step.type = 'trigger';
step.appKey = 'webhook';
expect(await step.getTriggerCommand()).toBe(null);
});
});
describe('getActionCommand', () => {
it('should return action comamand when app key and key are defined in action step', async () => {
const step = new Step();
step.type = 'action';
step.appKey = 'ntfy';
step.key = 'sendMessage';
const findOneByKeySpy = vi.spyOn(App, 'findOneByKey');
const actionCommand = await step.getActionCommand();
expect(findOneByKeySpy).toHaveBeenCalledWith(step.appKey);
expect(actionCommand.key).toBe(step.key);
});
it('should return null when key is not defined', async () => {
const step = new Step();
step.type = 'action';
step.appKey = 'ntfy';
expect(await step.getActionCommand()).toBe(null);
});
});
describe('getSetupFields', () => {
it('should return trigger setup substep fields in trigger step', async () => {
const step = new Step();
step.appKey = 'webhook';
step.key = 'catchRawWebhook';
step.type = 'trigger';
expect(await step.getSetupFields()).toStrictEqual([
{
label: 'Wait until flow is done',
key: 'workSynchronously',
type: 'dropdown',
required: true,
options: [
{ label: 'Yes', value: true },
{ label: 'No', value: false },
],
},
]);
});
it('should return action setup substep fields in action step', async () => {
const step = new Step();
step.appKey = 'datastore';
step.key = 'getValue';
step.type = 'action';
expect(await step.getSetupFields()).toStrictEqual([
{
label: 'Key',
key: 'key',
type: 'string',
required: true,
description: 'The key of your value to get.',
variables: true,
},
]);
});
});
it.todo('getSetupAndDynamicFields');
it.todo('createDynamicFields');
it.todo('createDynamicData');
it.todo('updateWebhookUrl');
describe('delete', () => {
it('should delete the step and align the positions', async () => {
const flow = await createFlow();
await createStep({ flowId: flow.id, position: 1, type: 'trigger' });
await createStep({ flowId: flow.id, position: 2 });
const stepToDelete = await createStep({ flowId: flow.id, position: 3 });
await createStep({ flowId: flow.id, position: 4 });
await stepToDelete.delete();
const steps = await flow.$relatedQuery('steps');
const stepIds = steps.map((step) => step.id);
expect(stepIds).not.toContain(stepToDelete.id);
});
it('should align the positions of remaining steps', async () => {
const flow = await createFlow();
await createStep({ flowId: flow.id, position: 1, type: 'trigger' });
await createStep({ flowId: flow.id, position: 2 });
const stepToDelete = await createStep({ flowId: flow.id, position: 3 });
await createStep({ flowId: flow.id, position: 4 });
await stepToDelete.delete();
const steps = await flow.$relatedQuery('steps');
const stepPositions = steps.map((step) => step.position);
expect(stepPositions).toMatchObject([1, 2, 3]);
});
it('should delete related execution steps', async () => {
const step = await createStep();
const executionStep = await createExecutionStep({ stepId: step.id });
await step.delete();
expect(await executionStep.$query()).toBe(undefined);
});
});
describe('updateFor', async () => {
let step,
userRole,
user,
userConnection,
anotherUser,
anotherUserConnection;
beforeEach(async () => {
userRole = await createRole({ name: 'User' });
anotherUser = await createUser({ roleId: userRole.id });
user = await createUser({ roleId: userRole.id });
userConnection = await createConnection({
key: 'deepl',
userId: user.id,
});
anotherUserConnection = await createConnection({
key: 'deepl',
userId: anotherUser.id,
});
await createPermission({
roleId: userRole.id,
action: 'read',
subject: 'Connection',
conditions: ['isCreator'],
});
step = await createStep();
});
it('should update step with the given payload and mark it as incomplete', async () => {
const stepData = {
appKey: 'deepl',
key: 'translateText',
connectionId: anotherUserConnection.id,
parameters: {
key: 'value',
},
};
const anotherUserWithRoleAndPermissions = await anotherUser
.$query()
.withGraphFetched({ permissions: true, role: true });
const updatedStep = await step.updateFor(
anotherUserWithRoleAndPermissions,
stepData
);
expect(updatedStep).toMatchObject({
...stepData,
status: 'incomplete',
});
});
it('should invoke updateWebhookUrl', async () => {
const updateWebhookUrlSpy = vi
.spyOn(Step.prototype, 'updateWebhookUrl')
.mockResolvedValue();
const stepData = {
appKey: 'deepl',
key: 'translateText',
};
await step.updateFor(user, stepData);
expect(updateWebhookUrlSpy).toHaveBeenCalledOnce();
});
it('should not update step when inaccessible connection is given', async () => {
const stepData = {
appKey: 'deepl',
key: 'translateText',
connectionId: userConnection.id,
};
const anotherUserWithRoleAndPermissions = await anotherUser
.$query()
.withGraphFetched({ permissions: true, role: true });
await expect(() =>
step.updateFor(anotherUserWithRoleAndPermissions, stepData)
).rejects.toThrowError('NotFoundError');
});
it('should not update step when given app key and key do not exist', async () => {
const stepData = {
appKey: 'deepl',
key: 'not-existing-key',
};
await expect(() => step.updateFor(user, stepData)).rejects.toThrowError(
'DeepL does not have an action with the "not-existing-key" key!'
);
});
});
describe('$afterInsert', () => {
it('should call super.$afterInsert', async () => {
const superAfterInsertSpy = vi.spyOn(Base.prototype, '$afterInsert');
await createStep();
expect(superAfterInsertSpy).toHaveBeenCalled();
});
it('should call Telemetry.stepCreated', async () => {
const telemetryStepCreatedSpy = vi
.spyOn(Telemetry, 'stepCreated')
.mockImplementation(() => {});
const step = await createStep();
expect(telemetryStepCreatedSpy).toHaveBeenCalledWith(step);
});
});
describe('$afterUpdate', () => {
it('should call super.$afterUpdate', async () => {
const superAfterUpdateSpy = vi.spyOn(Base.prototype, '$afterUpdate');
const step = await createStep();
await step.$query().patch({ position: 2 });
expect(superAfterUpdateSpy).toHaveBeenCalledOnce();
});
it('$afterUpdate should call Telemetry.stepUpdated', async () => {
const telemetryStepUpdatedSpy = vi
.spyOn(Telemetry, 'stepUpdated')
.mockImplementation(() => {});
const step = await createStep();
await step.$query().patch({ position: 2 });
expect(telemetryStepUpdatedSpy).toHaveBeenCalled({});
});
});
});