335 lines
13 KiB
TypeScript
335 lines
13 KiB
TypeScript
import type { Server } from 'node:http';
|
|
|
|
import { INestApplication } from '@nestjs/common';
|
|
import { JwtService } from '@nestjs/jwt';
|
|
import { Test } from '@nestjs/testing';
|
|
import assert from 'node:assert/strict';
|
|
import request from 'supertest';
|
|
|
|
import { AppModule } from '../src/app.module';
|
|
import { TbankClient } from '../src/integrations/tbank/tbank.client';
|
|
import { configureApp } from '../src/main';
|
|
import {
|
|
IncomeAnalyticsResourceDto,
|
|
IncomeSummaryResourceDto,
|
|
} from '../src/modules/investments/income/income-analytics.dto';
|
|
import { moscowDate } from '../src/modules/investments/investment-calendar';
|
|
import { BrokerageOperationsRepository } from '../src/modules/investments/operations/infrastructure/brokerage-operations.repository';
|
|
import { TbankHourlyScheduler } from '../src/modules/investments/tbank-sync/tbank-hourly.scheduler';
|
|
import { PrismaService } from '../src/prisma/prisma.service';
|
|
|
|
// Financial proof uses real persisted rows/coverage and the configured HTTP boundary.
|
|
describe('local income completeness and exact money (SQLite)', () => {
|
|
let app: INestApplication<Server>;
|
|
let prisma: PrismaService;
|
|
let revision: string;
|
|
const upstream = {
|
|
validateToken: jest.fn(),
|
|
getOpenAccounts: jest.fn(),
|
|
getOperationsByCursor: jest.fn(),
|
|
};
|
|
const from = new Date('2024-12-31T21:00:00Z');
|
|
const to = new Date('2025-01-31T21:00:00Z');
|
|
const authorization = `Bearer ${new JwtService().sign({ sub: 'income-owner', type: 'access' }, { secret: process.env.JWT_SECRET })}`;
|
|
const get = (
|
|
account = 'all',
|
|
period = 'from=2025-01-01&to=2025-01-31',
|
|
) =>
|
|
request(app.getHttpServer())
|
|
.get(`/api/v1/brokerage-accounts/${account}/income-analytics?${period}`)
|
|
.set('Authorization', authorization)
|
|
.timeout(2000);
|
|
const read = async (account = 'all', period?: string) =>
|
|
(await get(account, period).expect(200)).body as IncomeAnalyticsResourceDto;
|
|
const coverage = async (accountId: string, start = from, end = to) =>
|
|
prisma.tbankOperationCoverage.create({
|
|
data: {
|
|
userId: 'income-owner',
|
|
revision,
|
|
accountId,
|
|
from: start,
|
|
to: end,
|
|
asOf: end,
|
|
fetchedAt: new Date('2025-02-01Z'),
|
|
},
|
|
});
|
|
beforeAll(async () => {
|
|
const module = await Test.createTestingModule({ imports: [AppModule] })
|
|
.overrideProvider(TbankClient)
|
|
.useValue(upstream)
|
|
.overrideProvider(TbankHourlyScheduler)
|
|
.useValue({ tick: jest.fn() })
|
|
.compile();
|
|
app = module.createNestApplication();
|
|
configureApp(app);
|
|
await app.init();
|
|
await app.listen(0, '127.0.0.1');
|
|
prisma = app.get(PrismaService);
|
|
await prisma.user.create({
|
|
data: {
|
|
id: 'income-owner',
|
|
email: 'income@example.invalid',
|
|
passwordHash: 'synthetic',
|
|
},
|
|
});
|
|
});
|
|
afterAll(async () => app?.close());
|
|
beforeEach(async () => {
|
|
await prisma.tbankConnection.deleteMany();
|
|
jest.clearAllMocks();
|
|
upstream.validateToken.mockResolvedValue(
|
|
['a', 'b'].map((id) => ({
|
|
id,
|
|
name: id,
|
|
type: 'ACCOUNT_TYPE_TINKOFF',
|
|
openedDate: { seconds: '1577836800' },
|
|
})),
|
|
);
|
|
upstream.getOperationsByCursor.mockImplementation(
|
|
() => new Promise(() => undefined),
|
|
);
|
|
await request(app.getHttpServer())
|
|
.put('/api/v1/tbank-connection')
|
|
.set('Authorization', authorization)
|
|
.send({ token: 'synthetic' })
|
|
.expect(200);
|
|
revision = (
|
|
await prisma.tbankConnection.findUniqueOrThrow({
|
|
where: { userId: 'income-owner' },
|
|
})
|
|
).revision;
|
|
for (const accountId of ['a', 'b']) {
|
|
await coverage(accountId);
|
|
for (const [id, units, nano, currency, type] of [
|
|
['same', '10', 1, 'rub', 'OPERATION_TYPE_COUPON'],
|
|
['negative', '-2', -500000000, 'rub', 'OPERATION_TYPE_DIVIDEND'],
|
|
] as const) {
|
|
await prisma.tbankOperation.create({
|
|
data: {
|
|
userId: 'income-owner',
|
|
revision,
|
|
accountId,
|
|
brokerOperationId: id,
|
|
category: 'income',
|
|
occurredAt: new Date('2025-01-15Z'),
|
|
payload: {
|
|
id,
|
|
type,
|
|
date: { seconds: String(Date.parse('2025-01-15Z') / 1000) },
|
|
payment: { units, nano, currency },
|
|
},
|
|
},
|
|
});
|
|
}
|
|
}
|
|
});
|
|
it('returns exact selected sums and reuses covered reads without RPC', async () => {
|
|
for (let index = 0; index < 2; index++) {
|
|
const result = await read();
|
|
assert(result.data);
|
|
expect(result.meta.availability).toBe('available');
|
|
expect(result.meta.completeness).toBe('complete');
|
|
expect(result.data.total.total).toEqual({ currency: 'RUB', value: '15.000000002' });
|
|
expect(result.data.entries).toHaveLength(4);
|
|
expect(
|
|
result.data.entries
|
|
.filter((row) => row.id === 'same')
|
|
.map((row) => row.account?.id),
|
|
).toEqual(['a', 'b']);
|
|
expect(result.meta.dependencies.map(({ role }) => role)).toEqual([
|
|
'selected',
|
|
]);
|
|
expect(result.data.calculatedAt).toBe('2025-02-01T00:00:00.000Z');
|
|
await prisma.$disconnect();
|
|
}
|
|
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
|
|
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
|
|
});
|
|
it('uses saved instrument logos for income entries without changing their financial data', async () => {
|
|
const before = await read();
|
|
const base = before.data;
|
|
assert(base);
|
|
const fetchedAt = new Date();
|
|
await prisma.tbankOperation.update({
|
|
where: {
|
|
userId_revision_accountId_brokerOperationId: {
|
|
userId: 'income-owner', revision, accountId: 'a', brokerOperationId: 'same',
|
|
},
|
|
},
|
|
data: {
|
|
payload: {
|
|
id: 'same', figi: 'income-figi', type: 'OPERATION_TYPE_COUPON',
|
|
date: { seconds: String(Date.parse('2025-01-15Z') / 1000) },
|
|
payment: { units: '10', nano: 1, currency: 'rub' },
|
|
},
|
|
},
|
|
});
|
|
await prisma.tbankInstrument.create({
|
|
data: {
|
|
provider: 'tbank', uid: 'income-uid', figi: 'income-figi',
|
|
instrumentType: 'bond', fetchedAt,
|
|
metadata: { uid: 'income-uid', figi: 'income-figi', name: 'Bond', brand: { logoName: 'bond.png' } },
|
|
},
|
|
});
|
|
await prisma.tbankInstrumentLookup.create({
|
|
data: {
|
|
provider: 'tbank', identifierType: 'figi', identifier: 'income-figi',
|
|
matchedUids: ['income-uid'], fetchedAt,
|
|
},
|
|
});
|
|
const after = await read();
|
|
assert(after.data);
|
|
expect(after.data.entries.find((entry) => entry.account?.id === 'a' && entry.id === 'same')?.logoName).toBe('bond.png');
|
|
expect(after.data.entries.find((entry) => entry.account?.id === 'b' && entry.id === 'same')?.logoName).toBeNull();
|
|
expect(after.data.total).toEqual(base.total);
|
|
expect(after.data.calculatedAt).toBe(base.calculatedAt);
|
|
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
|
|
});
|
|
it('requires every account and separates missing, partial and confirmed empty periods', async () => {
|
|
await prisma.tbankOperationCoverage.deleteMany({
|
|
where: { accountId: 'b' },
|
|
});
|
|
expect((await read()).data).toBeNull();
|
|
const incomplete = await read();
|
|
expect(incomplete.meta.availability).toBe('temporarily-unavailable');
|
|
expect(incomplete.meta.completeness).toBe('incomplete');
|
|
expect((await read('a')).data).not.toBeNull();
|
|
expect((await read('a', 'from=2025-03-01&to=2025-03-31')).data).toBeNull();
|
|
await coverage(
|
|
'a',
|
|
new Date('2025-02-28T21:00:00Z'),
|
|
new Date('2025-03-31T21:00:00Z'),
|
|
);
|
|
expect(
|
|
(await read('a', 'from=2025-03-01&to=2025-03-31')).data?.total.total,
|
|
).toEqual({ currency: 'RUB', value: '0' });
|
|
expect(upstream.getOperationsByCursor).toHaveBeenCalledWith(
|
|
'synthetic', expect.objectContaining({ accountId: 'b' }),
|
|
);
|
|
});
|
|
it('counts corrected identities once for the selected period', async () => {
|
|
await prisma.tbankOperation.updateMany({
|
|
where: { accountId: 'a', brokerOperationId: 'same' },
|
|
data: {
|
|
payload: {
|
|
id: 'same',
|
|
type: 'OPERATION_TYPE_COUPON',
|
|
date: { seconds: String(Date.parse('2025-01-15Z') / 1000) },
|
|
payment: { units: '20', nano: 1, currency: 'rub' },
|
|
},
|
|
},
|
|
});
|
|
const result = await read();
|
|
assert(result.data);
|
|
expect(result.data.entries).toHaveLength(4);
|
|
expect(result.data.total.total).toEqual({ currency: 'RUB', value: '25.000000002' });
|
|
});
|
|
it('preserves the complete financial result and timestamp through failed atomic reconciliation', async () => {
|
|
const before = await read();
|
|
const repo = app.get(BrokerageOperationsRepository);
|
|
const attempt = await repo.request(
|
|
'income-owner',
|
|
revision,
|
|
['a', 'b'].map((accountId) => ({ accountId, from, to })),
|
|
'test',
|
|
true,
|
|
new Set(),
|
|
1,
|
|
);
|
|
assert(attempt);
|
|
const fence = {
|
|
id: attempt.work.id,
|
|
attemptId: attempt.work.attemptId,
|
|
userId: 'income-owner',
|
|
revision,
|
|
};
|
|
const pending = await repo.pending(fence);
|
|
assert(pending);
|
|
await repo.stagePage(fence, pending.intervals[0], undefined, {
|
|
hasNext: true,
|
|
nextCursor: 'unfinished',
|
|
items: [
|
|
{
|
|
id: 'new',
|
|
type: 'OPERATION_TYPE_COUPON',
|
|
date: { seconds: String(pending.intervals[0].from.valueOf() / 1000) },
|
|
payment: { currency: 'rub', units: '999' },
|
|
},
|
|
],
|
|
});
|
|
expect((await read()).data).toEqual(before.data);
|
|
await repo.fail(fence, false);
|
|
const after = await read();
|
|
expect(after.data).toEqual(before.data);
|
|
// Without an hourly walk the durable attempt is absent; the failed
|
|
// source work stays a technical observation, not the hourly attempt.
|
|
expect(after.meta.lastAttempt.state).toBe('none');
|
|
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
|
|
});
|
|
it('retains current-month completeness and does not renew source timestamps', async () => {
|
|
const now = new Date();
|
|
const today = moscowDate(now);
|
|
const start = new Date(`${today.slice(0, 7)}-01T00:00:00+03:00`);
|
|
const asOf = new Date(now.valueOf() - 10_000);
|
|
for (const accountId of ['a', 'b']) {
|
|
await coverage(accountId, start, asOf);
|
|
}
|
|
const response = await request(app.getHttpServer())
|
|
.get('/api/v1/brokerage-accounts/all/income-summary')
|
|
.set('Authorization', authorization)
|
|
.expect(200);
|
|
const result = response.body as IncomeSummaryResourceDto;
|
|
expect(result.data?.period).toEqual({
|
|
from: `${today.slice(0, 7)}-01`,
|
|
to: today,
|
|
});
|
|
expect(result.meta.availability).toBe('available');
|
|
expect(result.meta.completeness).toBe('complete');
|
|
expect(result.meta.coverage?.asOf).toBe(asOf.toISOString());
|
|
expect(result.data?.calculatedAt).toBe('2025-02-01T00:00:00.000Z');
|
|
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
|
|
});
|
|
it('ordinary covered income reads local data without membership or operations RPC', async () => {
|
|
await prisma.tbankConnection.update({
|
|
where: { userId: 'income-owner' },
|
|
data: { membershipAt: new Date('2020-01-01Z') },
|
|
});
|
|
const response = await get('a').expect(200);
|
|
const result = response.body as IncomeAnalyticsResourceDto;
|
|
expect(result.data).not.toBeNull();
|
|
expect(result.meta.completeness).toBe('complete');
|
|
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
|
|
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
|
|
});
|
|
it('ignores retired sync=observe on income reads and keeps the cold response incomplete', async () => {
|
|
await prisma.tbankConnection.update({
|
|
where: { userId: 'income-owner' },
|
|
data: { membershipAt: new Date('2020-01-01Z') },
|
|
});
|
|
const response = await request(app.getHttpServer())
|
|
.get('/api/v1/brokerage-accounts/a/income-analytics?from=2025-02-01&to=2025-02-28&sync=observe')
|
|
.set('Authorization', authorization)
|
|
.expect(200);
|
|
const result = response.body as IncomeAnalyticsResourceDto;
|
|
expect(result.data).toBeNull();
|
|
expect(result.meta.completeness).toBe('incomplete');
|
|
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
|
|
});
|
|
it('returns while a cold upstream is blocked and validates access/period before scheduling', async () => {
|
|
await get('absent').expect(404);
|
|
await get('a', 'from=2025-02-31&to=2025-03-01').expect(400);
|
|
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
|
|
const response = await get('all').expect(200);
|
|
expect((response.body as IncomeAnalyticsResourceDto).data).not.toBeNull();
|
|
});
|
|
it('plans only the selected period for a leap day without scheduling it', async () => {
|
|
const result = await read('a', 'from=2024-02-29&to=2024-02-29');
|
|
expect(result.meta.dependencies.map(({ role }) => role)).toEqual([
|
|
'selected',
|
|
]);
|
|
expect(result.meta.dependencies[0].availability).toBe('initial-loading');
|
|
expect(result.data).toBeNull();
|
|
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
|
|
});
|
|
});
|