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

625 lines
24 KiB
TypeScript

import type { Server } from 'node:http';
import { INestApplication } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Test } from '@nestjs/testing';
import assert from 'node:assert/strict';
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 {
type TbankAccount,
TbankClient,
type TbankPortfolio,
} from '../src/integrations/tbank/tbank.client';
import { configureApp } from '../src/main';
import { BrokerageAccountsResourceDto } from '../src/modules/investments/brokerage-accounts/brokerage-account.dto';
import { IncomeAnalyticsResourceDto } from '../src/modules/investments/income/income-analytics.dto';
import { PortfolioService } from '../src/modules/investments/portfolio/application/portfolio.service';
import { PortfolioResourceDto } from '../src/modules/investments/portfolio/presentation/portfolio.dto';
import { TbankHourlyScheduler } from '../src/modules/investments/tbank-sync/tbank-hourly.scheduler';
import { PrismaService } from '../src/prisma/prisma.service';
function deferred<T>() {
let resolve: (value: T) => void = () => {
throw new Error('Uninitialized deferred');
};
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}
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' },
];
const upstream = {
validateToken: jest.fn(),
getOpenAccounts: jest.fn<Promise<TbankAccount[]>, [string]>(),
getPortfolio: jest.fn<Promise<TbankPortfolio>, [string, string]>(),
getPositions: jest.fn(),
getInstrumentByUid: jest.fn(),
getInstrumentByFigi: jest.fn(),
};
const auth = (owner = 'owner') =>
`Bearer ${new JwtService().sign({ sub: owner, type: 'access' }, { secret: process.env.JWT_SECRET })}`;
const get = (path: string, owner = 'owner') =>
request(app.getHttpServer())
.get(`/api/v1/${path}`)
.set('Authorization', auth(owner))
.timeout(1500);
const put = () =>
request(app.getHttpServer())
.put('/api/v1/tbank-connection')
.set('Authorization', auth())
.send({ token: 'synthetic' });
async function settled(scope = 'portfolio:') {
for (let index = 0; index < 500; index += 1) {
const work = await prisma.tbankSyncWork.findFirst({
where: { scopeKey: { startsWith: scope } },
});
if (work && ['complete', 'failed'].includes(work.state)) return work;
await delay(2);
}
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)
.useValue(upstream)
.overrideProvider(TbankHourlyScheduler)
.useValue({ tick: jest.fn() })
.compile();
app = module.createNestApplication();
configureApp(app);
await app.init();
await app.listen(0, '127.0.0.1');
prisma = app.get(PrismaService);
portfolioService = app.get(PortfolioService);
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.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);
upstream.getPortfolio.mockReset().mockResolvedValue({
positions: [
{
instrumentType: 'share',
figi: 'stock',
quantity: { units: '2' },
currentPrice: { units: '10', nano: 1, currency: 'rub' },
},
{ instrumentType: 'share', figi: 'zero', quantity: { units: '0' } },
],
});
upstream.getPositions.mockResolvedValue({
money: [{ units: '1', nano: 100000000, currency: 'rub' }],
});
});
afterAll(async () => app?.close());
it('returns missing without awaiting discovery, observe starts nothing, and unknown accounts become 404 after discovery', async () => {
await prisma.tbankConnection.create({
data: {
userId: 'owner',
connectedAt: new Date(),
encryptedToken: app.get(TbankTokenService).encrypt('synthetic'),
},
});
const cold = await get('brokerage-accounts/unknown/portfolio').expect(200);
expect((cold.body as PortfolioResourceDto).data).toBeNull();
expect((cold.body as PortfolioResourceDto).meta.availability).toBe(
'temporarily-unavailable',
);
await get('brokerage-accounts').expect(200);
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
expect(upstream.getPortfolio).not.toHaveBeenCalled();
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').expect(404);
await get('brokerage-accounts', 'other').expect(409);
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 () => {
await prisma.tbankInstrumentLookup.upsert({
where: {
provider_identifierType_identifier: {
provider: 'tbank',
identifierType: 'figi',
identifier: 'stock',
},
},
create: {
provider: 'tbank',
identifierType: 'figi',
identifier: 'stock',
matchedUids: [],
fetchedAt: new Date(),
},
update: { matchedUids: [], fetchedAt: new Date() },
});
await put().expect(200);
await seedMembership();
const blocked = deferred<TbankPortfolio>();
upstream.getPortfolio.mockReturnValueOnce(blocked.promise);
const cold = await get('brokerage-accounts').expect(200);
expect(
(cold.body as BrokerageAccountsResourceDto).data?.[0].portfolioSummary,
).toBeNull();
blocked.resolve({
positions: [
{
instrumentType: 'share',
figi: 'stock',
quantity: { units: '2' },
currentPrice: { units: '10', nano: 1, currency: 'rub' },
},
],
});
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(
(warm.body as BrokerageAccountsResourceDto).data?.[0].portfolioSummary,
).toEqual({
stockPositionCount: 1,
bondPositionCount: 0,
fundPositionCount: 0,
totalPositionCount: 1,
totals: { currency: 'RUB', value: '21.100000002' },
cash: { currency: 'RUB', value: '1.1' },
structure: {
totalValue: '21.100000002',
hasNegativeValues: false,
categories: [
{ type: 'STOCK', value: '20.000000002', sharePercent: '94.78673' },
{ type: 'BOND', value: '0', sharePercent: '0' },
{ type: 'OTHER', value: '0', sharePercent: '0' },
{ type: 'CASH', value: '1.1', sharePercent: '5.21327' },
],
unvaluedCategories: [],
},
unrealizedReturn: { absolute: '0', costBasis: '0', percent: null, partial: true },
});
const portfolio = await get('brokerage-accounts/account/portfolio').expect(
200,
);
expect(
(portfolio.body as PortfolioResourceDto).data?.totals,
).toEqual({ currency: 'RUB', value: '21.100000002' });
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
expect(upstream.getPortfolio).toHaveBeenCalledTimes(1);
expect(upstream.getInstrumentByFigi).not.toHaveBeenCalled();
expect(decrypt).not.toHaveBeenCalled();
decrypt.mockRestore();
const connection = await prisma.tbankConnection.findUniqueOrThrow({
where: { userId: 'owner' },
});
expect(JSON.stringify(portfolio.body)).not.toContain(connection.revision);
expect(JSON.stringify(portfolio.body)).not.toContain(
connection.encryptedToken,
);
});
it('overlays new shared labels without changing the saved financial snapshot', async () => {
await put().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').expect(200)
).body as PortfolioResourceDto;
const fetchedAt = new Date();
await prisma.tbankInstrument.create({
data: {
provider: 'tbank',
uid: 'enriched-stock',
figi: 'stock',
instrumentType: 'share',
metadata: {
uid: 'enriched-stock',
figi: 'stock',
name: 'Enriched name',
ticker: 'ENR',
brand: { logoName: 'enriched.png' },
},
fetchedAt,
},
});
await prisma.tbankInstrumentLookup.upsert({
where: {
provider_identifierType_identifier: {
provider: 'tbank',
identifierType: 'figi',
identifier: 'stock',
},
},
create: {
provider: 'tbank',
identifierType: 'figi',
identifier: 'stock',
matchedUids: ['enriched-stock'],
fetchedAt,
},
update: { matchedUids: ['enriched-stock'], fetchedAt },
});
const after = (
await get('brokerage-accounts/account/portfolio').expect(200)
).body as PortfolioResourceDto;
expect(after.data?.positions[0]).toMatchObject({
displayName: 'Enriched name',
ticker: 'ENR',
logoName: 'enriched.png',
quantity: '2',
});
expect(after.data?.totals).toEqual(before.data?.totals);
expect(after.data?.lastSuccessfulAt).toBe(before.data?.lastSuccessfulAt);
expect(after.meta.dataRevision).not.toBe(before.meta.dataRevision);
expect(upstream.getPortfolio).toHaveBeenCalledTimes(1);
});
it('keeps old totals on failed refresh indefinitely and does not retry through observe', async () => {
await put().expect(200);
await seedMembership();
await requestPortfolio('account');
await settled();
await prisma.tbankPortfolio.updateMany({
data: { fetchedAt: new Date(0) },
});
await prisma.tbankSyncWork.updateMany({
where: { userId: 'owner', scopeKey: 'portfolio:account' },
data: { state: 'complete' },
});
const stale = await get('brokerage-accounts/account/portfolio').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',
).expect(200);
expect((failed.body as PortfolioResourceDto).data).toEqual(previous);
expect((failed.body as PortfolioResourceDto).meta.lastAttempt.state).toBe('none');
expect(upstream.getPortfolio).toHaveBeenCalledTimes(1);
});
it('reports the durable hourly attempt in accounts and income meta with section relevance', async () => {
await put().expect(200);
await seedMembership();
await requestPortfolio('account');
await settled();
const connection = await prisma.tbankConnection.findUniqueOrThrow({
where: { userId: 'owner' },
});
const startedAt = new Date('2026-10-10T10:00:00.000Z');
const finishedAt = new Date('2026-10-10T10:05:00.000Z');
await prisma.tbankSyncWork.create({
data: {
id: 'hourly-attempt',
userId: 'owner',
revision: connection.revision,
scopeKey: 'hourly',
intent: {},
state: 'complete',
attemptId: 'hourly-attempt',
processRunId: 'test',
priority: 0,
progress: {
result: 'partial',
sections: [
{
section: 'portfolio',
accountId: 'account',
errorCode: 'TBANK_UNAVAILABLE',
},
{
section: 'operations',
accountId: 'account',
errorCode: 'TBANK_UNAVAILABLE',
},
],
},
createdAt: startedAt,
updatedAt: finishedAt,
},
});
const accounts = await get('brokerage-accounts').expect(200);
const accountsMeta = (accounts.body as BrokerageAccountsResourceDto).meta;
expect(accountsMeta.lastAttempt.state).toBe('partial');
expect(accountsMeta.lastAttempt.startedAt).toBe(startedAt.toISOString());
expect(accountsMeta.lastAttempt.finishedAt).toBe(finishedAt.toISOString());
expect(accountsMeta.lastAttempt.problems).toEqual([
{
section: 'portfolio',
accountId: 'account',
errorCode: 'TBANK_UNAVAILABLE',
},
]);
const income = await get(
'brokerage-accounts/account/income-analytics',
).expect(200);
const incomeMeta = (income.body as IncomeAnalyticsResourceDto).meta;
expect(incomeMeta.lastAttempt.state).toBe('partial');
expect(incomeMeta.lastAttempt.startedAt).toBe(startedAt.toISOString());
expect(incomeMeta.lastAttempt.finishedAt).toBe(finishedAt.toISOString());
expect(incomeMeta.lastAttempt.problems).toEqual([
{
section: 'operations',
accountId: 'account',
errorCode: 'TBANK_UNAVAILABLE',
},
]);
});
it('recovers abandoned discovery only on a data request and coalesces concurrent reads', async () => {
await put().expect(200);
const connection = await prisma.tbankConnection.update({
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',
userId: 'owner',
revision: connection.revision,
scopeKey: 'membership',
attemptId: 'abandoned',
processRunId: 'terminated-process',
state: 'running',
priority: 0,
intent: {},
progress: {},
},
});
const blocked = deferred<TbankAccount[]>();
upstream.getOpenAccounts.mockReturnValueOnce(blocked.promise);
await get('brokerage-accounts').expect(200);
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
expect(
(
await prisma.tbankSyncWork.findUniqueOrThrow({
where: { id: 'abandoned-membership' },
})
).attemptId,
).toBe('abandoned');
const reads = await Promise.all(
Array.from({ length: 20 }, () => get('brokerage-accounts').expect(200)),
);
expect(
reads.every(
(read) =>
(read.body as BrokerageAccountsResourceDto).data?.[0].id ===
'account',
),
).toBe(true);
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
blocked.resolve([]);
await prisma.tbankSyncWork.update({
where: { id: 'abandoned-membership' },
data: { state: 'complete' },
});
expect((await get('brokerage-accounts/account/portfolio').expect(200)).body).toMatchObject({ data: null, meta: { availability: 'temporarily-unavailable', completeness: 'incomplete' } });
expect(await prisma.tbankPortfolio.count()).toBe(0);
expect((await get('brokerage-accounts')).body).toMatchObject({
data: [{ id: 'account', portfolioSummary: null }],
});
});
it('keeps account summaries independent and never turns a new missing member into zero personal holdings', async () => {
const uid = '11111111-1111-4111-8111-111111111111';
upstream.validateToken.mockResolvedValueOnce([
...accounts,
{ 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'))
: Promise.resolve({
positions: [
{
instrumentUid: uid,
instrumentType: 'share',
quantity: { units: '2' },
currentPrice: { units: '10', currency: 'rub' },
},
],
}),
);
await get('brokerage-accounts').expect(200);
for (let index = 0; index < 200; index += 1) {
if (!(await prisma.tbankSyncWork.count({ where: { state: 'running' } }))) {
break;
}
await delay(5);
}
const summaries = (await get('brokerage-accounts').expect(200))
.body as BrokerageAccountsResourceDto;
expect(summaries.data?.[0].portfolioSummary).toMatchObject({
totals: { currency: 'RUB', value: '21.1' },
cash: { currency: 'RUB', value: '1.1' },
unrealizedReturn: { absolute: '0', costBasis: '0', percent: null, partial: true },
stockPositionCount: 1,
bondPositionCount: 0,
fundPositionCount: 0,
totalPositionCount: 1,
});
expect(summaries.data?.[1].portfolioSummary).toBeNull();
expect(summaries.meta.availability).toBe('available');
expect(summaries.meta.completeness).toBe('complete');
expect(
(await get('brokerage-accounts/all/portfolio')).body,
).toMatchObject({ data: null, meta: { availability: 'temporarily-unavailable', completeness: 'incomplete' } });
expect(
(await get(`stocks/${uid}/position`).expect(200)).body,
).toMatchObject({ data: null, meta: { availability: 'temporarily-unavailable', completeness: 'incomplete' } });
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 } },
});
const position = await get(`stocks/${uid}/position`).expect(
200,
);
expect(position.body).toMatchObject({
data: { quantity: '2', accounts: [{ account: { id: 'account' } }] },
meta: { availability: 'available', completeness: 'complete' },
});
await prisma.$disconnect();
expect(
(await get(`stocks/${uid}/position`).expect(200)).body,
).toEqual(position.body);
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
expect(upstream.getPortfolio).toHaveBeenCalledTimes(2);
await prisma.tbankConnection.updateMany({ data: { invalidToken: true } });
await get(`stocks/${uid}/position`).expect(400);
});
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) },
});
const fresh = await get(
'brokerage-accounts/all/portfolio',
).expect(200);
expect((fresh.body as PortfolioResourceDto).meta.freshness).toBe('fresh');
expect((fresh.body as PortfolioResourceDto).data).toMatchObject({
isStale: false,
});
await prisma.tbankConnection.updateMany({
data: { membershipAt: new Date(Date.now() - 301_000) },
});
const stale = await get(
'brokerage-accounts/all/portfolio',
).expect(200);
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();
});
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').expect(200);
expect((empty.body as BrokerageAccountsResourceDto).data).toEqual([]);
expect((empty.body as BrokerageAccountsResourceDto).meta.availability).toBe(
'available',
);
await put().expect(200);
await seedMembership();
const blocked = deferred<TbankPortfolio>();
const started = deferred<void>();
upstream.getPortfolio.mockImplementationOnce(() => {
started.resolve();
return blocked.promise;
});
// Starts the real portfolio producer, the same path the hourly walk uses.
const late = await portfolioService.request(
'owner',
(await prisma.tbankConnection.findUniqueOrThrow({ where: { userId: 'owner' } })).revision,
'account',
true,
);
assert(late);
// The late upstream call must have begun before the connection is replaced.
await started.promise;
await put().expect(200);
await seedMembership();
blocked.resolve({ positions: [] });
// Barrier: the fenced producer's completion, not a timer.
await late.done;
expect(await prisma.tbankPortfolio.count()).toBe(0);
const replaced = await get(
'brokerage-accounts/account/portfolio',
).expect(200);
expect((replaced.body as PortfolioResourceDto).data).toBeNull();
});
});