fix(investments): align roles and date contracts
Backend security / security (push) Successful in 9s
Backend verify / verify (push) Successful in 10m23s
Backend security / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Successful in 10m39s
Conventional commits / commits (pull_request) Successful in 29s

Identify income dependencies by role and document screen-specific dates.
Convert inclusive calendar days into half-open source ranges while
preserving operation timestamps and existing stored coverage.
This commit is contained in:
2026-09-27 11:38:13 +03:00
parent e4232138d0
commit 516e43203e
18 changed files with 500 additions and 74 deletions
+104
View File
@@ -1,5 +1,6 @@
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
@@ -22,6 +23,109 @@ describe('OpenAPI contract (integration)', () => {
afterAll(() => app.close());
it('distinguishes calendar screen dates from operation instants in requests and responses', () => {
const document = createOpenApiDocument(app);
const schemas = document.components?.schemas;
const screenSchema = {
oneOf: [
{ $ref: '#/components/schemas/IncomeInvestmentScreenDto' },
{ $ref: '#/components/schemas/EventsInvestmentScreenDto' },
{ $ref: '#/components/schemas/TimestampInvestmentScreenDto' },
],
discriminator: {
propertyName: 'screen',
mapping: {
income: '#/components/schemas/IncomeInvestmentScreenDto',
events: '#/components/schemas/EventsInvestmentScreenDto',
operations: '#/components/schemas/TimestampInvestmentScreenDto',
},
},
};
expect(
document.paths['/api/v1/tbank-sync'].post?.requestBody,
).toMatchObject({
content: { 'application/json': { schema: screenSchema } },
});
expect(schemas?.TbankSyncAcceptedDto).toMatchObject({
properties: { scope: screenSchema },
});
expect(schemas?.TbankSyncStatusDto).toMatchObject({
properties: { scope: { ...screenSchema, nullable: true } },
});
for (const [model, screen] of [
['IncomeInvestmentScreenDto', 'income'],
['EventsInvestmentScreenDto', 'events'],
]) {
const schema = schemas?.[model];
assert(schema && !('$ref' in schema));
expect(schema.required).toContain('screen');
expect(schema.required).toContain('accountId');
expect(schema).toMatchObject({
properties: {
screen: { enum: [screen] },
from: { type: 'string', format: 'date' },
to: { type: 'string', format: 'date' },
},
});
}
expect(schemas?.TimestampInvestmentScreenDto).toMatchObject({
properties: {
from: { type: 'string', format: 'date-time' },
to: { type: 'string', format: 'date-time' },
},
});
expect(schemas).not.toHaveProperty('InvestmentScreenDto');
for (const scope of ['all', '{accountId}']) {
for (const [resource, format] of [
['income-analytics', 'date'],
['events', 'date'],
['operations', 'date-time'],
]) {
const parameters =
document.paths[`/api/v1/brokerage-accounts/${scope}/${resource}`].get
?.parameters;
for (const name of ['from', 'to'])
expect(
parameters?.find(
(parameter) => 'name' in parameter && parameter.name === name,
),
).toMatchObject({
name,
in: 'query',
schema: { format },
});
}
}
});
it('exposes required semantic income dependency roles', () => {
const schemas = createOpenApiDocument(app).components?.schemas;
expect(schemas?.IncomeAnalyticsResourceDto).toMatchObject({
properties: {
meta: { $ref: '#/components/schemas/IncomeAnalyticsMetaDto' },
},
});
const meta = schemas?.IncomeAnalyticsMetaDto;
assert(meta && !('$ref' in meta));
expect(meta.required).toContain('dependencies');
expect(meta).toMatchObject({
properties: {
dependencies: {
type: 'array',
items: { $ref: '#/components/schemas/IncomeDependencyDto' },
},
},
});
const dependency = schemas?.IncomeDependencyDto;
assert(dependency && !('$ref' in dependency));
expect(dependency.required).toContain('role');
expect(dependency).toMatchObject({
properties: {
role: { type: 'string', enum: ['selected', 'previous', 'year'] },
},
});
});
it('generates the runtime contract without server startup or deployment configuration', async () => {
const directory = await mkdtemp(resolve(tmpdir(), 'wealthledger-openapi-'));
try {
+13 -4
View File
@@ -138,7 +138,15 @@ describe('local income completeness and exact money (SQLite)', () => {
).toEqual(['a', 'b']);
expect(result.data.previous).toBeNull();
expect(result.data.yearComparison).toBeNull();
expect(result.meta.dependencies?.[1].availability).toBe('missing');
expect(result.meta.dependencies.map(({ role }) => role)).toEqual([
'selected',
'previous',
'year',
]);
expect(
result.meta.dependencies.find(({ role }) => role === 'previous')
?.availability,
).toBe('missing');
expect(result.data.calculatedAt).toBe('2025-02-01T00:00:00.000Z');
await prisma.$disconnect();
}
@@ -264,9 +272,10 @@ describe('local income completeness and exact money (SQLite)', () => {
});
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(
result.meta.dependencies.find(({ role }) => role === 'year')
?.unavailableReason,
).toBe('CALENDAR_OR_ACCOUNT_HISTORY');
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
});
});
+51
View File
@@ -122,6 +122,25 @@ describe('screen sync HTTP and SQLite', () => {
from: '2026-02-31',
to: '2026-03-01',
},
...(['income', 'events'] as const).flatMap((screen) => [
{ screen, accountId: 'all', from: '2025-02-29', to: '2025-03-01' },
{ screen, accountId: 'all', from: '2024-03-01', to: '2024-02-29' },
{
screen,
accountId: 'all',
from: '2024-02-29T00:00:00Z',
to: '2024-02-29',
},
{
screen,
accountId: 'all',
from: '2024-02-29',
to: '2024-02-29T23:59:59Z',
},
]),
{ screen: 'income', accountId: 'all', from: '2024-02-29' },
{ screen: 'income', accountId: 'all', to: '2024-02-29' },
{ screen: 'events', accountId: 'all', to: '2000-01-01' },
{
screen: 'stock',
instrumentUid: '00000000-0000-4000-8000-000000000001',
@@ -145,6 +164,38 @@ describe('screen sync HTTP and SQLite', () => {
expect(await prisma.tbankSyncWork.count()).toBe(0);
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
});
it.each([
{
screen: 'income',
accountId: 'all',
from: '2024-02-29',
to: '2024-02-29',
},
{
screen: 'events',
accountId: 'all',
from: '2096-02-29',
to: '2096-02-29',
},
{ screen: 'events', accountId: 'all', from: '2096-02-29' },
{ screen: 'events', accountId: 'all', to: '2096-02-29' },
{
screen: 'operations',
accountId: 'all',
from: '2024-02-29T00:00:00Z',
to: '2024-03-01T00:00:00Z',
},
])(
'preserves valid calendar/instant scope in accepted and status responses: %j',
async (scope) => {
const response = await post(scope).expect(202);
const accepted = response.body as { id: string; scope: object };
expect(accepted.scope).toEqual(scope);
const result = await settled(accepted.id);
expect(result.scope).toEqual(scope);
expect(result.state).toBe('succeeded');
},
);
it('keeps failed work terminal, cancels abandoned work without restart, and hides old connection handles', async () => {
upstream.getOpenAccounts.mockRejectedValue(
new Error('upstream details never exposed'),
+34
View File
@@ -497,4 +497,38 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
await get(`brokerage-accounts/account/events?${query}`).expect(400);
expect(upstream.getDividends).not.toHaveBeenCalled();
});
it('uses UTC today for an end-only range and includes the entire leap day', async () => {
const today = new Date().toISOString().slice(0, 10);
await get('brokerage-accounts/account/events?to=2096-02-29').expect(200);
await settled();
expect(upstream.getDividends).toHaveBeenCalledWith(
'synthetic-owner',
shareUid,
today,
'2096-02-29',
);
upstream.getDividends.mockResolvedValue([
{
recordDate: {
seconds: String(Date.parse('2096-02-29T23:59:59Z') / 1000),
},
dividendNet: { units: '7', currency: 'rub' },
},
{
recordDate: time('2096-03-01'),
dividendNet: { units: '99', currency: 'rub' },
},
]);
const range = { from: '2096-02-29', to: '2096-02-29' };
await events(false, 'account', 'owner', range).expect(200);
await settled();
const result = (await events(true, 'account', 'owner', range).expect(200))
.body as UpcomingEventsResourceDto;
expect(result.data?.events).toHaveLength(1);
expect(result.data?.events[0]).toMatchObject({
date: '2096-02-29',
projectedAmount: { currency: 'RUB', value: '14' },
});
});
});