From e350146749293f2eaf8060f7626ff3c1cd8edf2c Mon Sep 17 00:00:00 2001 From: codex-bot Date: Wed, 28 Oct 2026 21:10:50 +0300 Subject: [PATCH] test: cover portfolio publish rollback and claim fence (WealthLedger/backend#105) (#146) --- test/tbank-portfolio-publish.e2e-spec.ts | 151 ++++++++++++++++++++--- 1 file changed, 135 insertions(+), 16 deletions(-) diff --git a/test/tbank-portfolio-publish.e2e-spec.ts b/test/tbank-portfolio-publish.e2e-spec.ts index 4c877e7..911be8d 100644 --- a/test/tbank-portfolio-publish.e2e-spec.ts +++ b/test/tbank-portfolio-publish.e2e-spec.ts @@ -1,6 +1,9 @@ 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'; @@ -16,9 +19,41 @@ describe('portfolio publication (SQLite)', () => { totalsByCurrency: [], positions: [], cashBalances: [], - structure: {}, - } as never; + structure: { currencies: [], unvaluedCategories: [] }, + } satisfies PortfolioDto; const accountId = 'account'; + const valuedSnapshot = { + ...snapshot, + totalsByCurrency: [ + { currency: 'RUB', value: '200' }, + { currency: 'USD', value: '10' }, + ], + } 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(); @@ -27,7 +62,9 @@ describe('portfolio publication (SQLite)', () => { }); }); afterAll(async () => prisma.$disconnect()); + afterEach(() => jest.restoreAllMocks()); beforeEach(async () => { + await prisma.tbankPortfolioValueHistory.deleteMany(); await prisma.tbankConnection.deleteMany(); await prisma.tbankConnection.create({ data: { @@ -48,20 +85,7 @@ describe('portfolio publication (SQLite)', () => { }); it('publishes the same snapshot for the running attempt of the current revision', async () => { - await prisma.tbankSyncWork.create({ - data: { - id: 'live', - userId: 'owner', - revision: 'current', - scopeKey: `portfolio:${accountId}`, - intent: {}, - state: 'running', - attemptId: 'live-attempt', - processRunId: 'run', - priority: 1, - progress: {}, - }, - }); + await createRunningAttempt(); const published = await repository.publish( { id: 'live', attemptId: 'live-attempt', userId: 'owner', revision: 'current' }, accountId, @@ -70,4 +94,99 @@ describe('portfolio publication (SQLite)', () => { assert.equal(published, true); expect(await prisma.tbankPortfolio.count()).toBe(1); }); + + it('rolls back the snapshot, first value point and claim when the second point 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', + currency: 'RUB', + value: '100', + capturedAt: previousCapturedAt, + }, + }, + }, + }); + await createRunningAttempt(); + interceptTransaction((tx) => { + const upsert = tx.tbankPortfolioValuePoint.upsert.bind(tx.tbankPortfolioValuePoint); + jest + .spyOn(tx.tbankPortfolioValuePoint, 'upsert') + .mockImplementationOnce((args) => upsert(args)) + .mockRejectedValueOnce(new Error('injected second point failure')); + }); + + await expect( + repository.publish( + { id: 'live', attemptId: 'live-attempt', userId: 'owner', revision: 'current' }, + accountId, + valuedSnapshot, + ), + ).rejects.toThrow('injected second 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); + }, + ); });