Backend security / security (push) Successful in 8s
Backend verify / verify (push) Failing after 10m45s
Conventional commits / commits (pull_request) Successful in 29s
Backend security / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 10m37s
Store account membership, portfolio, operations, forecasts and market sources in SQLite. Coalesce scoped refreshes, fence connection revisions, preserve complete financial snapshots and stabilize operation period state. BREAKING CHANGE: investment reads return data/meta resource envelopes and locally persisted snapshots; consumers must use the matching OpenAPI contract and explicit synchronization endpoints.
273 lines
10 KiB
TypeScript
273 lines
10 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { INestApplication } from '@nestjs/common';
|
|
import { JwtService } from '@nestjs/jwt';
|
|
import { Test } from '@nestjs/testing';
|
|
import type { Server } from 'node:http';
|
|
import request from 'supertest';
|
|
import { AppModule } from '../src/app.module';
|
|
import { configureApp } from '../src/main';
|
|
import { PrismaService } from '../src/prisma/prisma.service';
|
|
import { TbankClient } from '../src/integrations/tbank/tbank.client';
|
|
import { moscowDate } from '../src/modules/investments/investment-calendar';
|
|
import {
|
|
IncomeAnalyticsResourceDto,
|
|
IncomeSummaryResourceDto,
|
|
} from '../src/modules/investments/income/income-analytics.dto';
|
|
import { BrokerageOperationsRepository } from '../src/modules/investments/operations/infrastructure/brokerage-operations.repository';
|
|
|
|
// 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('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?.[1].availability).toBe('missing');
|
|
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();
|
|
expect((await read()).meta.availability).toBe('partial');
|
|
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.sync.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('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?.at(-1)?.unavailableReason).toBe(
|
|
'CALENDAR_OR_ACCOUNT_HISTORY',
|
|
);
|
|
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
|
|
});
|
|
});
|