moex-vibe/apps/backend/src/modules/tbank/services/broker-operation-sync.service.spec.ts

156 lines
4.9 KiB
TypeScript

import { PrismaService } from '../../prisma/prisma.service';
import { BrokerOperationSyncService } from './broker-operation-sync.service';
import { BrokerOperationsService } from './broker-operations.service';
describe('BrokerOperationSyncService', () => {
const operations = { getOperations: vi.fn() } as unknown as BrokerOperationsService;
const prisma = {
brokerOperation: { upsert: vi.fn() },
brokerOperationSyncState: { upsert: vi.fn() },
} as unknown as PrismaService;
beforeEach(() => {
vi.clearAllMocks();
});
it('syncs operation pages and stores raw payload', async () => {
vi.mocked(operations.getOperations)
.mockResolvedValueOnce({
data: {
accountId: 'acc-1',
hasNext: true,
nextCursor: 'next',
asOf: '2026-06-16T00:00:00.000Z',
items: [
{
cursor: 'c1',
accountId: 'acc-1',
id: 'op-1',
parentOperationId: null,
date: '2026-06-16T00:00:00.000Z',
type: 'OPERATION_TYPE_BUY',
category: 'trade',
description: null,
state: 'OPERATION_STATE_EXECUTED',
instrumentUid: 'uid-1',
figi: null,
ticker: 'SBER',
classCode: 'TQBR',
instrumentType: 'share',
payment: { currency: 'RUB', units: '-1000', nano: 0, value: -1000 },
price: null,
commission: null,
yield: null,
accruedInt: null,
quantity: 10,
quantityDone: 10,
},
],
},
meta: { fromCache: false, cachedAt: null },
})
.mockResolvedValueOnce({
data: { accountId: 'acc-1', hasNext: false, nextCursor: null, asOf: 'now', items: [] },
meta: { fromCache: false, cachedAt: null },
});
const service = new BrokerOperationSyncService(operations, prisma);
const result = await service.syncAccount('acc-1', {
from: '2026-06-01T00:00:00.000Z',
to: '2026-06-16T00:00:00.000Z',
});
expect(result.upserted).toBe(1);
expect(operations.getOperations).toHaveBeenNthCalledWith(2, 'acc-1', {
from: '2026-06-01T00:00:00.000Z',
to: '2026-06-16T00:00:00.000Z',
cursor: 'next',
limit: 1000,
state: 'OPERATION_STATE_EXECUTED',
});
expect(prisma.brokerOperation.upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { accountId_cursor: { accountId: 'acc-1', cursor: 'c1' } },
}),
);
expect(prisma.brokerOperationSyncState.upsert).toHaveBeenCalledWith(
expect.objectContaining({
update: expect.objectContaining({ lastCursor: 'next' }),
}),
);
});
it('stores operations under the synced account id', async () => {
vi.mocked(operations.getOperations).mockResolvedValueOnce({
data: {
accountId: 'acc-1',
hasNext: false,
nextCursor: null,
asOf: '2026-06-16T00:00:00.000Z',
items: [
{
cursor: 'c1',
accountId: 'unexpected-account',
id: 'op-1',
parentOperationId: null,
date: null,
type: 'OPERATION_TYPE_BUY',
category: 'trade',
description: null,
state: null,
instrumentUid: null,
figi: null,
ticker: null,
classCode: null,
instrumentType: null,
payment: null,
price: null,
commission: null,
yield: null,
accruedInt: null,
quantity: null,
quantityDone: null,
},
],
},
meta: { fromCache: false, cachedAt: null },
});
const service = new BrokerOperationSyncService(operations, prisma);
await service.syncAccount('acc-1', {
from: '2026-06-01T00:00:00.000Z',
to: '2026-06-16T00:00:00.000Z',
});
expect(prisma.brokerOperation.upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { accountId_cursor: { accountId: 'acc-1', cursor: 'c1' } },
create: expect.objectContaining({ accountId: 'acc-1' }),
update: expect.objectContaining({ accountId: 'acc-1' }),
}),
);
});
it('fails when a page claims more data without a next cursor', async () => {
vi.mocked(operations.getOperations).mockResolvedValueOnce({
data: {
accountId: 'acc-1',
hasNext: true,
nextCursor: null,
asOf: '2026-06-16T00:00:00.000Z',
items: [],
},
meta: { fromCache: false, cachedAt: null },
});
const service = new BrokerOperationSyncService(operations, prisma);
await expect(
service.syncAccount('acc-1', {
from: '2026-06-01T00:00:00.000Z',
to: '2026-06-16T00:00:00.000Z',
}),
).rejects.toThrow('T-Bank returned hasNext without nextCursor');
});
});