feat: add hourly portfolio sync history
Backend verify / commits (pull_request) Successful in 33s
Backend verify / security (pull_request) Failing after 12s
Backend verify / verify (pull_request) Skipped

This commit is contained in:
2026-10-09 09:04:41 +03:00
parent 03e75f8d2b
commit 0057925888
49 changed files with 1674 additions and 1870 deletions
+103 -33
View File
@@ -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(