diff --git a/plugins/orchestrator-backend/src/service/SonataFlowService.test.ts b/plugins/orchestrator-backend/src/service/SonataFlowService.test.ts new file mode 100644 index 0000000000..35c9575fc5 --- /dev/null +++ b/plugins/orchestrator-backend/src/service/SonataFlowService.test.ts @@ -0,0 +1,372 @@ +import { LoggerService } from '@backstage/backend-plugin-api'; + +import { WorkflowExecutionResponse } from '@janus-idp/backstage-plugin-orchestrator-common'; + +import { DataIndexService } from './DataIndexService'; +import { SonataFlowService } from './SonataFlowService'; + +describe('SonataFlowService', () => { + let loggerMock: jest.Mocked; + let sonataFlowService: SonataFlowService; + + beforeAll(() => { + loggerMock = { + info: jest.fn(), + debug: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + child: jest.fn(), + }; + sonataFlowService = new SonataFlowService( + {} as DataIndexService, + loggerMock, + ); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('fetchWorkflowInfoOnService', () => { + const serviceUrl = 'http://example.com'; + const definitionId = 'workflow-123'; + const urlToFetch = 'http://example.com/management/processes/workflow-123'; + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return workflow info when the fetch response is ok', async () => { + // Given + const mockResponse: Partial = { + ok: true, + json: jest.fn().mockResolvedValue({ id: 'workflow-123' }), + }; + global.fetch = jest.fn().mockResolvedValue(mockResponse as any); + + // When + const result = await sonataFlowService.fetchWorkflowInfoOnService({ + definitionId, + serviceUrl, + }); + + // Then + expect(fetch).toHaveBeenCalledWith(urlToFetch); + expect(result).toEqual({ id: definitionId }); + expect(loggerMock.debug).toHaveBeenCalledWith( + `Fetch workflow info result: {"id":"${definitionId}"}`, + ); + }); + + it('should log an error and return undefined when the fetch response is not ok', async () => { + // Given + const mockResponse: Partial = { + ok: false, + status: 500, + statusText: 'Not Found', + json: jest.fn().mockResolvedValue({ + details: 'Error details', + stack: 'Error stack trace', + }), + }; + global.fetch = jest.fn().mockResolvedValue(mockResponse as any); + + // When + const result = await sonataFlowService.fetchWorkflowInfoOnService({ + definitionId, + serviceUrl, + }); + + // Then + expect(fetch).toHaveBeenCalledWith(urlToFetch); + expect(result).toBeUndefined(); + expect(loggerMock.error).toHaveBeenCalledTimes(1); + expect(loggerMock.error).toHaveBeenCalledWith( + `Error when fetching workflow info: Error: ${await sonataFlowService.createPrefixFetchErrorMessage(urlToFetch, mockResponse as Response)}`, + ); + expect(loggerMock.info).not.toHaveBeenCalled(); + expect(loggerMock.debug).not.toHaveBeenCalled(); + expect(loggerMock.warn).not.toHaveBeenCalled(); + expect(loggerMock.child).not.toHaveBeenCalled(); + }); + + it('should log an error and return undefined when fetch throws an error', async () => { + // Given + const testErrorMsg = 'Network Error'; + global.fetch = jest.fn().mockRejectedValue(new Error('Network Error')); + + // When + const result = await sonataFlowService.fetchWorkflowInfoOnService({ + definitionId, + serviceUrl, + }); + + // Then + expect(fetch).toHaveBeenCalledWith(urlToFetch); + expect(result).toBeUndefined(); + expect(loggerMock.error).toHaveBeenCalledWith( + `Error when fetching workflow info: Error: ${testErrorMsg}`, + ); + }); + }); + describe('executeWorkflow', () => { + const serviceUrl = 'http://example.com/workflows'; + const definitionId = 'workflow-123'; + const urlToFetch = `${serviceUrl}/${definitionId}`; + const inputData = { var1: 'value1' }; + + const expectedFetchRequestInit = (): RequestInit => { + return { + method: 'POST', + body: JSON.stringify(inputData), + headers: { 'content-type': 'application/json' }, + }; + }; + + const setupTest = (responseConfig: { + ok: boolean; + status?: number; + statusText?: string; + json: any; + }): Partial => { + const mockResponse: Partial = { + ok: responseConfig.ok, + status: responseConfig.status || (responseConfig.ok ? 200 : 500), + statusText: responseConfig.statusText, + json: jest.fn().mockResolvedValue(responseConfig.json), + }; + global.fetch = jest.fn().mockResolvedValue(mockResponse as any); + return mockResponse; + }; + + const runErrorTest = async (): Promise< + WorkflowExecutionResponse | undefined + > => { + return await sonataFlowService.executeWorkflow({ + definitionId, + serviceUrl, + inputData, + }); + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + it('should return workflow execution response when the request is successful', async () => { + // Given + setupTest({ ok: true, json: { id: definitionId, status: 'completed' } }); + + // When + const result = await sonataFlowService.executeWorkflow({ + definitionId, + serviceUrl, + inputData: { var1: 'value1' }, + }); + + // Then + expect(fetch).toHaveBeenCalledWith( + urlToFetch, + expectedFetchRequestInit(), + ); + expect(result).toEqual({ id: definitionId, status: 'completed' }); + expect(loggerMock.debug).toHaveBeenCalledWith( + `Execute workflow result: {"id":"${definitionId}","status":"completed"}`, + ); + // Verify that all other logger methods were not called + expect(loggerMock.debug).toHaveBeenCalledTimes(1); + expect(loggerMock.info).not.toHaveBeenCalled(); + expect(loggerMock.error).not.toHaveBeenCalled(); + expect(loggerMock.warn).not.toHaveBeenCalled(); + expect(loggerMock.child).not.toHaveBeenCalled(); + }); + + it('should include businessKey in the URL if provided', async () => { + // Given + const businessKey = 'key-123'; + setupTest({ ok: true, json: { id: definitionId, status: 'completed' } }); + + // When + const result = await sonataFlowService.executeWorkflow({ + definitionId, + serviceUrl, + inputData, + businessKey, + }); + + // Then + expect(fetch).toHaveBeenCalledWith( + `${serviceUrl}/${definitionId}?businessKey=${businessKey}`, + expectedFetchRequestInit(), + ); + expect(result).toEqual({ id: definitionId, status: 'completed' }); + }); + it('should log an error and return undefined when the fetch response is not ok without extra info', async () => { + // When + const mockResponse = setupTest({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + json: { details: undefined, stack: undefined }, + }); + + const result = await runErrorTest(); + + // Then + expect(fetch).toHaveBeenCalledWith( + urlToFetch, + expectedFetchRequestInit(), + ); + expect(result).toBeUndefined(); + expect(loggerMock.error).toHaveBeenCalledTimes(1); + expect(loggerMock.error).toHaveBeenCalledWith( + `Error when executing workflow: Error: ${await sonataFlowService.createPrefixFetchErrorMessage(urlToFetch, mockResponse as Response, 'POST')}`, + ); + }); + it('should log an error and return undefined when the fetch response is not ok with extra info', async () => { + // When + const mockResponse = setupTest({ + ok: false, + json: { details: 'Error details test', stack: 'Error stacktrace test' }, + }); + + const result = await runErrorTest(); + + // Then + expect(fetch).toHaveBeenCalledWith( + urlToFetch, + expectedFetchRequestInit(), + ); + expect(result).toBeUndefined(); + expect(loggerMock.error).toHaveBeenCalledTimes(1); + expect(loggerMock.error).toHaveBeenCalledWith( + `Error when executing workflow: Error: ${await sonataFlowService.createPrefixFetchErrorMessage(urlToFetch, mockResponse as Response, 'POST')}`, + ); + }); + it('should log an error and return undefined when fetch throws an error', async () => { + // Given + global.fetch = jest.fn().mockRejectedValue(new Error('Network Error')); + + // When + const result = await sonataFlowService.executeWorkflow({ + definitionId, + serviceUrl, + inputData: inputData, + }); + + // Then + expect(fetch).toHaveBeenCalledWith( + urlToFetch, + expectedFetchRequestInit(), + ); + expect(result).toBeUndefined(); + expect(loggerMock.error).toHaveBeenCalledWith( + 'Error when executing workflow: Error: Network Error', + ); + }); + }); + + describe('createPrefixFetchErrorMessage', () => { + // Constants + const TEST_URL = 'http://example.com'; + const STATUS_TEXT_BAD_REQUEST = 'Bad Request'; + const STATUS_TEXT_NOT_FOUND = 'Not Found'; + const STATUS_TEXT_INTERNAL_SERVER_ERROR = 'Internal Server Error'; + const DETAILS = 'Some error details'; + const STACK_TRACE = 'Error stack trace'; + + it('should return the correct message with all fields provided', async () => { + // Given + const mockResponseJson = { details: DETAILS, stack: STACK_TRACE }; + const mockResponse = new Response(JSON.stringify(mockResponseJson), { + status: 400, + statusText: STATUS_TEXT_BAD_REQUEST, + }); + + // When + const result = await sonataFlowService.createPrefixFetchErrorMessage( + TEST_URL, + mockResponse, + 'POST', + ); + + // Then + const expectedMessage = `Request POST ${TEST_URL} failed with: StatusCode: 400 StatusText: ${STATUS_TEXT_BAD_REQUEST}, Details: ${DETAILS}, Stack: ${STACK_TRACE}`; + expect(result).toBe(expectedMessage); + }); + + it('should return the correct message without details and stack', async () => { + // Given + const mockResponseJson = {}; + const mockResponse = new Response(JSON.stringify(mockResponseJson), { + status: 404, + statusText: STATUS_TEXT_NOT_FOUND, + }); + + // When + const result = await sonataFlowService.createPrefixFetchErrorMessage( + TEST_URL, + mockResponse, + ); + + // Then + const expectedMessage = `Request GET ${TEST_URL} failed with: StatusCode: 404 StatusText: ${STATUS_TEXT_NOT_FOUND}`; + expect(result).toBe(expectedMessage); + }); + + it('should return the correct message with only status code', async () => { + // Given + const mockResponseJson = {}; + const mockResponse = new Response(JSON.stringify(mockResponseJson), { + status: 500, + }); + + // When + const result = await sonataFlowService.createPrefixFetchErrorMessage( + TEST_URL, + mockResponse, + ); + + // Then + const expectedMessage = `Request GET ${TEST_URL} failed with: StatusCode: 500 Unexpected error`; + expect(result).toBe(expectedMessage); + }); + + it('should return the unexpected error message if no other fields are present', async () => { + // Given + const mockResponseJson = {}; + const mockResponse = new Response(JSON.stringify(mockResponseJson)); + + // When + const result = await sonataFlowService.createPrefixFetchErrorMessage( + TEST_URL, + mockResponse, + ); + + // Then + const expectedMessage = `Request GET ${TEST_URL} failed with: StatusCode: 200 Unexpected error`; + expect(result).toBe(expectedMessage); + }); + + it('should handle response with undefined JSON gracefully', async () => { + // Given + const mockResponse = new Response(undefined, { + status: 500, + statusText: STATUS_TEXT_INTERNAL_SERVER_ERROR, + }); + jest.spyOn(mockResponse, 'json').mockResolvedValue(undefined); + + // When + const result = await sonataFlowService.createPrefixFetchErrorMessage( + TEST_URL, + mockResponse, + ); + + // Then + const expectedMessage = `Request GET ${TEST_URL} failed with: StatusCode: 500 StatusText: ${STATUS_TEXT_INTERNAL_SERVER_ERROR}`; + expect(result).toBe(expectedMessage); + }); + }); +}); diff --git a/plugins/orchestrator-backend/src/service/SonataFlowService.ts b/plugins/orchestrator-backend/src/service/SonataFlowService.ts index 0324cb144d..6a7e86db11 100644 --- a/plugins/orchestrator-backend/src/service/SonataFlowService.ts +++ b/plugins/orchestrator-backend/src/service/SonataFlowService.ts @@ -38,9 +38,8 @@ export class SonataFlowService { ); return json; } - const responseStr = JSON.stringify(response); - this.logger.error( - `Response was NOT okay when fetch(${urlToFetch}). Received response: ${responseStr}`, + throw new Error( + await this.createPrefixFetchErrorMessage(urlToFetch, response), ); } catch (error) { this.logger.error(`Error when fetching workflow info: ${error}`); @@ -104,15 +103,20 @@ export class SonataFlowService { ? `${args.serviceUrl}/${args.definitionId}?businessKey=${args.businessKey}` : `${args.serviceUrl}/${args.definitionId}`; - const result = await fetch(urlToFetch, { + const response = await fetch(urlToFetch, { method: 'POST', body: JSON.stringify(args.inputData), headers: { 'content-type': 'application/json' }, }); - const json = await result.json(); - this.logger.debug(`Execute workflow result: ${JSON.stringify(json)}`); - return json; + if (response.ok) { + const json = await response.json(); + this.logger.debug(`Execute workflow result: ${JSON.stringify(json)}`); + return json; + } + throw new Error( + `${await this.createPrefixFetchErrorMessage(urlToFetch, response, 'POST')}`, + ); } catch (error) { this.logger.error(`Error when executing workflow: ${error}`); } @@ -219,4 +223,31 @@ export class SonataFlowService { } return false; } + + public async createPrefixFetchErrorMessage( + urlToFetch: string, + response: Response, + httpMethod = 'GET', + ): Promise { + const res = await response.json(); + const errorInfo = []; + let errorMsg = `Request ${httpMethod} ${urlToFetch} failed with: StatusCode: ${response.status}`; + + if (response.statusText) { + errorInfo.push(`StatusText: ${response.statusText}`); + } + if (res?.details) { + errorInfo.push(`Details: ${res?.details}`); + } + if (res?.stack) { + errorInfo.push(`Stack: ${res?.stack}`); + } + if (errorInfo.length > 0) { + errorMsg += ` ${errorInfo.join(', ')}`; + } else { + errorMsg += ' Unexpected error'; + } + + return errorMsg; + } }