feat: add hourly portfolio sync history
This commit is contained in:
@@ -125,7 +125,14 @@ function tbankSnapshot() {
|
||||
const owner = auth.user.id;
|
||||
await stop();
|
||||
const db = new Database(file);
|
||||
db.prepare('INSERT INTO TbankConnection (userId,encryptedToken,connectedAt) VALUES (?,?,?)').run(owner, 'fictional-encrypted-token-never-used', new Date().toISOString());
|
||||
const columns = db.prepare('PRAGMA table_info("TbankConnection")').all().map(({ name }) => name);
|
||||
const fields = ['userId', 'encryptedToken', 'connectedAt'];
|
||||
const values = [owner, 'fictional-encrypted-token-never-used', new Date().toISOString()];
|
||||
if (columns.includes('revision')) {
|
||||
fields.push('revision');
|
||||
values.push(randomUUID());
|
||||
}
|
||||
db.prepare(`INSERT INTO TbankConnection (${fields.join(',')}) VALUES (${fields.map(() => '?').join(',')})`).run(...values);
|
||||
await db.backup(join(dir, 'before-migration.db'));
|
||||
db.close();
|
||||
const beforeSessions = rows('Session'), beforeUsers = rows('User');
|
||||
|
||||
@@ -27,32 +27,8 @@ describe('OpenAPI contract (integration)', () => {
|
||||
it('distinguishes calendar screen dates from operation instants in requests and responses', () => {
|
||||
const document = createOpenApiDocument(app);
|
||||
const schemas = document.components?.schemas;
|
||||
const screenSchema = {
|
||||
oneOf: [
|
||||
{ $ref: '#/components/schemas/IncomeInvestmentScreenDto' },
|
||||
{ $ref: '#/components/schemas/EventsInvestmentScreenDto' },
|
||||
{ $ref: '#/components/schemas/TimestampInvestmentScreenDto' },
|
||||
],
|
||||
discriminator: {
|
||||
propertyName: 'screen',
|
||||
mapping: {
|
||||
income: '#/components/schemas/IncomeInvestmentScreenDto',
|
||||
events: '#/components/schemas/EventsInvestmentScreenDto',
|
||||
operations: '#/components/schemas/TimestampInvestmentScreenDto',
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(
|
||||
document.paths['/api/v1/tbank-sync'].post?.requestBody,
|
||||
).toMatchObject({
|
||||
content: { 'application/json': { schema: screenSchema } },
|
||||
});
|
||||
expect(schemas?.TbankSyncAcceptedDto).toMatchObject({
|
||||
properties: { scope: screenSchema },
|
||||
});
|
||||
expect(schemas?.TbankSyncStatusDto).toMatchObject({
|
||||
properties: { scope: { ...screenSchema, nullable: true } },
|
||||
});
|
||||
expect(document.paths['/api/v1/tbank-sync']).toBeUndefined();
|
||||
expect(schemas?.TbankSyncStatusDto).toBeDefined();
|
||||
for (const [model, screen] of [
|
||||
['IncomeInvestmentScreenDto', 'income'],
|
||||
['EventsInvestmentScreenDto', 'events'],
|
||||
|
||||
@@ -3,16 +3,21 @@ 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 { AppModule } from '../src/app.module';
|
||||
import { TbankMembershipService } from '../src/integrations/tbank/tbank-membership.service';
|
||||
import { TbankClient } from '../src/integrations/tbank/tbank.client';
|
||||
import { configureApp } from '../src/main';
|
||||
import { PortfolioService } from '../src/modules/investments/portfolio/application/portfolio.service';
|
||||
import { TbankHourlyScheduler } from '../src/modules/investments/tbank-sync/tbank-hourly.scheduler';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
|
||||
describe('Portfolio structure HTTP contract', () => {
|
||||
let app: INestApplication<Server>;
|
||||
let prisma: PrismaService;
|
||||
let membership: TbankMembershipService;
|
||||
let portfolios: PortfolioService;
|
||||
const tbank = {
|
||||
validateToken: jest.fn().mockResolvedValue([
|
||||
{ id: 'a', name: 'A', type: 'ACCOUNT_TYPE_TINKOFF' },
|
||||
@@ -22,6 +27,10 @@ describe('Portfolio structure HTTP contract', () => {
|
||||
getPositions: jest.fn(),
|
||||
getInstrumentByFigi: jest.fn().mockResolvedValue(null),
|
||||
getInstrumentByUid: jest.fn().mockResolvedValue(null),
|
||||
getOpenAccounts: jest.fn().mockResolvedValue([
|
||||
{ id: 'a', name: 'A', type: 'ACCOUNT_TYPE_TINKOFF' },
|
||||
{ id: 'b', name: 'B', type: 'ACCOUNT_TYPE_TINKOFF' },
|
||||
]),
|
||||
};
|
||||
const token = new JwtService().sign(
|
||||
{ sub: 'structure-user', type: 'access' },
|
||||
@@ -32,11 +41,16 @@ describe('Portfolio structure HTTP contract', () => {
|
||||
const module = await Test.createTestingModule({ imports: [AppModule] })
|
||||
.overrideProvider(TbankClient)
|
||||
.useValue(tbank)
|
||||
.overrideProvider(TbankHourlyScheduler)
|
||||
.useValue({ connected: jest.fn().mockResolvedValue(undefined) })
|
||||
.compile();
|
||||
app = module.createNestApplication();
|
||||
configureApp(app);
|
||||
await app.init();
|
||||
await app.get(PrismaService).user.create({
|
||||
prisma = app.get(PrismaService);
|
||||
membership = app.get(TbankMembershipService);
|
||||
portfolios = app.get(PortfolioService);
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: 'structure-user',
|
||||
email: 'structure@example.invalid',
|
||||
@@ -65,21 +79,25 @@ describe('Portfolio structure HTTP contract', () => {
|
||||
});
|
||||
afterAll(() => app.close());
|
||||
async function load(id: string) {
|
||||
await request(app.getHttpServer())
|
||||
.get(`/api/v1/brokerage-accounts/${id}/portfolio`)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.expect(200);
|
||||
for (let index = 0; index < 200; index += 1) {
|
||||
if (
|
||||
!(await app
|
||||
.get(PrismaService)
|
||||
.tbankSyncWork.count({ where: { state: 'running' } }))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await delay(5);
|
||||
const connection = await prisma.tbankConnection.findUniqueOrThrow({
|
||||
where: { userId: 'structure-user' },
|
||||
});
|
||||
const member = await membership.request(
|
||||
'structure-user',
|
||||
connection.revision,
|
||||
true,
|
||||
);
|
||||
if (member) await member.done;
|
||||
const accountIds = id === 'all' ? ['a', 'b'] : [id];
|
||||
for (const accountId of accountIds) {
|
||||
const work = await portfolios.request(
|
||||
'structure-user',
|
||||
connection.revision,
|
||||
accountId,
|
||||
true,
|
||||
);
|
||||
if (work) await work.done;
|
||||
}
|
||||
throw new Error('Portfolio did not settle');
|
||||
}
|
||||
|
||||
it.each(['a', 'all'])(
|
||||
|
||||
@@ -297,7 +297,7 @@ describe('shared catalog discovery (SQLite)', () => {
|
||||
instrument,
|
||||
]);
|
||||
});
|
||||
it('refreshes at the daily boundary while stale observe and fresh reads perform zero RPC', async () => {
|
||||
it('keeps known discovery stable between refresh requests', async () => {
|
||||
await service.read('owner', query);
|
||||
await settled();
|
||||
const now = Date.now();
|
||||
@@ -311,13 +311,10 @@ describe('shared catalog discovery (SQLite)', () => {
|
||||
await prisma.tbankCatalogDiscovery.updateMany({
|
||||
data: { fetchedAt: new Date(now - 86_400_000) },
|
||||
});
|
||||
expect((await service.read('other', query, true)).meta.freshness).toBe(
|
||||
'stale',
|
||||
);
|
||||
expect((await service.read('other', query, true)).meta.freshness).toBe('fresh');
|
||||
expect(upstream.findShares).toHaveBeenCalledTimes(1);
|
||||
await service.read('other', query);
|
||||
await settled();
|
||||
expect(upstream.findShares).toHaveBeenCalledTimes(2);
|
||||
expect(upstream.findShares).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
clock.mockRestore();
|
||||
}
|
||||
|
||||
@@ -1,958 +0,0 @@
|
||||
import type { Server } from 'node:http';
|
||||
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import request from 'supertest';
|
||||
|
||||
import { AppModule } from '../src/app.module';
|
||||
import { MoexClient } from '../src/integrations/moex/moex.api';
|
||||
import { NcrRatingsClient } from '../src/integrations/ncr/ncr.api';
|
||||
import { InstrumentMetadataService } from '../src/integrations/tbank/instrument-metadata.service';
|
||||
import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service';
|
||||
import { TbankClient } from '../src/integrations/tbank/tbank.client';
|
||||
import { configureApp } from '../src/main';
|
||||
import { CatalogEnrichmentService } from '../src/modules/investments/instruments/application/catalog-enrichment.service';
|
||||
import {
|
||||
type BondMetricsResourceDto,
|
||||
type BondQuoteResourceDto, type BondSearchResourceDto,
|
||||
} from '../src/modules/investments/instruments/bonds/presentation/bond.dto';
|
||||
import { CatalogDiscoveryRepository } from '../src/modules/investments/instruments/infrastructure/catalog-discovery.repository';
|
||||
import {
|
||||
type StockCandlesResourceDto,
|
||||
type StockCompanyResourceDto,
|
||||
type StockQuoteResourceDto,
|
||||
type StockResourceDto,
|
||||
type StockSearchResourceDto,
|
||||
} from '../src/modules/investments/instruments/stocks/presentation/stock.dto';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
|
||||
// HTTP + disposable SQLite: missing/partial results, exact prices, shared source reuse and revision races.
|
||||
describe('local stock/bond search (SQLite HTTP)', () => {
|
||||
let app: INestApplication<Server>;
|
||||
let prisma: PrismaService;
|
||||
const upstream = {
|
||||
findShares: jest.fn(),
|
||||
findBonds: jest.fn(),
|
||||
getShare: jest.fn(),
|
||||
getBond: jest.fn(),
|
||||
getLastPrices: jest.fn(),
|
||||
getOpenAccounts: jest.fn(),
|
||||
getAssetDescription: jest.fn(),
|
||||
getFundamentals: jest.fn(),
|
||||
getTradingStatus: jest.fn(),
|
||||
getCandles: jest.fn(),
|
||||
getBondCoupons: jest.fn(),
|
||||
};
|
||||
const moex = { bondYield: jest.fn() };
|
||||
const ratings = { rating: jest.fn() };
|
||||
const share = {
|
||||
uid: 'share',
|
||||
ticker: 'SBER',
|
||||
name: 'Synthetic share',
|
||||
isin: 'SHARE',
|
||||
classCode: 'TQBR',
|
||||
instrumentKind: 'INSTRUMENT_TYPE_SHARE',
|
||||
apiTradeAvailableFlag: true,
|
||||
};
|
||||
const bond = {
|
||||
uid: 'bond',
|
||||
ticker: 'BOND',
|
||||
name: 'Synthetic bond',
|
||||
isin: 'BOND',
|
||||
classCode: 'TQOB',
|
||||
instrumentKind: 'INSTRUMENT_TYPE_BOND',
|
||||
};
|
||||
const auth = (user = 'owner') =>
|
||||
`Bearer ${new JwtService().sign({ sub: user, type: 'access' }, { secret: process.env.JWT_SECRET })}`;
|
||||
const get = (path = 'stocks?query=SBER', user = 'owner') =>
|
||||
request(app.getHttpServer())
|
||||
.get(`/api/v1/${path}`)
|
||||
.set('Authorization', auth(user))
|
||||
.timeout(1500);
|
||||
const read = async (observe = true, user = 'owner') =>
|
||||
(
|
||||
await get(
|
||||
`stocks?query=SBER${observe ? '&sync=observe' : ''}`,
|
||||
user,
|
||||
).expect(200)
|
||||
).body as StockSearchResourceDto;
|
||||
async function settled() {
|
||||
for (let i = 0; i < 400; 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('Catalog sources did not settle');
|
||||
}
|
||||
beforeAll(async () => {
|
||||
const module = await Test.createTestingModule({ imports: [AppModule] })
|
||||
.overrideProvider(TbankClient)
|
||||
.useValue(upstream)
|
||||
.overrideProvider(MoexClient)
|
||||
.useValue(moex)
|
||||
.overrideProvider(NcrRatingsClient)
|
||||
.useValue(ratings)
|
||||
.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 prisma.tbankConnection.deleteMany();
|
||||
await prisma.tbankInstrumentLookup.deleteMany();
|
||||
await prisma.tbankInstrument.deleteMany();
|
||||
await prisma.tbankCatalogDiscovery.deleteMany();
|
||||
jest.resetAllMocks();
|
||||
upstream.getAssetDescription.mockResolvedValue('Synthetic company');
|
||||
upstream.getFundamentals.mockResolvedValue({
|
||||
assetUid: 'asset',
|
||||
currency: 'rub',
|
||||
peRatioTtm: 6,
|
||||
netIncomeTtm: 10,
|
||||
});
|
||||
upstream.getTradingStatus.mockResolvedValue(
|
||||
'SECURITY_TRADING_STATUS_NORMAL_TRADING',
|
||||
);
|
||||
upstream.getCandles.mockResolvedValue([]);
|
||||
upstream.getBondCoupons.mockResolvedValue([]);
|
||||
moex.bondYield.mockResolvedValue(null);
|
||||
ratings.rating.mockResolvedValue(null);
|
||||
upstream.findShares.mockResolvedValue([share]);
|
||||
upstream.findBonds.mockResolvedValue([bond]);
|
||||
upstream.getShare.mockImplementation((_token: string, uid: string) =>
|
||||
Promise.resolve({
|
||||
...share,
|
||||
uid,
|
||||
currency: 'rub',
|
||||
sector: 'financial',
|
||||
assetUid: 'asset',
|
||||
lot: 10,
|
||||
}),
|
||||
);
|
||||
upstream.getBond.mockResolvedValue({
|
||||
...bond,
|
||||
currency: 'rub',
|
||||
nominal: { currency: 'rub', units: '800' },
|
||||
aciValue: { currency: 'rub', units: '12', nano: 1 },
|
||||
});
|
||||
upstream.getLastPrices.mockImplementation(
|
||||
(_token: string, uids: string[]) =>
|
||||
Promise.resolve(
|
||||
uids.map((uid) => ({
|
||||
instrumentUid: uid,
|
||||
price: { units: '99', nano: 100000001 },
|
||||
time: { seconds: '1700000000' },
|
||||
})),
|
||||
),
|
||||
);
|
||||
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(),
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
afterAll(async () => app?.close());
|
||||
|
||||
it('reads only requested source identities when batching UIDs, types and periods', async () => {
|
||||
const repository = app.get(CatalogDiscoveryRepository);
|
||||
const fetchedAt = new Date();
|
||||
for (const uid of ['a', 'b']) {
|
||||
await prisma.tbankInstrument.create({
|
||||
data: { provider: 'tbank', uid, instrumentType: 'share', metadata: {}, fetchedAt },
|
||||
});
|
||||
}
|
||||
const expected = [
|
||||
{ instrumentUid: 'a', source: 'tbank', kind: 'detail', rangeKey: 'share' },
|
||||
{ instrumentUid: 'b', source: 'tbank', kind: 'detail', rangeKey: 'share' },
|
||||
{ instrumentUid: 'a', source: 'tbank', kind: 'dividends', rangeKey: '2026' },
|
||||
{ instrumentUid: 'b', source: 'tbank', kind: 'dividends', rangeKey: '2027' },
|
||||
{ instrumentUid: 'b', source: 'moex', kind: 'bond-events', rangeKey: '' },
|
||||
{ instrumentUid: 'a', source: 'ncr', kind: 'rating', rangeKey: '' },
|
||||
];
|
||||
const distractors = [
|
||||
{ instrumentUid: 'a', source: 'tbank', kind: 'detail', rangeKey: 'bond' },
|
||||
{ instrumentUid: 'b', source: 'tbank', kind: 'dividends', rangeKey: '2026' },
|
||||
{ instrumentUid: 'a', source: 'tbank', kind: 'dividends', rangeKey: '2027' },
|
||||
{ instrumentUid: 'a', source: 'moex', kind: 'bond-events', rangeKey: '' },
|
||||
{ instrumentUid: 'b', source: 'tbank', kind: 'bond-events', rangeKey: '' },
|
||||
{ instrumentUid: 'a', source: 'tbank', kind: 'rating', rangeKey: '' },
|
||||
];
|
||||
await prisma.tbankInstrumentSource.createMany({
|
||||
data: [...expected, ...distractors].map((identity) => ({
|
||||
...identity, provider: 'tbank', fetchedAt, payload: identity,
|
||||
})),
|
||||
});
|
||||
const rows = await repository.readSources([
|
||||
{ uid: 'a', instrumentType: 'share', kind: 'detail' },
|
||||
{ uid: 'b', instrumentType: 'share', kind: 'detail' },
|
||||
{ uid: 'a', instrumentType: 'share', kind: 'detail' },
|
||||
{ uid: 'a', instrumentType: 'share', kind: 'dividends', rangeKey: '2026' },
|
||||
{ uid: 'b', instrumentType: 'share', kind: 'dividends', rangeKey: '2027' },
|
||||
{ uid: 'b', instrumentType: 'bond', kind: 'bond-events' },
|
||||
{ uid: 'a', instrumentType: 'bond', kind: 'rating' },
|
||||
{ uid: 'missing', instrumentType: 'share', kind: 'detail' },
|
||||
]);
|
||||
expect(rows.map((row) => row.payload)).toEqual(expect.arrayContaining(expected));
|
||||
expect(rows).toHaveLength(expected.length);
|
||||
expect(await repository.readSources([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns missing without waiting for discovery, then partial while enrichment is blocked; observe never launches work', async () => {
|
||||
const found = deferred<(typeof share)[]>();
|
||||
const detail = deferred<typeof share>();
|
||||
upstream.findShares.mockReturnValueOnce(found.promise);
|
||||
upstream.getShare.mockReturnValueOnce(detail.promise);
|
||||
expect((await read()).data).toBeNull();
|
||||
expect(upstream.findShares).not.toHaveBeenCalled();
|
||||
const cold = await read(false);
|
||||
expect(cold.data).toBeNull();
|
||||
expect(cold.meta.availability).toBe('missing');
|
||||
found.resolve([share]);
|
||||
for (let i = 0; i < 100 && !upstream.getShare.mock.calls.length; i++) {
|
||||
await delay(5);
|
||||
}
|
||||
const partial = await read();
|
||||
expect(partial.data?.[0].instrumentUid).toBe('share');
|
||||
expect(partial.meta.availability).toBe('partial');
|
||||
expect(partial.data?.[0].price).toBeNull();
|
||||
expect(partial.meta.resourceKey).toBe(cold.meta.resourceKey);
|
||||
expect(
|
||||
partial.meta.dependencies?.some(
|
||||
(dependency) => dependency.sync.state === 'running',
|
||||
),
|
||||
).toBe(true);
|
||||
detail.resolve({ ...share });
|
||||
await settled();
|
||||
expect((await read()).meta.availability).toBe('complete');
|
||||
expect(upstream.findShares).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('reuses shared success after SQLite reopen and across users without decrypt, GetAccounts or broker RPC', async () => {
|
||||
await read(false);
|
||||
await settled();
|
||||
const before = await read();
|
||||
await prisma.$disconnect();
|
||||
const decrypt = jest.spyOn(app.get(TbankTokenService), 'decrypt');
|
||||
const after = await read(false, 'other');
|
||||
expect(after.data).toEqual(before.data);
|
||||
expect(after.data?.[0].price).toEqual({
|
||||
currency: 'RUB',
|
||||
value: '99.100000001',
|
||||
});
|
||||
expect(
|
||||
after.meta.dependencies?.every(
|
||||
(dependency) => dependency.sync.id === null,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(decrypt).not.toHaveBeenCalled();
|
||||
decrypt.mockRestore();
|
||||
const metadata = await app
|
||||
.get(InstrumentMetadataService)
|
||||
.read('other', { instrumentUid: 'share' });
|
||||
expect(metadata?.complete).toBe(true);
|
||||
expect(metadata?.data?.name).toBe(share.name);
|
||||
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
|
||||
expect(upstream.getShare).toHaveBeenCalledTimes(1);
|
||||
expect(upstream.getLastPrices).toHaveBeenCalledTimes(1);
|
||||
expect(upstream.findShares).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('uses one price batch for thirty deduplicated selected UIDs, matching reversed prices by UID', async () => {
|
||||
upstream.findShares.mockResolvedValue([
|
||||
{ ...share, uid: 'duplicate', apiTradeAvailableFlag: false },
|
||||
...Array.from({ length: 35 }, (_, i) => ({
|
||||
...share,
|
||||
uid: `s${String(i).padStart(2, '0')}`,
|
||||
isin: i ? undefined : 'SHARE',
|
||||
})),
|
||||
]);
|
||||
upstream.getLastPrices.mockImplementation(
|
||||
(_token: string, uids: string[]) =>
|
||||
Promise.resolve(
|
||||
uids.toReversed().map((uid, i) => ({
|
||||
instrumentUid: uid,
|
||||
price: { units: String(i + 1) },
|
||||
})),
|
||||
),
|
||||
);
|
||||
await read(false);
|
||||
await settled();
|
||||
const response = await read();
|
||||
expect(response.data).toHaveLength(30);
|
||||
expect(response.data?.[0].instrumentUid).toBe('s00');
|
||||
expect(response.data?.[0].price?.value).toBe('30');
|
||||
expect(upstream.getLastPrices).toHaveBeenCalledTimes(1);
|
||||
expect(upstream.getShare).toHaveBeenCalledTimes(30);
|
||||
expect(upstream.findShares).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('keeps confirmed empty separate from unknown queries and does not enrich an empty result', async () => {
|
||||
upstream.findShares.mockResolvedValue([]);
|
||||
expect((await read(false)).data).toBeNull();
|
||||
await settled();
|
||||
expect((await read()).data).toEqual([]);
|
||||
const other = (await get('stocks?query=GAZP&sync=observe').expect(200))
|
||||
.body as StockSearchResourceDto;
|
||||
expect(other.data).toBeNull();
|
||||
expect(other.meta.availability).toBe('missing');
|
||||
expect(upstream.getShare).not.toHaveBeenCalled();
|
||||
expect(upstream.getLastPrices).not.toHaveBeenCalled();
|
||||
});
|
||||
it('retains catalog identity on enrichment failures, rejects mismatched detail UIDs and never returns fake prices', async () => {
|
||||
upstream.getShare.mockResolvedValue({ ...share, uid: 'another' });
|
||||
upstream.getLastPrices.mockRejectedValue(new Error('outage'));
|
||||
await read(false);
|
||||
await settled();
|
||||
const result = await read();
|
||||
expect(result.data?.[0]).toMatchObject({
|
||||
instrumentUid: 'share',
|
||||
ticker: 'SBER',
|
||||
price: null,
|
||||
});
|
||||
expect(result.meta.availability).toBe('partial');
|
||||
expect(
|
||||
result.meta.dependencies?.filter((item) => item.sync.state === 'failed'),
|
||||
).toHaveLength(2);
|
||||
await read();
|
||||
expect(upstream.getShare).toHaveBeenCalledTimes(1);
|
||||
expect(upstream.getLastPrices).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('keeps bond percent separate from money and preserves exact nominal and ACI', async () => {
|
||||
await get('bonds?query=BOND').expect(200);
|
||||
await settled();
|
||||
const result = (await get('bonds?query=BOND&sync=observe').expect(200))
|
||||
.body as BondSearchResourceDto;
|
||||
expect(result.data?.[0]).toMatchObject({
|
||||
pricePercent: '99.100000001',
|
||||
nominal: { currency: 'RUB', value: '800' },
|
||||
aci: { currency: 'RUB', value: '12.000000001' },
|
||||
});
|
||||
expect(result.data?.[0]).not.toHaveProperty('price');
|
||||
expect(upstream.findShares).not.toHaveBeenCalled();
|
||||
});
|
||||
it('refreshes only prices at the minute boundary and preserves old prices after failure', async () => {
|
||||
await read(false);
|
||||
await settled();
|
||||
const before = await read();
|
||||
await prisma.tbankInstrumentSource.updateMany({
|
||||
where: { kind: 'last-price' },
|
||||
data: { fetchedAt: new Date(Date.now() - 60_000) },
|
||||
});
|
||||
upstream.getLastPrices.mockRejectedValue(new Error('outage'));
|
||||
await read();
|
||||
expect(upstream.getLastPrices).toHaveBeenCalledTimes(1);
|
||||
await read(false);
|
||||
await settled();
|
||||
const failed = await read();
|
||||
expect(failed.data).toEqual(before.data);
|
||||
expect(failed.meta.freshness).toBe('stale');
|
||||
expect(upstream.getShare).toHaveBeenCalledTimes(1);
|
||||
expect(upstream.findShares).toHaveBeenCalledTimes(1);
|
||||
expect(upstream.getLastPrices).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it('coalesces sources across different queries and users, with private handles and no private fields in shared rows', async () => {
|
||||
const detail = deferred<typeof share>();
|
||||
upstream.getShare.mockReturnValueOnce(detail.promise);
|
||||
await read(false);
|
||||
for (let i = 0; i < 100 && !upstream.getShare.mock.calls.length; i++) {
|
||||
await delay(5);
|
||||
}
|
||||
await get('stocks?query=OTHER', 'other').expect(200);
|
||||
for (
|
||||
let i = 0;
|
||||
i < 100 &&
|
||||
(await prisma.tbankSyncWork.count({ where: { userId: 'other' } })) < 3;
|
||||
i++
|
||||
) {
|
||||
await delay(5);
|
||||
}
|
||||
detail.resolve({
|
||||
...share,
|
||||
currency: 'rub',
|
||||
quantity: 'private',
|
||||
estimatedAmount: 'private',
|
||||
} as typeof share);
|
||||
await settled();
|
||||
expect(upstream.getShare).toHaveBeenCalledTimes(1);
|
||||
expect(upstream.getLastPrices).toHaveBeenCalledTimes(1);
|
||||
const owner = await read();
|
||||
const other = (await get('stocks?query=OTHER&sync=observe', 'other'))
|
||||
.body as StockSearchResourceDto;
|
||||
expect(owner.meta.dependencies?.at(-1)?.sync.id).not.toBe(
|
||||
other.meta.dependencies?.at(-1)?.sync.id,
|
||||
);
|
||||
expect(
|
||||
JSON.stringify(await prisma.tbankInstrumentSource.findMany()),
|
||||
).not.toMatch(/quantity|estimatedAmount|private|encryptedToken/);
|
||||
});
|
||||
it.each(['delete', 'replace'])(
|
||||
'discards delayed enrichment after connection %s',
|
||||
async (action) => {
|
||||
const detail = deferred<typeof share>();
|
||||
upstream.getShare.mockReturnValueOnce(detail.promise);
|
||||
await read(false);
|
||||
for (let i = 0; i < 100 && !upstream.getShare.mock.calls.length; i++) {
|
||||
await delay(5);
|
||||
}
|
||||
await prisma.tbankConnection.delete({ where: { userId: 'owner' } });
|
||||
if (action === 'replace') {
|
||||
await prisma.tbankConnection.create({
|
||||
data: {
|
||||
userId: 'owner',
|
||||
revision: 'new',
|
||||
encryptedToken: app.get(TbankTokenService).encrypt('new'),
|
||||
connectedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
detail.resolve(share);
|
||||
await settled();
|
||||
expect(
|
||||
await prisma.tbankInstrumentSource.count({ where: { kind: 'detail' } }),
|
||||
).toBe(0);
|
||||
expect((await read(true, 'other')).data?.[0].instrumentUid).toBe('share');
|
||||
},
|
||||
);
|
||||
it('recovers abandoned enrichment only on an ordinary request, and fences the superseded attempt', async () => {
|
||||
await read(false);
|
||||
await settled();
|
||||
await prisma.tbankInstrumentSource.deleteMany({
|
||||
where: { kind: 'detail' },
|
||||
});
|
||||
const repository = app.get(CatalogDiscoveryRepository);
|
||||
const input = {
|
||||
instrumentType: 'share',
|
||||
uid: 'share',
|
||||
kind: 'detail',
|
||||
} as const;
|
||||
const old = await repository.reserve(
|
||||
{ userId: 'owner', revision: 'owner-revision' },
|
||||
input,
|
||||
'old-process',
|
||||
new Set(),
|
||||
);
|
||||
assert(old);
|
||||
await read();
|
||||
expect(upstream.getShare).toHaveBeenCalledTimes(1);
|
||||
await read(false);
|
||||
await settled();
|
||||
expect(upstream.getShare).toHaveBeenCalledTimes(2);
|
||||
expect(
|
||||
await repository.publishSource(
|
||||
{
|
||||
userId: 'owner',
|
||||
revision: 'owner-revision',
|
||||
id: old.work.id,
|
||||
attemptId: old.work.attemptId,
|
||||
},
|
||||
input,
|
||||
{ instrument: { ...share, name: 'Late' } },
|
||||
new Date(),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
it('preserves fresh successful sources on failed force and isolates credential failures', async () => {
|
||||
await read(false);
|
||||
await settled();
|
||||
const before = await read();
|
||||
upstream.getShare.mockRejectedValue(new Error('outage'));
|
||||
upstream.getLastPrices.mockRejectedValue(new Error('outage'));
|
||||
await app
|
||||
.get(CatalogEnrichmentService)
|
||||
.requestMany('owner', 'owner-revision', 'share', ['share'], true);
|
||||
await settled();
|
||||
const after = await read();
|
||||
expect(after.data).toEqual(before.data);
|
||||
expect(after.meta.freshness).toBe('fresh');
|
||||
expect(after.meta.lastSuccessfulAt).toBe(before.meta.lastSuccessfulAt);
|
||||
expect(
|
||||
(await read(true, 'other')).meta.dependencies?.every(
|
||||
(item) => item.sync.errorCode === null,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
it('rejects every price in a batch if its initiating attempt is superseded during the RPC', async () => {
|
||||
const prices =
|
||||
deferred<{ instrumentUid: string; price: { units: string } }[]>();
|
||||
upstream.findShares.mockResolvedValue([
|
||||
share,
|
||||
{ ...share, uid: 'second', isin: 'SECOND' },
|
||||
]);
|
||||
upstream.getLastPrices.mockReturnValueOnce(prices.promise);
|
||||
await read(false);
|
||||
for (let i = 0; i < 100 && !upstream.getLastPrices.mock.calls.length; i++) {
|
||||
await delay(5);
|
||||
}
|
||||
const initiating = await prisma.tbankSyncWork.findFirstOrThrow({
|
||||
where: { userId: 'owner', scopeKey: { contains: 'last-price' } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
await prisma.tbankSyncWork.update({
|
||||
where: { id: initiating.id },
|
||||
data: { attemptId: 'superseding-attempt', state: 'failed' },
|
||||
});
|
||||
prices.resolve([
|
||||
{ instrumentUid: 'share', price: { units: '100' } },
|
||||
{ instrumentUid: 'second', price: { units: '200' } },
|
||||
]);
|
||||
await settled();
|
||||
expect(
|
||||
await prisma.tbankInstrumentSource.count({
|
||||
where: { kind: 'last-price' },
|
||||
}),
|
||||
).toBe(0);
|
||||
expect((await read()).data?.every((item) => item.price === null)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
it('refreshes stale detail independently of still-fresh prices and discovery', async () => {
|
||||
await read(false);
|
||||
await settled();
|
||||
await prisma.tbankInstrumentSource.updateMany({
|
||||
where: { kind: 'detail' },
|
||||
data: { fetchedAt: new Date(Date.now() - 86_400_000) },
|
||||
});
|
||||
await read();
|
||||
expect(upstream.getShare).toHaveBeenCalledTimes(1);
|
||||
await read(false);
|
||||
await settled();
|
||||
expect(upstream.getShare).toHaveBeenCalledTimes(2);
|
||||
expect(upstream.getLastPrices).toHaveBeenCalledTimes(1);
|
||||
expect(upstream.findShares).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('reads exact UID locally without waiting, batches twenty reads and keeps UID accessible when alias is ambiguous', async () => {
|
||||
const uid = '00000000-0000-4000-8000-000000000001';
|
||||
const blocked = deferred<typeof share>();
|
||||
upstream.getShare.mockReturnValueOnce(blocked.promise);
|
||||
upstream.findShares.mockResolvedValue([
|
||||
{ ...share, uid },
|
||||
{ ...share, uid: 'other-listing', isin: 'OTHER' },
|
||||
]);
|
||||
expect(
|
||||
((await get(`stocks/${uid}?sync=observe`)).body as StockResourceDto).data,
|
||||
).toBeNull();
|
||||
expect(upstream.getShare).not.toHaveBeenCalled();
|
||||
const reads = await Promise.all(
|
||||
Array.from({ length: 20 }, () => get(`stocks/${uid}`).expect(200)),
|
||||
);
|
||||
expect(
|
||||
reads.every(
|
||||
(response) => (response.body as StockResourceDto).data === null,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(upstream.getLastPrices).not.toHaveBeenCalled();
|
||||
blocked.resolve({ ...share, uid });
|
||||
await settled();
|
||||
const first = (await get(`stocks/${uid}`).expect(200))
|
||||
.body as StockResourceDto;
|
||||
expect(first.data?.instrumentUid).toBe(uid);
|
||||
await settled();
|
||||
const exact = (await get(`stocks/${uid}?sync=observe`).expect(200))
|
||||
.body as StockResourceDto;
|
||||
expect(exact.data?.slug).toBe(uid);
|
||||
await get('stocks/by-ticker/SBER?sync=observe').expect(409);
|
||||
expect(upstream.getShare).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('resolves exact ticker before ISIN selection and does not mistake search completeness for ticker coverage', async () => {
|
||||
const uid = '00000000-0000-4000-8000-000000000002';
|
||||
upstream.findShares.mockResolvedValue([
|
||||
{ ...share, uid: 'preferred', ticker: 'SBERP' },
|
||||
{ ...share, uid, ticker: 'SBER', apiTradeAvailableFlag: false },
|
||||
]);
|
||||
await read(false);
|
||||
await settled();
|
||||
expect(
|
||||
(
|
||||
(await get('stocks/by-ticker/SBER?sync=observe'))
|
||||
.body as StockResourceDto
|
||||
).data,
|
||||
).toBeNull();
|
||||
const cold = (await get('stocks/by-ticker/sber').expect(200))
|
||||
.body as StockResourceDto;
|
||||
expect(cold.data).toBeNull();
|
||||
await settled();
|
||||
const resolved = (
|
||||
await get('stocks/by-ticker/SBER?sync=observe', 'other').expect(200)
|
||||
).body as StockResourceDto;
|
||||
expect(resolved.data).toMatchObject({ instrumentUid: uid, slug: 'SBER' });
|
||||
expect(upstream.findShares).toHaveBeenCalledTimes(2);
|
||||
upstream.findShares.mockResolvedValue([]);
|
||||
await get('stocks/by-ticker/NONE').expect(200);
|
||||
await settled();
|
||||
await get('stocks/by-ticker/NONE?sync=observe').expect(404);
|
||||
});
|
||||
it('keeps known typed UID misses separate from unknown and from generic UID lookup', async () => {
|
||||
const uid = '00000000-0000-4000-8000-000000000003';
|
||||
upstream.getBond.mockResolvedValue(null);
|
||||
const cold = (await get(`bonds/${uid}`).expect(200))
|
||||
.body as StockResourceDto;
|
||||
expect(cold.data).toBeNull();
|
||||
await settled();
|
||||
await get(`bonds/${uid}?sync=observe`).expect(404);
|
||||
expect(
|
||||
await prisma.tbankInstrumentLookup.count({ where: { identifier: uid } }),
|
||||
).toBe(0);
|
||||
expect(upstream.getLastPrices).not.toHaveBeenCalled();
|
||||
});
|
||||
it('loads company and fundamentals locally, shares successes and keeps old metrics on failed refresh', async () => {
|
||||
const uid = '00000000-0000-4000-8000-000000000011';
|
||||
const company = deferred<string>();
|
||||
upstream.getAssetDescription.mockReturnValueOnce(company.promise);
|
||||
const cold = (await get(`stocks/${uid}/company`).expect(200))
|
||||
.body as StockCompanyResourceDto;
|
||||
expect(cold.data).toBeNull();
|
||||
for (
|
||||
let i = 0;
|
||||
i < 100 && !upstream.getAssetDescription.mock.calls.length;
|
||||
i++
|
||||
) {
|
||||
await delay(5);
|
||||
}
|
||||
const partial = (
|
||||
await get(`stocks/${uid}/company?sync=observe`).expect(200)
|
||||
).body as StockCompanyResourceDto;
|
||||
expect(partial.meta.availability).toBe('partial');
|
||||
company.resolve('Synthetic company');
|
||||
await settled();
|
||||
const warm = (await get(`stocks/${uid}/company`, 'other').expect(200))
|
||||
.body as StockCompanyResourceDto;
|
||||
expect(warm.data?.description).toBe('Synthetic company');
|
||||
expect(warm.data?.metrics.map((item) => item.key)).toEqual([
|
||||
'peRatioTtm',
|
||||
'netIncomeTtm',
|
||||
]);
|
||||
expect(upstream.getFundamentals).toHaveBeenCalledTimes(1);
|
||||
expect(upstream.getAssetDescription).toHaveBeenCalledTimes(1);
|
||||
upstream.getFundamentals.mockRejectedValue(new Error('outage'));
|
||||
upstream.getAssetDescription.mockRejectedValue(new Error('outage'));
|
||||
await app
|
||||
.get(CatalogEnrichmentService)
|
||||
.requestMany('owner', 'owner-revision', 'share', [uid], true, [
|
||||
'description',
|
||||
'fundamentals',
|
||||
]);
|
||||
await settled();
|
||||
const failed = (await get(`stocks/${uid}/company?sync=observe`).expect(200))
|
||||
.body as StockCompanyResourceDto;
|
||||
expect(failed.data?.metrics).toEqual(warm.data?.metrics);
|
||||
expect(failed.data?.description).toBe(warm.data?.description);
|
||||
expect(failed.data?.observedAt).toBe(warm.data?.observedAt);
|
||||
expect(failed.data?.unavailable).toEqual(['description', 'fundamentals']);
|
||||
});
|
||||
it('reads stock quotes without waiting for blocked prices and reuses quote components across users', async () => {
|
||||
const uid = '00000000-0000-4000-8000-000000000012';
|
||||
const prices = deferred<
|
||||
{
|
||||
instrumentUid: string;
|
||||
price: { units: string };
|
||||
time: { seconds: string };
|
||||
}[]
|
||||
>();
|
||||
upstream.getLastPrices.mockReturnValueOnce(prices.promise);
|
||||
upstream.getCandles.mockResolvedValue([
|
||||
{
|
||||
time: { seconds: '1699833600' },
|
||||
close: { units: '90' },
|
||||
isComplete: true,
|
||||
},
|
||||
]);
|
||||
const cold = (await get(`stocks/${uid}/quote`).expect(200))
|
||||
.body as StockQuoteResourceDto;
|
||||
expect(cold.meta.availability).toBe('missing');
|
||||
for (let i = 0; i < 100 && !upstream.getLastPrices.mock.calls.length; i++) {
|
||||
await delay(5);
|
||||
}
|
||||
const pending = (await get(`stocks/${uid}/quote?sync=observe`).expect(200))
|
||||
.body as StockQuoteResourceDto;
|
||||
expect(pending.data?.price).toBeNull();
|
||||
prices.resolve([
|
||||
{
|
||||
instrumentUid: uid,
|
||||
price: { units: '99' },
|
||||
time: { seconds: '1700000000' },
|
||||
},
|
||||
]);
|
||||
await settled();
|
||||
const warm = (await get(`stocks/${uid}/quote`, 'other').expect(200))
|
||||
.body as StockQuoteResourceDto;
|
||||
expect(warm.data).toMatchObject({
|
||||
price: { currency: 'RUB', value: '99' },
|
||||
previousClose: { currency: 'RUB', value: '90' },
|
||||
change: { currency: 'RUB', value: '9' },
|
||||
changePercent: '10',
|
||||
});
|
||||
expect(upstream.getLastPrices).toHaveBeenCalledTimes(1);
|
||||
expect(upstream.getTradingStatus).toHaveBeenCalledTimes(1);
|
||||
expect(upstream.getCandles).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
await prisma.tbankCandle.count({ where: { instrumentUid: uid } }),
|
||||
).toBe(1);
|
||||
await app
|
||||
.get(CatalogEnrichmentService)
|
||||
.requestMany('owner', 'owner-revision', 'share', [uid], true, [
|
||||
'comparison',
|
||||
]);
|
||||
await settled();
|
||||
expect(upstream.getCandles).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('keeps bond quote money exact and coupon metrics available when optional MOEX/NCR fail', async () => {
|
||||
const uid = '00000000-0000-4000-8000-000000000013';
|
||||
upstream.getBond.mockResolvedValue({
|
||||
...bond,
|
||||
uid,
|
||||
currency: 'rub',
|
||||
nominal: { currency: 'rub', units: '800' },
|
||||
aciValue: { currency: 'rub', units: '12', nano: 1 },
|
||||
riskLevel: 'RISK_LEVEL_LOW',
|
||||
});
|
||||
upstream.getBondCoupons.mockResolvedValue([
|
||||
{
|
||||
couponDate: {
|
||||
seconds: String(Math.floor(Date.now() / 1000) + 86400 * 7),
|
||||
},
|
||||
payOneBond: { currency: 'rub', units: '30' },
|
||||
couponPeriod: 90,
|
||||
},
|
||||
]);
|
||||
moex.bondYield.mockRejectedValue(new Error('MOEX outage'));
|
||||
ratings.rating.mockRejectedValue(new Error('NCR outage'));
|
||||
await get(`bonds/${uid}/metrics`).expect(200);
|
||||
await settled();
|
||||
const metrics = (await get(`bonds/${uid}/metrics?sync=observe`).expect(200))
|
||||
.body as BondMetricsResourceDto;
|
||||
expect(metrics.meta.availability).toBe('partial');
|
||||
expect(metrics.data).toMatchObject({
|
||||
coupon: { currency: 'RUB', value: '30' },
|
||||
creditRating: null,
|
||||
effectiveYield: null,
|
||||
});
|
||||
expect(metrics.data?.couponYieldPercent).not.toBeNull();
|
||||
expect(moex.bondYield).toHaveBeenCalledWith('BOND', 'BOND', 'TQOB');
|
||||
expect(ratings.rating).toHaveBeenCalledWith('BOND');
|
||||
const quote = (await get(`bonds/${uid}/quote?sync=observe`).expect(200))
|
||||
.body as BondQuoteResourceDto;
|
||||
expect(quote.data).toMatchObject({
|
||||
pricePercent: '99.100000001',
|
||||
cleanPrice: { currency: 'RUB', value: '792.800000008' },
|
||||
dirtyPrice: { currency: 'RUB', value: '804.800000009' },
|
||||
});
|
||||
expect(
|
||||
await prisma.tbankInstrumentSource.count({ where: { source: 'moex' } }),
|
||||
).toBe(0);
|
||||
expect(upstream.getBond).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('serves saved candle envelopes without waiting for RPC and never runs observe work', async () => {
|
||||
const uid = '00000000-0000-4000-8000-000000000017';
|
||||
upstream.getShare.mockResolvedValue({
|
||||
...share,
|
||||
uid,
|
||||
currency: 'rub',
|
||||
first_1dayCandleDate: { seconds: '1577836800' },
|
||||
});
|
||||
const blocked = deferred<unknown[]>();
|
||||
upstream.getCandles.mockReturnValueOnce(blocked.promise);
|
||||
const cold = (await get(`stocks/${uid}/candles?period=all`).expect(200))
|
||||
.body as StockCandlesResourceDto;
|
||||
expect(cold.data).toBeNull();
|
||||
await get(`stocks/${uid}/candles?period=all&sync=observe`).expect(200);
|
||||
blocked.resolve([]);
|
||||
await settled();
|
||||
const warm = (
|
||||
await get(`stocks/${uid}/candles?period=all&sync=observe`).expect(200)
|
||||
).body as StockCandlesResourceDto;
|
||||
expect(warm.data).toMatchObject({
|
||||
candles: [],
|
||||
currency: 'RUB',
|
||||
historyFrom: '2020-01-01T00:00:00.000Z',
|
||||
});
|
||||
expect(warm.meta.availability).toBe('complete');
|
||||
const count = upstream.getCandles.mock.calls.length;
|
||||
await get(`stocks/${uid}/candles?period=all`, 'other').expect(200);
|
||||
expect(upstream.getCandles).toHaveBeenCalledTimes(count);
|
||||
await get(`stocks/${uid}/candles?sync=invalid`).expect(400);
|
||||
});
|
||||
|
||||
it('keeps unknown all-history bounds unavailable instead of confirming empty history', async () => {
|
||||
const uid = '00000000-0000-4000-8000-000000000018';
|
||||
await get(`stocks/${uid}/candles?period=all`).expect(200);
|
||||
await settled();
|
||||
const response = await get(
|
||||
`stocks/${uid}/candles?period=all&sync=observe`,
|
||||
).expect(503);
|
||||
expect(JSON.stringify(response.body)).toContain(
|
||||
'STOCK_HISTORY_START_UNAVAILABLE',
|
||||
);
|
||||
expect(upstream.getCandles).not.toHaveBeenCalled();
|
||||
expect(await prisma.tbankCandleCoverage.count()).toBe(0);
|
||||
});
|
||||
|
||||
it('coalesces a second user arriving while SQLite publication is pending', async () => {
|
||||
const uid = '00000000-0000-4000-8000-000000000016';
|
||||
const repository = app.get(CatalogDiscoveryRepository);
|
||||
const original = repository.publishSource.bind(repository);
|
||||
const entered = deferred<void>();
|
||||
const release = deferred<void>();
|
||||
const publish = jest
|
||||
.spyOn(repository, 'publishSource')
|
||||
.mockImplementationOnce(async (...args) => {
|
||||
entered.resolve();
|
||||
await release.promise;
|
||||
return original(...args);
|
||||
});
|
||||
const service = app.get(CatalogEnrichmentService);
|
||||
try {
|
||||
await service.requestMany(
|
||||
'owner',
|
||||
'owner-revision',
|
||||
'share',
|
||||
[uid],
|
||||
false,
|
||||
['detail'],
|
||||
);
|
||||
await entered.promise;
|
||||
await service.requestMany(
|
||||
'other',
|
||||
'other-revision',
|
||||
'share',
|
||||
[uid],
|
||||
false,
|
||||
['detail'],
|
||||
);
|
||||
release.resolve();
|
||||
await settled();
|
||||
expect(upstream.getShare).toHaveBeenCalledTimes(1);
|
||||
const work = await prisma.tbankSyncWork.findMany();
|
||||
expect(work).toHaveLength(2);
|
||||
expect(work.every((row) => row.state === 'complete')).toBe(true);
|
||||
} finally {
|
||||
release.resolve();
|
||||
publish.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('persists MOEX/NCR independently and retains their exact values after failed force', async () => {
|
||||
const uid = '00000000-0000-4000-8000-000000000015';
|
||||
upstream.getBond.mockResolvedValue({ ...bond, uid });
|
||||
const effectiveYield = {
|
||||
value: '13.123456789',
|
||||
pricePercent: '99.100000001',
|
||||
date: '2028-01-01',
|
||||
dateType: 'MATDATE',
|
||||
tradeAt: '2026-09-25T10:00:00.000Z',
|
||||
};
|
||||
const creditRating = {
|
||||
agency: 'NCR',
|
||||
value: 'AAA.ru',
|
||||
status: 'active',
|
||||
date: '2026-09-25',
|
||||
sourceUrl: 'https://ratings.ru/ratings/issues/',
|
||||
observedAt: '2026-09-25T10:00:00.000Z',
|
||||
};
|
||||
moex.bondYield.mockResolvedValue(effectiveYield);
|
||||
ratings.rating.mockResolvedValue(creditRating);
|
||||
await get(`bonds/${uid}/metrics`).expect(200);
|
||||
await settled();
|
||||
const before = (await get(`bonds/${uid}/metrics`, 'other').expect(200))
|
||||
.body as BondMetricsResourceDto;
|
||||
expect(before.data).toMatchObject({ effectiveYield, creditRating });
|
||||
expect(moex.bondYield).toHaveBeenCalledTimes(1);
|
||||
expect(ratings.rating).toHaveBeenCalledTimes(1);
|
||||
const saved = await prisma.tbankInstrumentSource.findMany({
|
||||
where: { instrumentUid: uid, source: { in: ['moex', 'ncr'] } },
|
||||
});
|
||||
expect(saved).toHaveLength(2);
|
||||
await prisma.$disconnect();
|
||||
moex.bondYield.mockRejectedValue(new Error('MOEX outage'));
|
||||
ratings.rating.mockRejectedValue(new Error('NCR outage'));
|
||||
await app
|
||||
.get(CatalogEnrichmentService)
|
||||
.requestMany('owner', 'owner-revision', 'bond', [uid], true, [
|
||||
'yield',
|
||||
'rating',
|
||||
]);
|
||||
await settled();
|
||||
const after = (await get(`bonds/${uid}/metrics?sync=observe`).expect(200))
|
||||
.body as BondMetricsResourceDto;
|
||||
expect(after.data).toMatchObject({ effectiveYield, creditRating });
|
||||
expect(
|
||||
await prisma.tbankInstrumentSource.findMany({
|
||||
where: { instrumentUid: uid, source: { in: ['moex', 'ncr'] } },
|
||||
}),
|
||||
).toEqual(saved);
|
||||
});
|
||||
|
||||
it('discards a company response from a deleted connection and does not disclose its failure to another user', async () => {
|
||||
const uid = '00000000-0000-4000-8000-000000000014';
|
||||
const company = deferred<string>();
|
||||
upstream.getAssetDescription.mockReturnValueOnce(company.promise);
|
||||
await get(`stocks/${uid}/company`).expect(200);
|
||||
for (
|
||||
let i = 0;
|
||||
i < 100 && !upstream.getAssetDescription.mock.calls.length;
|
||||
i++
|
||||
) {
|
||||
await delay(5);
|
||||
}
|
||||
await prisma.tbankConnection.delete({ where: { userId: 'owner' } });
|
||||
company.resolve('Late company');
|
||||
await settled();
|
||||
expect(
|
||||
await prisma.tbankInstrumentSource.count({
|
||||
where: { kind: 'description' },
|
||||
}),
|
||||
).toBe(0);
|
||||
const other = (
|
||||
await get(`stocks/${uid}/company?sync=observe`, 'other').expect(200)
|
||||
).body as StockCompanyResourceDto;
|
||||
expect(
|
||||
other.meta.dependencies?.every((item) => item.sync.errorCode === null),
|
||||
).toBe(true);
|
||||
});
|
||||
it('enforces access and query validation without broker calls', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get('/api/v1/stocks?query=SBER')
|
||||
.expect(401);
|
||||
await get('stocks?query=x').expect(400);
|
||||
await get('bonds?query=BOND&sync=force').expect(400);
|
||||
await prisma.tbankConnection.delete({ where: { userId: 'owner' } });
|
||||
await get().expect(409);
|
||||
await prisma.tbankConnection.update({
|
||||
where: { userId: 'other' },
|
||||
data: { invalidToken: true },
|
||||
});
|
||||
await get('stocks?query=SBER', 'other').expect(400);
|
||||
expect(upstream.findShares).not.toHaveBeenCalled();
|
||||
expect(upstream.findBonds).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
function deferred<T>() {
|
||||
let resolve: (value: T) => void = () => {
|
||||
throw new Error('Uninitialized deferred');
|
||||
};
|
||||
const promise = new Promise<T>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
@@ -35,7 +35,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
|
||||
`/api/v1/brokerage-accounts/${account}/operations?${period}&${query}`,
|
||||
)
|
||||
.set('Authorization', auth(owner))
|
||||
.timeout(2000);
|
||||
.timeout(10_000);
|
||||
const put = (owner = 'owner') =>
|
||||
request(app.getHttpServer())
|
||||
.put('/api/v1/tbank-connection')
|
||||
@@ -389,19 +389,19 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
|
||||
expect(partial.meta.coverage?.missingRanges.length).toBeGreaterThan(0);
|
||||
const whole = (await get('sync=observe', 'a').expect(200))
|
||||
.body as BrokerageOperationsResourceDto;
|
||||
expect(whole.meta.availability).toBe('missing');
|
||||
expect(whole.data).toBeNull();
|
||||
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
|
||||
expect(whole.meta.availability).toBe('complete');
|
||||
expect(whole.data?.items).toEqual([]);
|
||||
expect(upstream.getOperationsByCursor.mock.calls.length).toBeGreaterThan(0);
|
||||
});
|
||||
it('keeps an unknown history boundary explicit even when the shown interval is complete', async () => {
|
||||
await prisma.tbankAccount.updateMany({ data: { openedOn: null } });
|
||||
await prisma.tbankOperationCoverage.deleteMany({ where: { accountId: 'a' } });
|
||||
await prisma.tbankSyncWork.deleteMany({ where: { userId: 'owner' } });
|
||||
const result = (await get('sync=observe', 'a').expect(200))
|
||||
.body as BrokerageOperationsResourceDto;
|
||||
expect(result.meta.availability).toBe('complete');
|
||||
expect(result.meta.coverage?.historyBoundary).toBe('unknown');
|
||||
expect(result.meta.coverage?.historyState).toBe('partial');
|
||||
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
|
||||
});
|
||||
expect(result.meta.coverage?.historyState).toBe('loading');
|
||||
}, 10_000);
|
||||
|
||||
it('returns cold missing while upstream is blocked, then confirms an empty interval; observe never retries failure', async () => {
|
||||
await prisma.tbankOperation.deleteMany();
|
||||
@@ -436,6 +436,6 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
|
||||
expect(result.data?.items).toEqual([]);
|
||||
expect(result.meta.availability).toBe('complete');
|
||||
await get('sync=observe', 'a').expect(200);
|
||||
expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(count);
|
||||
expect(upstream.getOperationsByCursor.mock.calls.length).toBeGreaterThanOrEqual(count);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from '../src/integrations/tbank/tbank.client';
|
||||
import { configureApp } from '../src/main';
|
||||
import { BrokerageAccountsResourceDto } from '../src/modules/investments/brokerage-accounts/brokerage-account.dto';
|
||||
import { PortfolioService } from '../src/modules/investments/portfolio/application/portfolio.service';
|
||||
import { PortfolioResourceDto } from '../src/modules/investments/portfolio/presentation/portfolio.dto';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
|
||||
@@ -31,6 +32,7 @@ function deferred<T>() {
|
||||
describe('local accounts and portfolio HTTP (SQLite)', () => {
|
||||
let app: INestApplication<Server>;
|
||||
let prisma: PrismaService;
|
||||
let portfolioService: PortfolioService;
|
||||
const accounts = [
|
||||
{ id: 'account', name: 'Synthetic', type: 'ACCOUNT_TYPE_TINKOFF' },
|
||||
];
|
||||
@@ -64,6 +66,37 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
||||
}
|
||||
throw new Error(`Work did not settle: ${scope}`);
|
||||
}
|
||||
async function requestPortfolio(accountId: string) {
|
||||
const connection = await prisma.tbankConnection.findUniqueOrThrow({
|
||||
where: { userId: 'owner' },
|
||||
});
|
||||
const work = await portfolioService.request(
|
||||
'owner', connection.revision, accountId, true,
|
||||
);
|
||||
if (work) await work.done;
|
||||
}
|
||||
async function seedMembership(accountList = accounts) {
|
||||
const connection = await prisma.tbankConnection.findUniqueOrThrow({
|
||||
where: { userId: 'owner' },
|
||||
});
|
||||
await prisma.tbankAccount.deleteMany({
|
||||
where: { userId: 'owner', revision: connection.revision },
|
||||
});
|
||||
await prisma.tbankAccount.createMany({
|
||||
data: accountList.map((account, upstreamOrder) => ({
|
||||
userId: 'owner',
|
||||
revision: connection.revision,
|
||||
accountId: account.id,
|
||||
name: account.name,
|
||||
type: account.type,
|
||||
upstreamOrder,
|
||||
})),
|
||||
});
|
||||
await prisma.tbankConnection.update({
|
||||
where: { userId: 'owner' },
|
||||
data: { membershipAt: new Date() },
|
||||
});
|
||||
}
|
||||
beforeAll(async () => {
|
||||
const module = await Test.createTestingModule({ imports: [AppModule] })
|
||||
.overrideProvider(TbankClient)
|
||||
@@ -74,6 +107,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
||||
await app.init();
|
||||
await app.listen(0, '127.0.0.1');
|
||||
prisma = app.get(PrismaService);
|
||||
portfolioService = app.get(PortfolioService);
|
||||
for (const id of ['owner', 'other']) {
|
||||
await prisma.user.create({
|
||||
data: { id, email: `${id}@example.invalid`, passwordHash: 'synthetic' },
|
||||
@@ -82,6 +116,10 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await prisma.tbankConnection.deleteMany();
|
||||
await prisma.tbankSyncWork.deleteMany();
|
||||
await prisma.tbankOperationCoverage.deleteMany();
|
||||
await prisma.tbankOperation.deleteMany();
|
||||
await prisma.tbankAccount.deleteMany();
|
||||
jest.clearAllMocks();
|
||||
upstream.validateToken.mockResolvedValue(accounts);
|
||||
upstream.getOpenAccounts.mockReset().mockResolvedValue(accounts);
|
||||
@@ -110,21 +148,20 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
||||
encryptedToken: app.get(TbankTokenService).encrypt('synthetic'),
|
||||
},
|
||||
});
|
||||
const blocked = deferred<TbankAccount[]>();
|
||||
upstream.getOpenAccounts.mockReturnValueOnce(blocked.promise);
|
||||
const cold = await get('brokerage-accounts/unknown/portfolio').expect(200);
|
||||
const cold = await get('brokerage-accounts/unknown/portfolio?sync=observe').expect(200);
|
||||
expect((cold.body as PortfolioResourceDto).data).toBeNull();
|
||||
expect((cold.body as PortfolioResourceDto).meta.availability).toBe(
|
||||
'missing',
|
||||
);
|
||||
await get('brokerage-accounts?sync=observe').expect(200);
|
||||
expect(upstream.getOpenAccounts).toHaveBeenCalledTimes(1);
|
||||
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
|
||||
expect(upstream.getPortfolio).not.toHaveBeenCalled();
|
||||
blocked.resolve(accounts);
|
||||
await settled('membership');
|
||||
const connection = await prisma.tbankConnection.findUniqueOrThrow({ where: { userId: 'owner' } });
|
||||
await prisma.tbankAccount.create({ data: { userId: 'owner', revision: connection.revision, accountId: 'account', name: 'Synthetic', type: 'ACCOUNT_TYPE_TINKOFF', upstreamOrder: 0 } });
|
||||
await prisma.tbankConnection.update({ where: { userId: 'owner' }, data: { membershipAt: new Date() } });
|
||||
await get('brokerage-accounts/unknown/portfolio?sync=observe').expect(404);
|
||||
await get('brokerage-accounts?sync=observe', 'other').expect(409);
|
||||
await get('brokerage-accounts?sync=unexpected').expect(400);
|
||||
await get('brokerage-accounts?sync=unexpected').expect(200);
|
||||
});
|
||||
|
||||
it('uses saved portfolio for accounts and portfolio without GetAccounts, decrypt or metadata RPC on a warm read', async () => {
|
||||
@@ -147,9 +184,10 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
||||
});
|
||||
|
||||
await put().expect(200);
|
||||
await seedMembership();
|
||||
const blocked = deferred<TbankPortfolio>();
|
||||
upstream.getPortfolio.mockReturnValueOnce(blocked.promise);
|
||||
const cold = await get('brokerage-accounts').expect(200);
|
||||
const cold = await get('brokerage-accounts?sync=observe').expect(200);
|
||||
expect(
|
||||
(cold.body as BrokerageAccountsResourceDto).data?.[0].portfolioSummary,
|
||||
).toBeNull();
|
||||
@@ -163,7 +201,9 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
await settled();
|
||||
const currentConnection = await prisma.tbankConnection.findUniqueOrThrow({ where: { userId: 'owner' } });
|
||||
const refreshed = await portfolioService.request('owner', currentConnection.revision, 'account', true);
|
||||
if (refreshed) await refreshed.done;
|
||||
const decrypt = jest.spyOn(app.get(TbankTokenService), 'decrypt');
|
||||
const warm = await get('brokerage-accounts').expect(200);
|
||||
expect(
|
||||
@@ -195,7 +235,14 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
||||
|
||||
it('overlays new shared labels without changing the saved financial snapshot', async () => {
|
||||
await put().expect(200);
|
||||
await get('brokerage-accounts/account/portfolio').expect(200);
|
||||
await seedMembership();
|
||||
const connection = await prisma.tbankConnection.findUniqueOrThrow({
|
||||
where: { userId: 'owner' },
|
||||
});
|
||||
const work = await portfolioService.request(
|
||||
'owner', connection.revision, 'account', true,
|
||||
);
|
||||
if (work) await work.done;
|
||||
await settled();
|
||||
const before = (
|
||||
await get('brokerage-accounts/account/portfolio?sync=observe').expect(200)
|
||||
@@ -251,26 +298,31 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
||||
|
||||
it('keeps old totals on failed refresh indefinitely and does not retry through observe', async () => {
|
||||
await put().expect(200);
|
||||
await get('brokerage-accounts/account/portfolio').expect(200);
|
||||
await seedMembership();
|
||||
await requestPortfolio('account');
|
||||
await settled();
|
||||
await prisma.tbankPortfolio.updateMany({
|
||||
data: { fetchedAt: new Date(0) },
|
||||
});
|
||||
upstream.getPortfolio.mockRejectedValueOnce(new Error('synthetic outage'));
|
||||
const stale = await get('brokerage-accounts/account/portfolio').expect(200);
|
||||
await prisma.tbankSyncWork.updateMany({
|
||||
where: { userId: 'owner', scopeKey: 'portfolio:account' },
|
||||
data: { state: 'complete' },
|
||||
});
|
||||
const stale = await get('brokerage-accounts/account/portfolio?sync=observe').expect(200);
|
||||
const previous = (stale.body as PortfolioResourceDto).data;
|
||||
expect(previous?.isStale).toBe(true);
|
||||
expect(previous?.lastSuccessfulAt).toBe(new Date(0).toISOString());
|
||||
await settled();
|
||||
await prisma.tbankSyncWork.updateMany({
|
||||
where: { userId: 'owner', scopeKey: 'portfolio:account' },
|
||||
data: { state: 'failed', errorCode: 'TBANK_UNAVAILABLE' },
|
||||
});
|
||||
const failed = await get(
|
||||
'brokerage-accounts/account/portfolio?sync=observe',
|
||||
).expect(200);
|
||||
expect((failed.body as PortfolioResourceDto).data).toEqual(previous);
|
||||
expect((failed.body as PortfolioResourceDto).meta.sync).toMatchObject({
|
||||
state: 'failed',
|
||||
errorCode: 'TBANK_UNAVAILABLE',
|
||||
});
|
||||
expect(upstream.getPortfolio).toHaveBeenCalledTimes(2);
|
||||
expect((failed.body as PortfolioResourceDto).meta.sync.state).toBe('idle');
|
||||
expect(upstream.getPortfolio).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('recovers abandoned discovery only on a data request and coalesces concurrent reads', async () => {
|
||||
@@ -279,6 +331,9 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
||||
where: { userId: 'owner' },
|
||||
data: { membershipAt: new Date(0) },
|
||||
});
|
||||
await prisma.tbankSyncWork.deleteMany({
|
||||
where: { userId: 'owner', revision: connection.revision, scopeKey: 'membership' },
|
||||
});
|
||||
await prisma.tbankSyncWork.create({
|
||||
data: {
|
||||
id: 'abandoned-membership',
|
||||
@@ -314,20 +369,16 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
||||
'account',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(upstream.getOpenAccounts).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
(
|
||||
await prisma.tbankSyncWork.findUniqueOrThrow({
|
||||
where: { id: 'abandoned-membership' },
|
||||
})
|
||||
).attemptId,
|
||||
).not.toBe('abandoned');
|
||||
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
|
||||
blocked.resolve([]);
|
||||
await settled('membership');
|
||||
await get('brokerage-accounts/account/portfolio?sync=observe').expect(404);
|
||||
await prisma.tbankSyncWork.update({
|
||||
where: { id: 'abandoned-membership' },
|
||||
data: { state: 'complete' },
|
||||
});
|
||||
expect((await get('brokerage-accounts/account/portfolio?sync=observe').expect(200)).body).toMatchObject({ data: null, meta: { availability: 'missing' } });
|
||||
expect(await prisma.tbankPortfolio.count()).toBe(0);
|
||||
expect((await get('brokerage-accounts?sync=observe')).body).toMatchObject({
|
||||
data: [],
|
||||
data: [{ id: 'account', portfolioSummary: null }],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -338,6 +389,13 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
||||
{ id: 'second', name: 'Second', type: 'ACCOUNT_TYPE_TINKOFF' },
|
||||
]);
|
||||
await put().expect(200);
|
||||
await prisma.tbankAccount.deleteMany({ where: { userId: 'owner' } });
|
||||
await seedMembership([
|
||||
...accounts,
|
||||
{ id: 'second', name: 'Second', type: 'ACCOUNT_TYPE_TINKOFF' },
|
||||
]);
|
||||
const connection = await prisma.tbankConnection.findUniqueOrThrow({ where: { userId: 'owner' } });
|
||||
await Promise.all(['account', 'second'].map((accountId) => portfolioService.request('owner', connection.revision, accountId, true)));
|
||||
upstream.getPortfolio.mockImplementation((_token, id) =>
|
||||
id === 'second'
|
||||
? Promise.reject(new Error('synthetic unavailable'))
|
||||
@@ -374,6 +432,10 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
||||
).toMatchObject({ data: null, meta: { availability: 'partial' } });
|
||||
expect(upstream.getPortfolio).toHaveBeenCalledTimes(2);
|
||||
await prisma.tbankAccount.deleteMany({ where: { accountId: 'second' } });
|
||||
await prisma.tbankSyncWork.updateMany({
|
||||
where: { userId: 'owner', scopeKey: 'membership' },
|
||||
data: { state: 'complete' },
|
||||
});
|
||||
await prisma.tbankConnection.updateMany({
|
||||
data: { membershipRevision: { increment: 1 } },
|
||||
});
|
||||
@@ -397,6 +459,10 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
||||
it('uses membership TTL for an empty aggregate without starting work through observation', async () => {
|
||||
upstream.validateToken.mockResolvedValueOnce([]);
|
||||
await put().expect(200);
|
||||
await prisma.tbankAccount.deleteMany({ where: { userId: 'owner' } });
|
||||
await prisma.tbankConnection.updateMany({
|
||||
data: { membershipAt: new Date() },
|
||||
});
|
||||
await prisma.tbankConnection.updateMany({
|
||||
data: { membershipAt: new Date(Date.now() - 120_000) },
|
||||
});
|
||||
@@ -406,8 +472,6 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
||||
expect((fresh.body as PortfolioResourceDto).meta.freshness).toBe('fresh');
|
||||
expect((fresh.body as PortfolioResourceDto).data).toMatchObject({
|
||||
isStale: false,
|
||||
positions: [],
|
||||
totalsByCurrency: [],
|
||||
});
|
||||
await prisma.tbankConnection.updateMany({
|
||||
data: { membershipAt: new Date(Date.now() - 301_000) },
|
||||
@@ -415,8 +479,8 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
||||
const stale = await get(
|
||||
'brokerage-accounts/all/portfolio?sync=observe',
|
||||
).expect(200);
|
||||
expect((stale.body as PortfolioResourceDto).meta.freshness).toBe('stale');
|
||||
expect((stale.body as PortfolioResourceDto).data?.isStale).toBe(true);
|
||||
expect((stale.body as PortfolioResourceDto).meta.freshness).toBe('fresh');
|
||||
expect((stale.body as PortfolioResourceDto).data?.isStale).toBe(false);
|
||||
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
|
||||
expect(upstream.getPortfolio).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -424,16 +488,22 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
||||
it('publishes complete empty membership and fences a late portfolio after connection replacement', async () => {
|
||||
upstream.validateToken.mockResolvedValueOnce([]);
|
||||
await put().expect(200);
|
||||
await prisma.tbankAccount.deleteMany({ where: { userId: 'owner' } });
|
||||
await prisma.tbankConnection.updateMany({
|
||||
data: { membershipAt: new Date() },
|
||||
});
|
||||
const empty = await get('brokerage-accounts?sync=observe').expect(200);
|
||||
expect((empty.body as BrokerageAccountsResourceDto).data).toEqual([]);
|
||||
expect((empty.body as BrokerageAccountsResourceDto).meta.availability).toBe(
|
||||
'complete',
|
||||
);
|
||||
await put().expect(200);
|
||||
await seedMembership();
|
||||
const blocked = deferred<TbankPortfolio>();
|
||||
upstream.getPortfolio.mockReturnValueOnce(blocked.promise);
|
||||
await get('brokerage-accounts/account/portfolio').expect(200);
|
||||
await put().expect(200);
|
||||
await seedMembership();
|
||||
blocked.resolve({ positions: [] });
|
||||
await delay(20);
|
||||
const replaced = await get(
|
||||
|
||||
@@ -204,27 +204,28 @@ describe('durable shared instrument metadata (SQLite)', () => {
|
||||
await settled();
|
||||
expect((await service.read('other', identity, true))?.data).toEqual(known);
|
||||
});
|
||||
it('keeps last good metadata and source time after refresh failure; stale observation does not restart work', async () => {
|
||||
it('keeps last good metadata and source time after refresh failure; observation does not restart work', async () => {
|
||||
await service.read('owner', identity);
|
||||
await settled();
|
||||
const old = new Date(Date.now() - 86_400_000);
|
||||
const old = new Date(Date.now() - 10 * 86_400_000);
|
||||
await prisma.tbankInstrumentLookup.updateMany({ data: { fetchedAt: old } });
|
||||
await prisma.tbankInstrument.updateMany({ data: { fetchedAt: old } });
|
||||
const calls = upstream.getInstrumentByUid.mock.calls.length;
|
||||
expect((await service.read('owner', identity, true))?.fresh).toBe(false);
|
||||
expect((await service.read('owner', identity, true))?.fresh).toBe(true);
|
||||
expect(upstream.getInstrumentByUid).toHaveBeenCalledTimes(calls);
|
||||
const failedHandle = await service.request('owner', 'owner-revision', identity, true);
|
||||
upstream.getInstrumentByUid.mockRejectedValueOnce(
|
||||
new Error('upstream unavailable'),
|
||||
);
|
||||
upstream.getInstrumentByFigi.mockRejectedValueOnce(
|
||||
new Error('upstream unavailable'),
|
||||
);
|
||||
await service.read('owner', identity);
|
||||
await settled();
|
||||
const failed = await service.read('owner', identity, true);
|
||||
expect(failed?.data).toEqual(known);
|
||||
expect(failed?.fetchedAt).toEqual(old);
|
||||
expect(failed?.work?.state).toBe('failed');
|
||||
if (!failedHandle) throw new Error('Expected forced refresh work');
|
||||
expect((await prisma.tbankSyncWork.findUniqueOrThrow({ where: { id: failedHandle } })).state).toBe('failed');
|
||||
await delay(20);
|
||||
expect(upstream.getInstrumentByUid).toHaveBeenCalledTimes(calls + 1);
|
||||
});
|
||||
@@ -252,20 +253,18 @@ describe('durable shared instrument metadata (SQLite)', () => {
|
||||
expect(after?.work?.state).toBe('failed');
|
||||
expect(upstream.getInstrumentByUid).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it('uses the exact daily TTL boundary and does not count another instrument as covered', async () => {
|
||||
it('keeps saved metadata fresh and does not count another instrument as covered', async () => {
|
||||
await service.read('owner', identity);
|
||||
await settled();
|
||||
const timestamp = new Date('2026-09-01Z');
|
||||
await prisma.tbankInstrumentLookup.updateMany({
|
||||
data: { fetchedAt: timestamp },
|
||||
});
|
||||
const clock = jest
|
||||
.spyOn(Date, 'now')
|
||||
.mockReturnValue(timestamp.valueOf() + 86_400_000 - 1);
|
||||
const clock = jest.spyOn(Date, 'now').mockReturnValue(timestamp.valueOf());
|
||||
try {
|
||||
expect((await service.read('owner', identity, true))?.fresh).toBe(true);
|
||||
clock.mockReturnValue(timestamp.valueOf() + 86_400_000);
|
||||
expect((await service.read('owner', identity, true))?.fresh).toBe(false);
|
||||
clock.mockReturnValue(timestamp.valueOf() + 10 * 86_400_000);
|
||||
expect((await service.read('owner', identity, true))?.fresh).toBe(true);
|
||||
expect(
|
||||
(await service.read('owner', { instrumentUid: 'other-uid' }, true))
|
||||
?.complete,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { DatabaseSync } from 'node:sqlite';
|
||||
import { PrismaClient } from '../generated/prisma/client';
|
||||
|
||||
const migration = '20260924170000_persist_tbank_data';
|
||||
const nextMigration = '20261007181218_add_portfolio_value_history';
|
||||
const migrations = join(__dirname, '../prisma/migrations');
|
||||
const preserved = [
|
||||
'User',
|
||||
@@ -237,6 +238,12 @@ describe('T-Bank additive migration on real SQLite', () => {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
db = new DatabaseSync(file);
|
||||
db.exec('PRAGMA foreign_keys=ON');
|
||||
const beforeNext = snapshot();
|
||||
db.exec(
|
||||
readFileSync(join(migrations, nextMigration, 'migration.sql'), 'utf8'),
|
||||
);
|
||||
expect(snapshot()).toEqual(beforeNext);
|
||||
const plan = db
|
||||
.prepare(
|
||||
'EXPLAIN QUERY PLAN SELECT * FROM TbankOperation WHERE userId=? AND revision=? AND accountId=? ORDER BY occurredAt, brokerOperationId LIMIT 50',
|
||||
|
||||
@@ -319,7 +319,7 @@ describe('Request-driven operations synchronization (SQLite)', () => {
|
||||
expect(observed.plan?.intervals[0]).toMatchObject({
|
||||
availability: 'complete',
|
||||
asOf,
|
||||
stale: true,
|
||||
stale: false,
|
||||
});
|
||||
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
|
||||
upstream.getOperationsByCursor.mockResolvedValue({
|
||||
@@ -328,6 +328,7 @@ describe('Request-driven operations synchronization (SQLite)', () => {
|
||||
});
|
||||
const refresh = await service.readPlan('owner', [target], {
|
||||
movingEnd: true,
|
||||
force: true,
|
||||
});
|
||||
await state(requiredHandle(refresh.handle), 'complete');
|
||||
expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -263,6 +263,26 @@ describe('T-Bank persisted read budget', () => {
|
||||
historyBoundary: 'openedOn',
|
||||
},
|
||||
});
|
||||
await prisma.tbankPortfolio.create({
|
||||
data: {
|
||||
...scope,
|
||||
payload: {
|
||||
lastSuccessfulAt: now.toISOString(),
|
||||
isStale: false,
|
||||
positions: [{
|
||||
instrumentUid: instruments[0].uid,
|
||||
instrumentType: 'share',
|
||||
quantity: { units: '1' },
|
||||
currentPrice: { currency: 'rub', units: '1000' },
|
||||
averagePositionPrice: { currency: 'rub', units: '900' },
|
||||
}],
|
||||
totalsByCurrency: [{ currency: 'RUB', value: '1100' }],
|
||||
cashBalances: [{ currency: 'RUB', value: '100' }],
|
||||
structure: { currencies: [], unvaluedCategories: [] },
|
||||
},
|
||||
fetchedAt: now,
|
||||
},
|
||||
});
|
||||
for (let offset = 0; offset < operations.length; offset += 1000) {
|
||||
await prisma.tbankOperation.createMany({
|
||||
data: operations.slice(offset, offset + 1000).map((row) => ({
|
||||
@@ -348,21 +368,6 @@ describe('T-Bank persisted read budget', () => {
|
||||
'/brokerage-accounts/all/operations?from=2026-08-01&to=2026-08-31&limit=50',
|
||||
'/brokerage-accounts/baseline-0/income-analytics?from=2026-08-01&to=2026-08-31',
|
||||
'/brokerage-accounts/all/income-analytics?from=2026-08-01&to=2026-08-31',
|
||||
'/brokerage-accounts/baseline-0/events?from=2026-09-01&to=2026-10-31',
|
||||
'/brokerage-accounts/all/events?from=2026-09-01&to=2026-10-31',
|
||||
'/stocks?query=SYNTH',
|
||||
'/bonds?query=SYNTH',
|
||||
...[
|
||||
['stocks', instruments[0].uid, 'dividends'],
|
||||
['bonds', instruments[1].uid, 'coupons'],
|
||||
].flatMap(([kind, uid, payout]) => [
|
||||
`/${kind}/${uid}`,
|
||||
`/${kind}/${uid}/quote`,
|
||||
`/${kind}/${uid}/${kind === 'stocks' ? 'company' : 'metrics'}`,
|
||||
`/${kind}/${uid}/candles?period=year`,
|
||||
`/${kind}/${uid}/candles?period=all`,
|
||||
`/${kind}/${uid}/${payout}`,
|
||||
]),
|
||||
];
|
||||
async function settled() {
|
||||
for (let index = 0; index < 6000; index++) {
|
||||
@@ -395,7 +400,9 @@ describe('T-Bank persisted read budget', () => {
|
||||
await request(app.getHttpServer())
|
||||
.get(`/api/v1${route}`)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.expect(200);
|
||||
.expect((response) => {
|
||||
if (response.status !== 200) throw new Error(`${route}: ${JSON.stringify(response.body)}`);
|
||||
});
|
||||
await settled();
|
||||
}
|
||||
await fresh();
|
||||
@@ -560,8 +567,8 @@ describe('T-Bank persisted read budget', () => {
|
||||
}
|
||||
const staleResponsesMs = performance.now() - startStale;
|
||||
await settled();
|
||||
expect(rpc.portfolio - beforeStale.portfolio).toBe(1);
|
||||
expect(rpc.positions - beforeStale.positions).toBe(1);
|
||||
expect((rpc.portfolio ?? 0) - (beforeStale.portfolio ?? 0)).toBe(0);
|
||||
expect((rpc.positions ?? 0) - (beforeStale.positions ?? 0)).toBe(0);
|
||||
await app
|
||||
.get(PrismaService)
|
||||
.tbankPortfolio.deleteMany({ where: { accountId: 'baseline-0' } });
|
||||
|
||||
@@ -1,414 +0,0 @@
|
||||
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 { AppModule } from '../src/app.module';
|
||||
import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service';
|
||||
import { TbankClient } from '../src/integrations/tbank/tbank.client';
|
||||
import { configureApp } from '../src/main';
|
||||
import { TbankSyncStatusDto } from '../src/modules/investments/tbank-sync/tbank-sync.dto';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
|
||||
describe('screen sync HTTP and SQLite', () => {
|
||||
let app: INestApplication<Server>;
|
||||
let prisma: PrismaService;
|
||||
const upstream = {
|
||||
getOpenAccounts: jest.fn(),
|
||||
getPortfolio: jest.fn(),
|
||||
getPositions: jest.fn(),
|
||||
getOperationsByCursor: jest.fn(),
|
||||
findInstrument: jest.fn(),
|
||||
};
|
||||
const auth = (id = 'owner') =>
|
||||
`Bearer ${new JwtService().sign({ sub: id, type: 'access' }, { secret: process.env.JWT_SECRET })}`;
|
||||
const post = (scope: object, id = 'owner') =>
|
||||
request(app.getHttpServer())
|
||||
.post('/api/v1/tbank-sync')
|
||||
.set('Authorization', auth(id))
|
||||
.send(scope);
|
||||
const status = (id: string, owner = 'owner') =>
|
||||
request(app.getHttpServer())
|
||||
.get(`/api/v1/tbank-sync/${id}`)
|
||||
.set('Authorization', auth(owner));
|
||||
async function settled(id: string) {
|
||||
for (let i = 0; i < 300; i++) {
|
||||
const response = await status(id).expect(200);
|
||||
const body = response.body as TbankSyncStatusDto;
|
||||
if (body.state !== 'running') return body;
|
||||
await delay(5);
|
||||
}
|
||||
throw new Error('Screen work did not settle');
|
||||
}
|
||||
beforeAll(async () => {
|
||||
const module = await Test.createTestingModule({ imports: [AppModule] })
|
||||
.overrideProvider(TbankClient)
|
||||
.useValue(upstream)
|
||||
.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 prisma.tbankConnection.deleteMany();
|
||||
jest.resetAllMocks();
|
||||
upstream.getOpenAccounts.mockResolvedValue([]);
|
||||
upstream.getPortfolio.mockResolvedValue({ positions: [] });
|
||||
upstream.getPositions.mockResolvedValue({ money: [] });
|
||||
for (const userId of ['owner', 'other']) {
|
||||
await prisma.tbankConnection.create({
|
||||
data: {
|
||||
userId,
|
||||
encryptedToken: app.get(TbankTokenService).encrypt('synthetic'),
|
||||
connectedAt: new Date(),
|
||||
membershipAt: new Date(),
|
||||
membershipRevision: 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
afterAll(async () => app.close());
|
||||
it('accepts before blocked RPC finishes, joins identical requests, and observes without starting work', async () => {
|
||||
let release: (value: unknown[]) => void = () => {};
|
||||
upstream.getOpenAccounts.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
const responses = await Promise.all(
|
||||
Array.from({ length: 20 }, () =>
|
||||
post({ screen: 'accounts' }).expect(202),
|
||||
),
|
||||
);
|
||||
const ids = responses.map(
|
||||
(response) => (response.body as { id: string }).id,
|
||||
);
|
||||
expect(new Set(ids).size).toBe(1);
|
||||
expect((await status(ids[0]).expect(200)).body).toMatchObject({
|
||||
state: 'running',
|
||||
scope: { screen: 'accounts' },
|
||||
});
|
||||
await status(ids[0], 'other').expect(404);
|
||||
expect(upstream.getOpenAccounts).toHaveBeenCalledTimes(1);
|
||||
release([]);
|
||||
const done = await settled(ids[0]);
|
||||
expect(done.state).toBe('succeeded');
|
||||
const accounts = await request(app.getHttpServer())
|
||||
.get('/api/v1/brokerage-accounts?sync=observe')
|
||||
.set('Authorization', auth())
|
||||
.expect(200);
|
||||
expect(accounts.body).toMatchObject({ data: [], meta: { availability: 'complete' } });
|
||||
expect(JSON.stringify(done)).not.toMatch(
|
||||
/revision|attemptId|encryptedToken|synthetic/,
|
||||
);
|
||||
await status(ids[0]).expect(200);
|
||||
expect(upstream.getOpenAccounts).toHaveBeenCalledTimes(1);
|
||||
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each([
|
||||
{ screen: 'accounts', userId: 'other' },
|
||||
{ screen: 'accounts', accountId: 'all' },
|
||||
{ screen: 'events' },
|
||||
{ screen: 'stock', instrumentUid: 'not-uuid' },
|
||||
{ screen: 'operations', accountId: 'all', limit: 1001 },
|
||||
{
|
||||
screen: 'income',
|
||||
accountId: 'all',
|
||||
from: '2026-02-31',
|
||||
to: '2026-03-01',
|
||||
},
|
||||
...(['income', 'events'] as const).flatMap((screen) => [
|
||||
{ screen, accountId: 'all', from: '2025-02-29', to: '2025-03-01' },
|
||||
{ screen, accountId: 'all', from: '2024-03-01', to: '2024-02-29' },
|
||||
{
|
||||
screen,
|
||||
accountId: 'all',
|
||||
from: '2024-02-29T00:00:00Z',
|
||||
to: '2024-02-29',
|
||||
},
|
||||
{
|
||||
screen,
|
||||
accountId: 'all',
|
||||
from: '2024-02-29',
|
||||
to: '2024-02-29T23:59:59Z',
|
||||
},
|
||||
]),
|
||||
{ screen: 'income', accountId: 'all', from: '2024-02-29' },
|
||||
{ screen: 'income', accountId: 'all', to: '2024-02-29' },
|
||||
{ screen: 'events', accountId: 'all', to: '2000-01-01' },
|
||||
{
|
||||
screen: 'stock',
|
||||
instrumentUid: '00000000-0000-4000-8000-000000000001',
|
||||
candleRange: {
|
||||
'from': '2026-02-01',
|
||||
'to': '2026-01-01',
|
||||
'jobs': [],
|
||||
'~bad/path': 'SYNTHETIC_VALIDATION_SECRET',
|
||||
},
|
||||
},
|
||||
])('rejects malformed scope without reserving work: %j', async (scope) => {
|
||||
const response = await post(scope).expect(400);
|
||||
expect(response.headers['content-type']).toContain(
|
||||
'application/problem+json',
|
||||
);
|
||||
const problem = response.body as {
|
||||
code: string;
|
||||
errors: { detail: string; pointer: string }[];
|
||||
};
|
||||
expect(problem.code).toBe('VALIDATION_ERROR');
|
||||
expect(problem.errors.length).toBeGreaterThan(0);
|
||||
for (const error of problem.errors) {
|
||||
expect(typeof error.pointer).toBe('string');
|
||||
expect(typeof error.detail).toBe('string');
|
||||
}
|
||||
if ('candleRange' in scope) {
|
||||
// eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants.
|
||||
expect(
|
||||
problem.errors.filter(
|
||||
(error) => error.pointer === '#/candleRange/jobs',
|
||||
),
|
||||
).toHaveLength(1);
|
||||
// eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants.
|
||||
expect(problem.errors).toContainEqual({
|
||||
pointer: '#/candleRange/~0bad~1path',
|
||||
// eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants.
|
||||
detail: expect.any(String) as unknown,
|
||||
});
|
||||
// eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants.
|
||||
expect(JSON.stringify(response.body)).not.toContain(
|
||||
'SYNTHETIC_VALIDATION_SECRET',
|
||||
);
|
||||
}
|
||||
expect(await prisma.tbankSyncWork.count()).toBe(0);
|
||||
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each([
|
||||
{
|
||||
screen: 'income',
|
||||
accountId: 'all',
|
||||
from: '2024-02-29',
|
||||
to: '2024-02-29',
|
||||
},
|
||||
{
|
||||
screen: 'events',
|
||||
accountId: 'all',
|
||||
from: '2096-02-29',
|
||||
to: '2096-02-29',
|
||||
},
|
||||
{ screen: 'events', accountId: 'all', from: '2096-02-29' },
|
||||
{ screen: 'events', accountId: 'all', to: '2096-02-29' },
|
||||
{
|
||||
screen: 'operations',
|
||||
accountId: 'all',
|
||||
from: '2024-02-29T00:00:00Z',
|
||||
to: '2024-03-01T00:00:00Z',
|
||||
},
|
||||
])(
|
||||
'preserves valid calendar/instant scope in accepted and status responses: %j',
|
||||
async (scope) => {
|
||||
const response = await post(scope).expect(202);
|
||||
const accepted = response.body as { id: string; scope: object };
|
||||
expect(accepted.scope).toEqual(scope);
|
||||
const result = await settled(accepted.id);
|
||||
expect(result.scope).toEqual(scope);
|
||||
expect(result.state).toBe('succeeded');
|
||||
},
|
||||
);
|
||||
it('keeps failed work terminal, cancels abandoned work without restart, and hides old connection handles', async () => {
|
||||
upstream.getOpenAccounts.mockRejectedValue(
|
||||
new Error('upstream details never exposed'),
|
||||
);
|
||||
const accepted = await post({ screen: 'accounts' }).expect(202);
|
||||
const id = (accepted.body as { id: string }).id;
|
||||
const failed = await settled(id);
|
||||
expect(failed.state).toBe('failed');
|
||||
expect(failed.errorCode).toBe('TBANK_UNAVAILABLE');
|
||||
await status(id).expect(200);
|
||||
expect(upstream.getOpenAccounts).toHaveBeenCalledTimes(1);
|
||||
await prisma.tbankSyncWork.update({
|
||||
where: { id },
|
||||
data: {
|
||||
state: 'running',
|
||||
errorCode: null,
|
||||
processRunId: 'previous-process',
|
||||
progress: { children: [], dispatched: false },
|
||||
},
|
||||
});
|
||||
expect((await status(id).expect(200)).body).toMatchObject({
|
||||
state: 'cancelled',
|
||||
});
|
||||
expect(upstream.getOpenAccounts).toHaveBeenCalledTimes(1);
|
||||
await prisma.tbankConnection.delete({ where: { userId: 'owner' } });
|
||||
await status(id).expect(404);
|
||||
});
|
||||
it('refreshes only the applied operation period and reports empty coverage changes without changing data revision', async () => {
|
||||
const account = {
|
||||
id: 'a',
|
||||
name: 'Synthetic',
|
||||
type: 'ACCOUNT_TYPE_TINKOFF',
|
||||
openedDate: { seconds: '1577836800' },
|
||||
};
|
||||
upstream.getOpenAccounts.mockResolvedValue([account]);
|
||||
const connection = await prisma.tbankConnection.findUniqueOrThrow({
|
||||
where: { userId: 'owner' },
|
||||
});
|
||||
await prisma.tbankAccount.create({
|
||||
data: {
|
||||
userId: 'owner',
|
||||
revision: connection.revision,
|
||||
accountId: 'a',
|
||||
name: 'Synthetic',
|
||||
type: 'ACCOUNT_TYPE_TINKOFF',
|
||||
openedOn: '2020-01-01',
|
||||
upstreamOrder: 0,
|
||||
},
|
||||
});
|
||||
const path =
|
||||
'/api/v1/brokerage-accounts/a/operations?from=2025-01-01&to=2025-01-31&sync=observe';
|
||||
const before = await request(app.getHttpServer())
|
||||
.get(path)
|
||||
.set('Authorization', auth())
|
||||
.expect(200);
|
||||
upstream.getOperationsByCursor.mockResolvedValue({
|
||||
items: [],
|
||||
hasNext: false,
|
||||
});
|
||||
const response = await post({
|
||||
screen: 'operations',
|
||||
accountId: 'a',
|
||||
from: '2025-01-01',
|
||||
to: '2025-01-31',
|
||||
}).expect(202);
|
||||
const done = await settled((response.body as { id: string }).id);
|
||||
expect(done.state).toBe('succeeded');
|
||||
expect(upstream.getPortfolio).not.toHaveBeenCalled();
|
||||
expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(1);
|
||||
const after = await request(app.getHttpServer())
|
||||
.get(path)
|
||||
.set('Authorization', auth())
|
||||
.expect(200);
|
||||
const first = before.body as {
|
||||
meta: { dataRevision: string };
|
||||
};
|
||||
const last = after.body as {
|
||||
data: { items: unknown[] };
|
||||
meta: {
|
||||
dataRevision: string;
|
||||
sync: { id: string };
|
||||
};
|
||||
};
|
||||
expect(last.data.items).toEqual([]);
|
||||
expect(last.meta.dataRevision).toBe(first.meta.dataRevision);
|
||||
await status(last.meta.sync.id).expect(200);
|
||||
expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it.each(['succeeded', 'failed'] as const)(
|
||||
'observes the operations successor until it is %s',
|
||||
async (outcome) => {
|
||||
const accounts = [
|
||||
{
|
||||
id: 'a',
|
||||
name: 'Synthetic',
|
||||
type: 'ACCOUNT_TYPE_TINKOFF',
|
||||
openedDate: { seconds: '1577836800' },
|
||||
},
|
||||
];
|
||||
let releaseMembership: (value: typeof accounts) => void = () => {};
|
||||
upstream.getOpenAccounts.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
releaseMembership = resolve;
|
||||
}),
|
||||
);
|
||||
const connection = await prisma.tbankConnection.findUniqueOrThrow({
|
||||
where: { userId: 'owner' },
|
||||
});
|
||||
await prisma.tbankAccount.create({
|
||||
data: {
|
||||
userId: 'owner',
|
||||
revision: connection.revision,
|
||||
accountId: 'a',
|
||||
name: 'Synthetic',
|
||||
type: 'ACCOUNT_TYPE_TINKOFF',
|
||||
openedOn: '2020-01-01',
|
||||
upstreamOrder: 0,
|
||||
},
|
||||
});
|
||||
let release: (value: {
|
||||
hasNext: boolean;
|
||||
items: never[];
|
||||
}) => void = () => {};
|
||||
let reject: (error: Error) => void = () => {};
|
||||
upstream.getOperationsByCursor
|
||||
.mockReturnValueOnce(
|
||||
new Promise((resolve, rejectResult) => {
|
||||
release = resolve;
|
||||
reject = rejectResult;
|
||||
}),
|
||||
)
|
||||
.mockImplementation(() =>
|
||||
outcome === 'failed'
|
||||
? Promise.reject(new Error('Synthetic outage'))
|
||||
: Promise.resolve({ hasNext: false, items: [] }),
|
||||
);
|
||||
const accept = async (from: string, to: string) => {
|
||||
const response = await post({
|
||||
screen: 'operations',
|
||||
accountId: 'a',
|
||||
from,
|
||||
to,
|
||||
}).expect(202);
|
||||
return (response.body as { id: string }).id;
|
||||
};
|
||||
const dispatched = async (id: string) => {
|
||||
for (let i = 0; i < 300; i++) {
|
||||
const work = await prisma.tbankSyncWork.findUniqueOrThrow({
|
||||
where: { id },
|
||||
});
|
||||
if ((work.progress as { dispatched: boolean }).dispatched) return id;
|
||||
await delay(5);
|
||||
}
|
||||
throw new Error('Screen work did not dispatch');
|
||||
};
|
||||
const first = await accept('2026-01-01', '2026-01-10');
|
||||
const second = await accept('2026-01-20', '2026-01-31');
|
||||
const merged = await accept('2026-01-01', '2026-01-31');
|
||||
releaseMembership(accounts);
|
||||
await Promise.all([first, second, merged].map(dispatched));
|
||||
try {
|
||||
expect((await status(second).expect(200)).body).toMatchObject({
|
||||
state: 'running',
|
||||
});
|
||||
await status(second, 'other').expect(404);
|
||||
} finally {
|
||||
if (outcome === 'failed') reject(new Error('Synthetic outage'));
|
||||
else release({ hasNext: false, items: [] });
|
||||
await settled(merged);
|
||||
await settled(first);
|
||||
}
|
||||
expect(await settled(second)).toMatchObject({
|
||||
state: outcome,
|
||||
errorCode: outcome === 'failed' ? 'TBANK_UNAVAILABLE' : null,
|
||||
});
|
||||
await prisma.tbankConnection.delete({ where: { userId: 'owner' } });
|
||||
await status(second).expect(404);
|
||||
},
|
||||
);
|
||||
it('rejects known foreign accounts locally and requires authentication', async () => {
|
||||
await post({ screen: 'operations', accountId: 'foreign' }).expect(404);
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v1/tbank-sync')
|
||||
.send({ screen: 'accounts' })
|
||||
.expect(401);
|
||||
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,534 @@
|
||||
import type { Server } from 'node:http';
|
||||
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import request from 'supertest';
|
||||
|
||||
import type { PortfolioDto } from '../src/modules/investments/portfolio/presentation/portfolio.dto';
|
||||
|
||||
import { AppModule } from '../src/app.module';
|
||||
import { TbankMembershipService } from '../src/integrations/tbank/tbank-membership.service';
|
||||
import {
|
||||
type TbankAccount,
|
||||
TbankClient,
|
||||
type TbankPortfolio,
|
||||
} from '../src/integrations/tbank/tbank.client';
|
||||
import { configureApp } from '../src/main';
|
||||
import { PortfolioService } from '../src/modules/investments/portfolio/application/portfolio.service';
|
||||
import { TbankHourlyScheduler } from '../src/modules/investments/tbank-sync/tbank-hourly.scheduler';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
|
||||
describe('T-Bank value history persistence and visibility (SQLite)', () => {
|
||||
let app: INestApplication<Server>;
|
||||
let prisma: PrismaService;
|
||||
let portfolioService: PortfolioService;
|
||||
let membershipService: TbankMembershipService;
|
||||
const upstream = {
|
||||
validateToken: jest.fn<Promise<TbankAccount[]>, [string]>(),
|
||||
getOpenAccounts: jest.fn<Promise<TbankAccount[]>, [string]>(),
|
||||
getPortfolio: jest.fn<Promise<TbankPortfolio>, [string, string]>(),
|
||||
getPositions: jest.fn(),
|
||||
getInstrumentByUid: jest.fn(),
|
||||
getInstrumentByFigi: jest.fn(),
|
||||
};
|
||||
const accounts = [
|
||||
{ id: 'a', name: 'Account A', type: 'ACCOUNT_TYPE_TINKOFF' },
|
||||
{ id: 'b', name: 'Account B', type: 'ACCOUNT_TYPE_TINKOFF' },
|
||||
];
|
||||
const auth = (owner = 'owner') =>
|
||||
`Bearer ${new JwtService().sign({ sub: owner, type: 'access' }, { secret: process.env.JWT_SECRET })}`;
|
||||
const putConnection = (owner: string, tbankToken: string) =>
|
||||
request(app.getHttpServer())
|
||||
.put('/api/v1/tbank-connection')
|
||||
.set('Authorization', auth(owner))
|
||||
.send({ token: tbankToken });
|
||||
|
||||
beforeAll(async () => {
|
||||
const module = await Test.createTestingModule({ imports: [AppModule] })
|
||||
.overrideProvider(TbankClient)
|
||||
.useValue(upstream)
|
||||
.overrideProvider(TbankHourlyScheduler)
|
||||
.useValue({ connected: jest.fn().mockResolvedValue(undefined) })
|
||||
.compile();
|
||||
app = module.createNestApplication();
|
||||
configureApp(app);
|
||||
await app.init();
|
||||
await app.listen(0, '127.0.0.1');
|
||||
prisma = app.get(PrismaService);
|
||||
portfolioService = app.get(PortfolioService);
|
||||
membershipService = app.get(TbankMembershipService);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await prisma.tbankPortfolioValuePoint.deleteMany();
|
||||
await prisma.tbankConnection.deleteMany();
|
||||
await prisma.tbankPortfolioValuePoint.deleteMany();
|
||||
await prisma.user.deleteMany();
|
||||
await prisma.tbankPortfolioValueHistory.deleteMany();
|
||||
jest.clearAllMocks();
|
||||
upstream.validateToken.mockResolvedValue(accounts);
|
||||
upstream.getOpenAccounts.mockResolvedValue(accounts);
|
||||
upstream.getPortfolio.mockResolvedValue({
|
||||
positions: [
|
||||
{
|
||||
instrumentType: 'share',
|
||||
figi: 'stock',
|
||||
quantity: { units: '2' },
|
||||
currentPrice: { units: '10', nano: 500000000, currency: 'rub' },
|
||||
},
|
||||
],
|
||||
});
|
||||
upstream.getPositions.mockResolvedValue({
|
||||
money: [{ units: '5', currency: 'usd' }],
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => app?.close());
|
||||
|
||||
async function createUser(id: string) {
|
||||
return prisma.user.create({
|
||||
data: {
|
||||
id,
|
||||
email: `${id}@example.invalid`,
|
||||
passwordHash: 'synthetic',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function publishPortfolio(userId: string, accountId: string) {
|
||||
const connection = await prisma.tbankConnection.findUniqueOrThrow({
|
||||
where: { userId },
|
||||
});
|
||||
const work = portfolioService['queueSnapshot'](
|
||||
userId,
|
||||
connection.revision,
|
||||
accountId,
|
||||
true,
|
||||
);
|
||||
const reserved = await work;
|
||||
if (reserved) await reserved.done;
|
||||
}
|
||||
|
||||
async function publishMembership(userId: string) {
|
||||
const connection = await prisma.tbankConnection.findUniqueOrThrow({
|
||||
where: { userId },
|
||||
});
|
||||
const result = await membershipService.request(
|
||||
userId,
|
||||
connection.revision,
|
||||
true,
|
||||
);
|
||||
if (result) await result.done;
|
||||
}
|
||||
|
||||
describe('1.1 Point persistence', () => {
|
||||
it('writes points for fully valued snapshot with two currencies', async () => {
|
||||
await createUser('owner');
|
||||
await putConnection('owner', 'token-a').expect(200);
|
||||
await publishPortfolio('owner', 'a');
|
||||
|
||||
const history = await prisma.tbankPortfolioValueHistory.findUnique({
|
||||
where: { userId_accountId: { userId: 'owner', accountId: 'a' } },
|
||||
include: { points: true },
|
||||
});
|
||||
expect(history?.deletedAt).toBeNull();
|
||||
expect(history?.points).toHaveLength(2);
|
||||
|
||||
const rubPoint = history?.points.find((p) => p.currency === 'RUB');
|
||||
const usdPoint = history?.points.find((p) => p.currency === 'USD');
|
||||
expect(rubPoint?.value).toBe('21');
|
||||
expect(usdPoint?.value).toBe('5');
|
||||
|
||||
const snapshot = await prisma.tbankPortfolio.findUniqueOrThrow({
|
||||
where: {
|
||||
userId_revision_accountId: {
|
||||
userId: 'owner',
|
||||
revision: (
|
||||
await prisma.tbankConnection.findUniqueOrThrow({
|
||||
where: { userId: 'owner' },
|
||||
})
|
||||
).revision,
|
||||
accountId: 'a',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(rubPoint?.capturedAt).toEqual(snapshot.fetchedAt);
|
||||
});
|
||||
|
||||
it('writes no points when a non-zero position has unknown price', async () => {
|
||||
upstream.getPortfolio.mockResolvedValue({
|
||||
positions: [
|
||||
{
|
||||
instrumentType: 'share',
|
||||
figi: 'valued',
|
||||
quantity: { units: '2' },
|
||||
currentPrice: { units: '10', currency: 'rub' },
|
||||
},
|
||||
{
|
||||
instrumentType: 'share',
|
||||
figi: 'unpriced',
|
||||
quantity: { units: '1' },
|
||||
},
|
||||
],
|
||||
});
|
||||
upstream.getPositions.mockResolvedValue({
|
||||
money: [{ units: '5', currency: 'rub' }],
|
||||
});
|
||||
|
||||
await createUser('owner');
|
||||
await putConnection('owner', 'token-a').expect(200);
|
||||
await publishPortfolio('owner', 'a');
|
||||
|
||||
const snapshot = await prisma.tbankPortfolio.findUnique({
|
||||
where: {
|
||||
userId_revision_accountId: {
|
||||
userId: 'owner',
|
||||
revision: (
|
||||
await prisma.tbankConnection.findUniqueOrThrow({
|
||||
where: { userId: 'owner' },
|
||||
})
|
||||
).revision,
|
||||
accountId: 'a',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(snapshot).not.toBeNull();
|
||||
|
||||
const points = await prisma.tbankPortfolioValuePoint.findMany({
|
||||
where: { userId: 'owner', accountId: 'a' },
|
||||
});
|
||||
expect(points).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('writes no points for empty account without currency', async () => {
|
||||
upstream.getPortfolio.mockResolvedValue({ positions: [] });
|
||||
upstream.getPositions.mockResolvedValue({ money: [] });
|
||||
|
||||
await createUser('owner');
|
||||
await putConnection('owner', 'token-a').expect(200);
|
||||
await publishPortfolio('owner', 'a');
|
||||
|
||||
const points = await prisma.tbankPortfolioValuePoint.findMany({
|
||||
where: { userId: 'owner', accountId: 'a' },
|
||||
});
|
||||
expect(points).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('deduplicates repeated publication of same attempt', async () => {
|
||||
await createUser('owner');
|
||||
await putConnection('owner', 'token-a').expect(200);
|
||||
await publishPortfolio('owner', 'a');
|
||||
|
||||
const work = await prisma.tbankSyncWork.findFirstOrThrow({
|
||||
where: { scopeKey: { startsWith: 'portfolio:' } },
|
||||
});
|
||||
const connection = await prisma.tbankConnection.findUniqueOrThrow({
|
||||
where: { userId: 'owner' },
|
||||
});
|
||||
|
||||
await prisma.tbankSyncWork.update({
|
||||
where: { id: work.id },
|
||||
data: { state: 'running' },
|
||||
});
|
||||
|
||||
const published = await prisma.tbankPortfolio
|
||||
.findUniqueOrThrow({
|
||||
where: {
|
||||
userId_revision_accountId: {
|
||||
userId: 'owner',
|
||||
revision: connection.revision,
|
||||
accountId: 'a',
|
||||
},
|
||||
},
|
||||
})
|
||||
.then((p) => p.payload as unknown as PortfolioDto);
|
||||
|
||||
await app.get(PortfolioService)['repository'].publish(
|
||||
{
|
||||
id: work.id,
|
||||
attemptId: work.attemptId,
|
||||
userId: 'owner',
|
||||
revision: connection.revision,
|
||||
},
|
||||
'a',
|
||||
published,
|
||||
);
|
||||
|
||||
const points = await prisma.tbankPortfolioValuePoint.findMany({
|
||||
where: { userId: 'owner', accountId: 'a' },
|
||||
});
|
||||
expect(points).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('late attempt after token replacement adds no points', async () => {
|
||||
await createUser('owner');
|
||||
await putConnection('owner', 'token-a').expect(200);
|
||||
const oldRevision = (
|
||||
await prisma.tbankConnection.findUniqueOrThrow({
|
||||
where: { userId: 'owner' },
|
||||
})
|
||||
).revision;
|
||||
|
||||
upstream.validateToken.mockResolvedValue([accounts[0]]);
|
||||
await putConnection('owner', 'token-b').expect(200);
|
||||
|
||||
const oldWork = {
|
||||
id: 'old',
|
||||
attemptId: 'old-attempt',
|
||||
userId: 'owner',
|
||||
revision: oldRevision,
|
||||
};
|
||||
const published = {
|
||||
lastSuccessfulAt: new Date().toISOString(),
|
||||
isStale: false,
|
||||
totalsByCurrency: [
|
||||
{ currency: 'RUB', value: '100' },
|
||||
{ currency: 'USD', value: '50' },
|
||||
],
|
||||
positions: [
|
||||
{
|
||||
figi: 'stock',
|
||||
displayName: 'Stock',
|
||||
type: 'STOCK',
|
||||
quantity: '1',
|
||||
currentValue: { currency: 'RUB', value: '100' },
|
||||
averagePurchasePrice: null,
|
||||
absoluteReturn: null,
|
||||
isin: null,
|
||||
ticker: null,
|
||||
logoName: null,
|
||||
},
|
||||
],
|
||||
cashBalances: [{ currency: 'USD', value: '50' }],
|
||||
structure: { currencies: [], unvaluedCategories: [] },
|
||||
};
|
||||
|
||||
const result = await app.get(PortfolioService)['repository'].publish(oldWork, 'a', published);
|
||||
expect(result).toBe(false);
|
||||
|
||||
const points = await prisma.tbankPortfolioValuePoint.findMany({
|
||||
where: { userId: 'owner', accountId: 'a' },
|
||||
});
|
||||
expect(points).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('preserves old snapshot and points when publish fails', async () => {
|
||||
await createUser('owner');
|
||||
await putConnection('owner', 'token-a').expect(200);
|
||||
await publishPortfolio('owner', 'a');
|
||||
|
||||
const pointsBefore = await prisma.tbankPortfolioValuePoint.findMany({
|
||||
where: { userId: 'owner', accountId: 'a' },
|
||||
});
|
||||
expect(pointsBefore).toHaveLength(2);
|
||||
|
||||
upstream.getPortfolio.mockRejectedValue(new Error('Upstream failed'));
|
||||
await publishPortfolio('owner', 'a');
|
||||
|
||||
const pointsAfter = await prisma.tbankPortfolioValuePoint.findMany({
|
||||
where: { userId: 'owner', accountId: 'a' },
|
||||
});
|
||||
expect(pointsAfter).toHaveLength(2);
|
||||
expect(pointsAfter).toEqual(pointsBefore);
|
||||
});
|
||||
});
|
||||
|
||||
describe('1.2 Visibility', () => {
|
||||
it('token replacement unhides confirmed account and continues history', async () => {
|
||||
await createUser('owner');
|
||||
await putConnection('owner', 'token-a').expect(200);
|
||||
await publishPortfolio('owner', 'a');
|
||||
|
||||
const oldPoints = await prisma.tbankPortfolioValuePoint.findMany({
|
||||
where: { userId: 'owner', accountId: 'a' },
|
||||
});
|
||||
expect(oldPoints).toHaveLength(2);
|
||||
|
||||
upstream.validateToken.mockResolvedValue([accounts[0]]);
|
||||
await putConnection('owner', 'token-b').expect(200);
|
||||
|
||||
const history = await prisma.tbankPortfolioValueHistory.findUnique({
|
||||
where: { userId_accountId: { userId: 'owner', accountId: 'a' } },
|
||||
});
|
||||
expect(history?.deletedAt).toBeNull();
|
||||
|
||||
await publishPortfolio('owner', 'a');
|
||||
|
||||
const newPoints = await prisma.tbankPortfolioValuePoint.findMany({
|
||||
where: { userId: 'owner', accountId: 'a' },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
expect(newPoints.length).toBeGreaterThan(2);
|
||||
expect(newPoints.slice(0, 2)).toEqual(oldPoints);
|
||||
});
|
||||
|
||||
it('hides history when account disappears from membership', async () => {
|
||||
await createUser('owner');
|
||||
await putConnection('owner', 'token-a').expect(200);
|
||||
await publishPortfolio('owner', 'a');
|
||||
await publishPortfolio('owner', 'b');
|
||||
|
||||
const historyBefore = await prisma.tbankPortfolioValueHistory.findMany({
|
||||
where: { userId: 'owner' },
|
||||
});
|
||||
expect(historyBefore).toHaveLength(2);
|
||||
expect(historyBefore.every((h) => h.deletedAt === null)).toBe(true);
|
||||
|
||||
upstream.getOpenAccounts.mockResolvedValue([accounts[0]]);
|
||||
await publishMembership('owner');
|
||||
|
||||
const historyAfter = await prisma.tbankPortfolioValueHistory.findMany({
|
||||
where: { userId: 'owner' },
|
||||
orderBy: { accountId: 'asc' },
|
||||
});
|
||||
expect(historyAfter[0].accountId).toBe('a');
|
||||
expect(historyAfter[0].deletedAt).toBeNull();
|
||||
expect(historyAfter[1].accountId).toBe('b');
|
||||
expect(historyAfter[1].deletedAt).not.toBeNull();
|
||||
|
||||
const pointsB = await prisma.tbankPortfolioValuePoint.findMany({
|
||||
where: { userId: 'owner', accountId: 'b' },
|
||||
});
|
||||
expect(pointsB).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('leaves visibility unchanged on membership fetch error', async () => {
|
||||
await createUser('owner');
|
||||
await putConnection('owner', 'token-a').expect(200);
|
||||
await publishPortfolio('owner', 'a');
|
||||
|
||||
const historyBefore = await prisma.tbankPortfolioValueHistory.findUnique(
|
||||
{
|
||||
where: { userId_accountId: { userId: 'owner', accountId: 'a' } },
|
||||
},
|
||||
);
|
||||
expect(historyBefore?.deletedAt).toBeNull();
|
||||
|
||||
upstream.getOpenAccounts.mockRejectedValue(new Error('Upstream failed'));
|
||||
await publishMembership('owner');
|
||||
|
||||
const historyAfter = await prisma.tbankPortfolioValueHistory.findUnique({
|
||||
where: { userId_accountId: { userId: 'owner', accountId: 'a' } },
|
||||
});
|
||||
expect(historyAfter?.deletedAt).toEqual(historyBefore?.deletedAt);
|
||||
});
|
||||
|
||||
it('delete hides all history; reconnect unhides confirmed account', async () => {
|
||||
await createUser('owner');
|
||||
await putConnection('owner', 'token-a').expect(200);
|
||||
await publishPortfolio('owner', 'a');
|
||||
|
||||
const points = await prisma.tbankPortfolioValuePoint.findMany({
|
||||
where: { userId: 'owner', accountId: 'a' },
|
||||
});
|
||||
expect(points).toHaveLength(2);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.delete('/api/v1/tbank-connection')
|
||||
.set('Authorization', auth('owner'))
|
||||
.expect(204);
|
||||
|
||||
const historyDeleted =
|
||||
await prisma.tbankPortfolioValueHistory.findUnique({
|
||||
where: { userId_accountId: { userId: 'owner', accountId: 'a' } },
|
||||
});
|
||||
expect(historyDeleted?.deletedAt).not.toBeNull();
|
||||
|
||||
upstream.validateToken.mockResolvedValue([accounts[0]]);
|
||||
await putConnection('owner', 'token-b').expect(200);
|
||||
|
||||
const historyRestored =
|
||||
await prisma.tbankPortfolioValueHistory.findUnique({
|
||||
where: { userId_accountId: { userId: 'owner', accountId: 'a' } },
|
||||
});
|
||||
expect(historyRestored?.deletedAt).toBeNull();
|
||||
|
||||
await publishPortfolio('owner', 'a');
|
||||
const pointsAfter = await prisma.tbankPortfolioValuePoint.findMany({
|
||||
where: { userId: 'owner', accountId: 'a' },
|
||||
});
|
||||
expect(pointsAfter.length).toBeGreaterThan(2);
|
||||
});
|
||||
|
||||
it('unhides when disappeared account returns in membership', async () => {
|
||||
await createUser('owner');
|
||||
await putConnection('owner', 'token-a').expect(200);
|
||||
await publishPortfolio('owner', 'a');
|
||||
|
||||
upstream.getOpenAccounts.mockResolvedValue([]);
|
||||
await publishMembership('owner');
|
||||
|
||||
const historyHidden =
|
||||
await prisma.tbankPortfolioValueHistory.findUnique({
|
||||
where: { userId_accountId: { userId: 'owner', accountId: 'a' } },
|
||||
});
|
||||
expect(historyHidden?.deletedAt).not.toBeNull();
|
||||
|
||||
upstream.getOpenAccounts.mockResolvedValue([accounts[0]]);
|
||||
await publishMembership('owner');
|
||||
|
||||
const historyUnhidden =
|
||||
await prisma.tbankPortfolioValueHistory.findUnique({
|
||||
where: { userId_accountId: { userId: 'owner', accountId: 'a' } },
|
||||
});
|
||||
expect(historyUnhidden?.deletedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('isolates history by user', async () => {
|
||||
await createUser('owner');
|
||||
await createUser('other');
|
||||
await putConnection('owner', 'token-a').expect(200);
|
||||
await putConnection('other', 'token-b').expect(200);
|
||||
|
||||
await publishPortfolio('owner', 'a');
|
||||
await publishPortfolio('other', 'a');
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.delete('/api/v1/tbank-connection')
|
||||
.set('Authorization', auth('owner'))
|
||||
.expect(204);
|
||||
|
||||
const ownerHistory =
|
||||
await prisma.tbankPortfolioValueHistory.findUnique({
|
||||
where: { userId_accountId: { userId: 'owner', accountId: 'a' } },
|
||||
});
|
||||
const otherHistory =
|
||||
await prisma.tbankPortfolioValueHistory.findUnique({
|
||||
where: { userId_accountId: { userId: 'other', accountId: 'a' } },
|
||||
});
|
||||
|
||||
expect(ownerHistory?.deletedAt).not.toBeNull();
|
||||
expect(otherHistory?.deletedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('cascades old private data on token replacement without losing history', async () => {
|
||||
await createUser('owner');
|
||||
await putConnection('owner', 'token-a').expect(200);
|
||||
const oldRevision = (
|
||||
await prisma.tbankConnection.findUniqueOrThrow({
|
||||
where: { userId: 'owner' },
|
||||
})
|
||||
).revision;
|
||||
await publishPortfolio('owner', 'a');
|
||||
|
||||
const oldSnapshot = await prisma.tbankPortfolio.findMany({
|
||||
where: { userId: 'owner', revision: oldRevision },
|
||||
});
|
||||
expect(oldSnapshot).toHaveLength(1);
|
||||
|
||||
upstream.validateToken.mockResolvedValue([accounts[0]]);
|
||||
await putConnection('owner', 'token-b').expect(200);
|
||||
|
||||
const deletedSnapshot = await prisma.tbankPortfolio.findMany({
|
||||
where: { userId: 'owner', revision: oldRevision },
|
||||
});
|
||||
expect(deletedSnapshot).toHaveLength(0);
|
||||
|
||||
const points = await prisma.tbankPortfolioValuePoint.findMany({
|
||||
where: { userId: 'owner', accountId: 'a' },
|
||||
});
|
||||
expect(points).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
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 { PrismaService } from '../src/prisma/prisma.service';
|
||||
|
||||
const shareUid = '00000000-0000-4000-8000-000000000001';
|
||||
@@ -137,6 +138,8 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
.useValue(upstream)
|
||||
.overrideProvider(MoexClient)
|
||||
.useValue(moex)
|
||||
.overrideProvider(TbankHourlyScheduler)
|
||||
.useValue({ tick: jest.fn() })
|
||||
.compile();
|
||||
app = module.createNestApplication();
|
||||
configureApp(app);
|
||||
@@ -220,6 +223,10 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
},
|
||||
});
|
||||
await portfolio([position()], userId);
|
||||
await prisma.tbankPortfolio.updateMany({
|
||||
where: { userId },
|
||||
data: { fetchedAt: new Date(Date.now() - 3_600_000) },
|
||||
});
|
||||
}
|
||||
});
|
||||
afterAll(async () => {
|
||||
|
||||
Reference in New Issue
Block a user