import type { Server } from 'node:http'; import { INestApplication } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Test } from '@nestjs/testing'; import { setTimeout as delay } from 'node:timers/promises'; import request from 'supertest'; import type { BondCouponsResourceDto } from '../src/modules/investments/instruments/bonds/presentation/bond.dto'; import type { StockDividendsResourceDto } from '../src/modules/investments/instruments/stocks/presentation/stock.dto'; import type { UpcomingEventsResourceDto } from '../src/modules/investments/upcoming-events/upcoming-events.dto'; import { AppModule } from '../src/app.module'; import { MoexClient } from '../src/integrations/moex/moex.api'; import { TbankClient, tbankSourceTtlMs, TbankTokenService, } from '../src/integrations/tbank/tbank.api'; 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'; const bondUid = '00000000-0000-4000-8000-000000000002'; const newUid = '00000000-0000-4000-8000-000000000003'; const window = { from: '2099-02-01', to: '2099-02-28' }; const time = (date: string) => ({ seconds: String(Date.parse(`${date}T00:00:00Z`) / 1000), }); const position = (uid = shareUid, quantity = '2', type = 'STOCK') => ({ instrumentUid: uid, figi: uid, ticker: type === 'STOCK' ? 'STOCK' : 'BOND', displayName: 'Synthetic', type, quantity, averagePurchasePrice: null, currentPrice: null, currentValue: null, absoluteReturn: null, isin: null, logoName: null, }); describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { let app: INestApplication; let prisma: PrismaService; const upstream = { getShare: jest.fn(), getBond: jest.fn(), getDividends: jest.fn(), getBondCoupons: jest.fn(), getCandles: jest.fn(), getPortfolio: jest.fn(), getPositions: jest.fn(), getOpenAccounts: jest.fn(), getInstrumentByUid: jest.fn(), getInstrumentByFigi: jest.fn(), }; const moex = { bondEvents: jest.fn() }; const auth = (userId: string) => new JwtService().sign( { sub: userId, type: 'access' }, { secret: process.env.JWT_SECRET }, ); const get = (path: string, userId = 'owner') => request(app.getHttpServer()) .get(`/api/v1/${path}`) .set('Authorization', `Bearer ${auth(userId)}`) .timeout(1500); const events = ( scope = 'account', userId = 'owner', range = window, ) => get( `brokerage-accounts/${scope}/events?from=${range.from}&to=${range.to}`, userId, ); const refresh = async ( range: { from?: string; to?: string } = 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); if ( !(await prisma.tbankSyncWork.count({ where: { state: 'running' } })) ) { await delay(10); if ( !(await prisma.tbankSyncWork.count({ where: { state: 'running' } })) ) { return; } } } throw new Error('Forecast dependencies did not settle'); } async function portfolio( positions = [position()], userId = 'owner', accountId = 'account', ) { const payload = { lastSuccessfulAt: new Date().toISOString(), isStale: false, positions, totalsByCurrency: [], cashBalances: [], structure: { currencies: [], unvaluedCategories: [] }, }; await prisma.tbankPortfolio.upsert({ where: { userId_revision_accountId: { userId, revision: `${userId}-revision`, accountId, }, }, create: { userId, revision: `${userId}-revision`, accountId, payload, fetchedAt: new Date(), }, update: { payload, fetchedAt: new Date(), dataRevision: { increment: 1 }, }, }); } beforeAll(async () => { const module = await Test.createTestingModule({ imports: [AppModule] }) .overrideProvider(TbankClient) .useValue(upstream) .overrideProvider(MoexClient) .useValue(moex) .overrideProvider(TbankHourlyScheduler) .useValue({ tick: jest.fn() }) .compile(); app = module.createNestApplication(); configureApp(app); await app.init(); await app.listen(0, '127.0.0.1'); prisma = app.get(PrismaService); for (const id of ['owner', 'other']) { await prisma.user.create({ data: { id, email: `${id}@example.invalid`, passwordHash: 'synthetic' }, }); } }); beforeEach(async () => { await settled(); await prisma.tbankConnection.deleteMany(); await prisma.tbankInstrumentLookup.deleteMany(); await prisma.tbankInstrument.deleteMany(); jest.resetAllMocks(); upstream.getShare.mockImplementation((_token: string, uid: string) => Promise.resolve({ uid, ticker: 'STOCK', name: 'Synthetic share', currency: 'rub', }), ); upstream.getBond.mockImplementation((_token: string, uid: string) => Promise.resolve({ uid, ticker: 'BOND', name: 'Synthetic bond', currency: 'rub', }), ); upstream.getInstrumentByUid.mockImplementation( (_token: string, uid: string) => Promise.resolve({ uid, ticker: uid === bondUid ? 'BOND' : 'STOCK', name: 'Synthetic', }), ); upstream.getCandles.mockResolvedValue([]); upstream.getDividends.mockResolvedValue([ { recordDate: time('2099-02-01'), dividendNet: { units: '10', nano: 1, currency: 'usd' }, }, { recordDate: time('2099-02-10'), dividendType: 'Cancelled', dividendNet: { units: '999', currency: 'rub' }, }, ]); upstream.getBondCoupons.mockResolvedValue([]); moex.bondEvents.mockResolvedValue([ { type: 'coupon', date: '2099-02-01', value: '2', currency: 'RUB' }, { type: 'offer', date: '2099-02-28' }, { type: 'maturity', date: '2099-02-28', value: '500', currency: 'RUB' }, { type: 'offer', date: '2099-03-01' }, ]); for (const userId of ['owner', 'other']) { await prisma.tbankConnection.create({ data: { userId, revision: `${userId}-revision`, encryptedToken: app .get(TbankTokenService) .encrypt(`synthetic-${userId}`), connectedAt: new Date(), membershipAt: new Date(), membershipRevision: 1, accounts: { create: { accountId: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Synthetic', upstreamOrder: 0, }, }, }, }); await portfolio([position()], userId); await prisma.tbankPortfolio.updateMany({ where: { userId }, data: { fetchedAt: new Date(Date.now() - 3_600_000) }, }); } }); afterAll(async () => { await settled(); await app?.close(); }); it('returns unknown immediately, keeps observe passive and shares confirmed empty calendars across users', async () => { expect( ((await events().expect(200)).body as UpcomingEventsResourceDto).data, ).toBeNull(); expect(upstream.getDividends).not.toHaveBeenCalled(); const cold = (await events().expect(200)) .body as UpcomingEventsResourceDto; expect(cold.data).toBeNull(); 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); 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 app.get(UpcomingEventsService).refresh('owner', 'account', window); await settled(); const single = (await events().expect(200)) .body as UpcomingEventsResourceDto; expect(single.data?.events).toHaveLength(4); expect( single.data?.events.find((event) => event.type === 'offer') ?.projectedAmount, ).toBeNull(); expect(single.data?.incomeTotalsByCurrency).toEqual([ { currency: 'RUB', value: '6' }, { currency: 'USD', value: '20.000000002' }, ]); const all = (await events('all').expect(200)) .body as UpcomingEventsResourceDto; expect( all.data?.events.every((event) => event.account?.id === 'account'), ).toBe(true); expect(all.data?.incomeTotalsByCurrency).toEqual( single.data?.incomeTotalsByCurrency, ); expect(moex.bondEvents).toHaveBeenCalledWith('BOND'); expect(upstream.getBondCoupons).not.toHaveBeenCalled(); expect( JSON.stringify(await prisma.tbankInstrumentSource.findMany()), ).not.toMatch(/quantity|projectedAmount|estimatedAmount|synthetic-owner/); expect(upstream.getPortfolio).not.toHaveBeenCalled(); }); it('keeps the previous full forecast and timestamps after a new holding calendar fails, including reopen', async () => { await refresh(); await settled(); const before = (await events().expect(200)) .body as UpcomingEventsResourceDto; await portfolio([position(shareUid, '7'), position(newUid, '5')]); upstream.getDividends.mockImplementation((_token: string, uid: string) => uid === newUid ? Promise.reject(new Error('new calendar unavailable')) : Promise.resolve([]), ); await refresh(); const pending = (await events().expect(200)) .body as UpcomingEventsResourceDto; expect(pending.data).toEqual(before.data); await settled(); await prisma.$disconnect(); const after = (await events().expect(200)) .body as UpcomingEventsResourceDto; expect(after.data).toEqual(before.data); expect(after.meta.lastSuccessfulAt).toBe(before.meta.lastSuccessfulAt); 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('serves a disjoint range as an empty saved result and never relabels a membership snapshot', async () => { await refresh(); await settled(); const saved = (await events().expect(200)) .body as UpcomingEventsResourceDto; const newRange = ( await events('account', 'owner', { from: '2099-03-01', to: '2099-03-31', }).expect(200) ).body as UpcomingEventsResourceDto; expect(newRange.data).toEqual({ ...saved.data, events: [], incomeTotalsByCurrency: [], }); expect(newRange.data?.window).toEqual(window); await prisma.tbankConnection.update({ where: { userId: 'owner' }, data: { membershipRevision: { increment: 1 } }, }); await portfolio([position(newUid)]); expect( ((await events().expect(200)).body as UpcomingEventsResourceDto).data, ).toBeNull(); await events('foreign').expect(404); expect(upstream.getOpenAccounts).not.toHaveBeenCalled(); }); it('refuses a calculated snapshot if its portfolio changes before atomic publication', async () => { await refresh(); await settled(); const before = (await events().expect(200)) .body as UpcomingEventsResourceDto; await portfolio([position(shareUid, '3')]); const repository = app.get(ForecastRepository); const original = repository.resolve.bind(repository); const publish = jest .spyOn(repository, 'resolve') .mockImplementationOnce(async (input) => { await portfolio([position(shareUid, '9')]); return original(input); }); try { expect( ((await events().expect(200)).body as UpcomingEventsResourceDto).data, ).toEqual(before.data); } finally { publish.mockRestore(); } expect( ((await events().expect(200)).body as UpcomingEventsResourceDto).data ?.incomeTotalsByCurrency, ).toEqual([{ currency: 'USD', value: '90.000000009' }]); }); it('serves instrument payouts from shared calendars with private exact holdings and retains successful source timestamps on failure', async () => { const date = new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10); upstream.getDividends.mockResolvedValue([ { recordDate: time(date), paymentDate: time(date), dividendNet: { units: '10', nano: 1, currency: 'rub' }, }, ]); await get(`stocks/${shareUid}/dividends`).expect(200); await settled(); const before = ( await get(`stocks/${shareUid}/dividends`).expect(200) ).body as StockDividendsResourceDto; expect(before.data?.dividends[0].estimatedAmount).toEqual({ currency: 'RUB', value: '20.000000002', }); await portfolio([position(shareUid, '3')], 'other'); const other = ( await get(`stocks/${shareUid}/dividends`, 'other').expect(200) ).body as StockDividendsResourceDto; expect(other.data?.dividends[0].estimatedAmount?.value).toBe( '30.000000003', ); expect(upstream.getDividends).toHaveBeenCalledTimes(1); upstream.getDividends.mockRejectedValue(new Error('outage')); await app .get(CatalogEnrichmentService) .requestMany( 'owner', 'owner-revision', 'share', [shareUid], true, ['dividends'], { from: before.data?.from ?? '', to: before.data?.to ?? '' }, ); await settled(); expect( ( (await get(`stocks/${shareUid}/dividends`).expect(200)) .body as StockDividendsResourceDto ).data, ).toEqual(before.data); await portfolio([position(bondUid, '3', 'BOND')]); upstream.getBondCoupons.mockResolvedValue([ { couponDate: time(date), fixDate: time(date), payOneBond: { units: '2', currency: 'rub' }, }, ]); await get(`bonds/${bondUid}/coupons`).expect(200); await settled(); const coupons = ( await get(`bonds/${bondUid}/coupons`).expect(200) ).body as BondCouponsResourceDto; expect(coupons.data?.coupons[0].estimatedAmount).toEqual({ currency: 'RUB', value: '6', }); expect(moex.bondEvents).not.toHaveBeenCalled(); }); it.each([ ['stocks', shareUid, 'dividends', 'STOCK'], ['bonds', bondUid, 'coupons', 'BOND'], ])( 'ages the saved %s payout position without RPC or a financial revision, including incompatible fresh candidates', async (kind, uid, payout, type) => { const date = new Date(Date.now() + 7 * 86400000) .toISOString() .slice(0, 10); upstream.getDividends.mockResolvedValue([ { recordDate: time(date), dividendNet: { units: '10', nano: 1, currency: 'rub' }, }, ]); upstream.getBondCoupons.mockResolvedValue([ { couponDate: time(date), fixDate: time(date), payOneBond: { units: '2', currency: 'rub' }, }, ]); await portfolio([position(uid, '3', type)]); const path = `${kind}/${uid}/${payout}`; const read = async () => (await get(`${path}`).expect(200)).body as BondCouponsResourceDto | StockDividendsResourceDto; await get(path).expect(200); await settled(); const before = await read(); if (!before.data?.positionAt) { throw new Error('Expected a saved position'); } expect(before.data.positionIsStale).toBe(false); const saved = await prisma.tbankForecast.findFirstOrThrow(); const expiresAt = Date.parse(before.data.positionAt) + tbankSourceTtlMs.portfolio; const clock = jest.spyOn(Date, 'now').mockReturnValue(expiresAt - 1); jest.clearAllMocks(); try { expect((await read()).data).toEqual(before.data); clock.mockReturnValue(expiresAt); const aged = await read(); expect(aged.data).toEqual({ ...before.data, positionIsStale: true }); expect(aged.meta.freshness).toBe('stale'); expect(aged.meta.dataRevision).toBe(before.meta.dataRevision); expect(aged.meta.lastSuccessfulAt).toBe(before.meta.lastSuccessfulAt); expect(await prisma.tbankForecast.findFirstOrThrow()).toEqual(saved); await portfolio([position(uid, '9', type)]); await prisma.tbankPortfolio.updateMany({ where: { userId: 'owner' }, data: { fetchedAt: new Date(expiresAt) }, }); const repository = app.get(ForecastRepository); const resolve = repository.resolve.bind(repository); const publish = jest .spyOn(repository, 'resolve') .mockImplementationOnce(async (input) => { expect(input.candidate).toMatchObject({ positionAt: new Date(expiresAt).toISOString(), positionIsStale: false, }); await prisma.tbankPortfolio.updateMany({ where: { userId: 'owner' }, data: { dataRevision: { increment: 1 } }, }); return resolve(input); }); try { const fallback = await read(); expect(fallback.data).toEqual(aged.data); expect(fallback.meta.freshness).toBe('stale'); expect(fallback.meta.dataRevision).toBe(before.meta.dataRevision); expect(fallback.meta.lastSuccessfulAt).toBe( before.meta.lastSuccessfulAt, ); expect(await prisma.tbankForecast.findFirstOrThrow()).toEqual(saved); } finally { publish.mockRestore(); } for (const rpc of Object.values(upstream)) { expect(rpc).not.toHaveBeenCalled(); } expect(moex.bondEvents).not.toHaveBeenCalled(); } finally { clock.mockRestore(); } }, ); 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 refresh(); await settled(); const result = (await events().expect(200)) .body as UpcomingEventsResourceDto; expect(result.data?.incomeTotalsByCurrency).toEqual([ { currency: 'RUB', value: '6' }, ]); expect(moex.bondEvents).toHaveBeenCalledWith('BOND'); }); it.each([ ['missing block', { coupons: undefined }], [ 'invalid cursor', { 'coupons.cursor': { columns: ['INDEX', 'TOTAL'], data: [[0, 1]] } }, ], ])( 'preserves the saved calendar and forecast when real MOEX parsing rejects %s', async (_name, invalid) => { await portfolio([position(bondUid, '3', 'BOND')]); const response = { coupons: { columns: ['coupondate', 'value', 'faceunit'], data: [['2099-02-01', '2', 'RUB']], }, offers: { columns: ['offerdate'], data: [] }, description: { columns: ['name', 'value'], data: [] }, }; const fetchMock = jest .spyOn(global, 'fetch') .mockImplementation(() => Promise.resolve(Response.json(response))); moex.bondEvents.mockImplementation((ticker: string) => new MoexClient().bondEvents(ticker), ); try { await refresh(); await settled(); const before = (await events().expect(200)) .body as UpcomingEventsResourceDto; expect(before.data?.incomeTotalsByCurrency).toEqual([ { currency: 'RUB', value: '6' }, ]); const calendar = await prisma.tbankInstrumentSource.findFirstOrThrow({ where: { kind: 'bond-events' }, }); const forecast = await prisma.tbankForecast.findFirstOrThrow(); fetchMock.mockImplementation(() => Promise.resolve(Response.json({ ...response, ...invalid })), ); await app .get(CatalogEnrichmentService) .requestMany('owner', 'owner-revision', 'bond', [bondUid], true, [ 'bond-events', ]); await settled(); const after = (await events().expect(200)) .body as UpcomingEventsResourceDto; expect(after.data).toEqual(before.data); expect(after.meta.lastSuccessfulAt).toBe(before.meta.lastSuccessfulAt); expect(after.meta.dependencies?.some((dependency) => dependency.lastAttempt.state === 'failed')).toBe(true); expect( await prisma.tbankInstrumentSource.findFirstOrThrow({ where: { kind: 'bond-events' }, }), ).toEqual(calendar); expect(await prisma.tbankForecast.findFirstOrThrow()).toEqual(forecast); } finally { fetchMock.mockRestore(); } }, ); it('defaults to a 30-day window', async () => { await portfolio([position(bondUid, '3', 'BOND')]); await refresh(); await settled(); const result = ( await get( 'brokerage-accounts/account/events?from=2099-02-01', ).expect(200) ).body as UpcomingEventsResourceDto; expect(result.data?.events.map((event) => event.date)).toEqual([ '2099-02-01', '2099-02-28', '2099-02-28', '2099-03-01', ]); }); 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('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('keeps the latest wide forecast when an explicit narrower range has a complete candidate', async () => { await refresh(); const full = (await events().expect(200)) .body as UpcomingEventsResourceDto; const narrowRange = { from: '2099-02-01', to: '2099-02-10' }; await refresh(narrowRange); const narrow = (await events('account', 'owner', narrowRange).expect( 200, )).body as UpcomingEventsResourceDto; expect(narrow.data?.calculatedAt).toBe(full.data?.calculatedAt); expect(narrow.data?.window).toEqual(window); expect(narrow.data?.events.map((event) => event.date)).toEqual([ '2099-02-01', ]); expect(await prisma.tbankForecast.count()).toBe(1); }); describe('daily default window shift', () => { const day0 = '2099-02-01'; const day1 = '2099-02-02'; const windowOf = (from: string, to: string) => ({ from, to }); const w0 = windowOf(day0, '2099-03-03'); const w1 = windowOf(day1, '2099-03-04'); const dividend = (date: string) => ({ recordDate: time(date), dividendNet: { units: '10', nano: 1, currency: 'usd' }, }); const defaultEvents = async () => (await get('brokerage-accounts/account/events').expect(200)) .body as UpcomingEventsResourceDto; // Only Date is faked: the app and SQLite helpers need real timers. const clock = (date: string) => { jest.useFakeTimers({ doNotFake: [ 'hrtime', 'nextTick', 'performance', 'queueMicrotask', 'requestAnimationFrame', 'cancelAnimationFrame', 'requestIdleCallback', 'cancelIdleCallback', 'setImmediate', 'clearImmediate', 'setInterval', 'clearInterval', 'setTimeout', 'clearTimeout', ], now: new Date(`${date}T12:00:00Z`), }); }; afterEach(() => jest.useRealTimers()); it('publishes the new window as latest and serves it for stale or disjoint explicit ranges', async () => { clock(day0); upstream.getDividends.mockResolvedValue([ dividend('2099-02-05'), dividend('2099-03-04'), ]); await portfolio([position(shareUid, '2')]); await refresh({}); const first = await defaultEvents(); expect(first.data?.window).toEqual(w0); expect(first.data?.incomeTotalsByCurrency).toEqual([ { currency: 'USD', value: '20.000000002' }, ]); jest.setSystemTime(new Date(`${day1}T12:00:00Z`)); await portfolio([position(shareUid, '5')]); await refresh({}); const second = await defaultEvents(); expect(second.data?.window).toEqual(w1); expect(second.data?.calculatedAt).not.toBe(first.data?.calculatedAt); expect(second.data?.events.map((event) => event.date)).toEqual([ '2099-02-05', '2099-03-04', ]); expect(second.data?.incomeTotalsByCurrency).toEqual([ { currency: 'USD', value: '100.00000001' }, ]); const stale = (await events('account', 'owner', w0).expect(200)) .body as UpcomingEventsResourceDto; expect(stale.data?.calculatedAt).toBe(second.data?.calculatedAt); expect(stale.data?.window).toEqual(w1); const disjoint = ( await events('account', 'owner', { from: '2100-01-01', to: '2100-01-31', }).expect(200) ).body as UpcomingEventsResourceDto; expect(disjoint.data).toEqual({ ...second.data, events: [], incomeTotalsByCurrency: [], }); expect(await prisma.tbankForecast.count()).toBe(2); }); it('keeps the previous complete forecast when a holding calendar fails after the shift', async () => { clock(day0); upstream.getDividends.mockResolvedValue([dividend('2099-02-05')]); await refresh({}); const before = await defaultEvents(); expect(before.data?.window).toEqual(w0); jest.setSystemTime(new Date(`${day1}T12:00:00Z`)); await portfolio([position(shareUid, '7'), position(newUid, '5')]); upstream.getDividends.mockImplementation((_token: string, uid: string) => uid === newUid ? Promise.reject(new Error('new calendar unavailable')) : Promise.resolve([dividend('2099-02-05')]), ); await refresh({}); expect((await defaultEvents()).data).toEqual(before.data); expect(await prisma.tbankForecast.count()).toBe(1); }); it('publishes the shifted window even when no source depends on it', async () => { clock(day0); await portfolio([]); await refresh({}); expect((await defaultEvents()).data?.window).toEqual(w0); jest.setSystemTime(new Date(`${day1}T12:00:00Z`)); await refresh({}); expect((await defaultEvents()).data?.window).toEqual(w1); }); }); describe('hourly pass refreshes saved payout calendars', () => { const date = new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10); const dividend = (units: string, dividendType = 'Regular') => ({ recordDate: time(date), dividendType, dividendNet: { units, currency: 'usd' }, }); // One hourly walk: the scheduler stamps it once and passes it to every account. const startWalk = async () => { await delay(10); return new Date(); }; const pass = async (walkStartedAt: Date, accountId = 'account') => { await app .get(UpcomingEventsService) .refresh('owner', accountId, {}, walkStartedAt); await settled(); }; const latest = async () => (await get('brokerage-accounts/account/events').expect(200)) .body as UpcomingEventsResourceDto; const dividendRow = () => prisma.tbankInstrumentSource.findFirstOrThrow({ where: { instrumentUid: shareUid, kind: 'dividends' }, }); it('picks up a changed and then a cancelled payout on the next pass of the same window', async () => { upstream.getDividends.mockResolvedValue([dividend('10')]); await pass(await startWalk()); expect((await latest()).data?.incomeTotalsByCurrency).toEqual([ { currency: 'USD', value: '20' }, ]); const first = await dividendRow(); upstream.getDividends.mockResolvedValue([dividend('30')]); await pass(await startWalk()); expect((await latest()).data?.incomeTotalsByCurrency).toEqual([ { currency: 'USD', value: '60' }, ]); expect((await dividendRow()).dataRevision).toBe(first.dataRevision + 1); upstream.getDividends.mockResolvedValue([dividend('30', 'Cancelled')]); await pass(await startWalk()); expect((await latest()).data?.events).toEqual([]); }); it('keeps the first complete calendar, time and forecast when the next refresh fails', async () => { upstream.getDividends.mockResolvedValue([dividend('10')]); await pass(await startWalk()); const before = await latest(); const row = await dividendRow(); upstream.getDividends.mockRejectedValue(new Error('outage')); await pass(await startWalk()); const after = await latest(); expect(after.data).toEqual(before.data); expect(after.meta.lastSuccessfulAt).toBe(before.meta.lastSuccessfulAt); expect( after.meta.dependencies?.some( (dependency) => dependency.lastAttempt.state === 'failed', ), ).toBe(true); const kept = await dividendRow(); expect(kept.dataRevision).toBe(row.dataRevision); expect(kept.fetchedAt).toEqual(row.fetchedAt); }); it('loads one shared calendar once per walk across accounts and leaves detail, GET and a plain refresh alone', async () => { await prisma.tbankAccount.create({ data: { userId: 'owner', revision: 'owner-revision', accountId: 'second', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Second', upstreamOrder: 1, }, }); await portfolio([position()], 'owner', 'second'); upstream.getDividends.mockResolvedValue([dividend('10')]); await pass(await startWalk()); const detailCalls = () => upstream.getShare.mock.calls.length + upstream.getInstrumentByUid.mock.calls.length; const detailBefore = detailCalls(); expect(upstream.getDividends).toHaveBeenCalledTimes(1); const walk = await startWalk(); await pass(walk, 'account'); await pass(walk, 'second'); expect(upstream.getDividends).toHaveBeenCalledTimes(2); await app.get(UpcomingEventsService).refresh('owner', 'account', {}); await settled(); await latest(); await get('brokerage-accounts/all/events').expect(200); expect(upstream.getDividends).toHaveBeenCalledTimes(2); expect(detailCalls()).toBe(detailBefore); }); it('re-reads the bond calendar whose key never changes, only on a walk pass', async () => { await portfolio([position(bondUid, '3', 'BOND')]); await pass(await startWalk()); expect(moex.bondEvents).toHaveBeenCalledTimes(1); await app.get(UpcomingEventsService).refresh('owner', 'account', {}); await settled(); expect(moex.bondEvents).toHaveBeenCalledTimes(1); await pass(await startWalk()); expect(moex.bondEvents).toHaveBeenCalledTimes(2); }); }); 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( new Promise((resolve) => { release = resolve; }), ); await app.get(UpcomingEventsService).refresh('owner', 'account', window); for (let i = 0; i < 100 && !upstream.getDividends.mock.calls.length; i++) { await delay(5); } await prisma.tbankConnection.delete({ where: { userId: 'owner' } }); release([ { recordDate: time('2099-02-01'), dividendNet: { units: '999', currency: 'rub' }, }, ]); await delay(30); expect( await prisma.tbankInstrumentSource.count({ where: { kind: 'dividends' }, }), ).toBe(0); await events().expect(409); expect(await prisma.tbankForecast.count()).toBe(0); }); it.each([ 'from=not-a-date', 'from=2099-02-30', 'to=2099-13-01', 'from=2099-02-01T00:00:00Z', 'from=2099-02-28&to=2099-02-01', ])('rejects invalid query before source reads: %s', async (query) => { await get(`brokerage-accounts/account/events?${query}`).expect(400); expect(upstream.getDividends).not.toHaveBeenCalled(); }); it('ignores the retired sync query parameter', async () => { const response = await get(`brokerage-accounts/account/events?from=2099-02-01&to=2099-02-28&sync=invalid`).expect(200); expect(response.body).toHaveProperty('meta'); }); 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 refresh({ from: today, to: '2096-02-29' }); expect(upstream.getDividends).toHaveBeenCalledWith( 'synthetic-owner', shareUid, today, '2096-02-29', ); upstream.getDividends.mockResolvedValue([ { recordDate: { seconds: String(Date.parse('2096-02-29T23:59:59Z') / 1000), }, dividendNet: { units: '7', currency: 'rub' }, }, { recordDate: time('2096-03-01'), dividendNet: { units: '99', currency: 'rub' }, }, ]); const range = { from: '2096-02-29', to: '2096-02-29' }; await refresh(range); jest.clearAllMocks(); const result = (await events('account', 'owner', range).expect(200)) .body as UpcomingEventsResourceDto; expect(result.data?.events).toHaveLength(1); expect(result.data?.events[0]).toMatchObject({ date: '2096-02-29', projectedAmount: { currency: 'RUB', value: '14' }, }); expect(upstream.getDividends).not.toHaveBeenCalled(); }); });