import { INestApplication } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Test } from '@nestjs/testing'; import { setTimeout as delay } from 'node:timers/promises'; import type { Server } from 'node:http'; import request from 'supertest'; import { AppModule } from '../src/app.module'; import { configureApp } from '../src/main'; import { PrismaService } from '../src/prisma/prisma.service'; import { TbankClient } from '../src/integrations/tbank/tbank.client'; import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; import { TbankSyncStatusDto } from '../src/modules/investments/tbank-sync/tbank-sync.dto'; describe('screen sync HTTP and SQLite', () => { let app: INestApplication; let prisma: PrismaService; const upstream = { getOpenAccounts: jest.fn(), getPortfolio: jest.fn(), getPositions: jest.fn(), getOperationsByCursor: jest.fn(), findInstrument: jest.fn(), }; const auth = (id = 'owner') => `Bearer ${new JwtService().sign({ sub: id, type: 'access' }, { secret: process.env.JWT_SECRET })}`; const post = (scope: object, id = 'owner') => request(app.getHttpServer()) .post('/api/v1/tbank-sync') .set('Authorization', auth(id)) .send(scope); const status = (id: string, owner = 'owner') => request(app.getHttpServer()) .get(`/api/v1/tbank-sync/${id}`) .set('Authorization', auth(owner)); async function settled(id: string) { for (let i = 0; i < 300; i++) { const response = await status(id).expect(200); const body = response.body as TbankSyncStatusDto; if (body.state !== 'running') return body; await delay(5); } throw new Error('Screen work did not settle'); } 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); for (const id of ['owner', 'other']) await prisma.user.create({ data: { id, email: `${id}@example.invalid`, passwordHash: 'synthetic' }, }); }); beforeEach(async () => { await prisma.tbankConnection.deleteMany(); jest.resetAllMocks(); upstream.getOpenAccounts.mockResolvedValue([]); upstream.getPortfolio.mockResolvedValue({ positions: [] }); upstream.getPositions.mockResolvedValue({ money: [] }); for (const userId of ['owner', 'other']) await prisma.tbankConnection.create({ data: { userId, encryptedToken: app.get(TbankTokenService).encrypt('synthetic'), connectedAt: new Date(), membershipAt: new Date(), membershipRevision: 1, }, }); }); afterAll(async () => app.close()); it('accepts before blocked RPC finishes, joins identical requests, and observes without starting work', async () => { let release: (value: unknown[]) => void = () => {}; upstream.getOpenAccounts.mockReturnValue( new Promise((resolve) => { release = resolve; }), ); const responses = await Promise.all( Array.from({ length: 20 }, () => post({ screen: 'accounts' }).expect(202), ), ); const ids = responses.map( (response) => (response.body as { id: string }).id, ); expect(new Set(ids).size).toBe(1); expect((await status(ids[0]).expect(200)).body).toMatchObject({ state: 'running', scope: { screen: 'accounts' }, }); await status(ids[0], 'other').expect(404); expect(upstream.getOpenAccounts).toHaveBeenCalledTimes(1); release([]); const done = await settled(ids[0]); expect(done.state).toBe('succeeded'); expect(done.resources[0]).toMatchObject({ resourceKey: 'accounts', availability: 'complete', }); expect(JSON.stringify(done)).not.toMatch( /revision|attemptId|encryptedToken|synthetic/, ); await status(ids[0]).expect(200); expect(upstream.getOpenAccounts).toHaveBeenCalledTimes(1); expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); }); it.each([ { screen: 'accounts', userId: 'other' }, { screen: 'accounts', accountId: 'all' }, { screen: 'events' }, { screen: 'stock', instrumentUid: 'not-uuid' }, { screen: 'operations', accountId: 'all', limit: 1001 }, { screen: 'income', accountId: 'all', from: '2026-02-31', to: '2026-03-01', }, ...(['income', 'events'] as const).flatMap((screen) => [ { screen, accountId: 'all', from: '2025-02-29', to: '2025-03-01' }, { screen, accountId: 'all', from: '2024-03-01', to: '2024-02-29' }, { screen, accountId: 'all', from: '2024-02-29T00:00:00Z', to: '2024-02-29', }, { screen, accountId: 'all', from: '2024-02-29', to: '2024-02-29T23:59:59Z', }, ]), { screen: 'income', accountId: 'all', from: '2024-02-29' }, { screen: 'income', accountId: 'all', to: '2024-02-29' }, { screen: 'events', accountId: 'all', to: '2000-01-01' }, { screen: 'stock', instrumentUid: '00000000-0000-4000-8000-000000000001', candleRange: { from: '2026-02-01', to: '2026-01-01', jobs: [] }, }, ])('rejects malformed scope without reserving work: %j', async (scope) => { const response = await post(scope).expect(400); expect(response.headers['content-type']).toContain( 'application/problem+json', ); const problem = response.body as { code: string; errors: { pointer: string; detail: string }[]; }; expect(problem.code).toBe('VALIDATION_ERROR'); expect(problem.errors.length).toBeGreaterThan(0); for (const error of problem.errors) { expect(typeof error.pointer).toBe('string'); expect(typeof error.detail).toBe('string'); } expect(await prisma.tbankSyncWork.count()).toBe(0); expect(upstream.getOpenAccounts).not.toHaveBeenCalled(); }); it.each([ { screen: 'income', accountId: 'all', from: '2024-02-29', to: '2024-02-29', }, { screen: 'events', accountId: 'all', from: '2096-02-29', to: '2096-02-29', }, { screen: 'events', accountId: 'all', from: '2096-02-29' }, { screen: 'events', accountId: 'all', to: '2096-02-29' }, { screen: 'operations', accountId: 'all', from: '2024-02-29T00:00:00Z', to: '2024-03-01T00:00:00Z', }, ])( 'preserves valid calendar/instant scope in accepted and status responses: %j', async (scope) => { const response = await post(scope).expect(202); const accepted = response.body as { id: string; scope: object }; expect(accepted.scope).toEqual(scope); const result = await settled(accepted.id); expect(result.scope).toEqual(scope); expect(result.state).toBe('succeeded'); }, ); it('keeps failed work terminal, cancels abandoned work without restart, and hides old connection handles', async () => { upstream.getOpenAccounts.mockRejectedValue( new Error('upstream details never exposed'), ); const accepted = await post({ screen: 'accounts' }).expect(202); const id = (accepted.body as { id: string }).id; const failed = await settled(id); expect(failed.state).toBe('failed'); expect(failed.errorCode).toBe('TBANK_UNAVAILABLE'); await status(id).expect(200); expect(upstream.getOpenAccounts).toHaveBeenCalledTimes(1); await prisma.tbankSyncWork.update({ where: { id }, data: { state: 'running', errorCode: null, processRunId: 'previous-process', progress: { children: [], dispatched: false }, }, }); expect((await status(id).expect(200)).body).toMatchObject({ state: 'cancelled', }); expect(upstream.getOpenAccounts).toHaveBeenCalledTimes(1); await prisma.tbankConnection.delete({ where: { userId: 'owner' } }); await status(id).expect(404); }); it('refreshes only the applied operation period and reports empty coverage changes without changing data revision', async () => { const account = { id: 'a', name: 'Synthetic', type: 'ACCOUNT_TYPE_TINKOFF', openedDate: { seconds: '1577836800' }, }; upstream.getOpenAccounts.mockResolvedValue([account]); const connection = await prisma.tbankConnection.findUniqueOrThrow({ where: { userId: 'owner' }, }); await prisma.tbankAccount.create({ data: { userId: 'owner', revision: connection.revision, accountId: 'a', name: 'Synthetic', type: 'ACCOUNT_TYPE_TINKOFF', openedOn: '2020-01-01', upstreamOrder: 0, }, }); const path = '/api/v1/brokerage-accounts/a/operations?from=2025-01-01&to=2025-01-31&sync=observe'; const before = await request(app.getHttpServer()) .get(path) .set('Authorization', auth()) .expect(200); upstream.getOperationsByCursor.mockResolvedValue({ items: [], hasNext: false, }); const response = await post({ screen: 'operations', accountId: 'a', from: '2025-01-01', to: '2025-01-31', }).expect(202); const done = await settled((response.body as { id: string }).id); expect(done.state).toBe('succeeded'); expect(upstream.getPortfolio).not.toHaveBeenCalled(); expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(1); const after = await request(app.getHttpServer()) .get(path) .set('Authorization', auth()) .expect(200); const first = before.body as { meta: { dataRevision: string; stateVersion: string }; }; const last = after.body as { data: { items: unknown[] }; meta: { dataRevision: string; stateVersion: string; sync: { id: string }; }; }; expect(last.data.items).toEqual([]); expect(last.meta.dataRevision).toBe(first.meta.dataRevision); expect(last.meta.stateVersion).not.toBe(first.meta.stateVersion); expect(done.resources[0].stateVersion).toBe(last.meta.stateVersion); await status(last.meta.sync.id).expect(200); expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(1); }); it('rejects known foreign accounts locally and requires authentication', async () => { await post({ screen: 'operations', accountId: 'foreign' }).expect(404); await request(app.getHttpServer()) .post('/api/v1/tbank-sync') .send({ screen: 'accounts' }) .expect(401); expect(upstream.getOpenAccounts).not.toHaveBeenCalled(); }); });