Files
backend/test/tbank-value-history.e2e-spec.ts
T
ksv741 3b55b611b5 refactor!: support RUB only (WealthLedger/main#55)
Filter non-RUB data at ingest, collapse per-currency collections into single RUB Money, drop currency from TbankPortfolioValuePoint, remove NON_RUB_ACTIVITY and screener currency filter.

BREAKING CHANGE: per-currency API fields replaced with single RUB Money; stock screener currency param removed.
2026-11-11 08:22:16 +03:00

533 lines
18 KiB
TypeScript

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: 'rub' }],
});
});
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 a single RUB point for a fully valued snapshot', 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(1);
const rubPoint = history?.points[0];
expect(rubPoint?.value).toBe('26');
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 a zero point for an empty fully valued account', 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(1);
expect(points[0].value).toBe('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(1);
});
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,
totals: { currency: 'RUB', value: '100' },
positions: [
{
figi: 'stock',
displayName: 'Stock',
type: 'STOCK',
quantity: '1',
currentValue: { currency: 'RUB', value: '100' },
averagePurchasePrice: null,
absoluteReturn: null,
isin: null,
ticker: null,
logoName: null,
sector: null,
issuer: null,
},
],
cash: { currency: 'RUB', value: '0' },
structure: { totalValue: '100', hasNegativeValues: false, categories: [], 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(1);
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(1);
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(1);
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(1);
expect(newPoints.slice(0, 1)).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(1);
});
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(1);
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(1);
});
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(1);
});
});
});