feat: add hourly portfolio sync history
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user