From b3b2c1414ce0d046a945af1f7a40416b3ff46347 Mon Sep 17 00:00:00 2001 From: Mihajlo Medjedovic Date: Tue, 4 Mar 2025 14:48:22 +0100 Subject: [PATCH] chore: better test coverage --- src/spec/SAS9ApiClient.spec.ts | 130 ++++++++++ src/spec/SASjsApiClient.spec.ts | 231 ++++++++++++++++++ src/types/errors/spec/SAS9AuthError.spec.ts | 30 +++ src/utils/spec/parseSasViyaLog.spec.ts | 24 ++ src/utils/spec/parseViyaDebugResponse.spec.ts | 72 ++++++ 5 files changed, 487 insertions(+) create mode 100644 src/spec/SAS9ApiClient.spec.ts create mode 100644 src/spec/SASjsApiClient.spec.ts create mode 100644 src/types/errors/spec/SAS9AuthError.spec.ts create mode 100644 src/utils/spec/parseSasViyaLog.spec.ts create mode 100644 src/utils/spec/parseViyaDebugResponse.spec.ts diff --git a/src/spec/SAS9ApiClient.spec.ts b/src/spec/SAS9ApiClient.spec.ts new file mode 100644 index 0000000..8534d26 --- /dev/null +++ b/src/spec/SAS9ApiClient.spec.ts @@ -0,0 +1,130 @@ +/** + * @jest-environment node + */ +import * as https from 'https' +import NodeFormData from 'form-data' +import { SAS9ApiClient } from '../SAS9ApiClient' +import { Sas9RequestClient } from '../request/Sas9RequestClient' + +// Mock the Sas9RequestClient so that we can control its behavior +jest.mock('../request/Sas9RequestClient', () => { + return { + Sas9RequestClient: jest + .fn() + .mockImplementation( + (serverUrl: string, httpsAgentOptions?: https.AgentOptions) => { + return { + login: jest.fn().mockResolvedValue(undefined), + post: jest.fn().mockResolvedValue({ result: 'execution result' }) + } + } + ) + } +}) + +describe('SAS9ApiClient', () => { + const serverUrl = 'http://test-server.com' + const jobsPath = '/SASStoredProcess/do' + let client: SAS9ApiClient + let mockRequestClient: any + + beforeEach(() => { + client = new SAS9ApiClient(serverUrl, jobsPath) + // Retrieve the instance of the mocked Sas9RequestClient + mockRequestClient = (Sas9RequestClient as jest.Mock).mock.results[0].value + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + describe('getConfig', () => { + it('should return the correct configuration', () => { + const config = client.getConfig() + expect(config).toEqual({ serverUrl }) + }) + }) + + describe('setConfig', () => { + it('should update the serverUrl when a valid value is provided', () => { + const newUrl = 'http://new-server.com' + client.setConfig(newUrl) + expect(client.getConfig()).toEqual({ serverUrl: newUrl }) + }) + + it('should not update the serverUrl when an empty string is provided', () => { + const originalConfig = client.getConfig() + client.setConfig('') + expect(client.getConfig()).toEqual(originalConfig) + }) + }) + + describe('executeScript', () => { + const linesOfCode = ['line1;', 'line2;'] + const userName = 'testUser' + const password = 'testPass' + const fixedTimestamp = '1234567890' + const expectedFilename = `sasjs-execute-sas9-${fixedTimestamp}.sas` + + beforeAll(() => { + // Stub generateTimestamp so that we get a consistent filename in our tests. + jest + .spyOn(require('@sasjs/utils/time'), 'generateTimestamp') + .mockReturnValue(fixedTimestamp) + }) + + afterAll(() => { + jest.restoreAllMocks() + }) + + it('should execute the script and return the result', async () => { + const result = await client.executeScript(linesOfCode, userName, password) + + // Verify that login is called with the correct parameters. + expect(mockRequestClient.login).toHaveBeenCalledWith( + userName, + password, + jobsPath + ) + + // Build the expected stored process URL. + const codeInjectorPath = `/User Folders/${userName}/My Folder/sasjs/runner` + const expectedUrl = + `${jobsPath}/?` + '_program=' + codeInjectorPath + '&_debug=log' + + // Verify that post was called with the expected stored process URL. + expect(mockRequestClient.post).toHaveBeenCalledWith( + expectedUrl, + expect.any(NodeFormData), + undefined, + expect.stringContaining('multipart/form-data; boundary='), + expect.objectContaining({ + 'Content-Length': expect.any(Number), + 'Content-Type': expect.stringContaining( + 'multipart/form-data; boundary=' + ), + Accept: '*/*' + }) + ) + + // The method should return the result from the post call. + expect(result).toEqual('execution result') + }) + + it('should include the force output code in the uploaded form data', async () => { + await client.executeScript(linesOfCode, userName, password) + // Retrieve the form data passed to post + const postCallArgs = (mockRequestClient.post as jest.Mock).mock.calls[0] + const formData: NodeFormData = postCallArgs[1] + + // We can inspect the boundary and ensure that the filename was generated correctly. + expect(formData.getBoundary()).toBeDefined() + + // The filename is used as the key for the form field. + const formDataBuffer = formData.getBuffer().toString() + expect(formDataBuffer).toContain(expectedFilename) + // Also check that the force output code is appended. + expect(formDataBuffer).toContain("put 'Executed sasjs run';") + }) + }) +}) diff --git a/src/spec/SASjsApiClient.spec.ts b/src/spec/SASjsApiClient.spec.ts new file mode 100644 index 0000000..e0b84b2 --- /dev/null +++ b/src/spec/SASjsApiClient.spec.ts @@ -0,0 +1,231 @@ +import NodeFormData from 'form-data' +import { + SASjsApiClient, + SASjsAuthResponse, + ScriptExecutionResult +} from '../SASjsApiClient' +import { AuthConfig, ServicePackSASjs } from '@sasjs/utils/types' +import { ExecutionQuery } from '../types' + +// Create a mock request client with a post method. +const mockPost = jest.fn() +const mockRequestClient = { + post: mockPost +} + +// Instead of referencing external variables, inline the dummy values in the mock factories. +jest.mock('../auth/getTokens', () => ({ + getTokens: jest.fn().mockResolvedValue({ access_token: 'dummyAccessToken' }) +})) + +jest.mock('../auth/getAccessTokenForSasjs', () => ({ + getAccessTokenForSasjs: jest.fn().mockResolvedValue({ + access_token: 'newAccessToken', + refresh_token: 'newRefreshToken' + } as any) +})) + +jest.mock('../auth/refreshTokensForSasjs', () => ({ + refreshTokensForSasjs: jest.fn().mockResolvedValue({ + access_token: 'newAccessToken', + refresh_token: 'newRefreshToken' + } as any) +})) + +// For deployZipFile, mock the file reading function. +jest.mock('@sasjs/utils/file', () => ({ + createReadStream: jest.fn().mockResolvedValue('readStreamDummy') +})) + +// Dummy result to compare against. +const dummyResult = { + status: 'OK', + message: 'Success', + streamServiceName: 'service', + example: {} +} + +describe('SASjsApiClient', () => { + let client: SASjsApiClient + + beforeEach(() => { + client = new SASjsApiClient(mockRequestClient as any) + mockPost.mockReset() + }) + + describe('deploy', () => { + it('should deploy service pack using JSON', async () => { + // Arrange: Simulate a successful response. + mockPost.mockResolvedValue({ result: dummyResult }) + + const dataJson: ServicePackSASjs = { + appLoc: '', + someOtherProp: 'value' + } as any + const appLoc = '/base/appLoc' + const authConfig: AuthConfig = { + client: 'clientId', + secret: 'secret', + access_token: 'token', + refresh_token: 'refresh' + } + + // Act + const result = await client.deploy(dataJson, appLoc, authConfig) + + // Assert: Ensure that the JSON gets the appLoc set if not defined. + expect(dataJson.appLoc).toBe(appLoc) + expect(mockPost).toHaveBeenCalledWith( + 'SASjsApi/drive/deploy', + dataJson, + 'dummyAccessToken', + undefined, + {}, + { maxContentLength: Infinity, maxBodyLength: Infinity } + ) + expect(result).toEqual(dummyResult) + }) + }) + + describe('deployZipFile', () => { + it('should deploy zip file and return the result', async () => { + // Arrange: Simulate a successful response. + mockPost.mockResolvedValue({ result: dummyResult }) + const zipFilePath = 'path/to/deploy.zip' + const authConfig: AuthConfig = { + client: 'clientId', + secret: 'secret', + access_token: 'token', + refresh_token: 'refresh' + } + + // Act + const result = await client.deployZipFile(zipFilePath, authConfig) + + // Assert: Verify that POST is called with multipart form-data. + expect(mockPost).toHaveBeenCalled() + const callArgs = mockPost.mock.calls[0] + expect(callArgs[0]).toBe('SASjsApi/drive/deploy/upload') + expect(result).toEqual(dummyResult) + }) + }) + + describe('executeJob', () => { + it('should execute a job with absolute program path', async () => { + // Arrange + const query: ExecutionQuery = { _program: '/absolute/path' } as any + const appLoc = '/base/appLoc' + const authConfig: AuthConfig = { access_token: 'anyToken' } as any + mockPost.mockResolvedValue({ + result: { jobId: 123 }, + log: 'execution log' + }) + + // Act + const { result, log } = await client.executeJob(query, appLoc, authConfig) + + // Assert: The program path should not be prefixed. + expect(mockPost).toHaveBeenCalledWith( + 'SASjsApi/stp/execute', + { _debug: 131, ...query, _program: '/absolute/path' }, + 'anyToken' + ) + expect(result).toEqual({ jobId: 123 }) + expect(log).toBe('execution log') + }) + + it('should execute a job with relative program path', async () => { + // Arrange + const query: ExecutionQuery = { _program: 'relative/path' } as any + const appLoc = '/base/appLoc' + mockPost.mockResolvedValue({ result: { jobId: 456 }, log: 'another log' }) + + // Act + const { result, log } = await client.executeJob(query, appLoc) + + // Assert: The program path should be prefixed with appLoc. + expect(mockPost).toHaveBeenCalledWith( + 'SASjsApi/stp/execute', + { _debug: 131, ...query, _program: '/base/appLoc/relative/path' }, + undefined + ) + expect(result).toEqual({ jobId: 456 }) + expect(log).toBe('another log') + }) + }) + + describe('executeScript', () => { + it('should execute a script and return the execution result', async () => { + // Arrange + const code = 'data _null_; run;' + const runTime = 'sas' + const authConfig: AuthConfig = { + client: 'clientId', + secret: 'secret', + access_token: 'token', + refresh_token: 'refresh' + } + const responsePayload = { + log: 'log output', + printOutput: 'print output', + result: 'web output' + } + mockPost.mockResolvedValue(responsePayload) + + // Act + const result: ScriptExecutionResult = await client.executeScript( + code, + runTime, + authConfig + ) + + // Assert + expect(mockPost).toHaveBeenCalledWith( + 'SASjsApi/code/execute', + { code, runTime }, + 'dummyAccessToken' + ) + expect(result.log).toBe('log output') + expect(result.printOutput).toBe('print output') + expect(result.webout).toBe('web output') + }) + + it('should throw an error with a prefixed message when POST fails', async () => { + // Arrange + const code = 'data _null_; run;' + const errorMessage = 'Network Error' + mockPost.mockRejectedValue(new Error(errorMessage)) + + // Act & Assert + await expect(client.executeScript(code)).rejects.toThrow( + /Error while sending POST request to execute code/ + ) + }) + }) + + describe('getAccessToken', () => { + it('should exchange auth code for access token', async () => { + // Act + const result = await client.getAccessToken('clientId', 'authCode123') + + // Assert: The result should match the dummy auth response. + expect(result).toEqual({ + access_token: 'newAccessToken', + refresh_token: 'newRefreshToken' + }) + }) + }) + + describe('refreshTokens', () => { + it('should exchange refresh token for new tokens', async () => { + // Act + const result = await client.refreshTokens('refreshToken123') + + // Assert: The result should match the dummy auth response. + expect(result).toEqual({ + access_token: 'newAccessToken', + refresh_token: 'newRefreshToken' + }) + }) + }) +}) diff --git a/src/types/errors/spec/SAS9AuthError.spec.ts b/src/types/errors/spec/SAS9AuthError.spec.ts new file mode 100644 index 0000000..2043ae9 --- /dev/null +++ b/src/types/errors/spec/SAS9AuthError.spec.ts @@ -0,0 +1,30 @@ +import { SAS9AuthError } from '../SAS9AuthError' + +describe('SAS9AuthError', () => { + it('should have the correct error message', () => { + const error = new SAS9AuthError() + expect(error.message).toBe( + 'The credentials you provided cannot be authenticated. Please provide a valid set of credentials.' + ) + }) + + it('should have the correct error name', () => { + const error = new SAS9AuthError() + expect(error.name).toBe('AuthorizeError') + }) + + it('should be an instance of SAS9AuthError', () => { + const error = new SAS9AuthError() + expect(error).toBeInstanceOf(SAS9AuthError) + }) + + it('should be an instance of Error', () => { + const error = new SAS9AuthError() + expect(error).toBeInstanceOf(Error) + }) + + it('should set the prototype correctly', () => { + const error = new SAS9AuthError() + expect(Object.getPrototypeOf(error)).toBe(SAS9AuthError.prototype) + }) +}) diff --git a/src/utils/spec/parseSasViyaLog.spec.ts b/src/utils/spec/parseSasViyaLog.spec.ts new file mode 100644 index 0000000..c9e8a4a --- /dev/null +++ b/src/utils/spec/parseSasViyaLog.spec.ts @@ -0,0 +1,24 @@ +import { parseSasViyaLog } from '../parseSasViyaLog' + +describe('parseSasViyaLog', () => { + it('should parse sas viya log if environment is Node', () => { + const logResponse = { + items: [{ line: 'Line 1' }, { line: 'Line 2' }, { line: 'Line 3' }] + } + + const expectedLog = 'Line 1\nLine 2\nLine 3' + const result = parseSasViyaLog(logResponse) + expect(result).toEqual(expectedLog) + }) + + it('should handle exceptions and return the original logResponse', () => { + // Create a logResponse that will cause an error in the mapping process. + const logResponse: any = { + items: null + } + // Since logResponse.items is null, the ternary operator returns the else branch. + const expectedLog = JSON.stringify(logResponse) + const result = parseSasViyaLog(logResponse) + expect(result).toEqual(expectedLog) + }) +}) diff --git a/src/utils/spec/parseViyaDebugResponse.spec.ts b/src/utils/spec/parseViyaDebugResponse.spec.ts new file mode 100644 index 0000000..d31c02c --- /dev/null +++ b/src/utils/spec/parseViyaDebugResponse.spec.ts @@ -0,0 +1,72 @@ +import { RequestClient } from '../../request/RequestClient' +import { parseSasViyaDebugResponse } from '../parseViyaDebugResponse' + +describe('parseSasViyaDebugResponse', () => { + let requestClient: RequestClient + const serverUrl = 'http://test-server.com' + + beforeEach(() => { + requestClient = { + get: jest.fn() + } as unknown as RequestClient + }) + + it('should extract URL and call get for Viya 3.5 iframe style', async () => { + const iframeUrl = '/path/to/log.json' + const response = `` + const resultData = { message: 'success' } + + // Mock the get method to resolve with an object containing the JSON result as string. + ;(requestClient.get as jest.Mock).mockResolvedValue({ + result: JSON.stringify(resultData) + }) + + const result = await parseSasViyaDebugResponse( + response, + requestClient, + serverUrl + ) + + expect(requestClient.get).toHaveBeenCalledWith( + serverUrl + iframeUrl, + undefined, + 'text/plain' + ) + expect(result).toEqual(resultData) + }) + + it('should extract URL and call get for Viya 4 iframe style', async () => { + const iframeUrl = '/another/path/to/log.json' + // Note: For Viya 4, the regex splits in such a way that the extracted URL includes an extra starting double-quote. + // For example, the URL becomes: '"/another/path/to/log.json' + const response = `` + const resultData = { status: 'ok' } + + ;(requestClient.get as jest.Mock).mockResolvedValue({ + result: JSON.stringify(resultData) + }) + + const result = await parseSasViyaDebugResponse( + response, + requestClient, + serverUrl + ) + // Expect the extra starting double-quote as per the current implementation. + const expectedUrl = serverUrl + `"` + iframeUrl + + expect(requestClient.get).toHaveBeenCalledWith( + expectedUrl, + undefined, + 'text/plain' + ) + expect(result).toEqual(resultData) + }) + + it('should throw an error if iframe URL is not found', async () => { + const response = `No iframe here` + + await expect( + parseSasViyaDebugResponse(response, requestClient, serverUrl) + ).rejects.toThrow('Unable to find webout file URL.') + }) +})