Backend security / security (push) Successful in 8s
Backend verify / verify (push) Failing after 10m45s
Conventional commits / commits (pull_request) Successful in 29s
Backend security / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 10m37s
Store account membership, portfolio, operations, forecasts and market sources in SQLite. Coalesce scoped refreshes, fence connection revisions, preserve complete financial snapshots and stabilize operation period state. BREAKING CHANGE: investment reads return data/meta resource envelopes and locally persisted snapshots; consumers must use the matching OpenAPI contract and explicit synchronization endpoints.
501 lines
17 KiB
TypeScript
501 lines
17 KiB
TypeScript
import { INestApplication } from '@nestjs/common';
|
|
import { JwtService } from '@nestjs/jwt';
|
|
import { Test } from '@nestjs/testing';
|
|
import type { Server } from 'node:http';
|
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
import request from 'supertest';
|
|
import { AppModule } from '../src/app.module';
|
|
import { configureApp } from '../src/main';
|
|
import { PrismaService } from '../src/prisma/prisma.service';
|
|
import {
|
|
TbankClient,
|
|
TbankTokenService,
|
|
} from '../src/integrations/tbank/tbank.api';
|
|
import { MoexClient } from '../src/integrations/moex/moex.api';
|
|
import { CatalogEnrichmentService } from '../src/modules/investments/instruments/application/catalog-enrichment.service';
|
|
import { ForecastRepository } from '../src/modules/investments/portfolio/infrastructure/forecast.repository';
|
|
import type { UpcomingEventsResourceDto } from '../src/modules/investments/upcoming-events/upcoming-events.dto';
|
|
import type { StockDividendsResourceDto } from '../src/modules/investments/instruments/stocks/presentation/stock.dto';
|
|
import type { BondCouponsResourceDto } from '../src/modules/investments/instruments/bonds/presentation/bond.dto';
|
|
|
|
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('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('does not turn a missing MOEX block into a complete empty forecast and defaults to a 30-day window', async () => {
|
|
await portfolio([position(bondUid, '3', 'BOND')]);
|
|
moex.bondEvents.mockRejectedValueOnce(new Error('missing MOEX block'));
|
|
await events(false).expect(200);
|
|
await settled();
|
|
expect(
|
|
((await events().expect(200)).body as UpcomingEventsResourceDto).data,
|
|
).toBeNull();
|
|
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();
|
|
});
|
|
});
|