mirror of
https://github.com/n8n-io/n8n.git
synced 2025-11-20 17:46:34 +00:00
Merge 36491d13b8 into 4c2c1ce9d1
This commit is contained in:
commit
bad6e4603c
@ -22,4 +22,8 @@ export class WorkflowsConfig {
|
||||
/** Whether to enable workflow dependency indexing */
|
||||
@Env('N8N_WORKFLOWS_INDEXING_ENABLED')
|
||||
indexingEnabled: boolean = false;
|
||||
|
||||
/** DO NOT USE - Enable draft/publish workflow feature */
|
||||
@Env('N8N_WORKFLOWS_DRAFT_PUBLISH_ENABLED')
|
||||
draftPublishEnabled: boolean = false;
|
||||
}
|
||||
|
||||
@ -410,3 +410,8 @@ export interface ISimplifiedPinData {
|
||||
pairedItem?: IPairedItemData | IPairedItemData[] | number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export type WorkflowHistoryUpdate = Omit<
|
||||
Partial<WorkflowHistory>,
|
||||
'versionId' | 'workflowId' | 'createdAt' | 'updatedAt'
|
||||
>;
|
||||
|
||||
@ -22,6 +22,12 @@ export class WorkflowHistory extends WithTimestamps {
|
||||
@Column()
|
||||
authors: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
name: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
description: string;
|
||||
|
||||
@ManyToOne('WorkflowEntity', {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import type { User } from '@n8n/db';
|
||||
import type { User, WorkflowHistoryUpdate } from '@n8n/db';
|
||||
import { WorkflowHistory, WorkflowHistoryRepository } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
|
||||
@ -7,11 +7,11 @@ import type { EntityManager } from '@n8n/typeorm';
|
||||
import type { IWorkflowBase } from 'n8n-workflow';
|
||||
import { ensureError, UnexpectedError } from 'n8n-workflow';
|
||||
|
||||
import { WorkflowFinderService } from '../workflow-finder.service';
|
||||
|
||||
import { SharedWorkflowNotFoundError } from '@/errors/shared-workflow-not-found.error';
|
||||
import { WorkflowHistoryVersionNotFoundError } from '@/errors/workflow-history-version-not-found.error';
|
||||
|
||||
import { WorkflowFinderService } from '../workflow-finder.service';
|
||||
|
||||
@Service()
|
||||
export class WorkflowHistoryService {
|
||||
constructor(
|
||||
@ -97,4 +97,8 @@ export class WorkflowHistoryService {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async updateVersion(versionId: string, workflowId: string, updateData: WorkflowHistoryUpdate) {
|
||||
await this.workflowHistoryRepository.update({ versionId, workflowId }, { ...updateData });
|
||||
}
|
||||
}
|
||||
|
||||
@ -72,4 +72,12 @@ export declare namespace WorkflowRequest {
|
||||
type ManualRun = AuthenticatedRequest<{ workflowId: string }, {}, ManualRunPayload, {}>;
|
||||
|
||||
type Share = AuthenticatedRequest<{ workflowId: string }, {}, { shareWithIds: string[] }>;
|
||||
|
||||
type Activate = AuthenticatedRequest<
|
||||
{ workflowId: string },
|
||||
{},
|
||||
{ versionId: string; name?: string; description?: string }
|
||||
>;
|
||||
|
||||
type Deactivate = AuthenticatedRequest<{ workflowId: string }>;
|
||||
}
|
||||
|
||||
@ -1,6 +1,12 @@
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { GlobalConfig } from '@n8n/config';
|
||||
import type { User, WorkflowEntity, ListQueryDb, WorkflowFolderUnionFull } from '@n8n/db';
|
||||
import type {
|
||||
User,
|
||||
WorkflowEntity,
|
||||
ListQueryDb,
|
||||
WorkflowFolderUnionFull,
|
||||
WorkflowHistoryUpdate,
|
||||
} from '@n8n/db';
|
||||
import {
|
||||
SharedWorkflow,
|
||||
ExecutionRepository,
|
||||
@ -233,6 +239,8 @@ export class WorkflowService {
|
||||
);
|
||||
}
|
||||
|
||||
const isDraftPublishDisabled = !this.globalConfig.workflows.draftPublishEnabled;
|
||||
|
||||
if (
|
||||
Object.keys(omit(workflowUpdateData, ['id', 'versionId', 'active', 'activeVersionId']))
|
||||
.length > 0
|
||||
@ -253,7 +261,8 @@ export class WorkflowService {
|
||||
}
|
||||
|
||||
// Convert 'active' boolean from frontend to 'activeVersionId' for backend
|
||||
if ('active' in workflowUpdateData) {
|
||||
// Forbid updating active fields with FF on
|
||||
if (isDraftPublishDisabled && 'active' in workflowUpdateData) {
|
||||
if (workflowUpdateData.active) {
|
||||
workflowUpdateData.activeVersionId = workflowUpdateData.versionId ?? workflow.versionId;
|
||||
} else {
|
||||
@ -288,7 +297,7 @@ export class WorkflowService {
|
||||
* If a trigger or poller in the workflow was updated, the new value
|
||||
* will take effect only on removing and re-adding.
|
||||
*/
|
||||
if (wasActive) {
|
||||
if (isDraftPublishDisabled && wasActive) {
|
||||
await this.activeWorkflowManager.remove(workflowId);
|
||||
}
|
||||
|
||||
@ -318,7 +327,7 @@ export class WorkflowService {
|
||||
await validateEntity(workflowUpdateData);
|
||||
}
|
||||
|
||||
const updatePayload: QueryDeepPartialEntity<WorkflowEntity> = pick(workflowUpdateData, [
|
||||
const fieldsToUpdate = [
|
||||
'name',
|
||||
'active',
|
||||
'nodes',
|
||||
@ -328,16 +337,25 @@ export class WorkflowService {
|
||||
'staticData',
|
||||
'pinData',
|
||||
'versionId',
|
||||
'activeVersionId',
|
||||
'description',
|
||||
]);
|
||||
];
|
||||
|
||||
// Forbid updating active fields with FF on
|
||||
if (isDraftPublishDisabled) {
|
||||
fieldsToUpdate.push('activeVersionId');
|
||||
}
|
||||
|
||||
const updatePayload: QueryDeepPartialEntity<WorkflowEntity> = pick(
|
||||
workflowUpdateData,
|
||||
fieldsToUpdate,
|
||||
);
|
||||
|
||||
// Save the workflow to history first, so we can retrieve the complete version object for the update
|
||||
if (versionChanged) {
|
||||
await this.workflowHistoryService.saveVersion(user, workflowUpdateData, workflowId);
|
||||
}
|
||||
|
||||
if (needsActiveVersionUpdate) {
|
||||
if (isDraftPublishDisabled && needsActiveVersionUpdate) {
|
||||
const versionIdToFetch = versionChanged ? workflowUpdateData.versionId : workflow.versionId;
|
||||
const version = await this.workflowHistoryService.getVersion(
|
||||
user,
|
||||
@ -403,63 +421,209 @@ export class WorkflowService {
|
||||
publicApi: false,
|
||||
});
|
||||
|
||||
if (activationStatusChanged && isNowActive) {
|
||||
// Workflow is being activated
|
||||
this.eventService.emit('workflow-activated', {
|
||||
user,
|
||||
workflowId,
|
||||
workflow: updatedWorkflow,
|
||||
publicApi: false,
|
||||
});
|
||||
} else if (activationStatusChanged && !isNowActive) {
|
||||
// Workflow is being deactivated
|
||||
this.eventService.emit('workflow-deactivated', {
|
||||
user,
|
||||
workflowId,
|
||||
workflow: updatedWorkflow,
|
||||
publicApi: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (isNowActive) {
|
||||
// When the workflow is supposed to be active add it again
|
||||
try {
|
||||
await this.externalHooks.run('workflow.activate', [updatedWorkflow]);
|
||||
await this.activeWorkflowManager.add(workflowId, wasActive ? 'update' : 'activate');
|
||||
} catch (error) {
|
||||
// If workflow could not be activated set it again to inactive
|
||||
// and revert the versionId and activeVersionId change so UI remains consistent
|
||||
await this.workflowRepository.update(workflowId, {
|
||||
active: false,
|
||||
activeVersion: null,
|
||||
versionId: workflow.versionId,
|
||||
// Skip activation/deactivation logic if draft/publish feature flag is enabled
|
||||
if (isDraftPublishDisabled) {
|
||||
if (activationStatusChanged && isNowActive) {
|
||||
// Workflow is being activated
|
||||
this.eventService.emit('workflow-activated', {
|
||||
user,
|
||||
workflowId,
|
||||
workflow: updatedWorkflow,
|
||||
publicApi: false,
|
||||
});
|
||||
|
||||
// Also set it in the returned data
|
||||
updatedWorkflow.active = false;
|
||||
updatedWorkflow.activeVersionId = null;
|
||||
updatedWorkflow.activeVersion = null;
|
||||
|
||||
// Emit deactivation event since activation failed
|
||||
} else if (activationStatusChanged && !isNowActive) {
|
||||
// Workflow is being deactivated
|
||||
this.eventService.emit('workflow-deactivated', {
|
||||
user,
|
||||
workflowId,
|
||||
workflow: updatedWorkflow,
|
||||
publicApi: false,
|
||||
});
|
||||
}
|
||||
|
||||
let message;
|
||||
if (error instanceof NodeApiError) message = error.description;
|
||||
message = message ?? (error as Error).message;
|
||||
|
||||
// Now return the original error for UI to display
|
||||
throw new BadRequestError(message);
|
||||
if (isNowActive) {
|
||||
// When the workflow is supposed to be active add it again
|
||||
await this._addToActiveWorkflowManager(
|
||||
user,
|
||||
workflowId,
|
||||
updatedWorkflow,
|
||||
wasActive ? 'update' : 'activate',
|
||||
workflow.versionId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return updatedWorkflow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Private helper to add a workflow to the active workflow manager
|
||||
* @param originalVersionId - Optional versionId to roll back to if activation fails
|
||||
*/
|
||||
private async _addToActiveWorkflowManager(
|
||||
user: User,
|
||||
workflowId: string,
|
||||
workflow: WorkflowEntity,
|
||||
mode: 'activate' | 'update',
|
||||
originalVersionId?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.externalHooks.run('workflow.activate', [workflow]);
|
||||
await this.activeWorkflowManager.add(workflowId, mode);
|
||||
} catch (error) {
|
||||
// If workflow could not be activated, set it again to inactive
|
||||
// and revert the versionId and activeVersionId change so UI remains consistent
|
||||
const rollbackPayload: QueryDeepPartialEntity<WorkflowEntity> = {
|
||||
active: false,
|
||||
activeVersion: null,
|
||||
};
|
||||
|
||||
// Roll back versionId if provided (used in update flow)
|
||||
if (originalVersionId !== undefined) {
|
||||
rollbackPayload.versionId = originalVersionId;
|
||||
}
|
||||
|
||||
await this.workflowRepository.update(workflowId, rollbackPayload);
|
||||
|
||||
// Also set it in the returned data
|
||||
workflow.active = false;
|
||||
workflow.activeVersionId = null;
|
||||
workflow.activeVersion = null;
|
||||
|
||||
// Emit deactivation event since activation failed
|
||||
this.eventService.emit('workflow-deactivated', {
|
||||
user,
|
||||
workflowId,
|
||||
workflow,
|
||||
publicApi: false,
|
||||
});
|
||||
|
||||
let message;
|
||||
if (error instanceof NodeApiError) message = error.description;
|
||||
message = message ?? (error as Error).message;
|
||||
|
||||
// Now return the original error for UI to display
|
||||
throw new BadRequestError(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Activates a workflow by setting its activeVersionId and adding it to the active workflow manager.
|
||||
* @param user - The user activating the workflow
|
||||
* @param workflowId - The ID of the workflow to activate
|
||||
* @param versionId - The version ID to activate
|
||||
* @returns The activated workflow
|
||||
*/
|
||||
async activateWorkflow(
|
||||
user: User,
|
||||
workflowId: string,
|
||||
versionId: string,
|
||||
options?: { name?: string; description?: string },
|
||||
): Promise<WorkflowEntity> {
|
||||
const workflow = await this.workflowFinderService.findWorkflowForUser(
|
||||
workflowId,
|
||||
user,
|
||||
['workflow:update'],
|
||||
{ includeActiveVersion: true },
|
||||
);
|
||||
|
||||
if (!workflow) {
|
||||
this.logger.warn('User attempted to activate a workflow without permissions', {
|
||||
workflowId,
|
||||
userId: user.id,
|
||||
});
|
||||
throw new NotFoundError(
|
||||
'You do not have permission to activate this workflow. Ask the owner to share it with you.',
|
||||
);
|
||||
}
|
||||
|
||||
if (workflow.activeVersionId === versionId) {
|
||||
return workflow;
|
||||
}
|
||||
|
||||
await this.workflowRepository.update(workflowId, {
|
||||
activeVersionId: versionId,
|
||||
active: true,
|
||||
});
|
||||
|
||||
if (options) {
|
||||
const updateFields: WorkflowHistoryUpdate = {};
|
||||
if (options.name !== undefined) updateFields.name = options.name;
|
||||
if (options.description !== undefined) updateFields.description = options.description;
|
||||
await this.workflowHistoryService.updateVersion(versionId, workflowId, updateFields);
|
||||
}
|
||||
|
||||
const updatedWorkflow = await this.workflowRepository.findOne({
|
||||
where: { id: workflowId },
|
||||
});
|
||||
|
||||
if (!updatedWorkflow) {
|
||||
throw new NotFoundError(`Workflow with ID "${workflowId}" could not be found.`);
|
||||
}
|
||||
|
||||
this.eventService.emit('workflow-activated', {
|
||||
user,
|
||||
workflowId,
|
||||
workflow: updatedWorkflow,
|
||||
publicApi: false,
|
||||
});
|
||||
|
||||
await this._addToActiveWorkflowManager(user, workflowId, updatedWorkflow, 'activate');
|
||||
|
||||
return updatedWorkflow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivates a workflow by removing it from the active workflow manager and setting activeVersionId to null.
|
||||
* @param user - The user deactivating the workflow
|
||||
* @param workflowId - The ID of the workflow to deactivate
|
||||
* @returns The deactivated workflow
|
||||
*/
|
||||
async deactivateWorkflow(user: User, workflowId: string): Promise<WorkflowEntity> {
|
||||
const workflow = await this.workflowFinderService.findWorkflowForUser(
|
||||
workflowId,
|
||||
user,
|
||||
['workflow:update'],
|
||||
{ includeActiveVersion: true },
|
||||
);
|
||||
|
||||
if (!workflow) {
|
||||
this.logger.warn('User attempted to deactivate a workflow without permissions', {
|
||||
workflowId,
|
||||
userId: user.id,
|
||||
});
|
||||
throw new NotFoundError(
|
||||
'You do not have permission to deactivate this workflow. Ask the owner to share it with you.',
|
||||
);
|
||||
}
|
||||
|
||||
if (workflow.activeVersionId === null) {
|
||||
return workflow;
|
||||
}
|
||||
|
||||
// Remove from active workflow manager
|
||||
await this.activeWorkflowManager.remove(workflowId);
|
||||
|
||||
await this.workflowRepository.update(workflowId, {
|
||||
active: false,
|
||||
activeVersionId: null,
|
||||
});
|
||||
|
||||
// Update the workflow object for response
|
||||
workflow.active = false;
|
||||
workflow.activeVersionId = null;
|
||||
workflow.activeVersion = null;
|
||||
|
||||
this.eventService.emit('workflow-deactivated', {
|
||||
user,
|
||||
workflowId,
|
||||
workflow,
|
||||
publicApi: false,
|
||||
});
|
||||
|
||||
return workflow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a workflow and returns it.
|
||||
*
|
||||
|
||||
@ -479,6 +479,44 @@ export class WorkflowsController {
|
||||
return workflow;
|
||||
}
|
||||
|
||||
@Post('/:workflowId/activate')
|
||||
@ProjectScope('workflow:update')
|
||||
async activate(req: WorkflowRequest.Activate) {
|
||||
const { workflowId } = req.params;
|
||||
const { versionId, name, description } = req.body;
|
||||
|
||||
if (!versionId) {
|
||||
throw new BadRequestError('versionId is required');
|
||||
}
|
||||
|
||||
const options: { name?: string; description?: string } = {};
|
||||
if (name !== undefined) options.name = name;
|
||||
if (description !== undefined) options.description = description;
|
||||
|
||||
const workflow = await this.workflowService.activateWorkflow(
|
||||
req.user,
|
||||
workflowId,
|
||||
versionId,
|
||||
Object.keys(options).length > 0 ? options : undefined,
|
||||
);
|
||||
|
||||
const scopes = await this.workflowService.getWorkflowScopes(req.user, workflowId);
|
||||
|
||||
return { ...workflow, scopes };
|
||||
}
|
||||
|
||||
@Post('/:workflowId/deactivate')
|
||||
@ProjectScope('workflow:update')
|
||||
async deactivate(req: WorkflowRequest.Deactivate) {
|
||||
const { workflowId } = req.params;
|
||||
|
||||
const workflow = await this.workflowService.deactivateWorkflow(req.user, workflowId);
|
||||
|
||||
const scopes = await this.workflowService.getWorkflowScopes(req.user, workflowId);
|
||||
|
||||
return { ...workflow, scopes };
|
||||
}
|
||||
|
||||
@Post('/:workflowId/run')
|
||||
@ProjectScope('workflow:execute')
|
||||
async runManually(req: WorkflowRequest.ManualRun, _res: unknown) {
|
||||
|
||||
@ -13,6 +13,7 @@ import {
|
||||
testDb,
|
||||
mockInstance,
|
||||
} from '@n8n/backend-test-utils';
|
||||
import { GlobalConfig } from '@n8n/config';
|
||||
import type { User, ListQueryDb, WorkflowFolderUnionFull, Role } from '@n8n/db';
|
||||
import {
|
||||
ProjectRepository,
|
||||
@ -60,6 +61,7 @@ const { objectContaining, arrayContaining, any } = expect;
|
||||
const activeWorkflowManagerLike = mockInstance(ActiveWorkflowManager);
|
||||
|
||||
let projectRepository: ProjectRepository;
|
||||
let globalConfig: GlobalConfig;
|
||||
let folderListMissingRole: Role;
|
||||
|
||||
beforeEach(async () => {
|
||||
@ -75,6 +77,7 @@ beforeEach(async () => {
|
||||
]);
|
||||
await cleanupRolesAndScopes();
|
||||
projectRepository = Container.get(ProjectRepository);
|
||||
globalConfig = Container.get(GlobalConfig);
|
||||
owner = await createOwner();
|
||||
authOwnerAgent = testServer.authAgentFor(owner);
|
||||
member = await createMember();
|
||||
@ -86,6 +89,9 @@ beforeEach(async () => {
|
||||
displayName: 'Workflow Read-Only',
|
||||
description: 'Can only read and list workflows',
|
||||
});
|
||||
|
||||
// Default: draft/publish feature disabled
|
||||
globalConfig.workflows.draftPublishEnabled = false;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@ -2716,6 +2722,275 @@ describe('PATCH /workflows/:workflowId', () => {
|
||||
|
||||
expect(response.statusCode).toBe(500);
|
||||
});
|
||||
|
||||
describe('with draft/publish feature enabled', () => {
|
||||
beforeEach(() => {
|
||||
globalConfig.workflows.draftPublishEnabled = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalConfig.workflows.draftPublishEnabled = false;
|
||||
});
|
||||
|
||||
test('should not update activeVersionId when updating with active: true', async () => {
|
||||
const workflow = await createWorkflow({}, owner);
|
||||
const payload = {
|
||||
versionId: workflow.versionId,
|
||||
active: true,
|
||||
};
|
||||
|
||||
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload);
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(activeWorkflowManagerLike.add).not.toBeCalled();
|
||||
|
||||
const { data } = response.body;
|
||||
expect(data.activeVersionId).toBeNull();
|
||||
});
|
||||
|
||||
test('should not deactivate workflow when updating with active: false', async () => {
|
||||
const workflow = await createActiveWorkflow({}, owner);
|
||||
await setActiveVersion(workflow.id, workflow.versionId);
|
||||
|
||||
const payload = {
|
||||
versionId: workflow.versionId,
|
||||
active: false,
|
||||
};
|
||||
|
||||
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload);
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(activeWorkflowManagerLike.remove).not.toBeCalled();
|
||||
|
||||
const { data } = response.body;
|
||||
expect(data.activeVersionId).toBe(workflow.versionId);
|
||||
});
|
||||
|
||||
test('should not modify activeVersionId when explicitly provided', async () => {
|
||||
const workflow = await createWorkflow({}, owner);
|
||||
const payload = {
|
||||
versionId: workflow.versionId,
|
||||
activeVersionId: workflow.versionId,
|
||||
};
|
||||
|
||||
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload);
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(activeWorkflowManagerLike.add).not.toBeCalled();
|
||||
|
||||
const { data } = response.body;
|
||||
expect(data.activeVersionId).toBeNull(); // Should not be activated
|
||||
});
|
||||
|
||||
test('should allow updating active workflow without updating its active version', async () => {
|
||||
const workflow = await createActiveWorkflow({}, owner);
|
||||
await setActiveVersion(workflow.id, workflow.versionId);
|
||||
|
||||
const payload = {
|
||||
name: 'Updated Active Workflow',
|
||||
versionId: workflow.versionId,
|
||||
};
|
||||
|
||||
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload);
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(activeWorkflowManagerLike.remove).not.toBeCalled();
|
||||
expect(activeWorkflowManagerLike.add).not.toBeCalled();
|
||||
|
||||
const { data } = response.body;
|
||||
expect(data.name).toBe('Updated Active Workflow');
|
||||
expect(data.versionId).not.toBe(workflow.versionId); // New version created
|
||||
expect(data.activeVersionId).toBe(workflow.versionId); // Should remain active
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /workflows/:workflowId/activate', () => {
|
||||
test('should activate workflow with provided versionId', async () => {
|
||||
const workflow = await createWorkflowWithHistory({}, owner);
|
||||
|
||||
const response = await authOwnerAgent
|
||||
.post(`/workflows/${workflow.id}/activate`)
|
||||
.send({ versionId: workflow.versionId });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate');
|
||||
|
||||
const { data } = response.body;
|
||||
expect(data.id).toBe(workflow.id);
|
||||
expect(data.activeVersionId).toBe(workflow.versionId);
|
||||
});
|
||||
|
||||
test('should return 400 when versionId is missing', async () => {
|
||||
const workflow = await createWorkflow({}, owner);
|
||||
|
||||
const response = await authOwnerAgent.post(`/workflows/${workflow.id}/activate`).send({});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.body.message).toContain('versionId is required');
|
||||
});
|
||||
|
||||
test('should return 404 when workflow does not exist', async () => {
|
||||
const response = await authOwnerAgent
|
||||
.post('/workflows/non-existent-id/activate')
|
||||
.send({ versionId: uuid() });
|
||||
|
||||
expect(response.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
test('should return 403 when user does not have update permission', async () => {
|
||||
const workflow = await createWorkflow({}, owner);
|
||||
|
||||
const response = await authMemberAgent
|
||||
.post(`/workflows/${workflow.id}/activate`)
|
||||
.send({ versionId: workflow.versionId });
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
test('should set activeVersion relation when activating', async () => {
|
||||
const workflow = await createWorkflowWithHistory({}, owner);
|
||||
|
||||
await authOwnerAgent
|
||||
.post(`/workflows/${workflow.id}/activate`)
|
||||
.send({ versionId: workflow.versionId });
|
||||
|
||||
const updatedWorkflow = await Container.get(WorkflowRepository).findOne({
|
||||
where: { id: workflow.id },
|
||||
relations: ['activeVersion'],
|
||||
});
|
||||
|
||||
expect(updatedWorkflow?.activeVersionId).toBe(workflow.versionId);
|
||||
expect(updatedWorkflow?.activeVersion).not.toBeNull();
|
||||
expect(updatedWorkflow?.activeVersion?.versionId).toBe(workflow.versionId);
|
||||
});
|
||||
|
||||
test('should update version name when provided during activation', async () => {
|
||||
const workflow = await createWorkflowWithHistory({}, owner);
|
||||
const newVersionName = 'Production Version';
|
||||
|
||||
const response = await authOwnerAgent
|
||||
.post(`/workflows/${workflow.id}/activate`)
|
||||
.send({ versionId: workflow.versionId, name: newVersionName });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate');
|
||||
|
||||
const { data } = response.body;
|
||||
expect(data.activeVersionId).toBe(workflow.versionId);
|
||||
|
||||
const workflowHistoryRepository = Container.get(WorkflowHistoryRepository);
|
||||
const historyVersion = await workflowHistoryRepository.findOne({
|
||||
where: { workflowId: workflow.id, versionId: workflow.versionId },
|
||||
});
|
||||
|
||||
expect(historyVersion?.name).toBe(newVersionName);
|
||||
});
|
||||
|
||||
test('should update version description when provided during activation', async () => {
|
||||
const workflow = await createWorkflowWithHistory({}, owner);
|
||||
const newDescription = 'This is the stable production release';
|
||||
|
||||
const response = await authOwnerAgent
|
||||
.post(`/workflows/${workflow.id}/activate`)
|
||||
.send({ versionId: workflow.versionId, description: newDescription });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate');
|
||||
|
||||
const { data } = response.body;
|
||||
expect(data.activeVersionId).toBe(workflow.versionId);
|
||||
|
||||
const workflowHistoryRepository = Container.get(WorkflowHistoryRepository);
|
||||
const historyVersion = await workflowHistoryRepository.findOne({
|
||||
where: { workflowId: workflow.id, versionId: workflow.versionId },
|
||||
});
|
||||
|
||||
expect(historyVersion?.description).toBe(newDescription);
|
||||
});
|
||||
|
||||
test('should update both version name and description when provided during activation', async () => {
|
||||
const workflow = await createWorkflowWithHistory({}, owner);
|
||||
const newVersionName = 'Production Version';
|
||||
const newDescription = 'Major update with new features';
|
||||
|
||||
const response = await authOwnerAgent.post(`/workflows/${workflow.id}/activate`).send({
|
||||
versionId: workflow.versionId,
|
||||
name: newVersionName,
|
||||
description: newDescription,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate');
|
||||
|
||||
const { data } = response.body;
|
||||
expect(data.activeVersionId).toBe(workflow.versionId);
|
||||
|
||||
const workflowHistoryRepository = Container.get(WorkflowHistoryRepository);
|
||||
const historyVersion = await workflowHistoryRepository.findOne({
|
||||
where: { workflowId: workflow.id, versionId: workflow.versionId },
|
||||
});
|
||||
|
||||
expect(historyVersion?.name).toBe(newVersionName);
|
||||
expect(historyVersion?.description).toBe(newDescription);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /workflows/:workflowId/deactivate', () => {
|
||||
test('should deactivate active workflow', async () => {
|
||||
const workflow = await createActiveWorkflow({}, owner);
|
||||
|
||||
const response = await authOwnerAgent.post(`/workflows/${workflow.id}/deactivate`);
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(activeWorkflowManagerLike.remove).toBeCalledWith(workflow.id);
|
||||
|
||||
const { data } = response.body;
|
||||
expect(data.id).toBe(workflow.id);
|
||||
expect(data.active).toBe(false);
|
||||
expect(data.activeVersionId).toBeNull();
|
||||
});
|
||||
|
||||
test('should handle deactivating already inactive workflow', async () => {
|
||||
const workflow = await createWorkflow({}, owner);
|
||||
|
||||
const response = await authOwnerAgent.post(`/workflows/${workflow.id}/deactivate`);
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(activeWorkflowManagerLike.remove).not.toBeCalled();
|
||||
|
||||
const { data } = response.body;
|
||||
expect(data.activeVersionId).toBeNull();
|
||||
});
|
||||
|
||||
test('should return 404 when workflow does not exist', async () => {
|
||||
const response = await authOwnerAgent.post('/workflows/non-existent-id/deactivate');
|
||||
|
||||
expect(response.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
test('should return 403 when user does not have update permission', async () => {
|
||||
const workflow = await createActiveWorkflow({}, owner);
|
||||
|
||||
const response = await authMemberAgent.post(`/workflows/${workflow.id}/deactivate`);
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
test('should clear activeVersion relation when deactivating', async () => {
|
||||
const workflow = await createActiveWorkflow({}, owner);
|
||||
await setActiveVersion(workflow.id, workflow.versionId);
|
||||
|
||||
await authOwnerAgent.post(`/workflows/${workflow.id}/deactivate`);
|
||||
|
||||
const updatedWorkflow = await Container.get(WorkflowRepository).findOne({
|
||||
where: { id: workflow.id },
|
||||
relations: ['activeVersion'],
|
||||
});
|
||||
|
||||
expect(updatedWorkflow?.activeVersionId).toBeNull();
|
||||
expect(updatedWorkflow?.activeVersion).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /workflows/:workflowId/run', () => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user