533 lines
18 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|