diff --git a/src/sn/flow/FlowManager.ts b/src/sn/flow/FlowManager.ts index 01651da..8d3c288 100644 --- a/src/sn/flow/FlowManager.ts +++ b/src/sn/flow/FlowManager.ts @@ -22,7 +22,9 @@ import { FlowTestResult, FlowDefinitionResult, ProcessFlowApiResponse, - ProcessFlowTestPayload + ProcessFlowTestPayload, + CopyFlowOptions, + FlowCopyResult } from './FlowModels'; import { TableAPIRequest } from '../../comm/http/TableAPIRequest'; import { ProcessFlowRequest } from '../../comm/http/ProcessFlowRequest'; @@ -428,7 +430,7 @@ export class FlowManager { const apiResult = this._extractProcessFlowResult(response); - if (apiResult.errorCode !== 0 || (apiResult.errorCode == null && apiResult.errorMessage)) { + if ((apiResult.errorCode != null && apiResult.errorCode !== 0) || (apiResult.errorCode == null && apiResult.errorMessage)) { return { success: false, errorMessage: apiResult.errorMessage || 'Unknown error from processflow API', @@ -527,7 +529,7 @@ export class FlowManager { const apiResult = this._extractProcessFlowResult(response); - if (apiResult.errorCode !== 0 || (apiResult.errorCode == null && apiResult.errorMessage)) { + if ((apiResult.errorCode != null && apiResult.errorCode !== 0) || (apiResult.errorCode == null && apiResult.errorMessage)) { this._logger.error(`Test flow failed: ${apiResult.errorMessage}`); return { success: false, @@ -556,6 +558,93 @@ export class FlowManager { } } + /** + * Copy an existing flow into a target scoped application. + * + * This uses the ProcessFlow REST API (`POST /api/now/processflow/flow/{id}/copy`) + * to create a duplicate of a flow in a specified scope. The copied flow lands in + * draft/unpublished state and can then be modified, tested, and published. + * + * @param options Copy flow options including sourceFlowId, name, and targetScope + * @returns FlowCopyResult with the new flow's sys_id on success + */ + public async copyFlow(options: CopyFlowOptions): Promise { + if (!options.sourceFlowId || options.sourceFlowId.trim().length === 0) { + throw new Error('Source flow identifier is required (sys_id or scoped_name)'); + } + if (!options.name || options.name.trim().length === 0) { + throw new Error('Name is required for the copied flow'); + } + if (!options.targetScope || options.targetScope.trim().length === 0) { + throw new Error('Target scope sys_id is required'); + } + + this._logger.info(`Copying flow "${options.sourceFlowId}" as "${options.name}" into scope ${options.targetScope}`); + + // Resolve source flow identifier to sys_id + let resolved: ResolvedFlowIdentifier; + try { + resolved = await this._resolveFlowIdentifier(options.sourceFlowId); + } catch (error) { + const err = error as Error; + return { + success: false, + errorMessage: err.message + }; + } + + const pfr = new ProcessFlowRequest(this._instance); + const query = { sysparm_transaction_scope: options.targetScope }; + const payload = { name: options.name, scope: options.targetScope }; + + try { + const response = await pfr.post( + 'flow/{flow_sys_id}/copy', + { flow_sys_id: resolved.sysId }, + query, + payload + ); + + const apiResult = this._extractProcessFlowResult(response); + + if ((apiResult.errorCode != null && apiResult.errorCode !== 0) || (apiResult.errorCode == null && apiResult.errorMessage)) { + this._logger.error(`Copy flow failed: ${apiResult.errorMessage}`); + return { + success: false, + errorMessage: apiResult.errorMessage || 'Unknown error from processflow copy API', + errorCode: apiResult.errorCode, + rawResponse: apiResult + }; + } + + const newFlowSysId = apiResult.data as string; + if (!newFlowSysId || typeof newFlowSysId !== 'string' || newFlowSysId.trim().length === 0) { + return { + success: false, + errorMessage: 'ProcessFlow copy API returned no flow sys_id', + rawResponse: apiResult + }; + } + + this._logger.info(`Flow copied successfully, new sys_id: ${newFlowSysId}`); + + return { + success: true, + newFlowSysId, + errorCode: 0, + rawResponse: apiResult + }; + } catch (error) { + const err = error as Error; + this._logger.error(`Error copying flow "${options.sourceFlowId}": ${err.message}`); + return { + success: false, + errorMessage: `Failed to copy flow: ${err.message}`, + rawResponse: null + }; + } + } + /** * Extract the result from a ProcessFlow API response, handling both * Axios-style (data.result) and RequestHandler-style (bodyObject.result) response shapes. diff --git a/src/sn/flow/FlowModels.ts b/src/sn/flow/FlowModels.ts index 0566e7c..1f2045f 100644 --- a/src/sn/flow/FlowModels.ts +++ b/src/sn/flow/FlowModels.ts @@ -325,3 +325,39 @@ export interface ProcessFlowTestPayload { outputMap: Record; runOnThread: boolean; } + +// ============================================================ +// Copy Flow Types (ProcessFlow REST API) +// ============================================================ + +/** + * Options for copying a flow into a target scoped application via the ProcessFlow REST API. + */ +export interface CopyFlowOptions { + /** Source flow identifier: either a 32-char hex sys_id or a scoped name (e.g. "global.change__standard") */ + sourceFlowId: string; + + /** Display name for the new copied flow */ + name: string; + + /** Target scope sys_id to copy the flow into */ + targetScope: string; +} + +/** Result from copying a flow via the ProcessFlow REST API. */ +export interface FlowCopyResult { + /** Whether the copy operation completed successfully */ + success: boolean; + + /** sys_id of the newly created flow copy */ + newFlowSysId?: string; + + /** Error message from the API or from local processing */ + errorMessage?: string; + + /** Numeric error code from the API (0 = no error) */ + errorCode?: number; + + /** The raw API response for advanced inspection */ + rawResponse?: unknown; +} diff --git a/test/integration/sn/flow/FlowManager_IT.test.ts b/test/integration/sn/flow/FlowManager_IT.test.ts index 5341b40..3e686eb 100644 --- a/test/integration/sn/flow/FlowManager_IT.test.ts +++ b/test/integration/sn/flow/FlowManager_IT.test.ts @@ -3,7 +3,7 @@ import { getCredentials } from "@servicenow/sdk-cli/dist/auth/index.js"; import { SN_INSTANCE_ALIAS } from '../../../test_utils/test_config'; import { FlowManager } from '../../../../src/sn/flow/FlowManager'; -import { FlowExecutionResult, FlowContextStatusResult, FlowPublishResult, FlowDefinitionResult, FlowTestResult } from '../../../../src/sn/flow/FlowModels'; +import { FlowExecutionResult, FlowContextStatusResult, FlowPublishResult, FlowDefinitionResult, FlowTestResult, FlowCopyResult } from '../../../../src/sn/flow/FlowModels'; const SECONDS = 1000; @@ -531,4 +531,77 @@ describe('FlowManager - Integration Tests', () => { expect(result.errorMessage).toBeDefined(); }, 120 * SECONDS); }); + + // ============================================================ + // copyFlow (ProcessFlow REST API) + // ============================================================ + + describe('copyFlow', () => { + // Source: "Change - Standard" OOB flow + const SOURCE_FLOW_SYS_ID = process.env.TEST_COPY_SOURCE_FLOW || 'e89e3ade731310108ef62d2b04f6a744'; + // Target scope: "My Awesome App" on dev224436 + const TARGET_SCOPE = process.env.TEST_COPY_TARGET_SCOPE || '4a5a6115402946939ee48e3fe80f60f8'; + + it('should copy a flow into a target scope', async () => { + const copyName = `IT Copy ${Date.now()}`; + const result: FlowCopyResult = await flowMgr.copyFlow({ + sourceFlowId: SOURCE_FLOW_SYS_ID, + name: copyName, + targetScope: TARGET_SCOPE + }); + + console.log('\n=== copyFlow ==='); + console.log('Success:', result.success); + console.log('New Flow SysId:', result.newFlowSysId); + console.log('Error Code:', result.errorCode); + console.log('Error Message:', result.errorMessage); + + expect(result.success).toBe(true); + expect(result.newFlowSysId).toBeDefined(); + expect(result.newFlowSysId).toMatch(/^[0-9a-f]{32}$/); + expect(result.errorCode).toBe(0); + }, 120 * SECONDS); + + it('should return the new flow definition when fetched', async () => { + const copyName = `IT Copy Verify ${Date.now()}`; + const copyResult: FlowCopyResult = await flowMgr.copyFlow({ + sourceFlowId: SOURCE_FLOW_SYS_ID, + name: copyName, + targetScope: TARGET_SCOPE + }); + + console.log('\n=== copyFlow + verify ==='); + console.log('Copy Success:', copyResult.success); + console.log('New Flow SysId:', copyResult.newFlowSysId); + + expect(copyResult.success).toBe(true); + expect(copyResult.newFlowSysId).toBeDefined(); + + // Verify the new flow exists by fetching its definition + const defResult = await flowMgr.getFlowDefinition(copyResult.newFlowSysId!); + + console.log('Definition fetch success:', defResult.success); + console.log('Flow name:', defResult.definition?.name); + console.log('Flow status:', defResult.definition?.status); + + expect(defResult.success).toBe(true); + expect(defResult.definition).toBeDefined(); + expect(defResult.definition!.name).toBe(copyName); + }, 120 * SECONDS); + + it('should handle non-existent source flow gracefully', async () => { + const result: FlowCopyResult = await flowMgr.copyFlow({ + sourceFlowId: 'global.nonexistent_flow_xyz_99999', + name: 'Should Not Exist', + targetScope: TARGET_SCOPE + }); + + console.log('\n=== copyFlow (non-existent source) ==='); + console.log('Success:', result.success); + console.log('Error:', result.errorMessage); + + expect(result.success).toBe(false); + expect(result.errorMessage).toBeDefined(); + }, 120 * SECONDS); + }); }); diff --git a/test/unit/sn/flow/FlowManager.test.ts b/test/unit/sn/flow/FlowManager.test.ts index 46b1144..b9439ed 100644 --- a/test/unit/sn/flow/FlowManager.test.ts +++ b/test/unit/sn/flow/FlowManager.test.ts @@ -1822,4 +1822,207 @@ describe('FlowManager - Unit Tests', () => { expect(postArgs.path).toContain(FLOW_SYS_ID); }); }); + + // ================================================================ + // copyFlow + // ================================================================ + + describe('copyFlow', () => { + const SOURCE_FLOW_SYS_ID = 'abc123def456789012345678901234ab'; + const NEW_FLOW_SYS_ID = 'new999def456789012345678901234cc'; + const TARGET_SCOPE = 'scope456def789012345678901234dd'; + + it('should throw for empty sourceFlowId', async () => { + await expect(flowMgr.copyFlow({ + sourceFlowId: '', + name: 'My Copy', + targetScope: TARGET_SCOPE + })).rejects.toThrow('Source flow identifier is required'); + }); + + it('should throw for empty name', async () => { + await expect(flowMgr.copyFlow({ + sourceFlowId: SOURCE_FLOW_SYS_ID, + name: '', + targetScope: TARGET_SCOPE + })).rejects.toThrow('Name is required'); + }); + + it('should throw for empty targetScope', async () => { + await expect(flowMgr.copyFlow({ + sourceFlowId: SOURCE_FLOW_SYS_ID, + name: 'My Copy', + targetScope: '' + })).rejects.toThrow('Target scope sys_id is required'); + }); + + it('should return success with new flow sys_id on successful copy', async () => { + jest.spyOn(flowMgr as any, '_resolveFlowIdentifier').mockResolvedValueOnce({ + sysId: SOURCE_FLOW_SYS_ID, name: 'global.source_flow' + }); + + mockRequestHandler.post.mockResolvedValueOnce({ + data: { + result: { + data: NEW_FLOW_SYS_ID, + errorMessage: '', + errorCode: 0, + integrationsPluginActive: false + } + } + }); + + const result = await flowMgr.copyFlow({ + sourceFlowId: SOURCE_FLOW_SYS_ID, + name: 'My Copy', + targetScope: TARGET_SCOPE + }); + + expect(result.success).toBe(true); + expect(result.newFlowSysId).toBe(NEW_FLOW_SYS_ID); + expect(result.errorCode).toBe(0); + }); + + it('should send correct payload and query parameters', async () => { + jest.spyOn(flowMgr as any, '_resolveFlowIdentifier').mockResolvedValueOnce({ + sysId: SOURCE_FLOW_SYS_ID, name: 'global.source_flow' + }); + + mockRequestHandler.post.mockResolvedValueOnce({ + data: { + result: { data: NEW_FLOW_SYS_ID, errorMessage: '', errorCode: 0, integrationsPluginActive: false } + } + }); + + await flowMgr.copyFlow({ + sourceFlowId: SOURCE_FLOW_SYS_ID, + name: 'Copy of My Flow', + targetScope: TARGET_SCOPE + }); + + const postArgs = mockRequestHandler.post.mock.calls[0][0]; + + // Verify path contains the source flow sys_id and /copy + expect(postArgs.path).toContain(SOURCE_FLOW_SYS_ID); + expect(postArgs.path).toContain('/copy'); + + // Verify query parameter + expect(postArgs.query).toBeDefined(); + expect(postArgs.query.sysparm_transaction_scope).toBe(TARGET_SCOPE); + + // Verify payload + const payload = postArgs.json; + expect(payload).toEqual({ + name: 'Copy of My Flow', + scope: TARGET_SCOPE + }); + }); + + it('should resolve scoped name to sys_id before copying', async () => { + const resolveSpy = jest.spyOn(flowMgr as any, '_resolveFlowIdentifier').mockResolvedValueOnce({ + sysId: SOURCE_FLOW_SYS_ID, name: 'global.change__standard' + }); + + mockRequestHandler.post.mockResolvedValueOnce({ + data: { + result: { data: NEW_FLOW_SYS_ID, errorMessage: '', errorCode: 0, integrationsPluginActive: false } + } + }); + + await flowMgr.copyFlow({ + sourceFlowId: 'global.change__standard', + name: 'My Copy', + targetScope: TARGET_SCOPE + }); + + expect(resolveSpy).toHaveBeenCalledWith('global.change__standard'); + const postArgs = mockRequestHandler.post.mock.calls[0][0]; + expect(postArgs.path).toContain(SOURCE_FLOW_SYS_ID); + }); + + it('should return failure when flow identifier cannot be resolved', async () => { + jest.spyOn(flowMgr as any, '_resolveFlowIdentifier').mockRejectedValueOnce( + new Error('Flow not found: no sys_hub_flow record matches internal_name="bad.flow"') + ); + + const result = await flowMgr.copyFlow({ + sourceFlowId: 'bad.flow', + name: 'My Copy', + targetScope: TARGET_SCOPE + }); + + expect(result.success).toBe(false); + expect(result.errorMessage).toContain('Flow not found'); + }); + + it('should return failure when copy API returns error', async () => { + jest.spyOn(flowMgr as any, '_resolveFlowIdentifier').mockResolvedValueOnce({ + sysId: SOURCE_FLOW_SYS_ID, name: 'global.source_flow' + }); + + mockRequestHandler.post.mockResolvedValueOnce({ + data: { + result: { + data: '', + errorMessage: 'Insufficient privileges to copy flow', + errorCode: 1, + integrationsPluginActive: false + } + } + }); + + const result = await flowMgr.copyFlow({ + sourceFlowId: SOURCE_FLOW_SYS_ID, + name: 'My Copy', + targetScope: TARGET_SCOPE + }); + + expect(result.success).toBe(false); + expect(result.errorMessage).toContain('Insufficient privileges'); + expect(result.errorCode).toBe(1); + }); + + it('should handle HTTP errors gracefully', async () => { + jest.spyOn(flowMgr as any, '_resolveFlowIdentifier').mockResolvedValueOnce({ + sysId: SOURCE_FLOW_SYS_ID, name: 'global.source_flow' + }); + + mockRequestHandler.post.mockRejectedValueOnce(new Error('503 Service Unavailable')); + + const result = await flowMgr.copyFlow({ + sourceFlowId: SOURCE_FLOW_SYS_ID, + name: 'My Copy', + targetScope: TARGET_SCOPE + }); + + expect(result.success).toBe(false); + expect(result.errorMessage).toContain('503 Service Unavailable'); + }); + + it('should return failure when API returns null data with errorCode 0', async () => { + jest.spyOn(flowMgr as any, '_resolveFlowIdentifier').mockResolvedValueOnce({ + sysId: SOURCE_FLOW_SYS_ID, name: 'global.source_flow' + }); + + mockRequestHandler.post.mockResolvedValueOnce({ + data: { + result: { + data: null, + errorMessage: '', + errorCode: 0, + integrationsPluginActive: false + } + } + }); + + const result = await flowMgr.copyFlow({ + sourceFlowId: SOURCE_FLOW_SYS_ID, + name: 'My Copy', + targetScope: TARGET_SCOPE + }); + + expect(result.success).toBe(false); + expect(result.errorMessage).toContain('no flow sys_id'); + }); + }); });