633 lines
24 KiB
TypeScript
633 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,
|
|
totalsByCurrency: [{ currency: 'RUB', value: '21.100000002' }],
|
|
cashBalances: [{ currency: 'RUB', value: '1.1' }],
|
|
structure: {
|
|
currencies: [{
|
|
currency: 'RUB',
|
|
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: [],
|
|
},
|
|
unrealizedReturnByCurrency: [
|
|
{ currency: 'RUB', absolute: '0', costBasis: '0', percent: null, partial: true },
|
|
],
|
|
});
|
|
const portfolio = await get('brokerage-accounts/account/portfolio').expect(
|
|
200,
|
|
);
|
|
expect(
|
|
(portfolio.body as PortfolioResourceDto).data?.totalsByCurrency,
|
|
).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?.totalsByCurrency).toEqual(before.data?.totalsByCurrency);
|
|
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({
|
|
totalsByCurrency: [{ currency: 'RUB', value: '21.1' }],
|
|
cashBalances: [{ currency: 'RUB', value: '1.1' }],
|
|
structure: { currencies: [{ currency: 'RUB' }] },
|
|
unrealizedReturnByCurrency: [
|
|
{ currency: 'RUB', 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();
|
|
});
|
|
});
|