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; 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.meta.dependencies.map(({ role }) => role)).toEqual([ 'selected', ]); 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('uses saved instrument logos for income entries without changing their financial data', async () => { const before = await read(); const base = before.data; assert(base); const fetchedAt = new Date(); await prisma.tbankOperation.update({ where: { userId_revision_accountId_brokerOperationId: { userId: 'income-owner', revision, accountId: 'a', brokerOperationId: 'same', }, }, data: { payload: { id: 'same', figi: 'income-figi', type: 'OPERATION_TYPE_COUPON', date: { seconds: String(Date.parse('2025-01-15Z') / 1000) }, payment: { units: '10', nano: 1, currency: 'rub' }, }, }, }); await prisma.tbankInstrument.create({ data: { provider: 'tbank', uid: 'income-uid', figi: 'income-figi', instrumentType: 'bond', fetchedAt, metadata: { uid: 'income-uid', figi: 'income-figi', name: 'Bond', brand: { logoName: 'bond.png' } }, }, }); await prisma.tbankInstrumentLookup.create({ data: { provider: 'tbank', identifierType: 'figi', identifier: 'income-figi', matchedUids: ['income-uid'], fetchedAt, }, }); const after = await read(); assert(after.data); expect(after.data.entries.find((entry) => entry.account?.id === 'a' && entry.id === 'same')?.logoName).toBe('bond.png'); expect(after.data.entries.find((entry) => entry.account?.id === 'b' && entry.id === 'same')?.logoName).toBeNull(); expect(after.data.totalsByCurrency).toEqual(base.totalsByCurrency); expect(after.data.calculatedAt).toBe(base.calculatedAt); 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('counts corrected identities once for the selected period', async () => { 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); expect(result.data.entries).toHaveLength(6); expect( result.data.totalsByCurrency.find(({ currency }) => currency === 'RUB') ?.total, ).toEqual({ currency: 'RUB', value: '25.000000002' }); }); 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); // Without an hourly walk the durable attempt is absent; the failed // source work stays a technical observation, not the hourly attempt. expect(after.meta.lastAttempt.state).toBe('none'); 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('plans only the selected period for a leap day without scheduling it', async () => { const result = await read('a', 'from=2024-02-29&to=2024-02-29'); expect(result.meta.dependencies.map(({ role }) => role)).toEqual([ 'selected', ]); expect(result.meta.dependencies[0].availability).toBe('temporarily-unavailable'); expect(result.data).toBeNull(); expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); }); });