692 lines
24 KiB
TypeScript
692 lines
24 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 { 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 = (
|
|
observe = true,
|
|
scope = 'account',
|
|
userId = 'owner',
|
|
range = window,
|
|
) =>
|
|
get(
|
|
`brokerage-accounts/${scope}/events?from=${range.from}&to=${range.to}${observe ? '&sync=observe' : ''}`,
|
|
userId,
|
|
);
|
|
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)
|
|
.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);
|
|
}
|
|
});
|
|
afterAll(async () => {
|
|
await settled();
|
|
await app?.close();
|
|
});
|
|
|
|
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))
|
|
.body as UpcomingEventsResourceDto;
|
|
expect(cold.data).toBeNull();
|
|
for (let i = 0; i < 100 && !upstream.getDividends.mock.calls.length; i++) {
|
|
await delay(5);
|
|
}
|
|
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');
|
|
});
|
|
|
|
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 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(true, '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 events(false).expect(200);
|
|
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([]),
|
|
);
|
|
const pending = (await events(false).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.sync.state).toBe('failed');
|
|
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 settled();
|
|
await events().expect(200);
|
|
const newRange = (
|
|
await events(true, 'account', 'owner', {
|
|
from: '2099-03-01',
|
|
to: '2099-03-31',
|
|
}).expect(200)
|
|
).body as UpcomingEventsResourceDto;
|
|
expect(newRange.data).toBeNull();
|
|
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(true, 'foreign').expect(404);
|
|
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('refuses a calculated snapshot if its portfolio changes before atomic publication', async () => {
|
|
await events(false).expect(200);
|
|
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?sync=observe`).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?sync=observe`).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?sync=observe`).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}?sync=observe`).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 events(false).expect(200);
|
|
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 events(false).expect(200);
|
|
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.sync.state).toBe('failed');
|
|
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 events(false).expect(200);
|
|
await settled();
|
|
await get('brokerage-accounts/account/events?from=2099-02-01').expect(200);
|
|
await settled();
|
|
const result = (
|
|
await get(
|
|
'brokerage-accounts/account/events?from=2099-02-01&sync=observe',
|
|
).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('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 events(false).expect(200);
|
|
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',
|
|
'sync=invalid',
|
|
])('rejects invalid query before source reads: %s', async (query) => {
|
|
await get(`brokerage-accounts/account/events?${query}`).expect(400);
|
|
expect(upstream.getDividends).not.toHaveBeenCalled();
|
|
});
|
|
|
|
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();
|
|
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 events(false, 'account', 'owner', range).expect(200);
|
|
await settled();
|
|
const result = (await events(true, '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' },
|
|
});
|
|
});
|
|
});
|