Files
backend/test/tbank-income-local.e2e-spec.ts
T
ksv741 036864b69e
Backend verify / commits (pull_request) Successful in 28s
Backend verify / security (pull_request) Successful in 9s
Backend verify / verify (pull_request) Successful in 4m48s
fix: scope income and ledger sync to requested intervals (WealthLedger/backend#57)
2026-10-28 21:10:57 +03:00

369 lines
14 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',
observe = true,
) =>
request(app.getHttpServer())
.get(
`/api/v1/brokerage-accounts/${account}/income-analytics?${period}${observe ? '&sync=observe' : ''}`,
)
.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'],
['usd', '3', 0, 'usd', 'OPERATION_TYPE_COUPON'],
] 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; reopen and observe need no 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.totalsByCurrency.map((row) => row.total)).toEqual([
{ currency: 'RUB', value: '15.000000002' },
{ currency: 'USD', value: '6' },
]);
expect(result.data.entries).toHaveLength(6);
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.totalsByCurrency).toEqual(base.totalsByCurrency);
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?.totalsByCurrency,
).toEqual([]);
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
});
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(6);
expect(
result.data.totalsByCurrency.find(({ currency }) => currency === 'RUB')
?.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 observed 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?sync=observe')
.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', undefined, false).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('cold income imports only the missing requested interval', async () => {
await prisma.tbankConnection.update({
where: { userId: 'income-owner' },
data: { membershipAt: new Date('2020-01-01Z') },
});
let release: () => void = () => {
throw new Error('Upstream not initialized');
};
const blocked = new Promise<{ hasNext: boolean; items: never[] }>((resolve) => {
release = () => resolve({ hasNext: false, items: [] });
});
upstream.getOperationsByCursor.mockReturnValue(blocked);
const first = await get('a', 'from=2025-02-01&to=2025-02-28', false).expect(200);
const cold = first.body as IncomeAnalyticsResourceDto;
release();
expect(cold.data).toBeNull();
expect(cold.meta.completeness).toBe('incomplete');
let result: IncomeAnalyticsResourceDto = cold;
for (let attempt = 0; attempt < 100; attempt++) {
const response = await get('a', 'from=2025-02-01&to=2025-02-28').expect(200);
result = response.body as IncomeAnalyticsResourceDto;
if (result.meta.completeness === 'complete') break;
await new Promise((resolve) => setTimeout(resolve, 10));
}
expect(result.data).not.toBeNull();
expect(result.meta.completeness).toBe('complete');
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
expect(upstream.getOperationsByCursor.mock.calls).toEqual([
['synthetic', expect.objectContaining({
accountId: 'a',
from: { seconds: '1738357199' },
to: { seconds: '1738368001' },
})],
['synthetic', expect.objectContaining({
accountId: 'a',
from: { seconds: '1738367999' },
to: { seconds: '1740776401' },
})],
]);
});
it('returns while a cold upstream is blocked and validates access/period before scheduling', async () => {
await get('absent', undefined, false).expect(404);
await get('a', 'from=2025-02-31&to=2025-03-01', false).expect(400);
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
const response = await get('all', undefined, false).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('temporarily-unavailable');
expect(result.data).toBeNull();
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
});
});