Files
backend/test/upcoming-events.e2e-spec.ts
codex-bot 809936e62d
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 1m32s
Backend verify / verify (push) Failing after 9m25s
refactor!: support RUB only (WealthLedger/main#55) (#204)
BREAKING CHANGE: per-currency API fields replaced with single RUB Money.
2026-11-11 10:07:11 +03:00

955 lines
34 KiB
TypeScript

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<Server>;
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,
totals: { currency: 'RUB', value: '0' },
cash: { currency: 'RUB', value: '0' },
structure: { totalValue: '0', hasNegativeValues: false, categories: [], 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?.incomeTotal).toEqual({ currency: 'RUB', value: '6' });
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?.incomeTotal).toEqual(
single.data?.incomeTotal,
);
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: [],
incomeTotal: { currency: 'RUB', value: '0' },
});
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
?.incomeTotal,
).toEqual({ currency: 'RUB', value: '0' });
});
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?.incomeTotal).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?.incomeTotal).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: 'rub' },
});
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?.incomeTotal).toEqual({ currency: 'RUB', 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?.incomeTotal).toEqual({ currency: 'RUB', 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: [],
incomeTotal: { currency: 'RUB', value: '0' },
});
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: 'rub' },
});
// 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?.incomeTotal).toEqual({ currency: 'RUB', value: '20' });
const first = await dividendRow();
upstream.getDividends.mockResolvedValue([dividend('30')]);
await pass(await startWalk());
expect((await latest()).data?.incomeTotal).toEqual({ currency: 'RUB', 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<unknown[]>((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();
});
});