Files
backend/test/tbank-screen-sync.e2e-spec.ts
T

419 lines
15 KiB
TypeScript

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<Server>;
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();
});
});