diff --git a/.changeset/warm-hornets-compete.md b/.changeset/warm-hornets-compete.md new file mode 100644 index 0000000000..bd1ca21dd4 --- /dev/null +++ b/.changeset/warm-hornets-compete.md @@ -0,0 +1,5 @@ +--- +'@chainlink/ftse-sftp-adapter': major +--- + +Adding Downloading and parsing logic for russell and ftse csv files from ftse sftp server diff --git a/packages/scripts/src/generate-readme/readmeBlacklist.json b/packages/scripts/src/generate-readme/readmeBlacklist.json index c8f7602b6b..009a74ecf2 100644 --- a/packages/scripts/src/generate-readme/readmeBlacklist.json +++ b/packages/scripts/src/generate-readme/readmeBlacklist.json @@ -64,7 +64,6 @@ "xsushi-price", "coinbase-prime", "harris-and-trotter", - "nav-consulting", - "ftse-sftp" + "nav-consulting" ] } diff --git a/packages/sources/ftse-sftp/src/config/index.ts b/packages/sources/ftse-sftp/src/config/index.ts index f940414c42..ab3fe28373 100644 --- a/packages/sources/ftse-sftp/src/config/index.ts +++ b/packages/sources/ftse-sftp/src/config/index.ts @@ -24,6 +24,11 @@ export const config = new AdapterConfig({ sensitive: true, required: true, }, + SFTP_READY_TIMEOUT_MS: { + description: 'How long (in milliseconds) to wait for the SSH handshake to complete', + type: 'number', + default: 30000, + }, BACKGROUND_EXECUTE_MS: { description: 'The amount of time the background execute should sleep before performing the next request', diff --git a/packages/sources/ftse-sftp/src/endpoint/index.ts b/packages/sources/ftse-sftp/src/endpoint/index.ts index ac952e0cec..5b3c040753 100644 --- a/packages/sources/ftse-sftp/src/endpoint/index.ts +++ b/packages/sources/ftse-sftp/src/endpoint/index.ts @@ -1,3 +1 @@ -import * as sftp from './sftp' - -export { sftp } +export { endpoint as sftp } from './sftp' diff --git a/packages/sources/ftse-sftp/src/endpoint/sftp.ts b/packages/sources/ftse-sftp/src/endpoint/sftp.ts index cdc8c2d3ed..02e1d5376d 100644 --- a/packages/sources/ftse-sftp/src/endpoint/sftp.ts +++ b/packages/sources/ftse-sftp/src/endpoint/sftp.ts @@ -11,6 +11,7 @@ export const inputParameters = new InputParameters( required: true, type: 'string', description: 'Abstract identifier of the index to fetch the data for', + options: ['FTSE100INDEX', 'Russell1000INDEX', 'Russell2000INDEX', 'Russell3000INDEX'], }, }, [ @@ -29,7 +30,7 @@ export const inputParameters = new InputParameters( ], ) -export type TInputParameters = typeof inputParameters.definition +export type Instrument = (typeof inputParameters.validated)['instrument'] /** * Union type for all possible response data structures @@ -41,6 +42,7 @@ export type BaseEndpointTypes = { Response: { Result: number Data: { + filename: string result: IndexResponseData } } @@ -48,7 +50,7 @@ export type BaseEndpointTypes = { } export const endpoint = new AdapterEndpoint({ - name: 'ftse_sftp', + name: 'sftp', transport: sftpTransport, inputParameters, }) diff --git a/packages/sources/ftse-sftp/src/index.ts b/packages/sources/ftse-sftp/src/index.ts index c458ca757c..d9fede50cd 100644 --- a/packages/sources/ftse-sftp/src/index.ts +++ b/packages/sources/ftse-sftp/src/index.ts @@ -1 +1,13 @@ -export * from './parsing' +import { expose, ServerInstance } from '@chainlink/external-adapter-framework' +import { Adapter } from '@chainlink/external-adapter-framework/adapter' +import { config } from './config' +import { sftp } from './endpoint' + +export const adapter = new Adapter({ + defaultEndpoint: sftp.name, + name: 'FTSE_SFTP', + config, + endpoints: [sftp], +}) + +export const server = (): Promise => expose(adapter) diff --git a/packages/sources/ftse-sftp/src/transport/constants.ts b/packages/sources/ftse-sftp/src/transport/constants.ts new file mode 100644 index 0000000000..23332ce54e --- /dev/null +++ b/packages/sources/ftse-sftp/src/transport/constants.ts @@ -0,0 +1,23 @@ +import { Instrument } from '../endpoint/sftp' + +export const FTSE100INDEX: Instrument = 'FTSE100INDEX' +export const RUSSELL_1000_INDEX: Instrument = 'Russell1000INDEX' +export const RUSSELL_2000_INDEX: Instrument = 'Russell2000INDEX' +export const RUSSELL_3000_INDEX: Instrument = 'Russell3000INDEX' + +export const instrumentToDirectoryMap: Record = { + [FTSE100INDEX]: '/data/valuation/uk_all_share/', + [RUSSELL_1000_INDEX]: + '/data/Returns_and_Values/Russell_US_Indexes_Daily_Index_Values_Real_Time_TXT/', + [RUSSELL_2000_INDEX]: + '/data/Returns_and_Values/Russell_US_Indexes_Daily_Index_Values_Real_Time_TXT/', + [RUSSELL_3000_INDEX]: + '/data/Returns_and_Values/Russell_US_Indexes_Daily_Index_Values_Real_Time_TXT/', +} + +export const instrumentToFileRegexMap: Record = { + [FTSE100INDEX]: /^ukallv\d{4}\.csv$/, + [RUSSELL_1000_INDEX]: /^daily_values_russell_\d{6}\.CSV$/, + [RUSSELL_2000_INDEX]: /^daily_values_russell_\d{6}\.CSV$/, + [RUSSELL_3000_INDEX]: /^daily_values_russell_\d{6}\.CSV$/, +} diff --git a/packages/sources/ftse-sftp/src/transport/sftp.ts b/packages/sources/ftse-sftp/src/transport/sftp.ts index c5860bbb34..475d3dd0e5 100644 --- a/packages/sources/ftse-sftp/src/transport/sftp.ts +++ b/packages/sources/ftse-sftp/src/transport/sftp.ts @@ -1,18 +1,23 @@ import { EndpointContext } from '@chainlink/external-adapter-framework/adapter' import { TransportDependencies } from '@chainlink/external-adapter-framework/transports' import { SubscriptionTransport } from '@chainlink/external-adapter-framework/transports/abstract/subscription' -import { sleep } from '@chainlink/external-adapter-framework/util' -import SftpClient from 'ssh2-sftp-client' -import { BaseEndpointTypes } from '../endpoint/sftp' +import { AdapterResponse, makeLogger, sleep } from '@chainlink/external-adapter-framework/util' +import { AdapterInputError } from '@chainlink/external-adapter-framework/validation/error' +import { ConnectOptions } from 'ssh2-sftp-client' +import { BaseEndpointTypes, IndexResponseData, inputParameters, Instrument } from '../endpoint/sftp' +import { CSVParserFactory } from '../parsing/factory' +import { instrumentToDirectoryMap, instrumentToFileRegexMap } from './constants' +import { getFileContentsFromFileRegex } from './utils' + +const logger = makeLogger('FTSE SFTP Adapter') + +type RequestParams = typeof inputParameters.validated export class SftpTransport extends SubscriptionTransport { config!: BaseEndpointTypes['Settings'] - endpointName!: string - sftpClient: SftpClient constructor() { super() - this.sftpClient = new SftpClient() } async initialize( @@ -23,13 +28,102 @@ export class SftpTransport extends SubscriptionTransport { ): Promise { await super.initialize(dependencies, adapterSettings, endpointName, transportName) this.config = adapterSettings - this.endpointName = endpointName } - async backgroundHandler(context: EndpointContext): Promise { + async backgroundHandler( + context: EndpointContext, + entries: RequestParams[], + ): Promise { + await Promise.all(entries.map(async (param) => this.handleRequest(param))) await sleep(context.adapterSettings.BACKGROUND_EXECUTE_MS) } + async handleRequest(param: RequestParams) { + let response: AdapterResponse + try { + response = await this._handleRequest(param) + } catch (e) { + const errorMessage = e instanceof Error ? e.message : 'Unknown error occurred' + response = { + statusCode: 502, + errorMessage, + timestamps: { + providerDataRequestedUnixMs: 0, + providerDataReceivedUnixMs: 0, + providerIndicatedTimeUnixMs: undefined, + }, + } + } + + await this.responseCache.write(this.name, [{ params: param, response }]) + } + + async _handleRequest( + param: RequestParams, + ): Promise> { + const providerDataRequestedUnixMs = Date.now() + + const { filename, result, parsedData } = await this.tryDownloadAndParseFile(param.instrument) + + logger.debug(`Successfully processed data for instrument: ${param.instrument}`) + return { + data: { + filename, + result: parsedData, + }, + statusCode: 200, + result, + timestamps: { + providerDataRequestedUnixMs, + providerDataReceivedUnixMs: Date.now(), + providerIndicatedTimeUnixMs: undefined, + }, + } + } + + private async tryDownloadAndParseFile(instrument: Instrument): Promise<{ + filename: string + result: number + parsedData: IndexResponseData + }> { + const connectOptions: ConnectOptions = { + host: this.config.SFTP_HOST, + port: this.config.SFTP_PORT, + username: this.config.SFTP_USERNAME, + password: this.config.SFTP_PASSWORD, + readyTimeout: this.config.SFTP_READY_TIMEOUT_MS, + } + + const directory = instrumentToDirectoryMap[instrument] + const filenameRegex = instrumentToFileRegexMap[instrument] + + const { filename, fileContent } = await getFileContentsFromFileRegex({ + connectOptions, + directory, + filenameRegex, + }) + + // we need latin1 here because the file contains special characters like "®" + const csvContent = fileContent.toString('latin1') + + const parser = CSVParserFactory.detectParserByInstrument(instrument) + + if (!parser) { + throw new AdapterInputError({ + statusCode: 500, + message: `Parser initialization failed for instrument: ${instrument}`, + }) + } + + const { result, parsedData } = await parser.parse(csvContent) + + return { + filename, + result, + parsedData: parsedData as IndexResponseData, + } + } + getSubscriptionTtlFromConfig(adapterSettings: BaseEndpointTypes['Settings']): number { return adapterSettings.BACKGROUND_EXECUTE_MS || 60000 } diff --git a/packages/sources/ftse-sftp/test/fixtures/index.ts b/packages/sources/ftse-sftp/test/fixtures/index.ts index a69fb63b2f..21809e906c 100644 --- a/packages/sources/ftse-sftp/test/fixtures/index.ts +++ b/packages/sources/ftse-sftp/test/fixtures/index.ts @@ -1,5 +1,6 @@ import fs from 'fs' import path from 'path' +import { FileInfo } from 'ssh2-sftp-client' // Helper function to read fixture files export function readFixtureFile(filename: string): string { @@ -24,23 +25,26 @@ export const expectedRussellData = { close: 3547.4, } -// Test data generation helpers -export const createFTSETestData = (dataRows: string[]): string => { - const header = `02/09/2025 (C) FTSE International Limited 2025. All Rights Reserved -FTSE UK All-Share Indices Valuation Service +export const ftseFilename = 'ukallv0209.csv' +export const russellFilename = 'daily_values_russell_250827.CSV' -Index Code,Index/Sector Name,Number of Constituents,Index Base Currency,USD Index,GBP Index,EUR Index,JPY Index,AUD Index,CNY Index,HKD Index,CAD Index,LOC Index,Base Currency (GBP) Index,USD TRI,GBP TRI,EUR TRI,JPY TRI,AUD TRI,CNY TRI,HKD TRI,CAD TRI,LOC TRI,Base Currency (GBP) TRI,Mkt Cap (USD),Mkt Cap (GBP),Mkt Cap (EUR),Mkt Cap (JPY),Mkt Cap (AUD),Mkt Cap (CNY),Mkt Cap (HKD),Mkt Cap (CAD),Mkt Cap (LOC),Mkt Cap Base Currency (GBP),XD Adjustment (YTD),Dividend Yield` +export const ftseDirectory = '/data/valuation/uk_all_share/' +export const russellDirectory = + '/data/Returns_and_Values/Russell_US_Indexes_Daily_Index_Values_Real_Time_TXT/' - return header + '\n' + dataRows.join('\n') +export const fileContents: Record = { + [path.join(ftseDirectory, ftseFilename)]: ftseCsvFixture, + [path.join(russellDirectory, russellFilename)]: russellCsvFixture, } -export const createRussellTestData = (dataRows: string[]): string => { - const header = `"Daily Values",,,,,,,,,,,,,, -,,,,,,,,,,,,,, -,,,,,,,,,,,,,, -"As of August 27, 2025",,,,,,,,"Last 5 Trading Days",,,,"1 Year Ending",, -,,,,,,,,"Closing Values",,,,"Closing Values",, -,"Open","High","Low","Close","Net Chg","% Chg","High","Low","Net Chg","% Chg","High","Low","Net Chg","% Chg"` - - return header + '\n' + dataRows.join('\n') -} +export const directoryListings = { + [ftseDirectory]: [ + 'vall_icb2302.csv', + 'vall1809.csv', + 'valllst.csv', + ftseFilename, + 'ukallvlst.csv', + 'vall_icb2302_v1.csv', + ].map((name) => ({ name })), + [russellDirectory]: ['history', russellFilename].map((name) => ({ name })), +} as unknown as Record diff --git a/packages/sources/ftse-sftp/test/integration/__snapshots__/adapter.test.ts.snap b/packages/sources/ftse-sftp/test/integration/__snapshots__/adapter.test.ts.snap new file mode 100644 index 0000000000..f1ae15d5d8 --- /dev/null +++ b/packages/sources/ftse-sftp/test/integration/__snapshots__/adapter.test.ts.snap @@ -0,0 +1,87 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`execute sftp endpoint should return error for unknown instrument 1`] = ` +{ + "error": { + "message": "[Param: instrument] input is not one of valid options (FTSE100INDEX,Russell1000INDEX,Russell2000INDEX,Russell3000INDEX)", + "name": "AdapterError", + }, + "status": "errored", + "statusCode": 400, +} +`; + +exports[`execute sftp endpoint should return success for FTSE100INDEX 1`] = ` +{ + "data": { + "filename": "ukallv0209.csv", + "result": { + "gbpIndex": 9116.68749114, + "indexBaseCurrency": "GBP", + "indexCode": "UKX", + "indexSectorName": "FTSE 100 Index", + "numberOfConstituents": 100, + }, + }, + "result": 9116.68749114, + "statusCode": 200, + "timestamps": { + "providerDataReceivedUnixMs": 978347471111, + "providerDataRequestedUnixMs": 978347471111, + }, +} +`; + +exports[`execute sftp endpoint should return success for Russell1000INDEX 1`] = ` +{ + "data": { + "filename": "daily_values_russell_250827.CSV", + "result": { + "close": 3547.4, + "indexName": "Russell 1000® Index", + }, + }, + "result": 3547.4, + "statusCode": 200, + "timestamps": { + "providerDataReceivedUnixMs": 978347471111, + "providerDataRequestedUnixMs": 978347471111, + }, +} +`; + +exports[`execute sftp endpoint should return success for Russell2000INDEX 1`] = ` +{ + "data": { + "filename": "daily_values_russell_250827.CSV", + "result": { + "close": 2373.8, + "indexName": "Russell 2000® Index", + }, + }, + "result": 2373.8, + "statusCode": 200, + "timestamps": { + "providerDataReceivedUnixMs": 978347471111, + "providerDataRequestedUnixMs": 978347471111, + }, +} +`; + +exports[`execute sftp endpoint should return success for Russell3000INDEX 1`] = ` +{ + "data": { + "filename": "daily_values_russell_250827.CSV", + "result": { + "close": 3690.93, + "indexName": "Russell 3000® Index", + }, + }, + "result": 3690.93, + "statusCode": 200, + "timestamps": { + "providerDataReceivedUnixMs": 978347471111, + "providerDataRequestedUnixMs": 978347471111, + }, +} +`; diff --git a/packages/sources/ftse-sftp/test/integration/adapter.test.ts b/packages/sources/ftse-sftp/test/integration/adapter.test.ts new file mode 100644 index 0000000000..1ccb289fba --- /dev/null +++ b/packages/sources/ftse-sftp/test/integration/adapter.test.ts @@ -0,0 +1,125 @@ +import { + TestAdapter, + makeStub, + setEnvVariables, +} from '@chainlink/external-adapter-framework/util/testing-utils' +import * as nock from 'nock' +import SftpClient from 'ssh2-sftp-client' +import { directoryListings, fileContents } from '../fixtures' + +const mockSftpClient = makeStub('mockSftpClient', { + connect: jest.fn(), + list: jest.fn(), + get: jest.fn(), + end: jest.fn(), +} as unknown as SftpClient) + +jest.mock( + 'ssh2-sftp-client', + () => + function () { + return mockSftpClient + }, +) + +const mockSuccessfulSftpResponses = () => { + jest.mocked(mockSftpClient.list).mockImplementation(async (directory: string) => { + const result = directoryListings[directory] + if (!result) { + throw new Error(`No mock listing for directory '${directory}'`) + } + return result + }) + + jest.mocked(mockSftpClient.get).mockImplementation(async (filename: string) => { + const result = fileContents[filename] + if (!result) { + throw new Error(`No mock file contents for filename '${filename}'`) + } + return Buffer.from(result, 'latin1') + }) +} + +describe('execute', () => { + let spy: jest.SpyInstance + let testAdapter: TestAdapter + let oldEnv: NodeJS.ProcessEnv + + beforeAll(async () => { + oldEnv = JSON.parse(JSON.stringify(process.env)) + process.env.SFTP_HOST = 'sftp.example.com' + process.env.SFTP_USERNAME = 'username' + process.env.SFTP_PASSWORD = 'password' + process.env.BACKGROUND_EXECUTE_MS = process.env.BACKGROUND_EXECUTE_MS ?? '0' + const mockDate = new Date('2001-01-01T11:11:11.111Z') + spy = jest.spyOn(Date, 'now').mockReturnValue(mockDate.getTime()) + + const adapter = (await import('./../../src')).adapter + adapter.rateLimiting = undefined + testAdapter = await TestAdapter.startWithMockedCache(adapter, { + testAdapter: {} as TestAdapter, + }) + + mockSuccessfulSftpResponses() + }) + + afterAll(async () => { + setEnvVariables(oldEnv) + await testAdapter.api.close() + nock.restore() + nock.cleanAll() + spy.mockRestore() + }) + + describe('sftp endpoint', () => { + it('should return success for FTSE100INDEX', async () => { + const data = { + endpoint: 'sftp', + instrument: 'FTSE100INDEX', + } + const response = await testAdapter.request(data) + expect(response.json()).toMatchSnapshot() + expect(response.statusCode).toBe(200) + }) + + it('should return success for Russell1000INDEX', async () => { + const data = { + endpoint: 'sftp', + instrument: 'Russell1000INDEX', + } + const response = await testAdapter.request(data) + expect(response.json()).toMatchSnapshot() + expect(response.statusCode).toBe(200) + }) + + it('should return success for Russell2000INDEX', async () => { + const data = { + endpoint: 'sftp', + instrument: 'Russell2000INDEX', + } + const response = await testAdapter.request(data) + expect(response.json()).toMatchSnapshot() + expect(response.statusCode).toBe(200) + }) + + it('should return success for Russell3000INDEX', async () => { + const data = { + endpoint: 'sftp', + instrument: 'Russell3000INDEX', + } + const response = await testAdapter.request(data) + expect(response.json()).toMatchSnapshot() + expect(response.statusCode).toBe(200) + }) + + it('should return error for unknown instrument', async () => { + const data = { + endpoint: 'sftp', + instrument: 'unknown', + } + const response = await testAdapter.request(data) + expect(response.json()).toMatchSnapshot() + expect(response.statusCode).toBe(400) + }) + }) +}) diff --git a/packages/sources/ftse-sftp/test/unit/sftp.test.ts b/packages/sources/ftse-sftp/test/unit/sftp.test.ts new file mode 100644 index 0000000000..ea1a5b9489 --- /dev/null +++ b/packages/sources/ftse-sftp/test/unit/sftp.test.ts @@ -0,0 +1,249 @@ +import { EndpointContext } from '@chainlink/external-adapter-framework/adapter' +import { TransportDependencies } from '@chainlink/external-adapter-framework/transports' +import { deferredPromise, LoggerFactoryProvider } from '@chainlink/external-adapter-framework/util' +import { makeStub } from '@chainlink/external-adapter-framework/util/testing-utils' +import path from 'path' +import { BaseEndpointTypes } from '../../src/endpoint/sftp' +import { FTSE100INDEX } from '../../src/transport/constants' +import { SftpTransport } from '../../src/transport/sftp' + +const originalEnv = { ...process.env } + +const restoreEnv = () => { + for (const key of Object.keys(process.env)) { + if (key in originalEnv) { + process.env[key] = originalEnv[key] + } else { + delete process.env[key] + } + } +} + +const mockSftpClient = makeStub('mockSftpClient', { + connect: jest.fn(), + list: jest.fn(), + get: jest.fn(), + end: jest.fn(), +}) + +jest.mock( + 'ssh2-sftp-client', + () => + function () { + return mockSftpClient + }, +) + +LoggerFactoryProvider.set() + +describe('SftpTransport', () => { + const transportName = 'default_single_transport' + const endpointName = 'sftp' + const SFTP_HOST = 'sftp.example.com' + const SFTP_PORT = 22 + const SFTP_USERNAME = 'username' + const SFTP_PASSWORD = 'password' + const SFTP_READY_TIMEOUT_MS = 30000 + const BACKGROUND_EXECUTE_MS = 1500 + + const adapterSettings = makeStub('adapterSettings', { + SFTP_HOST, + SFTP_PORT, + SFTP_USERNAME, + SFTP_PASSWORD, + SFTP_READY_TIMEOUT_MS, + WARMUP_SUBSCRIPTION_TTL: 10_000, + BACKGROUND_EXECUTE_MS, + MAX_COMMON_KEY_SIZE: 300, + } as unknown as BaseEndpointTypes['Settings']) + + const context = makeStub('context', { + adapterSettings, + } as EndpointContext) + + const responseCache = { + write: jest.fn(), + } + + const dependencies = makeStub('dependencies', { + responseCache, + subscriptionSetFactory: { + buildSet: jest.fn(), + }, + } as unknown as TransportDependencies) + + let transport: SftpTransport + + beforeEach(async () => { + restoreEnv() + jest.resetAllMocks() + jest.useFakeTimers() + + transport = new SftpTransport() + + await transport.initialize(dependencies, adapterSettings, endpointName, transportName) + }) + + describe('backgroundHandler', () => { + it('should sleep after handleRequest', async () => { + const t0 = Date.now() + let t1 = 0 + transport.backgroundHandler(context, []).then(() => { + t1 = Date.now() + }) + await jest.runAllTimersAsync() + expect(t1 - t0).toBe(BACKGROUND_EXECUTE_MS) + }) + }) + + describe('handleRequest', () => { + it('should cache response', async () => { + const filename = 'ukallv0918.csv' + const indexCode = 'UKX' + const indexSectorName = 'FTSE 100 Index' + const numberOfConstituents = 100 + const indexBaseCurrency = 'GBP' + const gbpIndex = 7654.32 + const fileContent = `Header line 1 + Header line 2 + + Index Code,Index/Sector Name,Number of Constituents,Index Base Currency,GBP Index + ${indexCode},${indexSectorName},${numberOfConstituents},${indexBaseCurrency},${gbpIndex}` + + mockSftpClient.list.mockResolvedValue([{ name: filename }]) + mockSftpClient.get.mockResolvedValue(Buffer.from(fileContent, 'latin1')) + + const param = makeStub('param', { + instrument: FTSE100INDEX, + }) + await transport.handleRequest(param) + + const expectedResponse = { + statusCode: 200, + result: gbpIndex, + data: { + filename, + result: { + indexCode, + indexSectorName, + numberOfConstituents, + indexBaseCurrency, + gbpIndex, + }, + }, + timestamps: { + providerDataRequestedUnixMs: Date.now(), + providerDataReceivedUnixMs: Date.now(), + providerIndicatedTimeUnixMs: undefined, + }, + } + + expect(responseCache.write).toBeCalledWith(transportName, [ + { + params: param, + response: expectedResponse, + }, + ]) + expect(responseCache.write).toBeCalledTimes(1) + }) + }) + + describe('_handleRequest', () => { + describe('for FTSE100INDEX', () => { + const directory = '/data/valuation/uk_all_share/' + const filename = 'ukallv0918.csv' + const indexCode = 'UKX' + const indexSectorName = 'FTSE 100 Index' + const numberOfConstituents = 100 + const indexBaseCurrency = 'GBP' + const gbpIndex = 7654.32 + const fileContent = `Header line 1 + Header line 2 + + Index Code,Index/Sector Name,Number of Constituents,Index Base Currency,GBP Index + ${indexCode},${indexSectorName},${numberOfConstituents},${indexBaseCurrency},${gbpIndex}` + + it('should return result for FTSE100INDEX', async () => { + mockSftpClient.list.mockResolvedValue([{ name: filename }]) + mockSftpClient.get.mockResolvedValue(Buffer.from(fileContent, 'latin1')) + + const param = makeStub('param', { + instrument: FTSE100INDEX, + }) + const response = await transport._handleRequest(param) + + expect(response).toEqual({ + statusCode: 200, + result: gbpIndex, + data: { + filename, + result: { + indexCode, + indexSectorName, + numberOfConstituents, + indexBaseCurrency, + gbpIndex, + }, + }, + timestamps: { + providerDataRequestedUnixMs: Date.now(), + providerDataReceivedUnixMs: Date.now(), + providerIndicatedTimeUnixMs: undefined, + }, + }) + + expect(mockSftpClient.connect).toHaveBeenCalledWith({ + host: SFTP_HOST, + port: SFTP_PORT, + username: SFTP_USERNAME, + password: SFTP_PASSWORD, + readyTimeout: 30000, + }) + expect(mockSftpClient.connect).toHaveBeenCalledTimes(1) + expect(mockSftpClient.list).toHaveBeenCalledWith(directory) + expect(mockSftpClient.list).toHaveBeenCalledTimes(1) + expect(mockSftpClient.get).toHaveBeenCalledWith(path.join(directory, filename)) + expect(mockSftpClient.get).toHaveBeenCalledTimes(1) + }) + + it('should record received timestamp separate from requested timestamp', async () => { + const [getFilePromise, resolveGetFile] = deferredPromise() + + mockSftpClient.list.mockResolvedValue([{ name: filename }]) + mockSftpClient.get.mockReturnValue(getFilePromise) + + const param = makeStub('param', { + instrument: FTSE100INDEX, + }) + + const requestTimestamp = Date.now() + const responsePromise = transport._handleRequest(param) + jest.advanceTimersByTime(1234) + const responseTimestamp = Date.now() + expect(responseTimestamp).toBeGreaterThan(requestTimestamp) + + resolveGetFile(Buffer.from(fileContent, 'latin1')) + + expect(await responsePromise).toEqual({ + statusCode: 200, + result: gbpIndex, + data: { + filename, + result: { + indexCode, + indexSectorName, + numberOfConstituents, + indexBaseCurrency, + gbpIndex, + }, + }, + timestamps: { + providerDataRequestedUnixMs: requestTimestamp, + providerDataReceivedUnixMs: responseTimestamp, + providerIndicatedTimeUnixMs: undefined, + }, + }) + }) + }) + }) +})