mirror of https://github.com/ghostfolio/ghostfolio
Browse Source
existingAssetProfiles was snapshotted once before the asset-profile creation loop and never refreshed, so two entries in the same import payload for the same new symbol both saw "no existing profile" and both called symbolProfileService.add(), causing a unique constraint violation on the second insert. Track identifiers already created during the loop and skip the redundant create. Closes #7459pull/7513/head
3 changed files with 249 additions and 2 deletions
@ -0,0 +1,232 @@ |
|||||
|
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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.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'; |
||||
|
|
||||
|
jest.mock('@ghostfolio/api/app/account/account.service', () => { |
||||
|
return { |
||||
|
AccountService: jest.fn().mockImplementation(() => { |
||||
|
return { |
||||
|
getAccounts: () => Promise.resolve([]) |
||||
|
}; |
||||
|
}) |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
jest.mock('@ghostfolio/api/app/activities/activities.service', () => { |
||||
|
return { |
||||
|
ActivitiesService: jest.fn().mockImplementation(() => { |
||||
|
return { |
||||
|
getActivities: () => Promise.resolve({ activities: [] }), |
||||
|
createActivity: () => { |
||||
|
return Promise.resolve({ |
||||
|
id: 'ee3949fa-9df5-4b4e-9856-14dd1cfe9c86', |
||||
|
SymbolProfile: { symbol: 'Repeated Fee' } |
||||
|
}); |
||||
|
} |
||||
|
}; |
||||
|
}) |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
jest.mock( |
||||
|
'@ghostfolio/api/services/data-provider/data-provider.service', |
||||
|
() => { |
||||
|
return { |
||||
|
DataProviderService: jest.fn().mockImplementation(() => { |
||||
|
return { |
||||
|
getDataSourceForImport: () => DataSource.MANUAL, |
||||
|
validateActivities: () => Promise.resolve({}) |
||||
|
}; |
||||
|
}) |
||||
|
}; |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
jest.mock( |
||||
|
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service', |
||||
|
() => { |
||||
|
return { |
||||
|
ExchangeRateDataService: jest.fn().mockImplementation(() => { |
||||
|
return { |
||||
|
toCurrencyAtDate: () => Promise.resolve(0) |
||||
|
}; |
||||
|
}) |
||||
|
}; |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
jest.mock('@ghostfolio/api/services/market-data/market-data.service', () => { |
||||
|
return { |
||||
|
MarketDataService: jest.fn().mockImplementation(() => { |
||||
|
return { |
||||
|
updateMany: () => Promise.resolve() |
||||
|
}; |
||||
|
}) |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
jest.mock( |
||||
|
'@ghostfolio/api/services/queues/data-gathering/data-gathering.service', |
||||
|
() => { |
||||
|
return { |
||||
|
DataGatheringService: jest.fn().mockImplementation(() => { |
||||
|
return { |
||||
|
gatherSymbols: () => undefined |
||||
|
}; |
||||
|
}) |
||||
|
}; |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
jest.mock( |
||||
|
'@ghostfolio/api/services/symbol-profile/symbol-profile.service', |
||||
|
() => { |
||||
|
return { |
||||
|
SymbolProfileService: jest.fn().mockImplementation(() => { |
||||
|
return { |
||||
|
add: jest.fn().mockResolvedValue(undefined), |
||||
|
getSymbolProfiles: () => Promise.resolve([]) |
||||
|
}; |
||||
|
}) |
||||
|
}; |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
jest.mock('@ghostfolio/api/services/tag/tag.service', () => { |
||||
|
return { |
||||
|
TagService: jest.fn().mockImplementation(() => { |
||||
|
return { |
||||
|
getTagsForUser: () => Promise.resolve([]) |
||||
|
}; |
||||
|
}) |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
describe('ImportService', () => { |
||||
|
let accountService: AccountService; |
||||
|
let activitiesService: ActivitiesService; |
||||
|
let dataGatheringService: DataGatheringService; |
||||
|
let dataProviderService: DataProviderService; |
||||
|
let exchangeRateDataService: ExchangeRateDataService; |
||||
|
let importService: ImportService; |
||||
|
let marketDataService: MarketDataService; |
||||
|
let symbolProfileService: SymbolProfileService; |
||||
|
let tagService: TagService; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
accountService = new AccountService(null, null, null, null, null); |
||||
|
activitiesService = new ActivitiesService( |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
null |
||||
|
); |
||||
|
dataGatheringService = new DataGatheringService( |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
null |
||||
|
); |
||||
|
dataProviderService = new DataProviderService( |
||||
|
null, |
||||
|
[], |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
null |
||||
|
); |
||||
|
exchangeRateDataService = new ExchangeRateDataService( |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
null |
||||
|
); |
||||
|
marketDataService = new MarketDataService(null); |
||||
|
symbolProfileService = new SymbolProfileService(null); |
||||
|
tagService = new TagService(null); |
||||
|
|
||||
|
importService = new ImportService( |
||||
|
accountService, |
||||
|
activitiesService, |
||||
|
null, |
||||
|
dataGatheringService, |
||||
|
dataProviderService, |
||||
|
exchangeRateDataService, |
||||
|
marketDataService, |
||||
|
null, |
||||
|
null, |
||||
|
symbolProfileService, |
||||
|
tagService |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
it('creates only one asset profile when the import payload contains duplicate new manual asset profiles', async () => { |
||||
|
const user = { |
||||
|
id: 'da8a5786-1223-4a51-9a86-2b60433c9f3f', |
||||
|
permissions: [], |
||||
|
settings: { settings: { baseCurrency: 'USD' } } |
||||
|
} as unknown as UserWithSettings; |
||||
|
|
||||
|
const assetProfile = { |
||||
|
currency: 'USD', |
||||
|
dataSource: DataSource.MANUAL, |
||||
|
isActive: true, |
||||
|
marketData: [], |
||||
|
name: 'Repeated Fee', |
||||
|
symbol: 'Repeated Fee' |
||||
|
}; |
||||
|
|
||||
|
await importService.import({ |
||||
|
accountsWithBalancesDto: [], |
||||
|
activitiesDto: [ |
||||
|
{ |
||||
|
currency: 'USD', |
||||
|
dataSource: DataSource.MANUAL, |
||||
|
date: '2024-01-01T00:00:00.000Z', |
||||
|
fee: 1, |
||||
|
quantity: 0, |
||||
|
symbol: 'Repeated Fee', |
||||
|
type: 'FEE', |
||||
|
unitPrice: 0 |
||||
|
}, |
||||
|
{ |
||||
|
currency: 'USD', |
||||
|
dataSource: DataSource.MANUAL, |
||||
|
date: '2024-01-02T00:00:00.000Z', |
||||
|
fee: 1, |
||||
|
quantity: 0, |
||||
|
symbol: 'Repeated Fee', |
||||
|
type: 'FEE', |
||||
|
unitPrice: 0 |
||||
|
} |
||||
|
], |
||||
|
assetProfilesWithMarketDataDto: [assetProfile, assetProfile], |
||||
|
maxActivitiesToImport: 10, |
||||
|
tagsDto: [], |
||||
|
user |
||||
|
}); |
||||
|
|
||||
|
expect(symbolProfileService.add).toHaveBeenCalledTimes(1); |
||||
|
}); |
||||
|
}); |
||||
Loading…
Reference in new issue