feat: reconcile hourly investment resource states #50
@@ -21,6 +21,7 @@ import { configureApp } from '../src/main';
|
||||
import { CatalogEnrichmentService } from '../src/modules/investments/instruments/application/catalog-enrichment.service';
|
||||
import { ForecastRepository } from '../src/modules/investments/portfolio/infrastructure/forecast.repository';
|
||||
import { TbankHourlyScheduler } from '../src/modules/investments/tbank-sync/tbank-hourly.scheduler';
|
||||
import { UpcomingEventsService } from '../src/modules/investments/upcoming-events/upcoming-events.service';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
|
||||
const shareUid = '00000000-0000-4000-8000-000000000001';
|
||||
@@ -81,6 +82,10 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
`brokerage-accounts/${scope}/events?from=${range.from}&to=${range.to}${observe ? '&sync=observe' : ''}`,
|
||||
userId,
|
||||
);
|
||||
const refresh = async (range = window, accountId = 'account') => {
|
||||
await app.get(UpcomingEventsService).refresh('owner', accountId, range);
|
||||
await settled();
|
||||
};
|
||||
async function settled() {
|
||||
for (let i = 0; i < 500; i++) {
|
||||
await delay(5);
|
||||
@@ -235,39 +240,29 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
});
|
||||
|
||||
it('returns unknown immediately, keeps observe passive and shares confirmed empty calendars across users', async () => {
|
||||
let release: (value: unknown[]) => void = () => {};
|
||||
upstream.getDividends.mockReturnValueOnce(
|
||||
new Promise<unknown[]>((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
((await events().expect(200)).body as UpcomingEventsResourceDto).data,
|
||||
).toBeNull();
|
||||
expect(upstream.getDividends).not.toHaveBeenCalled();
|
||||
const cold = (await events(false).expect(200))
|
||||
const cold = (await events().expect(200))
|
||||
.body as UpcomingEventsResourceDto;
|
||||
expect(cold.data).toBeNull();
|
||||
for (let i = 0; i < 100 && !upstream.getDividends.mock.calls.length; i++) {
|
||||
await delay(5);
|
||||
}
|
||||
expect(await prisma.tbankForecast.count()).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps an ordinary GET local and does not start catalog work', async () => {
|
||||
await portfolio([position()]);
|
||||
jest.clearAllMocks();
|
||||
|
||||
await events().expect(200);
|
||||
release([]);
|
||||
await settled();
|
||||
const warm = (await events().expect(200)).body as UpcomingEventsResourceDto;
|
||||
expect(warm.meta.availability).toBe('complete');
|
||||
expect(warm.data?.events).toEqual([]);
|
||||
const other = (await events(false, 'account', 'other').expect(200))
|
||||
.body as UpcomingEventsResourceDto;
|
||||
expect(other.data?.events).toEqual([]);
|
||||
expect(upstream.getDividends).toHaveBeenCalledTimes(1);
|
||||
expect(await prisma.tbankForecast.count()).toBe(2);
|
||||
expect(JSON.stringify(warm)).not.toContain('owner-revision');
|
||||
|
||||
expect(upstream.getDividends).not.toHaveBeenCalled();
|
||||
expect(upstream.getInstrumentByUid).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves source identity, inclusive dates, exact currencies, cancellation and offers in account/all scopes', async () => {
|
||||
await portfolio([position(), position(bondUid, '3', 'BOND')]);
|
||||
await events(false).expect(200);
|
||||
await app.get(UpcomingEventsService).refresh('owner', 'account', window);
|
||||
await settled();
|
||||
const single = (await events().expect(200))
|
||||
.body as UpcomingEventsResourceDto;
|
||||
@@ -297,7 +292,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
});
|
||||
|
||||
it('keeps the previous full forecast and timestamps after a new holding calendar fails, including reopen', async () => {
|
||||
await events(false).expect(200);
|
||||
await refresh();
|
||||
await settled();
|
||||
const before = (await events().expect(200))
|
||||
.body as UpcomingEventsResourceDto;
|
||||
@@ -307,7 +302,8 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
? Promise.reject(new Error('new calendar unavailable'))
|
||||
: Promise.resolve([]),
|
||||
);
|
||||
const pending = (await events(false).expect(200))
|
||||
await refresh();
|
||||
const pending = (await events().expect(200))
|
||||
.body as UpcomingEventsResourceDto;
|
||||
expect(pending.data).toEqual(before.data);
|
||||
await settled();
|
||||
@@ -316,14 +312,14 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
.body as UpcomingEventsResourceDto;
|
||||
expect(after.data).toEqual(before.data);
|
||||
expect(after.meta.lastSuccessfulAt).toBe(before.meta.lastSuccessfulAt);
|
||||
expect(after.meta.sync.state).toBe('failed');
|
||||
expect(after.meta.dependencies?.some((dependency) => dependency.lastAttempt.state === 'failed')).toBe(true);
|
||||
const count = upstream.getDividends.mock.calls.length;
|
||||
await events().expect(200);
|
||||
expect(upstream.getDividends).toHaveBeenCalledTimes(count);
|
||||
});
|
||||
|
||||
it('never relabels another range or membership snapshot and rejects inaccessible accounts locally', async () => {
|
||||
await events(false).expect(200);
|
||||
await refresh();
|
||||
await settled();
|
||||
await events().expect(200);
|
||||
const newRange = (
|
||||
@@ -346,7 +342,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
});
|
||||
|
||||
it('refuses a calculated snapshot if its portfolio changes before atomic publication', async () => {
|
||||
await events(false).expect(200);
|
||||
await refresh();
|
||||
await settled();
|
||||
const before = (await events().expect(200))
|
||||
.body as UpcomingEventsResourceDto;
|
||||
@@ -531,7 +527,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
it('keeps optional metadata failure separate from calendar completeness and uses portfolio ticker', async () => {
|
||||
upstream.getBond.mockRejectedValue(new Error('metadata unavailable'));
|
||||
await portfolio([position(bondUid, '3', 'BOND')]);
|
||||
await events(false).expect(200);
|
||||
await refresh();
|
||||
await settled();
|
||||
const result = (await events().expect(200))
|
||||
.body as UpcomingEventsResourceDto;
|
||||
@@ -566,7 +562,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
new MoexClient().bondEvents(ticker),
|
||||
);
|
||||
try {
|
||||
await events(false).expect(200);
|
||||
await refresh();
|
||||
await settled();
|
||||
const before = (await events().expect(200))
|
||||
.body as UpcomingEventsResourceDto;
|
||||
@@ -590,7 +586,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
.body as UpcomingEventsResourceDto;
|
||||
expect(after.data).toEqual(before.data);
|
||||
expect(after.meta.lastSuccessfulAt).toBe(before.meta.lastSuccessfulAt);
|
||||
expect(after.meta.sync.state).toBe('failed');
|
||||
expect(after.meta.dependencies?.some((dependency) => dependency.lastAttempt.state === 'failed')).toBe(true);
|
||||
expect(
|
||||
await prisma.tbankInstrumentSource.findFirstOrThrow({
|
||||
where: { kind: 'bond-events' },
|
||||
@@ -605,9 +601,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
|
||||
it('defaults to a 30-day window', async () => {
|
||||
await portfolio([position(bondUid, '3', 'BOND')]);
|
||||
await events(false).expect(200);
|
||||
await settled();
|
||||
await get('brokerage-accounts/account/events?from=2099-02-01').expect(200);
|
||||
await refresh();
|
||||
await settled();
|
||||
const result = (
|
||||
await get(
|
||||
@@ -622,6 +616,30 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('filters a saved source window without changing its calculated timestamp', async () => {
|
||||
await portfolio([position(bondUid, '3', 'BOND')]);
|
||||
await app.get(UpcomingEventsService).refresh('owner', 'account', {
|
||||
from: '2099-02-01',
|
||||
to: '2099-02-28',
|
||||
});
|
||||
await settled();
|
||||
const full = (await events().expect(200)).body as UpcomingEventsResourceDto;
|
||||
const calculatedAt = full.data?.calculatedAt;
|
||||
jest.clearAllMocks();
|
||||
|
||||
const narrow = (await events(false, 'account', 'owner', {
|
||||
from: '2099-02-01',
|
||||
to: '2099-02-10',
|
||||
}).expect(200)).body as UpcomingEventsResourceDto;
|
||||
|
||||
expect(narrow.data?.calculatedAt).toBe(calculatedAt);
|
||||
expect(narrow.data?.events.map((event) => event.date)).toEqual([
|
||||
'2099-02-01',
|
||||
]);
|
||||
expect(upstream.getDividends).not.toHaveBeenCalled();
|
||||
expect(moex.bondEvents).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('discards a late calendar from a replaced connection and prevents access to old private forecasts', async () => {
|
||||
let release: (value: unknown[]) => void = () => {};
|
||||
upstream.getDividends.mockReturnValueOnce(
|
||||
@@ -629,7 +647,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
await events(false).expect(200);
|
||||
await app.get(UpcomingEventsService).refresh('owner', 'account', window);
|
||||
for (let i = 0; i < 100 && !upstream.getDividends.mock.calls.length; i++) {
|
||||
await delay(5);
|
||||
}
|
||||
@@ -664,8 +682,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
|
||||
it('uses UTC today for an end-only range and includes the entire leap day', async () => {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
await get('brokerage-accounts/account/events?to=2096-02-29').expect(200);
|
||||
await settled();
|
||||
await refresh({ from: today, to: '2096-02-29' });
|
||||
expect(upstream.getDividends).toHaveBeenCalledWith(
|
||||
'synthetic-owner',
|
||||
shareUid,
|
||||
@@ -685,8 +702,8 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
},
|
||||
]);
|
||||
const range = { from: '2096-02-29', to: '2096-02-29' };
|
||||
await events(false, 'account', 'owner', range).expect(200);
|
||||
await settled();
|
||||
await refresh(range);
|
||||
jest.clearAllMocks();
|
||||
const result = (await events(true, 'account', 'owner', range).expect(200))
|
||||
.body as UpcomingEventsResourceDto;
|
||||
expect(result.data?.events).toHaveLength(1);
|
||||
@@ -694,5 +711,6 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
date: '2096-02-29',
|
||||
projectedAmount: { currency: 'RUB', value: '14' },
|
||||
});
|
||||
expect(upstream.getDividends).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user