You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

365 lines
11 KiB

import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { NON_INVESTMENT_ACTIVITY_TYPES } from '@ghostfolio/common/config';
import { CreateOrderDto } from '@ghostfolio/common/dtos';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import {
Activity,
AssetProfileIdentifier
} from '@ghostfolio/common/interfaces';
import { UserWithSettings } from '@ghostfolio/common/types';
import { DataSource, SymbolProfile } from '@prisma/client';
import { parseISO } from 'date-fns';
import { ImportService } from './import.service';
const CUSTOM_ASSET_PROFILE_SYMBOL = '1ad7d4a2-6b2d-4e0f-9b1f-2c0f8d3e5a7b';
describe('ImportService', () => {
let createActivity: jest.Mock;
let gatherSymbols: jest.Mock;
let importService: ImportService;
beforeEach(() => {
const configuration = {
DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: [],
ENABLE_FEATURE_SUBSCRIPTION: false,
MAX_ACTIVITIES_TO_IMPORT: Number.MAX_SAFE_INTEGER
};
createActivity = jest.fn();
gatherSymbols = jest.fn();
const validateActivities = jest.fn(
({ activitiesDto }: { activitiesDto: CreateOrderDto[] }) => {
const assetProfiles: {
[assetProfileIdentifier: string]: Partial<SymbolProfile>;
} = {};
for (const { currency, dataSource, symbol } of activitiesDto) {
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] ??= {
currency,
dataSource,
symbol,
name: symbol
};
}
return assetProfiles;
}
);
importService = new ImportService(
{
getAccounts: jest.fn().mockResolvedValue([])
} as unknown as AccountService,
{
createActivity,
getActivities: jest.fn().mockResolvedValue({ activities: [], count: 0 })
} as unknown as ActivitiesService,
null,
{
get: (key: keyof typeof configuration) => {
return configuration[key];
}
} as unknown as ConfigurationService,
{ gatherSymbols } as unknown as DataGatheringService,
{
validateActivities,
getDataSourceForImport: jest.fn().mockReturnValue(DataSource.MANUAL)
} as unknown as DataProviderService,
{
toCurrencyAtDate: jest.fn().mockResolvedValue(0)
} as unknown as ExchangeRateDataService,
null,
null,
null,
null,
{
getTagsForUser: jest.fn().mockResolvedValue([])
} as unknown as TagService
);
});
describe('import', () => {
it('keeps the asset profile of an investment activity after a non-investment activity', async () => {
mockCreatedAssetProfiles([
{ dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL },
{ dataSource: DataSource.YAHOO, symbol: 'AAPL' }
]);
const activities = await importActivities({
activitiesDto: [
createActivityDto({
dataSource: DataSource.YAHOO,
date: '2024-01-01T00:00:00.000Z',
symbol: 'AAPL',
type: 'FEE'
}),
createActivityDto({
dataSource: DataSource.YAHOO,
date: '2024-01-02T00:00:00.000Z',
symbol: 'AAPL',
type: 'BUY'
})
]
});
expect(
createActivity.mock.calls[1][0].SymbolProfile.connectOrCreate.create
).toMatchObject({ dataSource: DataSource.YAHOO, symbol: 'AAPL' });
expect(getAssetProfileIdentifiers(activities)).toEqual([
{ dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL },
{ dataSource: DataSource.YAHOO, symbol: 'AAPL' }
]);
expect(gatherSymbols.mock.calls[0][0].dataGatheringItems).toEqual([
{
dataSource: DataSource.MANUAL,
date: parseISO('2024-01-01T00:00:00.000Z'),
symbol: CUSTOM_ASSET_PROFILE_SYMBOL
},
{
dataSource: DataSource.YAHOO,
date: parseISO('2024-01-02T00:00:00.000Z'),
symbol: 'AAPL'
}
]);
});
it('shows the custom asset profile of a non-investment activity after an investment activity', async () => {
mockCreatedAssetProfiles([
{ dataSource: DataSource.YAHOO, symbol: 'AAPL' },
{ dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL }
]);
const activities = await importActivities({
activitiesDto: [
createActivityDto({
dataSource: DataSource.YAHOO,
date: '2024-01-01T00:00:00.000Z',
symbol: 'AAPL',
type: 'BUY'
}),
createActivityDto({
dataSource: DataSource.YAHOO,
date: '2024-01-02T00:00:00.000Z',
symbol: 'AAPL',
type: 'FEE'
})
]
});
expect(
createActivity.mock.calls[1][0].SymbolProfile.connectOrCreate.create
).toMatchObject({ dataSource: DataSource.YAHOO, symbol: 'AAPL' });
expect(getAssetProfileIdentifiers(activities)).toEqual([
{ dataSource: DataSource.YAHOO, symbol: 'AAPL' },
{ dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL }
]);
});
it.each(NON_INVESTMENT_ACTIVITY_TYPES)(
'refers the next %s activities of a data provider to the custom asset profile which createActivity() has created',
async (type) => {
mockCreatedAssetProfiles([
{
dataSource: DataSource.MANUAL,
symbol: CUSTOM_ASSET_PROFILE_SYMBOL
},
{ dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL }
]);
await importActivities({
activitiesDto: [
createActivityDto({
type,
dataSource: DataSource.YAHOO,
date: '2024-01-01T00:00:00.000Z',
symbol: 'AAPL'
}),
createActivityDto({
type,
dataSource: DataSource.YAHOO,
date: '2024-01-02T00:00:00.000Z',
symbol: 'AAPL'
})
]
});
expect(
createActivity.mock.calls[1][0].SymbolProfile.connectOrCreate.create
).toMatchObject({ symbol: CUSTOM_ASSET_PROFILE_SYMBOL });
}
);
it('refers the next activities without a data source to the custom asset profile which createActivity() has created', async () => {
mockCreatedAssetProfiles([
{ dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL },
{ dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL }
]);
await importActivities({
activitiesDto: [
createActivityDto({
date: '2024-01-01T00:00:00.000Z',
symbol: 'Broker fee',
type: 'FEE'
}),
createActivityDto({
date: '2024-01-02T00:00:00.000Z',
symbol: 'Broker fee',
type: 'FEE'
})
]
});
expect(
createActivity.mock.calls[1][0].SymbolProfile.connectOrCreate.create
).toMatchObject({
dataSource: DataSource.MANUAL,
symbol: CUSTOM_ASSET_PROFILE_SYMBOL
});
});
it('refers the next investment activities without a data source to the custom asset profile which createActivity() has created', async () => {
mockCreatedAssetProfiles([
{ dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL },
{ dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL },
{ dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL }
]);
await importActivities({
activitiesDto: [
createActivityDto({
date: '2024-01-01T00:00:00.000Z',
symbol: 'Gold',
type: 'BUY'
}),
createActivityDto({
date: '2024-01-02T00:00:00.000Z',
symbol: 'Gold',
type: 'BUY'
}),
createActivityDto({
date: '2024-01-03T00:00:00.000Z',
symbol: 'Gold',
type: 'SELL'
})
]
});
expect(
createActivity.mock.calls.slice(1).map(([activity]) => {
return activity.SymbolProfile.connectOrCreate.create;
})
).toMatchObject([
{ dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL },
{ dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL }
]);
expect(gatherSymbols.mock.calls[0][0].dataGatheringItems).toEqual([
{
dataSource: DataSource.MANUAL,
date: parseISO('2024-01-01T00:00:00.000Z'),
symbol: CUSTOM_ASSET_PROFILE_SYMBOL
}
]);
});
it('keeps the asset profiles of the activities in a dry run', async () => {
const activities = await importActivities({
activitiesDto: [
createActivityDto({
dataSource: DataSource.YAHOO,
date: '2024-01-01T00:00:00.000Z',
symbol: 'AAPL',
type: 'FEE'
}),
createActivityDto({
dataSource: DataSource.YAHOO,
date: '2024-01-02T00:00:00.000Z',
symbol: 'AAPL',
type: 'BUY'
})
],
isDryRun: true
});
expect(createActivity).not.toHaveBeenCalled();
expect(gatherSymbols).not.toHaveBeenCalled();
expect(getAssetProfileIdentifiers(activities)).toEqual([
{ dataSource: DataSource.YAHOO, symbol: 'AAPL' },
{ dataSource: DataSource.YAHOO, symbol: 'AAPL' }
]);
});
});
function importActivities({
activitiesDto,
isDryRun
}: {
activitiesDto: CreateOrderDto[];
isDryRun?: boolean;
}) {
return importService.import({
activitiesDto,
isDryRun,
accountsWithBalancesDto: [],
assetProfilesWithMarketDataDto: [],
platformsDto: [],
tagsDto: [],
user: {
id: 'user-id',
permissions: [],
settings: { settings: { baseCurrency: 'USD' } }
} as unknown as UserWithSettings
});
}
function mockCreatedAssetProfiles(assetProfiles: AssetProfileIdentifier[]) {
for (const assetProfile of assetProfiles) {
createActivity.mockImplementationOnce(
({
date,
type
}: Parameters<ActivitiesService['createActivity']>[0]) => {
return { date, type, SymbolProfile: assetProfile };
}
);
}
}
});
function createActivityDto({
dataSource,
date,
symbol,
type
}: Pick<CreateOrderDto, 'dataSource' | 'date' | 'symbol' | 'type'>) {
return {
dataSource,
date,
symbol,
type,
currency: 'USD',
fee: 0,
quantity: 1,
unitPrice: 100
} as CreateOrderDto;
}
function getAssetProfileIdentifiers(activities: Activity[]) {
return activities.map(({ assetProfile: { dataSource, symbol } }) => {
return { dataSource, symbol };
});
}