419 lines
15 KiB
TypeScript
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();
|
|
});
|
|
});
|