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) => 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); }, ); });