mirror of https://github.com/ghostfolio/ghostfolio
Browse Source
* Fix overwrite of asset profile data with empty lists * Update changelogpull/8365/head
committed by
GitHub
5 changed files with 570 additions and 70 deletions
@ -0,0 +1,358 @@ |
|||
import { Logger } from '@nestjs/common'; |
|||
|
|||
import { TrackinsightDataEnhancerService } from './trackinsight.service'; |
|||
|
|||
jest.mock('@ghostfolio/api/services/fetch/fetch.service', () => { |
|||
return { |
|||
FetchService: jest.fn() |
|||
}; |
|||
}); |
|||
|
|||
const FAILURE_MESSAGE = { |
|||
_failure_reason: 'http_error', |
|||
error: 'HTTP 405: Method Not Allowed', |
|||
http_status: 405, |
|||
status: 'failed', |
|||
url: 'https://www.trackinsight.com/data-api/holdings/QQQ.json' |
|||
}; |
|||
|
|||
const FUND = { |
|||
cusip: '46090E103', |
|||
isins: ['US46090E1038', 'US46090E3018'], |
|||
ticker: 'QQQ' |
|||
}; |
|||
|
|||
const HOLDINGS = { |
|||
countries: { |
|||
Other: { count: 1, weight: 0.01 }, |
|||
USA: { count: 99, weight: 0.99 } |
|||
}, |
|||
sectors: { |
|||
Financials: { count: 10, weight: 0.2 }, |
|||
'Information Technology': { count: 90, weight: 0.8 } |
|||
}, |
|||
topHoldings: [ |
|||
{ label: 'NVIDIA Corp.', weight: 0.08 }, |
|||
{ label: 'Other', weight: 0.92 } |
|||
], |
|||
weight: 0.9999 |
|||
}; |
|||
|
|||
const SEARCH_RESULT = { |
|||
results: { count: 221, docs: [{ ticker: 'QQQ' }], words: ['qqq'] } |
|||
}; |
|||
|
|||
describe('TrackinsightDataEnhancerService', () => { |
|||
let fetchService: { fetch: jest.Mock }; |
|||
let trackinsightDataEnhancerService: TrackinsightDataEnhancerService; |
|||
|
|||
const enhance = (response = {}, symbol = 'QQQ') => { |
|||
return trackinsightDataEnhancerService.enhance({ |
|||
symbol, |
|||
requestTimeout: 1000, |
|||
response: { assetClass: 'EQUITY', assetSubClass: 'ETF', ...response } |
|||
}); |
|||
}; |
|||
|
|||
const mockResponses = ({ |
|||
fund = Response.json(FUND), |
|||
holdings = Response.json(HOLDINGS), |
|||
search = Response.json(SEARCH_RESULT) |
|||
}: { |
|||
fund?: Response | Error; |
|||
holdings?: Response | Error; |
|||
search?: Response | Error; |
|||
}) => { |
|||
fetchService.fetch.mockImplementation((url: string) => { |
|||
let result = fund; |
|||
|
|||
if (url.includes('/search-api/')) { |
|||
result = search; |
|||
} else if (url.includes('/data-api/holdings/')) { |
|||
result = holdings; |
|||
} |
|||
|
|||
return result instanceof Error |
|||
? Promise.reject(result) |
|||
: Promise.resolve(result.clone()); |
|||
}); |
|||
}; |
|||
|
|||
beforeEach(() => { |
|||
fetchService = { fetch: jest.fn() }; |
|||
|
|||
trackinsightDataEnhancerService = new TrackinsightDataEnhancerService( |
|||
null, |
|||
fetchService as any |
|||
); |
|||
}); |
|||
|
|||
it('should skip an asset which is not a fund', async () => { |
|||
const response = await enhance({ assetSubClass: 'STOCK' }); |
|||
|
|||
expect(fetchService.fetch).not.toHaveBeenCalled(); |
|||
expect(response.countries).toBeUndefined(); |
|||
}); |
|||
|
|||
it('should enhance the asset profile with a valid response', async () => { |
|||
mockResponses({}); |
|||
|
|||
await expect(enhance()).resolves.toEqual({ |
|||
assetClass: 'EQUITY', |
|||
assetSubClass: 'ETF', |
|||
countries: [{ code: 'US', weight: 0.99 }], |
|||
cusip: '46090E103', |
|||
holdings: [{ name: 'NVIDIA Corp.', weight: 0.08 }], |
|||
isin: 'US46090E1038', |
|||
sectors: [ |
|||
{ name: 'Financial Services', weight: 0.2 }, |
|||
{ name: 'Technology', weight: 0.8 } |
|||
] |
|||
}); |
|||
}); |
|||
|
|||
it('should keep the lists of the data provider', async () => { |
|||
mockResponses({}); |
|||
|
|||
const countries = [{ code: 'DE', weight: 1 }]; |
|||
const holdings = [{ name: 'SAP SE', weight: 1 }]; |
|||
const sectors = [{ name: 'Technology', weight: 1 }]; |
|||
|
|||
const response = await enhance({ countries, holdings, sectors }); |
|||
|
|||
expect(response.countries).toEqual(countries); |
|||
expect(response.holdings).toEqual(holdings); |
|||
expect(response.sectors).toEqual(sectors); |
|||
}); |
|||
|
|||
it('should encode the symbol in the URL of the search', async () => { |
|||
mockResponses({ |
|||
search: Response.json({ results: { count: 0, docs: [] } }) |
|||
}); |
|||
|
|||
await enhance({}, 'BRK/B'); |
|||
|
|||
expect(fetchService.fetch.mock.calls[0][0]).toContain( |
|||
'/search_v2/BRK%2FB/' |
|||
); |
|||
}); |
|||
|
|||
it.each([ |
|||
['a failed request', new Error('The operation timed out')], |
|||
['an error status', new Response(null, { status: 405 })], |
|||
['a body which is not JSON', new Response('<html></html>')], |
|||
['a failure message', Response.json(FAILURE_MESSAGE)], |
|||
['a false tool call', Response.json({ url: 'https://www.example.com' })] |
|||
])( |
|||
'should not change the asset profile on %s in the search', |
|||
async (_, search) => { |
|||
mockResponses({ search }); |
|||
|
|||
await expect(enhance()).resolves.toEqual({ |
|||
assetClass: 'EQUITY', |
|||
assetSubClass: 'ETF' |
|||
}); |
|||
} |
|||
); |
|||
|
|||
it('should not use a search result without a ticker', async () => { |
|||
mockResponses({ |
|||
search: Response.json({ results: { count: 1, docs: [{}] } }) |
|||
}); |
|||
|
|||
await enhance(); |
|||
|
|||
expect(fetchService.fetch).toHaveBeenCalledTimes(1); |
|||
}); |
|||
|
|||
it.each([ |
|||
['a single result', 1, 'QQQ.US'], |
|||
['an exact match', 2, 'QQQ'], |
|||
['a match with an exchange', 2, 'XETRA:QQQ'] |
|||
])('should use the ticker of %s in the search', async (_, count, ticker) => { |
|||
mockResponses({ |
|||
search: Response.json({ results: { count, docs: [{ ticker }] } }) |
|||
}); |
|||
|
|||
await enhance(); |
|||
|
|||
expect(fetchService.fetch.mock.calls[1][0]).toContain( |
|||
`/data-api/funds/${ticker}.json` |
|||
); |
|||
}); |
|||
|
|||
it('should not use a search result with another ticker', async () => { |
|||
mockResponses({ |
|||
search: Response.json({ |
|||
results: { count: 2, docs: [{ ticker: 'QQQ.US' }] } |
|||
}) |
|||
}); |
|||
|
|||
await enhance(); |
|||
|
|||
expect(fetchService.fetch).toHaveBeenCalledTimes(1); |
|||
}); |
|||
|
|||
it('should search again without the suffix of the symbol', async () => { |
|||
mockResponses({ |
|||
search: Response.json({ |
|||
results: { count: 2, docs: [{ ticker: 'VWCE' }] } |
|||
}) |
|||
}); |
|||
|
|||
await enhance({}, 'VWCE.DE'); |
|||
|
|||
const urls = fetchService.fetch.mock.calls.map(([url]) => { |
|||
return url; |
|||
}); |
|||
|
|||
expect(urls[0]).toContain('/search_v2/VWCE.DE/'); |
|||
expect(urls[1]).toContain('/search_v2/VWCE/'); |
|||
expect(urls[2]).toContain('/data-api/funds/VWCE.json'); |
|||
}); |
|||
|
|||
it.each([ |
|||
['a failed request', new Error('The operation timed out')], |
|||
['an error status', new Response(null, { status: 502 })], |
|||
['a body which is not JSON', new Response('`AccessDenied`Access Denied')], |
|||
['a failure message', Response.json(FAILURE_MESSAGE)], |
|||
['a missing weight', Response.json({ ...HOLDINGS, weight: undefined })], |
|||
[ |
|||
'a weight which is not a number', |
|||
Response.json({ ...HOLDINGS, weight: '1' }) |
|||
], |
|||
[ |
|||
'a weight below the threshold', |
|||
Response.json({ ...HOLDINGS, weight: 0.5 }) |
|||
], |
|||
['a weight above the maximum', Response.json({ ...HOLDINGS, weight: 1.5 })], |
|||
[ |
|||
'an invalid weight of a country', |
|||
Response.json({ ...HOLDINGS, countries: { USA: { weight: 99 } } }) |
|||
], |
|||
[ |
|||
'an invalid weight of a sector', |
|||
Response.json({ ...HOLDINGS, sectors: { Materials: { weight: -0.1 } } }) |
|||
], |
|||
[ |
|||
'an invalid item of the top holdings', |
|||
Response.json({ ...HOLDINGS, topHoldings: [{ weight: 0.1 }] }) |
|||
], |
|||
[ |
|||
'top holdings which are not a list', |
|||
Response.json({ ...HOLDINGS, topHoldings: 'NVIDIA Corp.' }) |
|||
], |
|||
[ |
|||
'countries which are not an object', |
|||
Response.json({ ...HOLDINGS, countries: [{ weight: 0.5 }] }) |
|||
], |
|||
[ |
|||
'sectors which are not an object', |
|||
Response.json({ ...HOLDINGS, sectors: [{ weight: 0.5 }] }) |
|||
] |
|||
])('should not add lists on %s in the holdings', async (_, holdings) => { |
|||
mockResponses({ holdings }); |
|||
|
|||
const response = await enhance(); |
|||
|
|||
expect(response).not.toHaveProperty('countries'); |
|||
expect(response).not.toHaveProperty('holdings'); |
|||
expect(response).not.toHaveProperty('sectors'); |
|||
}); |
|||
|
|||
it('should keep the cusip and the isin if the holdings fail', async () => { |
|||
mockResponses({ holdings: Response.json(FAILURE_MESSAGE) }); |
|||
|
|||
const response = await enhance(); |
|||
|
|||
expect(response.cusip).toBe('46090E103'); |
|||
expect(response.isin).toBe('US46090E1038'); |
|||
}); |
|||
|
|||
it('should not report a failed request as an invalid response', async () => { |
|||
const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(); |
|||
|
|||
mockResponses({ holdings: new Response(null, { status: 502 }) }); |
|||
|
|||
await enhance(); |
|||
|
|||
expect(warn).toHaveBeenCalledTimes(1); |
|||
expect(warn).toHaveBeenCalledWith( |
|||
expect.stringContaining('HTTP status 502') |
|||
); |
|||
|
|||
warn.mockRestore(); |
|||
}); |
|||
|
|||
it('should set empty lists on a valid response without items', async () => { |
|||
mockResponses({ |
|||
holdings: Response.json({ |
|||
countries: {}, |
|||
sectors: {}, |
|||
topHoldings: [], |
|||
weight: 1 |
|||
}) |
|||
}); |
|||
|
|||
const response = await enhance(); |
|||
|
|||
expect(response.countries).toEqual([]); |
|||
expect(response.holdings).toEqual([]); |
|||
expect(response.sectors).toEqual([]); |
|||
}); |
|||
|
|||
it('should use the other lists if the top holdings are null', async () => { |
|||
mockResponses({ |
|||
holdings: Response.json({ ...HOLDINGS, topHoldings: null }) |
|||
}); |
|||
|
|||
const response = await enhance(); |
|||
|
|||
expect(response.countries).toEqual([{ code: 'US', weight: 0.99 }]); |
|||
expect(response.holdings).toEqual([]); |
|||
expect(response.sectors).toEqual([ |
|||
{ name: 'Financial Services', weight: 0.2 }, |
|||
{ name: 'Technology', weight: 0.8 } |
|||
]); |
|||
}); |
|||
|
|||
it('should use the other lists if the countries are null', async () => { |
|||
mockResponses({ |
|||
holdings: Response.json({ ...HOLDINGS, countries: null }) |
|||
}); |
|||
|
|||
const response = await enhance(); |
|||
|
|||
expect(response.countries).toEqual([]); |
|||
expect(response.holdings).toEqual([{ name: 'NVIDIA Corp.', weight: 0.08 }]); |
|||
}); |
|||
|
|||
it('should accept floating point noise in a weight', async () => { |
|||
mockResponses({ |
|||
holdings: Response.json({ |
|||
...HOLDINGS, |
|||
countries: { USA: { weight: 1.0000000000000002 } }, |
|||
weight: 1.0000000000000002 |
|||
}) |
|||
}); |
|||
|
|||
const response = await enhance(); |
|||
|
|||
expect(response.countries).toEqual([ |
|||
{ code: 'US', weight: 1.0000000000000002 } |
|||
]); |
|||
}); |
|||
|
|||
it.each([ |
|||
['a failure message', Response.json(FAILURE_MESSAGE)], |
|||
['an invalid cusip and isins', Response.json({ cusip: 1, isins: 'US' })], |
|||
['a non-string isin', Response.json({ isins: [1] })] |
|||
])('should not set the cusip or the isin on %s', async (_, fund) => { |
|||
mockResponses({ fund }); |
|||
|
|||
const response = await enhance(); |
|||
|
|||
expect(response).not.toHaveProperty('cusip'); |
|||
expect(response).not.toHaveProperty('isin'); |
|||
}); |
|||
}); |
|||
Loading…
Reference in new issue