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 { CatalogEnrichmentService } from '../src/modules/investments/instruments/application/catalog-enrichment.service';
import { ForecastRepository } from '../src/modules/investments/portfolio/infrastructure/forecast.repository'; import { ForecastRepository } from '../src/modules/investments/portfolio/infrastructure/forecast.repository';
import { TbankHourlyScheduler } from '../src/modules/investments/tbank-sync/tbank-hourly.scheduler'; 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'; import { PrismaService } from '../src/prisma/prisma.service';
const shareUid = '00000000-0000-4000-8000-000000000001'; 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' : ''}`, `brokerage-accounts/${scope}/events?from=${range.from}&to=${range.to}${observe ? '&sync=observe' : ''}`,
userId, userId,
); );
const refresh = async (range = window, accountId = 'account') => {
await app.get(UpcomingEventsService).refresh('owner', accountId, range);
await settled();
};
async function settled() { async function settled() {
for (let i = 0; i < 500; i++) { for (let i = 0; i < 500; i++) {
await delay(5); 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 () => { 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( expect(
((await events().expect(200)).body as UpcomingEventsResourceDto).data, ((await events().expect(200)).body as UpcomingEventsResourceDto).data,
).toBeNull(); ).toBeNull();
expect(upstream.getDividends).not.toHaveBeenCalled(); expect(upstream.getDividends).not.toHaveBeenCalled();
const cold = (await events(false).expect(200)) const cold = (await events().expect(200))
.body as UpcomingEventsResourceDto; .body as UpcomingEventsResourceDto;
expect(cold.data).toBeNull(); expect(cold.data).toBeNull();
for (let i = 0; i < 100 && !upstream.getDividends.mock.calls.length; i++) { expect(await prisma.tbankForecast.count()).toBe(0);
await delay(5); });
}
it('keeps an ordinary GET local and does not start catalog work', async () => {
await portfolio([position()]);
jest.clearAllMocks();
await events().expect(200); await events().expect(200);
release([]);
await settled(); expect(upstream.getDividends).not.toHaveBeenCalled();
const warm = (await events().expect(200)).body as UpcomingEventsResourceDto; expect(upstream.getInstrumentByUid).not.toHaveBeenCalled();
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');
}); });
it('preserves source identity, inclusive dates, exact currencies, cancellation and offers in account/all scopes', async () => { it('preserves source identity, inclusive dates, exact currencies, cancellation and offers in account/all scopes', async () => {
await portfolio([position(), position(bondUid, '3', 'BOND')]); await portfolio([position(), position(bondUid, '3', 'BOND')]);
await events(false).expect(200); await app.get(UpcomingEventsService).refresh('owner', 'account', window);
await settled(); await settled();
const single = (await events().expect(200)) const single = (await events().expect(200))
.body as UpcomingEventsResourceDto; .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 () => { 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(); await settled();
const before = (await events().expect(200)) const before = (await events().expect(200))
.body as UpcomingEventsResourceDto; .body as UpcomingEventsResourceDto;
@@ -307,7 +302,8 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
? Promise.reject(new Error('new calendar unavailable')) ? Promise.reject(new Error('new calendar unavailable'))
: Promise.resolve([]), : Promise.resolve([]),
); );
const pending = (await events(false).expect(200)) await refresh();
const pending = (await events().expect(200))
.body as UpcomingEventsResourceDto; .body as UpcomingEventsResourceDto;
expect(pending.data).toEqual(before.data); expect(pending.data).toEqual(before.data);
await settled(); await settled();
@@ -316,14 +312,14 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
.body as UpcomingEventsResourceDto; .body as UpcomingEventsResourceDto;
expect(after.data).toEqual(before.data); expect(after.data).toEqual(before.data);
expect(after.meta.lastSuccessfulAt).toBe(before.meta.lastSuccessfulAt); 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; const count = upstream.getDividends.mock.calls.length;
await events().expect(200); await events().expect(200);
expect(upstream.getDividends).toHaveBeenCalledTimes(count); expect(upstream.getDividends).toHaveBeenCalledTimes(count);
}); });
it('never relabels another range or membership snapshot and rejects inaccessible accounts locally', async () => { it('never relabels another range or membership snapshot and rejects inaccessible accounts locally', async () => {
await events(false).expect(200); await refresh();
await settled(); await settled();
await events().expect(200); await events().expect(200);
const newRange = ( 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 () => { it('refuses a calculated snapshot if its portfolio changes before atomic publication', async () => {
await events(false).expect(200); await refresh();
await settled(); await settled();
const before = (await events().expect(200)) const before = (await events().expect(200))
.body as UpcomingEventsResourceDto; .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 () => { it('keeps optional metadata failure separate from calendar completeness and uses portfolio ticker', async () => {
upstream.getBond.mockRejectedValue(new Error('metadata unavailable')); upstream.getBond.mockRejectedValue(new Error('metadata unavailable'));
await portfolio([position(bondUid, '3', 'BOND')]); await portfolio([position(bondUid, '3', 'BOND')]);
await events(false).expect(200); await refresh();
await settled(); await settled();
const result = (await events().expect(200)) const result = (await events().expect(200))
.body as UpcomingEventsResourceDto; .body as UpcomingEventsResourceDto;
@@ -566,7 +562,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
new MoexClient().bondEvents(ticker), new MoexClient().bondEvents(ticker),
); );
try { try {
await events(false).expect(200); await refresh();
await settled(); await settled();
const before = (await events().expect(200)) const before = (await events().expect(200))
.body as UpcomingEventsResourceDto; .body as UpcomingEventsResourceDto;
@@ -590,7 +586,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
.body as UpcomingEventsResourceDto; .body as UpcomingEventsResourceDto;
expect(after.data).toEqual(before.data); expect(after.data).toEqual(before.data);
expect(after.meta.lastSuccessfulAt).toBe(before.meta.lastSuccessfulAt); 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( expect(
await prisma.tbankInstrumentSource.findFirstOrThrow({ await prisma.tbankInstrumentSource.findFirstOrThrow({
where: { kind: 'bond-events' }, 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 () => { it('defaults to a 30-day window', async () => {
await portfolio([position(bondUid, '3', 'BOND')]); await portfolio([position(bondUid, '3', 'BOND')]);
await events(false).expect(200); await refresh();
await settled();
await get('brokerage-accounts/account/events?from=2099-02-01').expect(200);
await settled(); await settled();
const result = ( const result = (
await get( 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 () => { it('discards a late calendar from a replaced connection and prevents access to old private forecasts', async () => {
let release: (value: unknown[]) => void = () => {}; let release: (value: unknown[]) => void = () => {};
upstream.getDividends.mockReturnValueOnce( upstream.getDividends.mockReturnValueOnce(
@@ -629,7 +647,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
release = resolve; 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++) { for (let i = 0; i < 100 && !upstream.getDividends.mock.calls.length; i++) {
await delay(5); 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 () => { it('uses UTC today for an end-only range and includes the entire leap day', async () => {
const today = new Date().toISOString().slice(0, 10); const today = new Date().toISOString().slice(0, 10);
await get('brokerage-accounts/account/events?to=2096-02-29').expect(200); await refresh({ from: today, to: '2096-02-29' });
await settled();
expect(upstream.getDividends).toHaveBeenCalledWith( expect(upstream.getDividends).toHaveBeenCalledWith(
'synthetic-owner', 'synthetic-owner',
shareUid, shareUid,
@@ -685,8 +702,8 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
}, },
]); ]);
const range = { from: '2096-02-29', to: '2096-02-29' }; const range = { from: '2096-02-29', to: '2096-02-29' };
await events(false, 'account', 'owner', range).expect(200); await refresh(range);
await settled(); jest.clearAllMocks();
const result = (await events(true, 'account', 'owner', range).expect(200)) const result = (await events(true, 'account', 'owner', range).expect(200))
.body as UpcomingEventsResourceDto; .body as UpcomingEventsResourceDto;
expect(result.data?.events).toHaveLength(1); expect(result.data?.events).toHaveLength(1);
@@ -694,5 +711,6 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
date: '2096-02-29', date: '2096-02-29',
projectedAmount: { currency: 'RUB', value: '14' }, projectedAmount: { currency: 'RUB', value: '14' },
}); });
expect(upstream.getDividends).not.toHaveBeenCalled();
}); });
}); });