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');
});
it.each(['1ad7d4a2-6b2d-4e0f-9b1f-2c0f8d3e5a7b', 'GF_COPX'])(
'creates a custom asset profile with a new UUID if the asset profile of %s belongs to another user',
async (symbol) => {
const assetProfile = await getCreatedAssetProfile({
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'
});
it('connects to the existing asset profile of a symbol with the prefix', async () => {
const assetProfile = await getCreatedAssetProfile({
existingAssetProfile: { id: 'asset-profile-id' },
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 assetProfile = await getCreatedAssetProfile({ symbol });
@ -101,7 +82,7 @@ describe('ActivitiesService', () => {
existingAssetProfile,
symbol
}: {
existingAssetProfile?: { userId: string | null };
existingAssetProfile?: { id: string };
symbol: string;
}) {
const create = jest.fn(({ data }: Prisma.OrderCreateArgs) => {
@ -152,7 +133,15 @@ describe('ActivitiesService', () => {
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 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(
requestedSymbol
)
? await this.prismaService.symbolProfile.findUnique({
select: { userId: true },
select: { id: true },
where: {
dataSource_symbol: { dataSource, symbol: requestedSymbol }
}
@ -241,7 +239,6 @@ export class ActivitiesService {
if (
canUseCustomAssetProfileSymbol({
userId,
assetProfile: existingAssetProfile,
symbol: requestedSymbol
})

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

@ -32,6 +32,7 @@ describe('ImportService', () => {
let createActivity: jest.Mock;
let gatherSymbols: jest.Mock;
let importService: ImportService;
let updateManyMarketData: jest.Mock;
beforeEach(() => {
const configuration = {
@ -43,6 +44,7 @@ describe('ImportService', () => {
addSymbolProfile = jest.fn();
createActivity = jest.fn();
gatherSymbols = jest.fn();
updateManyMarketData = jest.fn();
const validateActivities = jest.fn(
({ activitiesDto }: { activitiesDto: CreateOrderDto[] }) => {
@ -85,7 +87,7 @@ describe('ImportService', () => {
{
toCurrencyAtDate: jest.fn().mockResolvedValue(0)
} as unknown as ExchangeRateDataService,
{ updateMany: jest.fn() } as unknown as MarketDataService,
{ updateMany: updateManyMarketData } as unknown as MarketDataService,
null,
null,
{
@ -321,6 +323,79 @@ describe('ImportService', () => {
).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 () => {
const activities = await importActivities({
activitiesDto: [

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

@ -26,8 +26,8 @@ import {
} from '@ghostfolio/common/dtos';
import { SubscriptionType } from '@ghostfolio/common/enums';
import {
canUseCustomAssetProfileSymbol,
getAssetProfileIdentifier,
hasGhostfolioPrefix,
isValidCustomAssetProfileSymbol,
parseDate
} from '@ghostfolio/common/helper';
@ -650,16 +650,21 @@ export class ImportService {
'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 (
(existingAssetProfile ||
!canUseCustomAssetProfileSymbol({ symbol, userId: user.id })) &&
(existingAssetProfile || hasGhostfolioPrefix(symbol)) &&
!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;

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

@ -41,60 +41,47 @@ describe('Helper', () => {
});
describe('Can use custom asset profile symbol', () => {
const assetProfile = { id: 'asset-profile-id' };
const symbol = '7e91b7d4-1430-4212-8380-289a06c9bbc1';
const userId = 'user-id';
it('Free-text symbol', () => {
expect(
canUseCustomAssetProfileSymbol({
userId,
assetProfile: null,
symbol: 'Penthouse Apartment'
})
).toEqual(false);
});
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', () => {
expect(
canUseCustomAssetProfileSymbol({
userId,
assetProfile: null,
symbol: 'GF_PENTHOUSE_APARTMENT'
})
).toEqual(false);
});
it('Asset profile of the admin', () => {
expect(
canUseCustomAssetProfileSymbol({
userId,
assetProfile: { userId: null },
symbol: 'GF_PENTHOUSE_APARTMENT'
})
).toEqual(true);
it('Existing asset profile with UUID', () => {
expect(canUseCustomAssetProfileSymbol({ assetProfile, symbol })).toEqual(
true
);
});
it('Asset profile of the user', () => {
it('Existing asset profile with Ghostfolio prefix', () => {
expect(
canUseCustomAssetProfileSymbol({
symbol,
userId,
assetProfile: { userId }
assetProfile,
symbol: 'GF_PENTHOUSE_APARTMENT'
})
).toEqual(true);
});
it('Asset profile of another user', () => {
expect(
canUseCustomAssetProfileSymbol({
symbol,
userId,
assetProfile: { userId: 'other-user-id' }
})
).toEqual(false);
});
});
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
* connect to the existing asset profile or to create a new one
* Checks if the symbol of a custom asset profile can be used, either to connect
* 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({
assetProfile,
symbol,
userId
symbol
}: {
assetProfile?: Pick<SymbolProfile, 'userId'> | null;
assetProfile: Pick<SymbolProfile, 'id'> | null;
symbol: string;
userId: string;
}): boolean {
if (!isValidCustomAssetProfileSymbol(symbol)) {
return false;
}
if (assetProfile) {
// Same as in the search: the custom asset profiles of the user and of the
// admin
return !assetProfile.userId || assetProfile.userId === userId;
return true;
}
// The prefix is reserved for the asset profiles of the admin
return !hasGhostfolioPrefix(symbol);
}

Loading…
Cancel
Save