Files
backend/test/tbank-income-local.e2e-spec.ts
T
ksv741 34f866fcec
Backend verify / commits (pull_request) Successful in 34s
Backend verify / security (pull_request) Successful in 13s
Backend verify / verify (pull_request) Successful in 5m38s
test: align resource availability metadata
2026-10-09 22:21:22 +03:00

288 lines
11 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 without a false zero comparison; 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.data.previous).toBeNull();
expect(result.data.yearComparison).toBeNull();
expect(result.meta.dependencies.map(({ role }) => role)).toEqual([
'selected',
'previous',
'year',
]);
const previous = result.meta.dependencies.find(({ role }) => role === 'previous');
expect(previous?.availability).toBe('temporarily-unavailable');
expect(previous?.completeness).toBe('incomplete');
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('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('completes previous/yearAgo independently and counts corrected identities once', async () => {
for (const account of ['a', 'b']) {
await coverage(account, new Date('2024-11-30T21:00:00Z'), from);
await coverage(
account,
new Date('2023-12-31T21:00:00Z'),
new Date('2024-01-31T21:00:00Z'),
);
}
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?.previous && result.data.yearComparison);
expect(result.data.entries).toHaveLength(6);
expect(
result.data.previous.totalsByCurrency[0].difference.total.value,
).toBe('25.000000002');
expect(
result.data.yearComparison.totalsByCurrency[0].compared.total.value,
).toBe('0');
});
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);
expect(after.meta.lastAttempt.state).toBe('failed');
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('distinguishes unavailable calendar comparison without scheduling it', async () => {
const result = await read('a', 'from=2024-02-29&to=2024-02-29');
expect(
result.meta.dependencies.find(({ role }) => role === 'year')
?.unavailableReason,
).toBe('CALENDAR_OR_ACCOUNT_HISTORY');
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
});
});