import type { Server } from 'node:http'; import { INestApplication } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Test } from '@nestjs/testing'; import { setTimeout as delay } from 'node:timers/promises'; import request from 'supertest'; import { AppModule } from '../src/app.module'; import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; import { TbankClient } from '../src/integrations/tbank/tbank.client'; import { configureApp } from '../src/main'; import { TbankSyncStatusDto } from '../src/modules/investments/tbank-sync/tbank-sync.dto'; import { PrismaService } from '../src/prisma/prisma.service'; 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': [], '~bad/path': 'SYNTHETIC_VALIDATION_SECRET', }, }, ])('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: { detail: string; pointer: 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'); } if ('candleRange' in scope) { // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. expect( problem.errors.filter( (error) => error.pointer === '#/candleRange/jobs', ), ).toHaveLength(1); // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. expect(problem.errors).toContainEqual({ pointer: '#/candleRange/~0bad~1path', // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. detail: expect.any(String) as unknown, }); // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. expect(JSON.stringify(response.body)).not.toContain( 'SYNTHETIC_VALIDATION_SECRET', ); } 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.each(['succeeded', 'failed'] as const)( 'observes the operations successor until it is %s', async (outcome) => { const accounts = [ { id: 'a', name: 'Synthetic', type: 'ACCOUNT_TYPE_TINKOFF', openedDate: { seconds: '1577836800' }, }, ]; let releaseMembership: (value: typeof accounts) => void = () => {}; upstream.getOpenAccounts.mockReturnValue( new Promise((resolve) => { releaseMembership = resolve; }), ); 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, }, }); let release: (value: { hasNext: boolean; items: never[]; }) => void = () => {}; let reject: (error: Error) => void = () => {}; upstream.getOperationsByCursor .mockReturnValueOnce( new Promise((resolve, rejectResult) => { release = resolve; reject = rejectResult; }), ) .mockImplementation(() => outcome === 'failed' ? Promise.reject(new Error('Synthetic outage')) : Promise.resolve({ hasNext: false, items: [] }), ); const accept = async (from: string, to: string) => { const response = await post({ screen: 'operations', accountId: 'a', from, to, }).expect(202); return (response.body as { id: string }).id; }; const dispatched = async (id: string) => { for (let i = 0; i < 300; i++) { const work = await prisma.tbankSyncWork.findUniqueOrThrow({ where: { id }, }); if ((work.progress as { dispatched: boolean }).dispatched) return id; await delay(5); } throw new Error('Screen work did not dispatch'); }; const first = await accept('2026-01-01', '2026-01-10'); const second = await accept('2026-01-20', '2026-01-31'); const merged = await accept('2026-01-01', '2026-01-31'); releaseMembership(accounts); await Promise.all([first, second, merged].map(dispatched)); try { expect((await status(second).expect(200)).body).toMatchObject({ state: 'running', progress: { completed: 1, total: 2, discovering: false }, }); await status(second, 'other').expect(404); } finally { if (outcome === 'failed') reject(new Error('Synthetic outage')); else release({ hasNext: false, items: [] }); await settled(merged); await settled(first); } expect(await settled(second)).toMatchObject({ state: outcome, errorCode: outcome === 'failed' ? 'TBANK_UNAVAILABLE' : null, progress: { completed: 2, total: 2, discovering: false }, }); await prisma.tbankConnection.delete({ where: { userId: 'owner' } }); await status(second).expect(404); }, ); 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(); }); });