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.
187 lines
6.0 KiB
TypeScript
187 lines
6.0 KiB
TypeScript
import { ConfigService } from '@nestjs/config';
|
|
import assert from 'node:assert/strict';
|
|
|
|
import type { Prisma } from '../generated/prisma/client';
|
|
import type { PortfolioDto } from '../src/modules/investments/portfolio/presentation/portfolio.dto';
|
|
|
|
import { PortfolioRepository } from '../src/modules/investments/portfolio/infrastructure/portfolio.repository';
|
|
import { PrismaService } from '../src/prisma/prisma.service';
|
|
|
|
// Real SQLite transaction: a running attempt of the current revision publishes its snapshot.
|
|
describe('portfolio publication (SQLite)', () => {
|
|
const prisma = new PrismaService(
|
|
new ConfigService({ DATABASE_URL: process.env.DATABASE_URL }),
|
|
);
|
|
const repository = new PortfolioRepository(prisma);
|
|
const snapshot = {
|
|
lastSuccessfulAt: '2026-01-01T00:00:00.000Z',
|
|
isStale: false,
|
|
totals: { currency: 'RUB', value: '0' },
|
|
positions: [],
|
|
cash: { currency: 'RUB', value: '0' },
|
|
structure: { totalValue: '0', hasNegativeValues: false, categories: [], unvaluedCategories: [] },
|
|
} satisfies PortfolioDto;
|
|
const accountId = 'account';
|
|
const valuedSnapshot = {
|
|
...snapshot,
|
|
totals: { currency: 'RUB', value: '200' },
|
|
} satisfies PortfolioDto;
|
|
const createRunningAttempt = () =>
|
|
prisma.tbankSyncWork.create({
|
|
data: {
|
|
id: 'live',
|
|
userId: 'owner',
|
|
revision: 'current',
|
|
scopeKey: `portfolio:${accountId}`,
|
|
intent: {},
|
|
state: 'running',
|
|
attemptId: 'live-attempt',
|
|
processRunId: 'run',
|
|
priority: 1,
|
|
progress: {},
|
|
},
|
|
});
|
|
const interceptTransaction = (intercept: (tx: Prisma.TransactionClient) => void) => {
|
|
const transaction = prisma.$transaction.bind(prisma);
|
|
jest.spyOn(prisma, '$transaction').mockImplementation(
|
|
(callback: (tx: Prisma.TransactionClient) => Promise<unknown>) =>
|
|
transaction((tx) => {
|
|
intercept(tx);
|
|
return callback(tx);
|
|
}),
|
|
);
|
|
};
|
|
|
|
beforeAll(async () => {
|
|
await prisma.$connect();
|
|
await prisma.user.create({
|
|
data: { id: 'owner', email: 'owner@example.invalid', passwordHash: 'synthetic' },
|
|
});
|
|
});
|
|
afterAll(async () => prisma.$disconnect());
|
|
afterEach(() => jest.restoreAllMocks());
|
|
beforeEach(async () => {
|
|
await prisma.tbankPortfolioValueHistory.deleteMany();
|
|
await prisma.tbankConnection.deleteMany();
|
|
await prisma.tbankConnection.create({
|
|
data: {
|
|
userId: 'owner',
|
|
revision: 'current',
|
|
encryptedToken: 'synthetic',
|
|
connectedAt: new Date(),
|
|
accounts: {
|
|
create: {
|
|
accountId,
|
|
type: 'brokerage',
|
|
name: 'Synthetic',
|
|
upstreamOrder: 0,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it('publishes the same snapshot for the running attempt of the current revision', async () => {
|
|
await createRunningAttempt();
|
|
const published = await repository.publish(
|
|
{ id: 'live', attemptId: 'live-attempt', userId: 'owner', revision: 'current' },
|
|
accountId,
|
|
snapshot,
|
|
);
|
|
assert.equal(published, true);
|
|
expect(await prisma.tbankPortfolio.count()).toBe(1);
|
|
});
|
|
|
|
it('rolls back the snapshot, value point and claim when the point write fails', async () => {
|
|
const previousFetchedAt = new Date('2025-12-31T00:00:00.000Z');
|
|
const previousCapturedAt = new Date('2025-12-31T01:00:00.000Z');
|
|
await prisma.tbankPortfolio.create({
|
|
data: {
|
|
userId: 'owner',
|
|
revision: 'current',
|
|
accountId,
|
|
payload: snapshot,
|
|
fetchedAt: previousFetchedAt,
|
|
},
|
|
});
|
|
await prisma.tbankPortfolioValueHistory.create({
|
|
data: {
|
|
userId: 'owner',
|
|
accountId,
|
|
points: {
|
|
create: {
|
|
attemptId: 'previous-attempt',
|
|
value: '100',
|
|
capturedAt: previousCapturedAt,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
await createRunningAttempt();
|
|
interceptTransaction((tx) => {
|
|
jest
|
|
.spyOn(tx.tbankPortfolioValuePoint, 'upsert')
|
|
.mockRejectedValueOnce(new Error('injected point failure'));
|
|
});
|
|
|
|
await expect(
|
|
repository.publish(
|
|
{ id: 'live', attemptId: 'live-attempt', userId: 'owner', revision: 'current' },
|
|
accountId,
|
|
valuedSnapshot,
|
|
),
|
|
).rejects.toThrow('injected point failure');
|
|
|
|
expect(
|
|
await prisma.tbankPortfolio.findUnique({
|
|
where: { userId_revision_accountId: { userId: 'owner', revision: 'current', accountId } },
|
|
}),
|
|
).toMatchObject({ payload: snapshot, fetchedAt: previousFetchedAt, dataRevision: 1 });
|
|
expect(
|
|
await prisma.tbankPortfolioValuePoint.findMany({
|
|
select: { attemptId: true, value: true, capturedAt: true },
|
|
}),
|
|
).toEqual([
|
|
{ attemptId: 'previous-attempt', value: '100', capturedAt: previousCapturedAt },
|
|
]);
|
|
expect(await prisma.tbankSyncWork.findUniqueOrThrow({ where: { id: 'live' } }))
|
|
.toMatchObject({ state: 'running', attemptId: 'live-attempt' });
|
|
});
|
|
|
|
it.each(['stale attempt', 'complete work', 'invalid token'] as const)(
|
|
'does not publish for %s',
|
|
async (scenario) => {
|
|
await createRunningAttempt();
|
|
if (scenario === 'complete work') {
|
|
await prisma.tbankSyncWork.update({
|
|
where: { id: 'live' },
|
|
data: { state: 'complete' },
|
|
});
|
|
}
|
|
if (scenario === 'invalid token') {
|
|
await prisma.tbankConnection.update({
|
|
where: { userId: 'owner' },
|
|
data: { invalidToken: true },
|
|
});
|
|
}
|
|
|
|
await expect(
|
|
repository.publish(
|
|
{
|
|
id: 'live',
|
|
attemptId: scenario === 'stale attempt' ? 'stale-attempt' : 'live-attempt',
|
|
userId: 'owner',
|
|
revision: 'current',
|
|
},
|
|
accountId,
|
|
valuedSnapshot,
|
|
),
|
|
).resolves.toBe(false);
|
|
|
|
expect(await prisma.tbankPortfolio.count()).toBe(0);
|
|
expect(await prisma.tbankPortfolioValueHistory.count()).toBe(0);
|
|
expect(await prisma.tbankPortfolioValuePoint.count()).toBe(0);
|
|
},
|
|
);
|
|
});
|