Browse Source

Fix creation of custom asset profiles with ghostfolio prefix

pull/8070/head
Thomas Kaul 1 week ago
parent
commit
a49476d5c6
  1. 47
      apps/api/src/app/activities/activities.service.spec.ts
  2. 5
      apps/api/src/app/activities/activities.service.ts
  3. 77
      apps/api/src/app/import/import.service.spec.ts
  4. 21
      apps/api/src/app/import/import.service.ts
  5. 39
      libs/common/src/lib/helper.spec.ts
  6. 16
      libs/common/src/lib/helper.ts

47
apps/api/src/app/activities/activities.service.spec.ts

@ -61,35 +61,16 @@ describe('ActivitiesService', () => {
expect(assetProfile.name).toBe('GF_COPX'); expect(assetProfile.name).toBe('GF_COPX');
}); });
it.each(['1ad7d4a2-6b2d-4e0f-9b1f-2c0f8d3e5a7b', 'GF_COPX'])( it('connects to the existing asset profile of a symbol with the prefix', async () => {
'creates a custom asset profile with a new UUID if the asset profile of %s belongs to another user', const assetProfile = await getCreatedAssetProfile({
async (symbol) => { existingAssetProfile: { id: 'asset-profile-id' },
const assetProfile = await getCreatedAssetProfile({ symbol: 'GF_COPX'
symbol, });
existingAssetProfile: { userId: 'other-user-id' }
});
expect(isUUID(assetProfile.symbol)).toBe(true);
expect(assetProfile.symbol).not.toBe(symbol);
}
);
it.each([
{ owner: 'the admin', userId: null },
{ owner: 'the user', userId: 'user-id' }
])(
'connects to the asset profile of $owner for a symbol with the prefix',
async ({ userId }) => {
const assetProfile = await getCreatedAssetProfile({
existingAssetProfile: { userId },
symbol: 'GF_COPX'
});
expect(assetProfile.symbol).toBe('GF_COPX'); expect(assetProfile.symbol).toBe('GF_COPX');
} });
);
it('connects to the custom asset profile of a UUID symbol', async () => { it('creates a custom asset profile with the requested UUID if no asset profile exists', async () => {
const symbol = '1ad7d4a2-6b2d-4e0f-9b1f-2c0f8d3e5a7b'; const symbol = '1ad7d4a2-6b2d-4e0f-9b1f-2c0f8d3e5a7b';
const assetProfile = await getCreatedAssetProfile({ symbol }); const assetProfile = await getCreatedAssetProfile({ symbol });
@ -101,7 +82,7 @@ describe('ActivitiesService', () => {
existingAssetProfile, existingAssetProfile,
symbol symbol
}: { }: {
existingAssetProfile?: { userId: string | null }; existingAssetProfile?: { id: string };
symbol: string; symbol: string;
}) { }) {
const create = jest.fn(({ data }: Prisma.OrderCreateArgs) => { const create = jest.fn(({ data }: Prisma.OrderCreateArgs) => {
@ -152,7 +133,15 @@ describe('ActivitiesService', () => {
userId: 'user-id' userId: 'user-id'
}); });
return create.mock.calls[0][0].data.SymbolProfile.connectOrCreate.create; const { create: assetProfile, where } =
create.mock.calls[0][0].data.SymbolProfile.connectOrCreate;
expect(where.dataSource_symbol).toEqual({
dataSource: DataSource.MANUAL,
symbol: assetProfile.symbol
});
return assetProfile;
} }
}); });

5
apps/api/src/app/activities/activities.service.ts

@ -226,13 +226,11 @@ export class ActivitiesService {
let name = data.SymbolProfile.connectOrCreate.create.name; let name = data.SymbolProfile.connectOrCreate.create.name;
let symbol: string; let symbol: string;
// Look up valid custom asset profile symbols only, since a free text is
// never the symbol of a custom asset profile
const existingAssetProfile = isValidCustomAssetProfileSymbol( const existingAssetProfile = isValidCustomAssetProfileSymbol(
requestedSymbol requestedSymbol
) )
? await this.prismaService.symbolProfile.findUnique({ ? await this.prismaService.symbolProfile.findUnique({
select: { userId: true }, select: { id: true },
where: { where: {
dataSource_symbol: { dataSource, symbol: requestedSymbol } dataSource_symbol: { dataSource, symbol: requestedSymbol }
} }
@ -241,7 +239,6 @@ export class ActivitiesService {
if ( if (
canUseCustomAssetProfileSymbol({ canUseCustomAssetProfileSymbol({
userId,
assetProfile: existingAssetProfile, assetProfile: existingAssetProfile,
symbol: requestedSymbol symbol: requestedSymbol
}) })

77
apps/api/src/app/import/import.service.spec.ts

@ -32,6 +32,7 @@ describe('ImportService', () => {
let createActivity: jest.Mock; let createActivity: jest.Mock;
let gatherSymbols: jest.Mock; let gatherSymbols: jest.Mock;
let importService: ImportService; let importService: ImportService;
let updateManyMarketData: jest.Mock;
beforeEach(() => { beforeEach(() => {
const configuration = { const configuration = {
@ -43,6 +44,7 @@ describe('ImportService', () => {
addSymbolProfile = jest.fn(); addSymbolProfile = jest.fn();
createActivity = jest.fn(); createActivity = jest.fn();
gatherSymbols = jest.fn(); gatherSymbols = jest.fn();
updateManyMarketData = jest.fn();
const validateActivities = jest.fn( const validateActivities = jest.fn(
({ activitiesDto }: { activitiesDto: CreateOrderDto[] }) => { ({ activitiesDto }: { activitiesDto: CreateOrderDto[] }) => {
@ -85,7 +87,7 @@ describe('ImportService', () => {
{ {
toCurrencyAtDate: jest.fn().mockResolvedValue(0) toCurrencyAtDate: jest.fn().mockResolvedValue(0)
} as unknown as ExchangeRateDataService, } as unknown as ExchangeRateDataService,
{ updateMany: jest.fn() } as unknown as MarketDataService, { updateMany: updateManyMarketData } as unknown as MarketDataService,
null, null,
null, null,
{ {
@ -321,6 +323,79 @@ describe('ImportService', () => {
).toMatchObject({ symbol, dataSource: DataSource.MANUAL }); ).toMatchObject({ symbol, dataSource: DataSource.MANUAL });
}); });
it('keeps the symbol with the prefix as the name of a custom asset profile without a name', async () => {
mockCreatedAssetProfiles([
{ dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL }
]);
await importActivities({
activitiesDto: [
createActivityDto({
dataSource: DataSource.MANUAL,
date: '2024-01-01T00:00:00.000Z',
symbol: 'GF_COPX',
type: 'BUY'
})
],
assetProfilesWithMarketDataDto: [
{
currency: 'USD',
dataSource: DataSource.MANUAL,
marketData: [],
symbol: 'GF_COPX'
}
]
});
expect(addSymbolProfile.mock.calls[0][0].name).toBe('GF_COPX');
});
it('merges the market data of asset profiles with the same symbol with the prefix', async () => {
mockCreatedAssetProfiles([
{ dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL }
]);
await importActivities({
activitiesDto: [
createActivityDto({
dataSource: DataSource.MANUAL,
date: '2024-01-01T00:00:00.000Z',
symbol: 'GF_COPX',
type: 'BUY'
})
],
assetProfilesWithMarketDataDto: [
{
currency: 'USD',
dataSource: DataSource.MANUAL,
marketData: [{ date: '2024-01-01', marketPrice: 100 }],
name: 'Global X Copper Miners ETF',
symbol: 'GF_COPX'
},
{
currency: 'USD',
dataSource: DataSource.MANUAL,
marketData: [{ date: '2024-01-02', marketPrice: 101 }],
name: 'Global X Copper Miners ETF',
symbol: 'GF_COPX'
}
]
});
const { symbol } = addSymbolProfile.mock.calls[0][0];
expect(addSymbolProfile).toHaveBeenCalledTimes(1);
expect(updateManyMarketData.mock.calls[0][0].data).toMatchObject([
{ symbol, date: '2024-01-01', marketPrice: 100 },
{ symbol, date: '2024-01-02', marketPrice: 101 }
]);
expect(
createActivity.mock.calls[0][0].SymbolProfile.connectOrCreate.create
).toMatchObject({ symbol, dataSource: DataSource.MANUAL });
});
it('keeps the asset profiles of the activities in a dry run', async () => { it('keeps the asset profiles of the activities in a dry run', async () => {
const activities = await importActivities({ const activities = await importActivities({
activitiesDto: [ activitiesDto: [

21
apps/api/src/app/import/import.service.ts

@ -26,8 +26,8 @@ import {
} from '@ghostfolio/common/dtos'; } from '@ghostfolio/common/dtos';
import { SubscriptionType } from '@ghostfolio/common/enums'; import { SubscriptionType } from '@ghostfolio/common/enums';
import { import {
canUseCustomAssetProfileSymbol,
getAssetProfileIdentifier, getAssetProfileIdentifier,
hasGhostfolioPrefix,
isValidCustomAssetProfileSymbol, isValidCustomAssetProfileSymbol,
parseDate parseDate
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
@ -650,16 +650,21 @@ export class ImportService {
'marketData' 'marketData'
); );
// Generate a new symbol if the user cannot use the symbol for a new
// asset profile, or if the asset profile exists. Unlike in
// createActivity(), this applies to an asset profile of the admin
// as well, since the asset profile of the import has its own data.
if ( if (
(existingAssetProfile || (existingAssetProfile || hasGhostfolioPrefix(symbol)) &&
!canUseCustomAssetProfileSymbol({ symbol, userId: user.id })) &&
!isDryRun !isDryRun
) { ) {
symbol = randomUUID(); const isSymbolOfAssetProfileToCreate = assetProfilesToCreate.some(
({ assetProfile: { symbol: symbolToCreate } }) => {
return symbolToCreate === assetProfileSymbolMapping[symbol];
}
);
assetProfile.name ??= symbol;
symbol = isSymbolOfAssetProfileToCreate
? assetProfileSymbolMapping[symbol]
: randomUUID();
} }
assetProfile.symbol = symbol; assetProfile.symbol = symbol;

39
libs/common/src/lib/helper.spec.ts

@ -41,60 +41,47 @@ describe('Helper', () => {
}); });
describe('Can use custom asset profile symbol', () => { describe('Can use custom asset profile symbol', () => {
const assetProfile = { id: 'asset-profile-id' };
const symbol = '7e91b7d4-1430-4212-8380-289a06c9bbc1'; const symbol = '7e91b7d4-1430-4212-8380-289a06c9bbc1';
const userId = 'user-id';
it('Free-text symbol', () => { it('Free-text symbol', () => {
expect( expect(
canUseCustomAssetProfileSymbol({ canUseCustomAssetProfileSymbol({
userId, assetProfile: null,
symbol: 'Penthouse Apartment' symbol: 'Penthouse Apartment'
}) })
).toEqual(false); ).toEqual(false);
}); });
it('New asset profile with UUID', () => { it('New asset profile with UUID', () => {
expect(canUseCustomAssetProfileSymbol({ symbol, userId })).toEqual(true); expect(
canUseCustomAssetProfileSymbol({ symbol, assetProfile: null })
).toEqual(true);
}); });
it('New asset profile with Ghostfolio prefix', () => { it('New asset profile with Ghostfolio prefix', () => {
expect( expect(
canUseCustomAssetProfileSymbol({ canUseCustomAssetProfileSymbol({
userId, assetProfile: null,
symbol: 'GF_PENTHOUSE_APARTMENT' symbol: 'GF_PENTHOUSE_APARTMENT'
}) })
).toEqual(false); ).toEqual(false);
}); });
it('Asset profile of the admin', () => { it('Existing asset profile with UUID', () => {
expect( expect(canUseCustomAssetProfileSymbol({ assetProfile, symbol })).toEqual(
canUseCustomAssetProfileSymbol({ true
userId, );
assetProfile: { userId: null },
symbol: 'GF_PENTHOUSE_APARTMENT'
})
).toEqual(true);
}); });
it('Asset profile of the user', () => { it('Existing asset profile with Ghostfolio prefix', () => {
expect( expect(
canUseCustomAssetProfileSymbol({ canUseCustomAssetProfileSymbol({
symbol, assetProfile,
userId, symbol: 'GF_PENTHOUSE_APARTMENT'
assetProfile: { userId }
}) })
).toEqual(true); ).toEqual(true);
}); });
it('Asset profile of another user', () => {
expect(
canUseCustomAssetProfileSymbol({
symbol,
userId,
assetProfile: { userId: 'other-user-id' }
})
).toEqual(false);
});
}); });
describe('Extract number from string', () => { describe('Extract number from string', () => {

16
libs/common/src/lib/helper.ts

@ -238,29 +238,25 @@ export function canOpenHoldingDetail({
} }
/** /**
* Checks if a user can use the symbol of a custom asset profile, either to * Checks if the symbol of a custom asset profile can be used, either to connect
* connect to the existing asset profile or to create a new one * to the existing asset profile or to create a new one. The prefix is reserved
* for the asset profiles of the admin, thus a new asset profile cannot use it.
*/ */
export function canUseCustomAssetProfileSymbol({ export function canUseCustomAssetProfileSymbol({
assetProfile, assetProfile,
symbol, symbol
userId
}: { }: {
assetProfile?: Pick<SymbolProfile, 'userId'> | null; assetProfile: Pick<SymbolProfile, 'id'> | null;
symbol: string; symbol: string;
userId: string;
}): boolean { }): boolean {
if (!isValidCustomAssetProfileSymbol(symbol)) { if (!isValidCustomAssetProfileSymbol(symbol)) {
return false; return false;
} }
if (assetProfile) { if (assetProfile) {
// Same as in the search: the custom asset profiles of the user and of the return true;
// admin
return !assetProfile.userId || assetProfile.userId === userId;
} }
// The prefix is reserved for the asset profiles of the admin
return !hasGhostfolioPrefix(symbol); return !hasGhostfolioPrefix(symbol);
} }

Loading…
Cancel
Save