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 request from 'supertest'; import { AppModule } from '../src/app.module'; import { TbankClient } from '../src/integrations/tbank/tbank.client'; import { configureApp } from '../src/main'; import { IncomeAnalyticsResourceDto, IncomeSummaryResourceDto, } from '../src/modules/investments/income/income-analytics.dto'; import { moscowDate } from '../src/modules/investments/investment-calendar'; import { BrokerageOperationsRepository } from '../src/modules/investments/operations/infrastructure/brokerage-operations.repository'; import { PrismaService } from '../src/prisma/prisma.service'; // Financial proof uses real persisted rows/coverage and the configured HTTP boundary. describe('local income completeness and exact money (SQLite)', () => { let app: INestApplication; let prisma: PrismaService; let revision: string; const upstream = { validateToken: jest.fn(), getOpenAccounts: jest.fn(), getOperationsByCursor: jest.fn(), }; const from = new Date('2024-12-31T21:00:00Z'); const to = new Date('2025-01-31T21:00:00Z'); const authorization = `Bearer ${new JwtService().sign({ sub: 'income-owner', type: 'access' }, { secret: process.env.JWT_SECRET })}`; const get = ( account = 'all', period = 'from=2025-01-01&to=2025-01-31', observe = true, ) => request(app.getHttpServer()) .get( `/api/v1/brokerage-accounts/${account}/income-analytics?${period}${observe ? '&sync=observe' : ''}`, ) .set('Authorization', authorization) .timeout(2000); const read = async (account = 'all', period?: string) => (await get(account, period).expect(200)).body as IncomeAnalyticsResourceDto; const coverage = async (accountId: string, start = from, end = to) => prisma.tbankOperationCoverage.create({ data: { userId: 'income-owner', revision, accountId, from: start, to: end, asOf: end, fetchedAt: new Date('2025-02-01Z'), }, }); beforeAll(async () => { const module = await Test.createTestingModule({ imports: [AppModule] }) .overrideProvider(TbankClient) .useValue(upstream) .compile(); app = module.createNestApplication(); configureApp(app); await app.init(); await app.listen(0, '127.0.0.1'); prisma = app.get(PrismaService); await prisma.user.create({ data: { id: 'income-owner', email: 'income@example.invalid', passwordHash: 'synthetic', }, }); }); afterAll(async () => app?.close()); beforeEach(async () => { await prisma.tbankConnection.deleteMany(); jest.clearAllMocks(); upstream.validateToken.mockResolvedValue( ['a', 'b'].map((id) => ({ id, name: id, type: 'ACCOUNT_TYPE_TINKOFF', openedDate: { seconds: '1577836800' }, })), ); upstream.getOperationsByCursor.mockImplementation( () => new Promise(() => undefined), ); await request(app.getHttpServer()) .put('/api/v1/tbank-connection') .set('Authorization', authorization) .send({ token: 'synthetic' }) .expect(200); revision = ( await prisma.tbankConnection.findUniqueOrThrow({ where: { userId: 'income-owner' }, }) ).revision; for (const accountId of ['a', 'b']) { await coverage(accountId); for (const [id, units, nano, currency, type] of [ ['same', '10', 1, 'rub', 'OPERATION_TYPE_COUPON'], ['negative', '-2', -500000000, 'rub', 'OPERATION_TYPE_DIVIDEND'], ['usd', '3', 0, 'usd', 'OPERATION_TYPE_COUPON'], ] as const) { await prisma.tbankOperation.create({ data: { userId: 'income-owner', revision, accountId, brokerOperationId: id, category: 'income', occurredAt: new Date('2025-01-15Z'), payload: { id, type, date: { seconds: String(Date.parse('2025-01-15Z') / 1000) }, payment: { units, nano, currency }, }, }, }); } } }); it('returns exact selected sums without a false zero comparison; reopen and observe need no RPC', async () => { for (let index = 0; index < 2; index++) { const result = await read(); assert(result.data); expect(result.meta.availability).toBe('available'); expect(result.meta.completeness).toBe('complete'); expect(result.data.totalsByCurrency.map((row) => row.total)).toEqual([ { currency: 'RUB', value: '15.000000002' }, { currency: 'USD', value: '6' }, ]); expect(result.data.entries).toHaveLength(6); expect( result.data.entries .filter((row) => row.id === 'same') .map((row) => row.account?.id), ).toEqual(['a', 'b']); expect(result.data.previous).toBeNull(); expect(result.data.yearComparison).toBeNull(); expect(result.meta.dependencies.map(({ role }) => role)).toEqual([ 'selected', 'previous', 'year', ]); const previous = result.meta.dependencies.find(({ role }) => role === 'previous'); expect(previous?.availability).toBe('temporarily-unavailable'); expect(previous?.completeness).toBe('incomplete'); expect(result.data.calculatedAt).toBe('2025-02-01T00:00:00.000Z'); await prisma.$disconnect(); } expect(upstream.getOpenAccounts).not.toHaveBeenCalled(); expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); }); it('requires every account and separates missing, partial and confirmed empty periods', async () => { await prisma.tbankOperationCoverage.deleteMany({ where: { accountId: 'b' }, }); expect((await read()).data).toBeNull(); const incomplete = await read(); expect(incomplete.meta.availability).toBe('temporarily-unavailable'); expect(incomplete.meta.completeness).toBe('incomplete'); expect((await read('a')).data).not.toBeNull(); expect((await read('a', 'from=2025-03-01&to=2025-03-31')).data).toBeNull(); await coverage( 'a', new Date('2025-02-28T21:00:00Z'), new Date('2025-03-31T21:00:00Z'), ); expect( (await read('a', 'from=2025-03-01&to=2025-03-31')).data?.totalsByCurrency, ).toEqual([]); expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); }); it('completes previous/yearAgo independently and counts corrected identities once', async () => { for (const account of ['a', 'b']) { await coverage(account, new Date('2024-11-30T21:00:00Z'), from); await coverage( account, new Date('2023-12-31T21:00:00Z'), new Date('2024-01-31T21:00:00Z'), ); } await prisma.tbankOperation.updateMany({ where: { accountId: 'a', brokerOperationId: 'same' }, data: { payload: { id: 'same', type: 'OPERATION_TYPE_COUPON', date: { seconds: String(Date.parse('2025-01-15Z') / 1000) }, payment: { units: '20', nano: 1, currency: 'rub' }, }, }, }); const result = await read(); assert(result.data?.previous && result.data.yearComparison); expect(result.data.entries).toHaveLength(6); expect( result.data.previous.totalsByCurrency[0].difference.total.value, ).toBe('25.000000002'); expect( result.data.yearComparison.totalsByCurrency[0].compared.total.value, ).toBe('0'); }); it('preserves the complete financial result and timestamp through failed atomic reconciliation', async () => { const before = await read(); const repo = app.get(BrokerageOperationsRepository); const attempt = await repo.request( 'income-owner', revision, ['a', 'b'].map((accountId) => ({ accountId, from, to })), 'test', true, new Set(), 1, ); assert(attempt); const fence = { id: attempt.work.id, attemptId: attempt.work.attemptId, userId: 'income-owner', revision, }; const pending = await repo.pending(fence); assert(pending); await repo.stagePage(fence, pending.intervals[0], undefined, { hasNext: true, nextCursor: 'unfinished', items: [ { id: 'new', type: 'OPERATION_TYPE_COUPON', date: { seconds: String(pending.intervals[0].from.valueOf() / 1000) }, payment: { currency: 'rub', units: '999' }, }, ], }); expect((await read()).data).toEqual(before.data); await repo.fail(fence, false); const after = await read(); expect(after.data).toEqual(before.data); expect(after.meta.lastAttempt.state).toBe('failed'); expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); }); it('retains current-month observed completeness and does not renew source timestamps', async () => { const now = new Date(); const today = moscowDate(now); const start = new Date(`${today.slice(0, 7)}-01T00:00:00+03:00`); const asOf = new Date(now.valueOf() - 10_000); for (const accountId of ['a', 'b']) { await coverage(accountId, start, asOf); } const response = await request(app.getHttpServer()) .get('/api/v1/brokerage-accounts/all/income-summary?sync=observe') .set('Authorization', authorization) .expect(200); const result = response.body as IncomeSummaryResourceDto; expect(result.data?.period).toEqual({ from: `${today.slice(0, 7)}-01`, to: today, }); expect(result.meta.availability).toBe('available'); expect(result.meta.completeness).toBe('complete'); expect(result.meta.coverage?.asOf).toBe(asOf.toISOString()); expect(result.data?.calculatedAt).toBe('2025-02-01T00:00:00.000Z'); expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); }); it('returns while a cold upstream is blocked and validates access/period before scheduling', async () => { await get('absent', undefined, false).expect(404); await get('a', 'from=2025-02-31&to=2025-03-01', false).expect(400); expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); const response = await get('all', undefined, false).expect(200); expect((response.body as IncomeAnalyticsResourceDto).data).not.toBeNull(); }); it('distinguishes unavailable calendar comparison without scheduling it', async () => { const result = await read('a', 'from=2024-02-29&to=2024-02-29'); expect( result.meta.dependencies.find(({ role }) => role === 'year') ?.unavailableReason, ).toBe('CALENDAR_OR_ACCOUNT_HISTORY'); expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); }); });