Files
backend/test/tbank-income-local.e2e-spec.ts
T
codex-bot 14bcb9da5f
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 17s
Backend verify / verify (push) Failing after 7m31s
fix: surface durable hourly attempt in accounts and income meta (WealthLedger/backend#58)
2026-10-14 18:00:20 +03:00

314 lines
12 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 { 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)
.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('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();
});
});