Browse Source

Invalidate portfolio snapshots when stock splits change

pull/7568/head
David Requeno 3 weeks ago
parent
commit
949606ddd0
  1. 57
      apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.spec.ts
  2. 21
      apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts

57
apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.spec.ts

@ -1,7 +1,9 @@
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import { AssetProfileSplitService } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.service'; import { AssetProfileSplitService } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { NotFoundException } from '@nestjs/common'; import { NotFoundException } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { AssetProfileSplit, DataSource } from '@prisma/client'; import { AssetProfileSplit, DataSource } from '@prisma/client';
import { AssetProfilesService } from './asset-profiles.service'; import { AssetProfilesService } from './asset-profiles.service';
@ -9,11 +11,15 @@ import { AssetProfilesService } from './asset-profiles.service';
describe('AssetProfilesService', () => { describe('AssetProfilesService', () => {
let assetProfilesService: AssetProfilesService; let assetProfilesService: AssetProfilesService;
let deleteById: jest.Mock; let deleteById: jest.Mock;
let emit: jest.Mock;
let findMany: jest.Mock;
let gatherSymbol: jest.Mock; let gatherSymbol: jest.Mock;
let upsert: jest.Mock; let upsert: jest.Mock;
beforeEach(() => { beforeEach(() => {
deleteById = jest.fn(); deleteById = jest.fn();
emit = jest.fn();
findMany = jest.fn().mockResolvedValue([]);
gatherSymbol = jest.fn(); gatherSymbol = jest.fn();
upsert = jest.fn(); upsert = jest.fn();
@ -28,8 +34,9 @@ describe('AssetProfilesService', () => {
null, null,
null, null,
null, null,
{ order: { findMany } } as never,
null, null,
null { emit } as unknown as EventEmitter2
); );
}); });
@ -60,6 +67,48 @@ describe('AssetProfilesService', () => {
}); });
expect(result).toBe(split); expect(result).toBe(split);
}); });
it('invalidates portfolio snapshots for users holding the asset', async () => {
const split = {} as AssetProfileSplit;
upsert.mockResolvedValue(split);
findMany.mockResolvedValue([{ userId: 'user-1' }, { userId: 'user-2' }]);
assetProfilesService = new AssetProfilesService(
null,
{
deleteById,
upsert
} as unknown as AssetProfileSplitService,
null,
{ gatherSymbol } as unknown as DataGatheringService,
null,
null,
null,
{ order: { findMany } } as never,
null,
{ emit } as unknown as EventEmitter2
);
await assetProfilesService.createSplit({
dataSource: DataSource.YAHOO,
date: new Date('2024-06-15T18:30:00.000Z'),
denominator: 1,
numerator: 2,
symbol: 'AAPL',
symbolProfileId: 'profile-id'
});
expect(findMany).toHaveBeenCalledWith({
distinct: ['userId'],
select: { userId: true },
where: { symbolProfileId: 'profile-id' }
});
expect(emit.mock.calls.map(([, event]) => event.getUserId())).toEqual([
'user-1',
'user-2'
]);
expect(emit.mock.calls[0][0]).toBe(PortfolioChangedEvent.getName());
});
}); });
describe('deleteSplit', () => { describe('deleteSplit', () => {
@ -72,10 +121,13 @@ describe('AssetProfilesService', () => {
symbolProfileId: 'profile-id' symbolProfileId: 'profile-id'
}) })
).rejects.toBeInstanceOf(NotFoundException); ).rejects.toBeInstanceOf(NotFoundException);
expect(emit).not.toHaveBeenCalled();
}); });
it('deletes an existing split using its profile scope', async () => { it('deletes an existing split using its profile scope', async () => {
deleteById.mockResolvedValue(true); deleteById.mockResolvedValue(true);
findMany.mockResolvedValue([{ userId: 'user-1' }]);
await expect( await expect(
assetProfilesService.deleteSplit({ assetProfilesService.deleteSplit({
@ -88,6 +140,9 @@ describe('AssetProfilesService', () => {
id: 'split-id', id: 'split-id',
symbolProfileId: 'profile-id' symbolProfileId: 'profile-id'
}); });
expect(emit.mock.calls.map(([, event]) => event.getUserId())).toEqual([
'user-1'
]);
}); });
}); });
}); });

21
apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts

@ -1,4 +1,5 @@
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import { AssetProfileSplitService } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.service'; import { AssetProfileSplitService } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.service';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
@ -25,6 +26,7 @@ import {
import { MarketDataPreset } from '@ghostfolio/common/types'; import { MarketDataPreset } from '@ghostfolio/common/types';
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { AssetClass, AssetSubClass, DataSource, Prisma } from '@prisma/client'; import { AssetClass, AssetSubClass, DataSource, Prisma } from '@prisma/client';
import { groupBy } from 'lodash'; import { groupBy } from 'lodash';
@ -37,6 +39,7 @@ export class AssetProfilesService {
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly eventEmitter: EventEmitter2,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService
@ -62,6 +65,7 @@ export class AssetProfilesService {
symbolProfileId symbolProfileId
}); });
await this.emitPortfolioChangedEvents(symbolProfileId);
await this.dataGatheringService.gatherSymbol({ dataSource, symbol }); await this.dataGatheringService.gatherSymbol({ dataSource, symbol });
return assetProfileSplit; return assetProfileSplit;
@ -82,6 +86,23 @@ export class AssetProfilesService {
if (!isDeleted) { if (!isDeleted) {
throw new NotFoundException(); throw new NotFoundException();
} }
await this.emitPortfolioChangedEvents(symbolProfileId);
}
private async emitPortfolioChangedEvents(symbolProfileId: string) {
const users = await this.prismaService.order.findMany({
distinct: ['userId'],
select: { userId: true },
where: { symbolProfileId }
});
for (const { userId } of users) {
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({ userId })
);
}
} }
public async getAssetProfile({ public async getAssetProfile({

Loading…
Cancel
Save