- Add state filter (EXECUTED/null) to analytics query (spec compliance) - Add service unit tests (11 tests) and controller test - Add sync button to operations page with mutation hook - Regenerate frontend types via codegen - Update tasks.md marking all items complete Backend: 114 tests, Frontend: 116 tests — all pass
233 lines
8.2 KiB
TypeScript
233 lines
8.2 KiB
TypeScript
import { NotFoundException } from '@nestjs/common';
|
|
import { CacheService } from '../../cache/cache.service';
|
|
import { BrokerAccountsService } from './broker-accounts.service';
|
|
import { BrokerAnalyticsService } from './broker-analytics.service';
|
|
import { PrismaService } from '../../prisma/prisma.service';
|
|
|
|
describe('BrokerAnalyticsService', () => {
|
|
const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService;
|
|
const prisma = { brokerOperation: { findMany: vi.fn() } } as unknown as PrismaService;
|
|
const cache = { getOrFetch: vi.fn() } as unknown as CacheService;
|
|
|
|
const acc1 = {
|
|
id: 'acc-1',
|
|
type: 'brokerage' as const,
|
|
name: 'Test Broker',
|
|
status: 'ACCOUNT_STATUS_OPEN',
|
|
openedAt: null,
|
|
accessLevel: null,
|
|
};
|
|
|
|
function mockCachePassthrough() {
|
|
vi.mocked(cache.getOrFetch).mockImplementation(
|
|
async (_prefix: string, _parts: string[], fetchFn: () => Promise<unknown>) => ({
|
|
data: await fetchFn(),
|
|
fromCache: false,
|
|
cachedAt: null,
|
|
}),
|
|
);
|
|
}
|
|
|
|
function makeOp(type: string, value: number, state?: string | null) {
|
|
return { type, payment: JSON.stringify({ value, currency: 'RUB' }), state } as any;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('throws 404 for missing account', async () => {
|
|
vi.mocked(accounts.findById).mockResolvedValue(null);
|
|
|
|
const service = new BrokerAnalyticsService(prisma, accounts, cache);
|
|
await expect(service.getAnalytics('missing')).rejects.toThrow(NotFoundException);
|
|
});
|
|
|
|
it('returns zeros for account with no operations', async () => {
|
|
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
|
mockCachePassthrough();
|
|
vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([]);
|
|
|
|
const service = new BrokerAnalyticsService(prisma, accounts, cache);
|
|
const result = await service.getAnalytics('acc-1');
|
|
|
|
expect(result.data).toEqual({
|
|
totalDeposits: 0,
|
|
totalWithdrawn: 0,
|
|
netInvested: 0,
|
|
totalDividends: 0,
|
|
totalCoupons: 0,
|
|
totalReceived: 0,
|
|
totalReturnPercent: null,
|
|
currency: 'RUB',
|
|
});
|
|
});
|
|
|
|
it('aggregates deposit types correctly', async () => {
|
|
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
|
mockCachePassthrough();
|
|
vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([
|
|
makeOp('OPERATION_TYPE_INPUT', 1000),
|
|
makeOp('OPERATION_TYPE_INPUT_SWIFT', 500),
|
|
makeOp('OPERATION_TYPE_INP_MULTI', 200),
|
|
makeOp('OPERATION_TYPE_OVER_PLACEMENT', 300),
|
|
makeOp('OPERATION_TYPE_TRANS_IIS_BS', 100),
|
|
makeOp('OPERATION_TYPE_TRANS_BS_BS', 50),
|
|
makeOp('OPERATION_TYPE_INPUT_ACQUIRING', 150),
|
|
]);
|
|
|
|
const service = new BrokerAnalyticsService(prisma, accounts, cache);
|
|
const result = await service.getAnalytics('acc-1');
|
|
|
|
expect(result.data.totalDeposits).toBe(2300);
|
|
expect(result.data.totalWithdrawn).toBe(0);
|
|
expect(result.data.netInvested).toBe(2300);
|
|
});
|
|
|
|
it('aggregates withdrawal types with absolute value', async () => {
|
|
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
|
mockCachePassthrough();
|
|
vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([
|
|
makeOp('OPERATION_TYPE_OUTPUT', -500),
|
|
makeOp('OPERATION_TYPE_OUTPUT_SWIFT', -200),
|
|
makeOp('OPERATION_TYPE_OUTPUT_ACQUIRING', -100),
|
|
makeOp('OPERATION_TYPE_OUT_MULTI', -50),
|
|
makeOp('OPERATION_TYPE_INPUT', 1000),
|
|
]);
|
|
|
|
const service = new BrokerAnalyticsService(prisma, accounts, cache);
|
|
const result = await service.getAnalytics('acc-1');
|
|
|
|
expect(result.data.totalDeposits).toBe(1000);
|
|
expect(result.data.totalWithdrawn).toBe(850);
|
|
expect(result.data.netInvested).toBe(150);
|
|
});
|
|
|
|
it('aggregates dividend and coupon types', async () => {
|
|
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
|
mockCachePassthrough();
|
|
vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([
|
|
makeOp('OPERATION_TYPE_DIVIDEND', 300),
|
|
makeOp('OPERATION_TYPE_DIV_EXT', 150),
|
|
makeOp('OPERATION_TYPE_COUPON', 75),
|
|
makeOp('OPERATION_TYPE_COUPON', 25),
|
|
]);
|
|
|
|
const service = new BrokerAnalyticsService(prisma, accounts, cache);
|
|
const result = await service.getAnalytics('acc-1');
|
|
|
|
expect(result.data.totalDividends).toBe(450);
|
|
expect(result.data.totalCoupons).toBe(100);
|
|
expect(result.data.totalReceived).toBe(550);
|
|
});
|
|
|
|
it('calculates totalReturnPercent correctly', async () => {
|
|
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
|
mockCachePassthrough();
|
|
vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([
|
|
makeOp('OPERATION_TYPE_INPUT', 10000),
|
|
makeOp('OPERATION_TYPE_DIVIDEND', 500),
|
|
makeOp('OPERATION_TYPE_COUPON', 200),
|
|
]);
|
|
|
|
const service = new BrokerAnalyticsService(prisma, accounts, cache);
|
|
const result = await service.getAnalytics('acc-1');
|
|
|
|
expect(result.data.netInvested).toBe(10000);
|
|
expect(result.data.totalReceived).toBe(700);
|
|
expect(result.data.totalReturnPercent).toBe(7);
|
|
});
|
|
|
|
it('returns null totalReturnPercent when netInvested <= 0', async () => {
|
|
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
|
mockCachePassthrough();
|
|
vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([
|
|
makeOp('OPERATION_TYPE_OUTPUT', -500),
|
|
makeOp('OPERATION_TYPE_DIVIDEND', 100),
|
|
]);
|
|
|
|
const service = new BrokerAnalyticsService(prisma, accounts, cache);
|
|
const result = await service.getAnalytics('acc-1');
|
|
|
|
expect(result.data.netInvested).toBe(-500);
|
|
expect(result.data.totalReturnPercent).toBeNull();
|
|
});
|
|
|
|
it('handles malformed payment JSON gracefully', async () => {
|
|
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
|
mockCachePassthrough();
|
|
vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([
|
|
{ type: 'OPERATION_TYPE_INPUT', payment: 'invalid-json', state: 'OPERATION_STATE_EXECUTED' },
|
|
{ type: 'OPERATION_TYPE_INPUT', payment: JSON.stringify({ value: 500, currency: 'RUB' }), state: 'OPERATION_STATE_EXECUTED' },
|
|
] as any);
|
|
|
|
const service = new BrokerAnalyticsService(prisma, accounts, cache);
|
|
const result = await service.getAnalytics('acc-1');
|
|
|
|
expect(result.data.totalDeposits).toBe(500);
|
|
});
|
|
|
|
it('ignores non-executed and non-null state operations', async () => {
|
|
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
|
mockCachePassthrough();
|
|
|
|
const service = new BrokerAnalyticsService(prisma, accounts, cache);
|
|
await service.getAnalytics('acc-1');
|
|
|
|
expect(prisma.brokerOperation.findMany).toHaveBeenCalledWith({
|
|
where: {
|
|
accountId: 'acc-1',
|
|
type: { in: expect.any(Array) },
|
|
payment: { not: null },
|
|
OR: [
|
|
{ state: 'OPERATION_STATE_EXECUTED' },
|
|
{ state: null },
|
|
],
|
|
},
|
|
select: { type: true, payment: true },
|
|
});
|
|
});
|
|
|
|
it('rounds all monetary values to 2 decimal places', async () => {
|
|
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
|
mockCachePassthrough();
|
|
vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([
|
|
makeOp('OPERATION_TYPE_INPUT', 100.336),
|
|
makeOp('OPERATION_TYPE_DIVIDEND', 50.789),
|
|
]);
|
|
|
|
const service = new BrokerAnalyticsService(prisma, accounts, cache);
|
|
const result = await service.getAnalytics('acc-1');
|
|
|
|
expect(result.data.totalDeposits).toBe(100.34);
|
|
expect(result.data.totalDividends).toBe(50.79);
|
|
expect(result.data.totalReceived).toBe(50.79);
|
|
expect(result.data.netInvested).toBe(100.34);
|
|
});
|
|
|
|
it('wraps result in ApiResponse envelope through cache', async () => {
|
|
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
|
vi.mocked(cache.getOrFetch).mockResolvedValue({
|
|
data: {
|
|
totalDeposits: 1000,
|
|
totalWithdrawn: 0,
|
|
netInvested: 1000,
|
|
totalDividends: 0,
|
|
totalCoupons: 0,
|
|
totalReceived: 0,
|
|
totalReturnPercent: null,
|
|
currency: 'RUB',
|
|
},
|
|
fromCache: true,
|
|
cachedAt: '2026-06-24T10:00:00.000Z',
|
|
});
|
|
|
|
const service = new BrokerAnalyticsService(prisma, accounts, cache);
|
|
const result = await service.getAnalytics('acc-1');
|
|
|
|
expect(result.data.netInvested).toBe(1000);
|
|
expect(result.meta.fromCache).toBe(true);
|
|
expect(result.meta.cachedAt).toBe('2026-06-24T10:00:00.000Z');
|
|
});
|
|
});
|