From 3bc5f4986602cabf4ac3092dbb3c784b9b6cd9ca Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Fri, 9 Oct 2026 20:41:38 +0300 Subject: [PATCH] test: align upcoming events with local reads --- test/upcoming-events.e2e-spec.ts | 94 +++++++++++++++++++------------- 1 file changed, 56 insertions(+), 38 deletions(-) diff --git a/test/upcoming-events.e2e-spec.ts b/test/upcoming-events.e2e-spec.ts index 05bea97..e338fee 100644 --- a/test/upcoming-events.e2e-spec.ts +++ b/test/upcoming-events.e2e-spec.ts @@ -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((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(); }); });