diff --git a/apps/api/src/app/import/import.service.spec.ts b/apps/api/src/app/import/import.service.spec.ts new file mode 100644 index 000000000..cf5e24a5c --- /dev/null +++ b/apps/api/src/app/import/import.service.spec.ts @@ -0,0 +1,268 @@ +import { AccountService } from '@ghostfolio/api/app/account/account.service'; +import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; +import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; +import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; +import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; +import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; +import { TagService } from '@ghostfolio/api/services/tag/tag.service'; +import { UserWithSettings } from '@ghostfolio/common/types'; + +import { DataSource } from '@prisma/client'; + +import { ImportService } from './import.service'; + +describe('ImportService', () => { + let importService: ImportService; + let accountService: Partial; + let activitiesService: Partial; + let dataGatheringService: Partial; + let dataProviderService: Partial; + let symbolProfileService: Partial; + let marketDataService: Partial; + let tagService: Partial; + + const userId = 'test-user-id'; + + const user = { + id: userId, + permissions: [], + settings: { settings: { baseCurrency: 'USD' } } + } as unknown as UserWithSettings; + + beforeEach(() => { + symbolProfileService = { + getSymbolProfiles: jest.fn(), + add: jest.fn() + }; + + marketDataService = { + updateMany: jest.fn() + }; + + dataProviderService = { + validateActivities: jest.fn().mockResolvedValue([]) + }; + + dataGatheringService = { + gatherSymbols: jest.fn() + }; + + activitiesService = { + getActivities: jest.fn().mockResolvedValue({ activities: [] }) + }; + + accountService = { + getAccounts: jest.fn().mockResolvedValue([]) + }; + + tagService = { + getTagsForUser: jest.fn().mockResolvedValue([]) + }; + + importService = new ImportService( + accountService as unknown as AccountService, + activitiesService as unknown as ActivitiesService, + null, // apiService + dataGatheringService as unknown as DataGatheringService, + dataProviderService as unknown as DataProviderService, + null, // exchangeRateDataService + marketDataService as MarketDataService, + null, // platformService + null, // portfolioService + symbolProfileService as SymbolProfileService, + tagService as unknown as TagService + ); + }); + + describe('import', () => { + it('should reuse existing global asset profile (userId is null) instead of creating a duplicate', async () => { + const existingGlobalProfile = { + dataSource: DataSource.YAHOO, + symbol: 'VOO', + userId: null + }; + + (symbolProfileService.getSymbolProfiles as jest.Mock).mockResolvedValue([ + existingGlobalProfile + ]); + (marketDataService.updateMany as jest.Mock).mockResolvedValue(undefined); + + await importService.import({ + accountsWithBalancesDto: [], + activitiesDto: [], + assetProfilesWithMarketDataDto: [ + { + currency: 'USD', + dataSource: DataSource.YAHOO, + symbol: 'VOO', + marketData: [] + } + ], + isDryRun: false, + maxActivitiesToImport: 100, + tagsDto: [], + user + }); + + expect(symbolProfileService.add).not.toHaveBeenCalled(); + }); + + it('should create a new profile with remapped symbol when asset profile belongs to a different user', async () => { + const otherUsersProfile = { + dataSource: DataSource.MANUAL, + symbol: 'MY_ASSET', + userId: 'other-user-id' + }; + + (symbolProfileService.getSymbolProfiles as jest.Mock).mockResolvedValue([ + otherUsersProfile + ]); + (marketDataService.updateMany as jest.Mock).mockResolvedValue(undefined); + + await importService.import({ + accountsWithBalancesDto: [], + activitiesDto: [], + assetProfilesWithMarketDataDto: [ + { + currency: 'USD', + dataSource: DataSource.MANUAL, + symbol: 'MY_ASSET', + marketData: [] + } + ], + isDryRun: false, + maxActivitiesToImport: 100, + tagsDto: [], + user + }); + + expect(symbolProfileService.add).toHaveBeenCalledTimes(1); + + const addedProfile = (symbolProfileService.add as jest.Mock).mock + .calls[0][0]; + + // Symbol should have been remapped to a UUID (not original) + expect(addedProfile.symbol).not.toBe('MY_ASSET'); + }); + + it('should reuse existing profile when it belongs to the importing user', async () => { + const usersOwnProfile = { + dataSource: DataSource.MANUAL, + symbol: 'MY_ASSET', + userId + }; + + (symbolProfileService.getSymbolProfiles as jest.Mock).mockResolvedValue([ + usersOwnProfile + ]); + (marketDataService.updateMany as jest.Mock).mockResolvedValue(undefined); + + await importService.import({ + accountsWithBalancesDto: [], + activitiesDto: [], + assetProfilesWithMarketDataDto: [ + { + currency: 'USD', + dataSource: DataSource.MANUAL, + symbol: 'MY_ASSET', + marketData: [] + } + ], + isDryRun: false, + maxActivitiesToImport: 100, + tagsDto: [], + user + }); + + expect(symbolProfileService.add).not.toHaveBeenCalled(); + }); + + it('should create a new profile when no existing profile exists', async () => { + (symbolProfileService.getSymbolProfiles as jest.Mock).mockResolvedValue( + [] + ); + (marketDataService.updateMany as jest.Mock).mockResolvedValue(undefined); + + await importService.import({ + accountsWithBalancesDto: [], + activitiesDto: [], + assetProfilesWithMarketDataDto: [ + { + currency: 'USD', + dataSource: DataSource.YAHOO, + symbol: 'AAPL', + marketData: [] + } + ], + isDryRun: false, + maxActivitiesToImport: 100, + tagsDto: [], + user + }); + + expect(symbolProfileService.add).toHaveBeenCalledTimes(1); + + const addedProfile = (symbolProfileService.add as jest.Mock).mock + .calls[0][0]; + + expect(addedProfile.symbol).toBe('AAPL'); + expect(addedProfile.user).toEqual({ connect: { id: userId } }); + }); + + it('should skip profile creation when isDryRun is true', async () => { + await importService.import({ + accountsWithBalancesDto: [], + activitiesDto: [], + assetProfilesWithMarketDataDto: [ + { + currency: 'USD', + dataSource: DataSource.YAHOO, + symbol: 'AAPL', + marketData: [] + } + ], + isDryRun: true, + maxActivitiesToImport: 100, + tagsDto: [], + user + }); + + expect(symbolProfileService.getSymbolProfiles).not.toHaveBeenCalled(); + expect(symbolProfileService.add).not.toHaveBeenCalled(); + expect(marketDataService.updateMany).not.toHaveBeenCalled(); + }); + + it('should call marketDataService.updateMany when reusing a global profile', async () => { + const existingGlobalProfile = { + dataSource: DataSource.YAHOO, + symbol: 'VOO', + userId: null + }; + + (symbolProfileService.getSymbolProfiles as jest.Mock).mockResolvedValue([ + existingGlobalProfile + ]); + (marketDataService.updateMany as jest.Mock).mockResolvedValue(undefined); + + await importService.import({ + accountsWithBalancesDto: [], + activitiesDto: [], + assetProfilesWithMarketDataDto: [ + { + currency: 'USD', + dataSource: DataSource.YAHOO, + symbol: 'VOO', + marketData: [] + } + ], + isDryRun: false, + maxActivitiesToImport: 100, + tagsDto: [], + user + }); + + expect(symbolProfileService.add).not.toHaveBeenCalled(); + expect(marketDataService.updateMany).toHaveBeenCalledWith({ data: [] }); + }); + }); +}); diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts index 9b1aa417c..e7ad30436 100644 --- a/apps/api/src/app/import/import.service.ts +++ b/apps/api/src/app/import/import.service.ts @@ -344,8 +344,13 @@ export class ImportService { } ); - // If there is no asset profile or if the asset profile belongs to a different user, then create a new asset profile - if (!existingAssetProfile || existingAssetProfile.userId !== user.id) { + // If there is no asset profile or if the asset profile belongs to a different user, then create a new asset profile. + // Global asset profiles (userId is null) are shared and should be reused by everyone. + if ( + !existingAssetProfile || + (existingAssetProfile.userId !== null && + existingAssetProfile.userId !== user.id) + ) { const assetProfile: CreateAssetProfileDto = omit( assetProfileWithMarketData, 'marketData'