From bfa3c76c25009331dfc86ee2fbef4abb24e8b1cc Mon Sep 17 00:00:00 2001 From: Sergey Konovalov Date: Thu, 20 Mar 2025 17:27:39 +0300 Subject: [PATCH] [test] Add tests for downloadUrlPromise --- Common/sources/utils.js | 2 +- tests/jest.config.js | 4 +- tests/unit/request.tests.js | 407 ++++++++++++++++++++++++++++++++++++ 3 files changed, 411 insertions(+), 2 deletions(-) create mode 100644 tests/unit/request.tests.js diff --git a/Common/sources/utils.js b/Common/sources/utils.js index cb91f1bd..d2ff34ec 100644 --- a/Common/sources/utils.js +++ b/Common/sources/utils.js @@ -41,7 +41,7 @@ var fs = require('fs'); var path = require('path'); const crypto = require('crypto'); var url = require('url'); -var axios = require('axios') +var axios = require('axios'); var co = require('co'); var URI = require("uri-js"); const escapeStringRegexp = require('escape-string-regexp'); diff --git a/tests/jest.config.js b/tests/jest.config.js index 2f3ba967..06b1f86a 100644 --- a/tests/jest.config.js +++ b/tests/jest.config.js @@ -120,7 +120,9 @@ module.exports = { // ], // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module - // moduleNameMapper: {}, + moduleNameMapper: { + '^axios$': '../../Common/node_modules/axios/dist/node/axios.cjs', + }, // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader // modulePathIgnorePatterns: [], diff --git a/tests/unit/request.tests.js b/tests/unit/request.tests.js new file mode 100644 index 00000000..a7cb7c6f --- /dev/null +++ b/tests/unit/request.tests.js @@ -0,0 +1,407 @@ +// Required modules +const { describe, test, expect, beforeEach, afterAll, jest } = require('@jest/globals'); +const { Readable, Writable } = require('stream'); +// Setup mocks for axios +jest.mock('axios'); +const axios = require('axios'); +const operationContext = require('../../Common/sources/operationContext'); +const utils = require('../../Common/sources/utils'); + +// Mock axios CancelToken as both a constructor and an object with source method +const cancelFn = jest.fn(); +axios.CancelToken = jest.fn().mockImplementation((executor) => { + // Execute the function passed to CancelToken constructor with our cancel function + if (typeof executor === 'function') { + executor(cancelFn); + } + return { cancel: cancelFn }; +}); + +// Also mock the source method +axios.CancelToken.source = jest.fn().mockReturnValue({ + token: 'mock-token', + cancel: jest.fn() +}); + +// Create operation context for tests +const ctx = new operationContext.Context(); + +// Helper functions for creating test streams +const createMockStream = (data) => { + // Convert string to Buffer if it's not already a buffer + const bufferData = Buffer.isBuffer(data) ? data : Buffer.from(data || JSON.stringify({ success: true }), 'utf8'); + // Create a Readable stream from buffer data + return Readable.from(bufferData); +}; + +const createMockWriter = () => { + const chunks = []; + return new Writable({ + write(chunk, encoding, callback) { + chunks.push(chunk); + callback(); + } + }); +}; + +// Common test parameters +const commonTestParams = { + uri: 'https://example.com/api/data', + timeout: 5000, + limit: 1024 * 1024, // 1MB + authorization: 'Bearer token123', + filterPrivate: true, + headers: { 'Accept': 'application/json' } +}; + +// Creates common parameter assertion +const createParamAssertion = (uri) => { + return expect.objectContaining({ + url: uri || commonTestParams.uri, + timeout: commonTestParams.timeout, + maxContentLength: commonTestParams.limit, + responseType: 'stream', + headers: expect.objectContaining({ + 'Accept': 'application/json', + 'Authorization': commonTestParams.authorization + }) + }); +}; + +describe('HTTP Request Functionality', () => { + beforeEach(() => { + // Reset all mocks before each test + jest.clearAllMocks(); + }); + + afterAll(() => { + // Clean up all mocks + jest.restoreAllMocks(); + }); + + describe('downloadUrlPromise', () => { + test('properly handles content streaming', async () => { + // Create mock data + const mockData = 'Sample data content'; + + // Mock successful response with stream + const mockResponse = { + status: 200, + headers: { + 'content-type': 'text/plain', + 'content-length': String(Buffer.byteLength(mockData, 'utf8')) + }, + data: createMockStream(mockData) + }; + + // Setup axios mock - axios() is used directly in the code, not axios.get() + axios.mockImplementation((config) => { + console.log('Mock axios called with config:', JSON.stringify(config, null, 2)); + return Promise.resolve(mockResponse); + }); + + // Create a proper writable stream for testing + const mockStreamWriter = createMockWriter(); + + // Test version with stream writer (returns undefined) + const resultWithStreamWriter = await utils.downloadUrlPromise( + ctx, + 'https://example.com/file', + commonTestParams.timeout, + commonTestParams.limit, + null, + false, + null, + mockStreamWriter + ); + + // Verify axios was called + expect(axios).toHaveBeenCalledTimes(1); + + // With stream writer, the function returns undefined + expect(resultWithStreamWriter).toBeUndefined(); + }); + + test('returns complete response without stream writer', async () => { + // Create mock data + const mockData = JSON.stringify({ data: 'test content' }); + + // Mock successful response with stream + const mockResponse = { + status: 200, + headers: { + 'content-type': 'application/json', + 'content-length': String(Buffer.byteLength(mockData, 'utf8')) + }, + data: createMockStream(mockData) + }; + + // Reset mocks and setup new behavior + jest.clearAllMocks(); + axios.mockImplementation(() => Promise.resolve(mockResponse)); + + // Call function without stream writer + const result = await utils.downloadUrlPromise( + ctx, + 'https://example.com/data', + commonTestParams.timeout, + commonTestParams.limit, + null, + false, + null, + null // No stream writer + ); + + // Verify axios was called + expect(axios).toHaveBeenCalledTimes(1); + + // Verify full response object is returned + expect(result).toBeDefined(); + expect(result).toHaveProperty('response', mockResponse); + expect(result).toHaveProperty('sha256'); + expect(result).toHaveProperty('body'); + expect(result.sha256).toMatch(/^[a-f0-9]{64}$/i); + expect(Buffer.isBuffer(result.body)).toBe(true); + }); + + test('throws error on non-200 status codes', async () => { + // Create error data + const errorData = JSON.stringify({ error: 'Not found' }); + + // Mock error response with stream + const mockErrorResponse = { + status: 404, + statusText: 'Not Found', + headers: { + 'content-type': 'application/json', + 'content-length': String(Buffer.byteLength(errorData, 'utf8')) + }, + data: createMockStream(errorData) + }; + + // Reset mocks and setup new behavior for error + jest.clearAllMocks(); + axios.mockImplementation(() => Promise.resolve(mockErrorResponse)); + + // Call function and expect it to throw + await expect(utils.downloadUrlPromise( + ctx, + 'https://example.com/not-found', + commonTestParams.timeout, + commonTestParams.limit, + null, + false, + null, + null + )).rejects.toThrow(/Error response: statusCode:404/); + + // Verify axios was called + expect(axios).toHaveBeenCalledTimes(1); + }); + + test('throws error when content-length exceeds limit', async () => { + // Create large data (but mock only returns the header) + const mockResponse = { + status: 200, + headers: { + 'content-type': 'application/json', + 'content-length': '2097152' // 2MB (greater than 1MB limit) + }, + data: createMockStream('{}') // actual data is irrelevant, header check happens first + }; + + // Reset mocks and setup new behavior + jest.clearAllMocks(); + axios.mockImplementation(() => Promise.resolve(mockResponse)); + + // Call function with a 1MB limit and expect it to throw + await expect(utils.downloadUrlPromise( + ctx, + 'https://example.com/large-file', + commonTestParams.timeout, + 1024 * 1024, // 1MB limit + null, + false, + null, + null + )).rejects.toThrow(/EMSGSIZE: Error response: content-length/); + + // Verify axios was called + expect(axios).toHaveBeenCalledTimes(1); + }); + + test('follows redirects correctly', async () => { + // Create a counter to track calls + let callCount = 0; + + // Mock redirect response + const redirectResponse = { + status: 302, + headers: { + location: 'https://example.com/redirected' + } + }; + + // Mock success response after redirect + const successData = JSON.stringify({ success: true }); + const successResponse = { + status: 200, + headers: { + 'content-type': 'application/json', + 'content-length': String(Buffer.byteLength(successData, 'utf8')) + }, + data: createMockStream(successData) + }; + + // Reset mocks and implement redirect then success + jest.clearAllMocks(); + axios.mockImplementation(() => { + if (callCount === 0) { + callCount++; + // First call - simulate redirect by throwing error with response + const err = new Error('Redirect'); + err.response = redirectResponse; + return Promise.reject(err); + } else { + // Second call - return success + return Promise.resolve(successResponse); + } + }); + + // Call function with original URL + const result = await utils.downloadUrlPromise( + ctx, + 'https://example.com/original', + commonTestParams.timeout, + commonTestParams.limit, + null, + false, + null, + null + ); + + // Verify axios was called twice (once for original, once for redirect) + expect(axios).toHaveBeenCalledTimes(2); + + // Verify the result is from the successful redirect + expect(result).toBeDefined(); + expect(result).toHaveProperty('response'); + expect(result).toHaveProperty('sha256'); + expect(result.response.status).toBe(200); + }); + + test('handles network errors correctly', async () => { + // Reset mocks and implement network error + jest.clearAllMocks(); + axios.mockImplementation(() => { + const err = new Error('Network Error'); + return Promise.reject(err); + }); + + // Call function and expect network error + await expect(utils.downloadUrlPromise( + ctx, + 'https://example.com/network-error', + commonTestParams.timeout, + commonTestParams.limit, + null, + false, + null, + null + )).rejects.toThrow('Network Error'); + + // Verify axios was called + expect(axios).toHaveBeenCalledTimes(1); + }); + + test('handles binary data correctly', async () => { + // Create binary data (simple buffer with pattern of bytes) + const binaryData = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]); // PNG file signature + + // Mock successful response with binary stream + const mockResponse = { + status: 200, + headers: { + 'content-type': 'image/png', + 'content-length': String(binaryData.length) + }, + data: createMockStream(binaryData) + }; + + // Reset mocks and setup for binary data + jest.clearAllMocks(); + axios.mockImplementation(() => Promise.resolve(mockResponse)); + + // Call function without stream writer + const result = await utils.downloadUrlPromise( + ctx, + 'https://example.com/image.png', + commonTestParams.timeout, + commonTestParams.limit, + null, + false, + null, + null + ); + + // Verify axios was called + expect(axios).toHaveBeenCalledTimes(1); + + // Verify binary data is preserved correctly + expect(result).toBeDefined(); + expect(result).toHaveProperty('body'); + expect(Buffer.isBuffer(result.body)).toBe(true); + expect(result.body.length).toBe(binaryData.length); + // Verify the content matches the original binary data + expect(Buffer.compare(result.body, binaryData)).toBe(0); + }); + + test('handles timeout correctly', async () => { + // Reset mocks + jest.clearAllMocks(); + + // Setup axios mock to simulate a timeout by triggering the CancelToken cancel function + axios.mockImplementation((config) => { + // Explicitly check for timeout config + expect(config).toHaveProperty('timeout'); + expect(config).toHaveProperty('cancelToken'); + + // Return a promise that never resolves - the cancel function + // will be called by setTimeout in the implementation + return new Promise(() => { + // setTimeout is used in the real implementation, but we don't need to do anything here + // as the cancelFn mock will be called by the code under test + }); + }); + + // Mock Implementation will call canceFn after "timeout" - simulate this + cancelFn.mockImplementation((message) => { + // This is what happens when the timeout occurs and cancel is called + const err = new Error(message || 'ETIMEDOUT: timeout'); + err.isCancel = true; // axios sets this flag + throw err; + }); + + // Call function and expect timeout error + await expect(utils.downloadUrlPromise( + ctx, + 'https://example.com/slow-response', + { connection: '100ms', inactivity: '100ms', wholeCycle: '200ms' }, // short timeout + commonTestParams.limit, + null, + false, + null, + null + )).rejects.toThrow(/ETIMEDOUT/); + + // Verify axios was called + expect(axios).toHaveBeenCalledTimes(1); + + // Verify timeout was properly configured + expect(axios).toHaveBeenCalledWith(expect.objectContaining({ + timeout: expect.any(Number), + cancelToken: expect.anything() + })); + }); + }); +}); \ No newline at end of file