test: align upcoming events with local reads
Backend verify / commits (pull_request) Successful in 37s
Backend verify / security (pull_request) Successful in 18s
Backend verify / verify (pull_request) Failing after 2m5s

This commit is contained in:
2026-10-09 20:41:38 +03:00
parent aee330c6c2
commit 3bc5f49866
+56 -38
View File
@@ -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();
});
});