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
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:
@@ -46,11 +46,26 @@ describe('TbankClient operations', () => {
|
|||||||
seconds: String(Math.floor(Date.parse('2026-08-19T00:00:00Z') / 1000)),
|
seconds: String(Math.floor(Date.parse('2026-08-19T00:00:00Z') / 1000)),
|
||||||
},
|
},
|
||||||
to: {
|
to: {
|
||||||
seconds: String(Math.floor(Date.parse('2026-09-18T00:00:00Z') / 1000)),
|
seconds: String(Math.floor(Date.parse('2026-09-19T00:00:00Z') / 1000)),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each(['getDividends', 'getBondCoupons'] as const)(
|
||||||
|
'%s converts a leap-day calendar window to a full half-open UTC day',
|
||||||
|
async (method) => {
|
||||||
|
const client = Object.create(TbankClient.prototype) as TbankClient;
|
||||||
|
const call = jest.fn().mockResolvedValue({});
|
||||||
|
(client as unknown as { call: jest.Mock }).call = call;
|
||||||
|
await client[method]('token', 'uid', '2024-02-29', '2024-02-29');
|
||||||
|
expect(call).toHaveBeenCalledWith('token', undefined, method, {
|
||||||
|
instrumentId: 'uid',
|
||||||
|
from: { seconds: String(Date.parse('2024-02-29T00:00:00Z') / 1000) },
|
||||||
|
to: { seconds: String(Date.parse('2024-03-01T00:00:00Z') / 1000) },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it('propagates the normalized upstream error', async () => {
|
it('propagates the normalized upstream error', async () => {
|
||||||
const client = Object.create(TbankClient.prototype) as TbankClient;
|
const client = Object.create(TbankClient.prototype) as TbankClient;
|
||||||
const error = new ServiceUnavailableException({
|
const error = new ServiceUnavailableException({
|
||||||
|
|||||||
@@ -292,7 +292,7 @@ export class TbankClient {
|
|||||||
{
|
{
|
||||||
instrumentId,
|
instrumentId,
|
||||||
from: { seconds: String(utcSeconds(from)) },
|
from: { seconds: String(utcSeconds(from)) },
|
||||||
to: { seconds: String(utcSeconds(to)) },
|
to: { seconds: String(utcSeconds(to, true)) },
|
||||||
},
|
},
|
||||||
).then((response) => response.dividends ?? []);
|
).then((response) => response.dividends ?? []);
|
||||||
}
|
}
|
||||||
@@ -344,7 +344,7 @@ export class TbankClient {
|
|||||||
{
|
{
|
||||||
instrumentId: uid,
|
instrumentId: uid,
|
||||||
from: { seconds: String(utcSeconds(from)) },
|
from: { seconds: String(utcSeconds(from)) },
|
||||||
to: { seconds: String(utcSeconds(to)) },
|
to: { seconds: String(utcSeconds(to, true)) },
|
||||||
},
|
},
|
||||||
).then((response) => response.events ?? []);
|
).then((response) => response.events ?? []);
|
||||||
}
|
}
|
||||||
@@ -552,6 +552,9 @@ export class TbankClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function utcSeconds(date: string): number {
|
// Calendar owners supply inclusive UTC days; RPC timestamps use [from, next(to)).
|
||||||
return Math.floor(Date.parse(`${date}T00:00:00Z`) / MILLISECONDS_PER_SECOND);
|
function utcSeconds(date: string, nextDay = false): number {
|
||||||
|
const instant = new Date(`${date}T00:00:00Z`);
|
||||||
|
if (nextDay) instant.setUTCDate(instant.getUTCDate() + 1);
|
||||||
|
return Math.floor(instant.valueOf() / MILLISECONDS_PER_SECOND);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { MoneyDto } from '../../../common/money.dto';
|
|||||||
import { BrokerageOperationDto } from '../operations/operations.api';
|
import { BrokerageOperationDto } from '../operations/operations.api';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
InvestmentDependencyDto,
|
||||||
InvestmentReadQueryDto,
|
InvestmentReadQueryDto,
|
||||||
InvestmentResourceMetaDto,
|
InvestmentResourceMetaDto,
|
||||||
} from '../investment-resource.dto';
|
} from '../investment-resource.dto';
|
||||||
@@ -11,12 +12,20 @@ import {
|
|||||||
const dateOnly = /^\d{4}-\d{2}-\d{2}$/;
|
const dateOnly = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
|
||||||
export class IncomeAnalyticsQueryDto extends InvestmentReadQueryDto {
|
export class IncomeAnalyticsQueryDto extends InvestmentReadQueryDto {
|
||||||
@ApiPropertyOptional({ format: 'date' })
|
@ApiPropertyOptional({
|
||||||
|
format: 'date',
|
||||||
|
description:
|
||||||
|
'Inclusive calendar date in Europe/Moscow. Supply both from/to or neither.',
|
||||||
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Matches(dateOnly)
|
@Matches(dateOnly)
|
||||||
from?: string;
|
from?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ format: 'date' })
|
@ApiPropertyOptional({
|
||||||
|
format: 'date',
|
||||||
|
description:
|
||||||
|
'Inclusive calendar date in Europe/Moscow, no later than today. Supply both from/to or neither.',
|
||||||
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Matches(dateOnly)
|
@Matches(dateOnly)
|
||||||
to?: string;
|
to?: string;
|
||||||
@@ -152,9 +161,27 @@ export class IncomeSummaryResourceDto {
|
|||||||
declare meta: InvestmentResourceMetaDto;
|
declare meta: InvestmentResourceMetaDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const incomeDependencyRoles = ['selected', 'previous', 'year'] as const;
|
||||||
|
|
||||||
|
export class IncomeDependencyDto extends InvestmentDependencyDto {
|
||||||
|
@ApiProperty({ enum: incomeDependencyRoles })
|
||||||
|
declare role: (typeof incomeDependencyRoles)[number];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class IncomeAnalyticsMetaDto extends InvestmentResourceMetaDto {
|
||||||
|
@ApiProperty({
|
||||||
|
type: IncomeDependencyDto,
|
||||||
|
isArray: true,
|
||||||
|
required: true,
|
||||||
|
description:
|
||||||
|
'Dependencies identified by role, not array order or resourceKey. A missing role is unavailable.',
|
||||||
|
})
|
||||||
|
declare dependencies: IncomeDependencyDto[];
|
||||||
|
}
|
||||||
|
|
||||||
export class IncomeAnalyticsResourceDto {
|
export class IncomeAnalyticsResourceDto {
|
||||||
@ApiProperty({ type: IncomeAnalyticsDto, nullable: true })
|
@ApiProperty({ type: IncomeAnalyticsDto, nullable: true })
|
||||||
declare data: IncomeAnalyticsDto | null;
|
declare data: IncomeAnalyticsDto | null;
|
||||||
@ApiProperty({ type: InvestmentResourceMetaDto })
|
@ApiProperty({ type: IncomeAnalyticsMetaDto })
|
||||||
declare meta: InvestmentResourceMetaDto;
|
declare meta: IncomeAnalyticsMetaDto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,44 @@ describe('IncomeAnalyticsService', () => {
|
|||||||
|
|
||||||
beforeEach(() => jest.clearAllMocks());
|
beforeEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
|
it('labels each comparison dependency independently of its resource key', async () => {
|
||||||
|
seed([]);
|
||||||
|
const response = await service.analytics(
|
||||||
|
'user',
|
||||||
|
'account',
|
||||||
|
'2026-01-01',
|
||||||
|
'2026-01-31',
|
||||||
|
);
|
||||||
|
expect(response.meta.dependencies).toEqual([
|
||||||
|
expect.objectContaining({ role: 'selected', resourceKey: 'period/0' }),
|
||||||
|
expect.objectContaining({ role: 'previous', resourceKey: 'period/1' }),
|
||||||
|
expect.objectContaining({ role: 'year', resourceKey: 'period/2' }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('labels an unavailable leap-day comparison as year', async () => {
|
||||||
|
operations.collectRanges.mockImplementation(
|
||||||
|
(_user: string, _account: string, build: () => unknown[]) =>
|
||||||
|
build().map((_, index) => ({ items: [], meta: completeMeta(index) })),
|
||||||
|
);
|
||||||
|
const response = await service.analytics(
|
||||||
|
'user',
|
||||||
|
'account',
|
||||||
|
'2024-02-29',
|
||||||
|
'2024-02-29',
|
||||||
|
);
|
||||||
|
expect(response.meta.dependencies).toEqual([
|
||||||
|
expect.objectContaining({ role: 'selected' }),
|
||||||
|
expect.objectContaining({ role: 'previous' }),
|
||||||
|
expect.objectContaining({
|
||||||
|
role: 'year',
|
||||||
|
availability: 'missing',
|
||||||
|
unavailableReason: 'CALENDAR_OR_ACCOUNT_HISTORY',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(response.data?.yearComparison).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
function seed(items: BrokerageOperationDto[]) {
|
function seed(items: BrokerageOperationDto[]) {
|
||||||
operations.collectRanges.mockImplementation(
|
operations.collectRanges.mockImplementation(
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import { investmentResourceMeta } from '../investment-resource.mapper';
|
import { investmentResourceMeta } from '../investment-resource.mapper';
|
||||||
import {
|
import {
|
||||||
IncomeAnalyticsResourceDto,
|
IncomeAnalyticsResourceDto,
|
||||||
|
IncomeDependencyDto,
|
||||||
IncomeSummaryResourceDto,
|
IncomeSummaryResourceDto,
|
||||||
} from './income-analytics.dto';
|
} from './income-analytics.dto';
|
||||||
import { DateOnlyRange, moscowDate } from '../investment-calendar';
|
import { DateOnlyRange, moscowDate } from '../investment-calendar';
|
||||||
@@ -90,35 +91,44 @@ export class IncomeAnalyticsService {
|
|||||||
observe,
|
observe,
|
||||||
);
|
);
|
||||||
const [current, prior, year] = periods;
|
const [current, prior, year] = periods;
|
||||||
const dependencies = periods.map((period) => period.meta);
|
// Preserve the legacy order while consumers migrate to explicit roles.
|
||||||
if (!plan.yearAgo)
|
const dependencies: IncomeDependencyDto[] = [
|
||||||
dependencies.push({
|
{ ...current.meta, role: 'selected' },
|
||||||
...investmentResourceMeta({
|
{ ...prior.meta, role: 'previous' },
|
||||||
resourceKey: 'income/yearAgo', // gitleaks:allow public resource identifier
|
{
|
||||||
dataRevision: 'unavailable',
|
...(plan.yearAgo && year
|
||||||
availability: 'missing',
|
? year.meta
|
||||||
fetchedAt: null,
|
: investmentResourceMeta({
|
||||||
freshForMs: 0,
|
resourceKey: 'income/yearAgo', // gitleaks:allow public resource identifier
|
||||||
work: null,
|
dataRevision: 'unavailable',
|
||||||
unavailableReason: 'CALENDAR_OR_ACCOUNT_HISTORY',
|
availability: 'missing',
|
||||||
}),
|
fetchedAt: null,
|
||||||
});
|
freshForMs: 0,
|
||||||
|
work: null,
|
||||||
|
unavailableReason: 'CALENDAR_OR_ACCOUNT_HISTORY',
|
||||||
|
})),
|
||||||
|
role: 'year',
|
||||||
|
},
|
||||||
|
];
|
||||||
const states = periods.map((period) => period.meta.sync);
|
const states = periods.map((period) => period.meta.sync);
|
||||||
const work =
|
const work =
|
||||||
states.find((state) => state.state === 'running') ??
|
states.find((state) => state.state === 'running') ??
|
||||||
states.find((state) => state.state === 'failed');
|
states.find((state) => state.state === 'failed');
|
||||||
const meta = investmentResourceMeta({
|
const meta = {
|
||||||
resourceKey: `income/${accountId}/${plan.selected.from}/${plan.selected.to}`,
|
...investmentResourceMeta({
|
||||||
dataRevision: current.meta.dataRevision,
|
resourceKey: `income/${accountId}/${plan.selected.from}/${plan.selected.to}`,
|
||||||
availability: current.meta.availability,
|
dataRevision: current.meta.dataRevision,
|
||||||
fetchedAt: current.meta.lastSuccessfulAt
|
availability: current.meta.availability,
|
||||||
? new Date(current.meta.lastSuccessfulAt)
|
fetchedAt: current.meta.lastSuccessfulAt
|
||||||
: null,
|
? new Date(current.meta.lastSuccessfulAt)
|
||||||
freshForMs: current.meta.freshness === 'fresh' ? Infinity : 0,
|
: null,
|
||||||
work: work?.id ? { ...work, id: work.id } : null,
|
freshForMs: current.meta.freshness === 'fresh' ? Infinity : 0,
|
||||||
coverage: current.meta.coverage,
|
work: work?.id ? { ...work, id: work.id } : null,
|
||||||
|
coverage: current.meta.coverage,
|
||||||
|
dependencies,
|
||||||
|
}),
|
||||||
dependencies,
|
dependencies,
|
||||||
});
|
};
|
||||||
if (current.items === null) return { data: null, meta };
|
if (current.items === null) return { data: null, meta };
|
||||||
assert(current.meta.lastSuccessfulAt);
|
assert(current.meta.lastSuccessfulAt);
|
||||||
const selected = qualifying(current.items).map((entry) => ({
|
const selected = qualifying(current.items).map((entry) => ({
|
||||||
|
|||||||
@@ -53,4 +53,18 @@ describe('income period planning', () => {
|
|||||||
to: new Date('2026-08-27T21:44:18.510Z'),
|
to: new Date('2026-08-27T21:44:18.510Z'),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
it.each([
|
||||||
|
['2024-02-29', '2024-02-28T21:00:00.000Z', '2024-02-29T21:00:00.000Z'],
|
||||||
|
['2025-12-31', '2025-12-30T21:00:00.000Z', '2025-12-31T21:00:00.000Z'],
|
||||||
|
])('maps inclusive %s to one full half-open Moscow day', (date, from, to) => {
|
||||||
|
expect(
|
||||||
|
dateOnlyBounds(
|
||||||
|
{ from: date, to: date },
|
||||||
|
new Date('2026-01-01T12:00:00Z'),
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
from: new Date(from),
|
||||||
|
to: new Date(to),
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,4 @@
|
|||||||
const MOSCOW_UTC_OFFSET_HOURS = 3;
|
const MOSCOW_UTC_OFFSET_HOURS = 3;
|
||||||
const MOSCOW_DAY_END_UTC = {
|
|
||||||
hours: 20,
|
|
||||||
minutes: 59,
|
|
||||||
seconds: 59,
|
|
||||||
milliseconds: 999,
|
|
||||||
};
|
|
||||||
export type DateOnlyRange = { from: string; to: string };
|
export type DateOnlyRange = { from: string; to: string };
|
||||||
const moscow = 'Europe/Moscow';
|
const moscow = 'Europe/Moscow';
|
||||||
const moscowFormatter = new Intl.DateTimeFormat('en-CA', {
|
const moscowFormatter = new Intl.DateTimeFormat('en-CA', {
|
||||||
@@ -19,25 +13,21 @@ export function moscowDate(value: Date): string {
|
|||||||
return `${get('year')}-${get('month')}-${get('day')}`;
|
return `${get('year')}-${get('month')}-${get('day')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function dateOnlyBounds(range: DateOnlyRange): { from: Date; to: Date } {
|
// Inclusive Moscow calendar dates become a half-open instant range, capped at now.
|
||||||
|
export function dateOnlyBounds(
|
||||||
|
range: DateOnlyRange,
|
||||||
|
now = new Date(),
|
||||||
|
): { from: Date; to: Date } {
|
||||||
const from = dateParts(range.from);
|
const from = dateParts(range.from);
|
||||||
const end = dateParts(range.to);
|
const end = dateParts(range.to);
|
||||||
const to = new Date(
|
const to = new Date(
|
||||||
Date.UTC(
|
Date.UTC(end[0], end[1] - 1, end[2] + 1, -MOSCOW_UTC_OFFSET_HOURS),
|
||||||
end[0],
|
|
||||||
end[1] - 1,
|
|
||||||
end[2],
|
|
||||||
MOSCOW_DAY_END_UTC.hours,
|
|
||||||
MOSCOW_DAY_END_UTC.minutes,
|
|
||||||
MOSCOW_DAY_END_UTC.seconds,
|
|
||||||
MOSCOW_DAY_END_UTC.milliseconds,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
from: new Date(
|
from: new Date(
|
||||||
Date.UTC(from[0], from[1] - 1, from[2], -MOSCOW_UTC_OFFSET_HOURS),
|
Date.UTC(from[0], from[1] - 1, from[2], -MOSCOW_UTC_OFFSET_HOURS),
|
||||||
),
|
),
|
||||||
to: to > new Date() ? new Date() : to,
|
to: to > now ? now : to,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -141,11 +141,8 @@ export function planInvestmentScreen(
|
|||||||
? [period.previous, ...(period.yearAgo ? [period.yearAgo] : [])]
|
? [period.previous, ...(period.yearAgo ? [period.yearAgo] : [])]
|
||||||
: []),
|
: []),
|
||||||
]) {
|
]) {
|
||||||
const bounds = dateOnlyBounds(range);
|
const bounds = dateOnlyBounds(range, now);
|
||||||
operations(
|
operations(bounds.from, bounds.to);
|
||||||
bounds.from,
|
|
||||||
new Date(Math.min(bounds.to.valueOf() + 1, now.valueOf())),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (screen.screen === 'income' && !period.yearAgo)
|
if (screen.screen === 'income' && !period.yearAgo)
|
||||||
unavailable.push({
|
unavailable.push({
|
||||||
|
|||||||
+8
-6
@@ -12,7 +12,11 @@ import type {
|
|||||||
TbankOperationItem,
|
TbankOperationItem,
|
||||||
TbankOperationsByCursorResponse,
|
TbankOperationsByCursorResponse,
|
||||||
} from '../../../../integrations/tbank/tbank.api';
|
} from '../../../../integrations/tbank/tbank.api';
|
||||||
import { DateOnlyRange, dateOnlyBounds } from '../../investment-calendar';
|
import {
|
||||||
|
DateOnlyRange,
|
||||||
|
dateOnlyBounds,
|
||||||
|
moscowDate,
|
||||||
|
} from '../../investment-calendar';
|
||||||
import { operationCategory } from './brokerage-operation.mapper';
|
import { operationCategory } from './brokerage-operation.mapper';
|
||||||
import {
|
import {
|
||||||
type OperationInterval,
|
type OperationInterval,
|
||||||
@@ -96,14 +100,12 @@ export class BrokerageOperationsRepository {
|
|||||||
? new Date(`${oldest}T00:00:00+03:00`)
|
? new Date(`${oldest}T00:00:00+03:00`)
|
||||||
: undefined;
|
: undefined;
|
||||||
const periods = input.periods?.(openedAt).map((period) => {
|
const periods = input.periods?.(openedAt).map((period) => {
|
||||||
const bounds = dateOnlyBounds(period);
|
const now = new Date();
|
||||||
// Date-only end is inclusive; persisted coverage is half-open.
|
const bounds = dateOnlyBounds(period, now);
|
||||||
const end = new Date(`${period.to}T21:00:00Z`);
|
|
||||||
return {
|
return {
|
||||||
...bounds,
|
...bounds,
|
||||||
key: `${period.from}/${period.to}`,
|
key: `${period.from}/${period.to}`,
|
||||||
movingEnd: end.valueOf() > Date.now(),
|
movingEnd: period.to >= moscowDate(now),
|
||||||
to: new Date(Math.min(end.valueOf(), Date.now())),
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const binding = createHash('sha256')
|
const binding = createHash('sha256')
|
||||||
|
|||||||
@@ -62,4 +62,13 @@ describe('manual sync screen boundary', () => {
|
|||||||
}),
|
}),
|
||||||
).toThrow();
|
).toThrow();
|
||||||
});
|
});
|
||||||
|
it('keeps independently supplied event dates and rejects an end before the default UTC start', () => {
|
||||||
|
for (const range of [{ from: '2096-02-29' }, { to: '2096-02-29' }]) {
|
||||||
|
const scope = { screen: 'events' as const, accountId: 'all', ...range };
|
||||||
|
expect(normalizeScreen(scope)).toEqual(scope);
|
||||||
|
}
|
||||||
|
expect(() =>
|
||||||
|
normalizeScreen({ screen: 'events', accountId: 'all', to: '2000-01-01' }),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -63,12 +63,19 @@ export function normalizeScreen(
|
|||||||
if (['stock', 'bond'].includes(input.screen) && !input.instrumentUid) fail();
|
if (['stock', 'bond'].includes(input.screen) && !input.instrumentUid) fail();
|
||||||
if (input.accountId !== undefined && !accountId) fail();
|
if (input.accountId !== undefined && !accountId) fail();
|
||||||
if (['income', 'events'].includes(input.screen)) {
|
if (['income', 'events'].includes(input.screen)) {
|
||||||
if (Boolean(input.from) !== Boolean(input.to)) fail();
|
if (input.screen === 'income' && Boolean(input.from) !== Boolean(input.to))
|
||||||
|
fail();
|
||||||
for (const value of [input.from, input.to])
|
for (const value of [input.from, input.to])
|
||||||
if (value && !/^\d{4}-\d{2}-\d{2}$/.test(value)) fail();
|
if (value && !/^\d{4}-\d{2}-\d{2}$/.test(value)) fail();
|
||||||
}
|
}
|
||||||
if (input.from && input.to && new Date(input.from) > new Date(input.to))
|
if (input.from && input.to && new Date(input.from) > new Date(input.to))
|
||||||
fail();
|
fail();
|
||||||
|
if (
|
||||||
|
input.screen === 'events' &&
|
||||||
|
input.to &&
|
||||||
|
(input.from ?? new Date().toISOString().slice(0, 10)) > input.to
|
||||||
|
)
|
||||||
|
fail();
|
||||||
if (
|
if (
|
||||||
input.candleRange &&
|
input.candleRange &&
|
||||||
new Date(input.candleRange.from) >= new Date(input.candleRange.to)
|
new Date(input.candleRange.from) >= new Date(input.candleRange.to)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
ApiAcceptedResponse,
|
ApiAcceptedResponse,
|
||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
ApiBody,
|
ApiBody,
|
||||||
|
ApiExtraModels,
|
||||||
ApiOkResponse,
|
ApiOkResponse,
|
||||||
ApiTags,
|
ApiTags,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
@@ -24,12 +25,21 @@ import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-r
|
|||||||
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
|
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
|
||||||
import {
|
import {
|
||||||
InvestmentScreenDto,
|
InvestmentScreenDto,
|
||||||
|
IncomeInvestmentScreenDto,
|
||||||
|
EventsInvestmentScreenDto,
|
||||||
|
TimestampInvestmentScreenDto,
|
||||||
|
investmentScreenSchema,
|
||||||
TbankSyncAcceptedDto,
|
TbankSyncAcceptedDto,
|
||||||
TbankSyncStatusDto,
|
TbankSyncStatusDto,
|
||||||
} from './tbank-sync.dto';
|
} from './tbank-sync.dto';
|
||||||
import { TbankSyncService } from './tbank-sync.service';
|
import { TbankSyncService } from './tbank-sync.service';
|
||||||
|
|
||||||
@ApiTags('tbank-sync')
|
@ApiTags('tbank-sync')
|
||||||
|
@ApiExtraModels(
|
||||||
|
IncomeInvestmentScreenDto,
|
||||||
|
EventsInvestmentScreenDto,
|
||||||
|
TimestampInvestmentScreenDto,
|
||||||
|
)
|
||||||
@ApiProblemResponses()
|
@ApiProblemResponses()
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@@ -38,7 +48,7 @@ export class TbankSyncController {
|
|||||||
constructor(private readonly sync: TbankSyncService) {}
|
constructor(private readonly sync: TbankSyncService) {}
|
||||||
@Post()
|
@Post()
|
||||||
@HttpCode(HttpStatus.ACCEPTED)
|
@HttpCode(HttpStatus.ACCEPTED)
|
||||||
@ApiBody({ type: InvestmentScreenDto })
|
@ApiBody({ schema: investmentScreenSchema })
|
||||||
@ApiAcceptedResponse({ type: TbankSyncAcceptedDto })
|
@ApiAcceptedResponse({ type: TbankSyncAcceptedDto })
|
||||||
request(
|
request(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import {
|
||||||
|
ApiProperty,
|
||||||
|
ApiPropertyOptional,
|
||||||
|
getSchemaPath,
|
||||||
|
OmitType,
|
||||||
|
PickType,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsArray,
|
IsArray,
|
||||||
@@ -46,11 +52,21 @@ export class InvestmentScreenDto {
|
|||||||
@MinLength(1)
|
@MinLength(1)
|
||||||
@MaxLength(maxAccountIdLength)
|
@MaxLength(maxAccountIdLength)
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
@ApiPropertyOptional({ format: 'date-time' })
|
@ApiPropertyOptional({
|
||||||
|
oneOf: [
|
||||||
|
{ type: 'string', format: 'date' },
|
||||||
|
{ type: 'string', format: 'date-time' },
|
||||||
|
],
|
||||||
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsDateString({ strict: true })
|
@IsDateString({ strict: true })
|
||||||
from?: string;
|
from?: string;
|
||||||
@ApiPropertyOptional({ format: 'date-time' })
|
@ApiPropertyOptional({
|
||||||
|
oneOf: [
|
||||||
|
{ type: 'string', format: 'date' },
|
||||||
|
{ type: 'string', format: 'date-time' },
|
||||||
|
],
|
||||||
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsDateString({ strict: true })
|
@IsDateString({ strict: true })
|
||||||
to?: string;
|
to?: string;
|
||||||
@@ -85,9 +101,98 @@ export class InvestmentScreenDto {
|
|||||||
@Max(maxPageSize)
|
@Max(maxPageSize)
|
||||||
limit?: number;
|
limit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class IncomeInvestmentScreenDto extends PickType(InvestmentScreenDto, [
|
||||||
|
'accountId',
|
||||||
|
]) {
|
||||||
|
@ApiProperty({ enum: ['income'] }) declare screen: 'income';
|
||||||
|
@ApiProperty({ required: true }) declare accountId: string;
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
format: 'date',
|
||||||
|
description:
|
||||||
|
'Inclusive start calendar date in Europe/Moscow (YYYY-MM-DD). Supply both from and to, or neither for the current Moscow month.',
|
||||||
|
})
|
||||||
|
from?: string;
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
format: 'date',
|
||||||
|
description:
|
||||||
|
'Inclusive end calendar date in Europe/Moscow (YYYY-MM-DD). Supply both from and to, or neither. Timestamp queries use the start of the following Moscow day as the exclusive end, capped at now.',
|
||||||
|
})
|
||||||
|
to?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EventsInvestmentScreenDto extends PickType(InvestmentScreenDto, [
|
||||||
|
'accountId',
|
||||||
|
]) {
|
||||||
|
@ApiProperty({ enum: ['events'] }) declare screen: 'events';
|
||||||
|
@ApiProperty({ required: true }) declare accountId: string;
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
format: 'date',
|
||||||
|
description:
|
||||||
|
'Inclusive UTC calendar start date (YYYY-MM-DD). May be supplied independently; defaults to today in UTC.',
|
||||||
|
})
|
||||||
|
from?: string;
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
format: 'date',
|
||||||
|
description:
|
||||||
|
'Inclusive UTC calendar end date (YYYY-MM-DD). May be supplied independently; defaults to 30 days after from. Timestamp queries use the start of the following UTC day as the exclusive end.',
|
||||||
|
})
|
||||||
|
to?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestampScreens = [
|
||||||
|
'accounts',
|
||||||
|
'account',
|
||||||
|
'operations',
|
||||||
|
'stocks',
|
||||||
|
'bonds',
|
||||||
|
'stock',
|
||||||
|
'bond',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export class TimestampInvestmentScreenDto extends OmitType(
|
||||||
|
InvestmentScreenDto,
|
||||||
|
['screen', 'from', 'to'],
|
||||||
|
) {
|
||||||
|
// The runtime validator retains legacy date-only inputs; the public contract uses instants.
|
||||||
|
@ApiProperty({ enum: timestampScreens })
|
||||||
|
declare screen: (typeof timestampScreens)[number];
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
format: 'date-time',
|
||||||
|
description: 'Start instant for operations.',
|
||||||
|
})
|
||||||
|
from?: string;
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
format: 'date-time',
|
||||||
|
description: 'End instant for operations.',
|
||||||
|
})
|
||||||
|
to?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const investmentScreenSchema = {
|
||||||
|
oneOf: [
|
||||||
|
IncomeInvestmentScreenDto,
|
||||||
|
EventsInvestmentScreenDto,
|
||||||
|
TimestampInvestmentScreenDto,
|
||||||
|
].map((model) => ({ $ref: getSchemaPath(model) })),
|
||||||
|
discriminator: {
|
||||||
|
propertyName: 'screen',
|
||||||
|
mapping: {
|
||||||
|
income: getSchemaPath(IncomeInvestmentScreenDto),
|
||||||
|
events: getSchemaPath(EventsInvestmentScreenDto),
|
||||||
|
...Object.fromEntries(
|
||||||
|
timestampScreens.map((screen) => [
|
||||||
|
screen,
|
||||||
|
getSchemaPath(TimestampInvestmentScreenDto),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export class TbankSyncAcceptedDto {
|
export class TbankSyncAcceptedDto {
|
||||||
@ApiProperty({ format: 'uuid' }) declare id: string;
|
@ApiProperty({ format: 'uuid' }) declare id: string;
|
||||||
@ApiProperty({ type: InvestmentScreenDto })
|
@ApiProperty({ type: Object, ...investmentScreenSchema })
|
||||||
declare scope: InvestmentScreenDto;
|
declare scope: InvestmentScreenDto;
|
||||||
@ApiProperty({ format: 'date-time' }) declare now: string;
|
@ApiProperty({ format: 'date-time' }) declare now: string;
|
||||||
}
|
}
|
||||||
@@ -98,7 +203,7 @@ export class TbankSyncProgressDto {
|
|||||||
}
|
}
|
||||||
export class TbankSyncStatusDto {
|
export class TbankSyncStatusDto {
|
||||||
@ApiProperty({ format: 'uuid' }) declare id: string;
|
@ApiProperty({ format: 'uuid' }) declare id: string;
|
||||||
@ApiProperty({ type: InvestmentScreenDto, nullable: true })
|
@ApiProperty({ type: Object, ...investmentScreenSchema, nullable: true })
|
||||||
declare scope: InvestmentScreenDto | null;
|
declare scope: InvestmentScreenDto | null;
|
||||||
@ApiProperty({ format: 'date-time' }) declare now: string;
|
@ApiProperty({ format: 'date-time' }) declare now: string;
|
||||||
@ApiProperty({ enum: ['running', 'succeeded', 'failed', 'cancelled'] })
|
@ApiProperty({ enum: ['running', 'succeeded', 'failed', 'cancelled'] })
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import { AccountReferenceDto } from '../account-reference.dto';
|
|||||||
export class UpcomingEventsQueryDto extends InvestmentReadQueryDto {
|
export class UpcomingEventsQueryDto extends InvestmentReadQueryDto {
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
format: 'date',
|
format: 'date',
|
||||||
description: 'Inclusive start date (YYYY-MM-DD). Defaults to today in UTC.',
|
description:
|
||||||
|
'Inclusive UTC calendar start date (YYYY-MM-DD). May be supplied independently; defaults to today in UTC.',
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||||
@@ -20,7 +21,7 @@ export class UpcomingEventsQueryDto extends InvestmentReadQueryDto {
|
|||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
format: 'date',
|
format: 'date',
|
||||||
description:
|
description:
|
||||||
'Inclusive end date (YYYY-MM-DD). Defaults to 30 days after from.',
|
'Inclusive UTC calendar end date (YYYY-MM-DD). May be supplied independently; defaults to 30 days after from. Timestamp queries use the start of the following UTC day as the exclusive end.',
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { INestApplication } from '@nestjs/common';
|
import { INestApplication } from '@nestjs/common';
|
||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
@@ -22,6 +23,109 @@ describe('OpenAPI contract (integration)', () => {
|
|||||||
|
|
||||||
afterAll(() => app.close());
|
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 () => {
|
it('generates the runtime contract without server startup or deployment configuration', async () => {
|
||||||
const directory = await mkdtemp(resolve(tmpdir(), 'wealthledger-openapi-'));
|
const directory = await mkdtemp(resolve(tmpdir(), 'wealthledger-openapi-'));
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -138,7 +138,15 @@ describe('local income completeness and exact money (SQLite)', () => {
|
|||||||
).toEqual(['a', 'b']);
|
).toEqual(['a', 'b']);
|
||||||
expect(result.data.previous).toBeNull();
|
expect(result.data.previous).toBeNull();
|
||||||
expect(result.data.yearComparison).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');
|
expect(result.data.calculatedAt).toBe('2025-02-01T00:00:00.000Z');
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
}
|
}
|
||||||
@@ -264,9 +272,10 @@ describe('local income completeness and exact money (SQLite)', () => {
|
|||||||
});
|
});
|
||||||
it('distinguishes unavailable calendar comparison without scheduling it', async () => {
|
it('distinguishes unavailable calendar comparison without scheduling it', async () => {
|
||||||
const result = await read('a', 'from=2024-02-29&to=2024-02-29');
|
const result = await read('a', 'from=2024-02-29&to=2024-02-29');
|
||||||
expect(result.meta.dependencies?.at(-1)?.unavailableReason).toBe(
|
expect(
|
||||||
'CALENDAR_OR_ACCOUNT_HISTORY',
|
result.meta.dependencies.find(({ role }) => role === 'year')
|
||||||
);
|
?.unavailableReason,
|
||||||
|
).toBe('CALENDAR_OR_ACCOUNT_HISTORY');
|
||||||
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
|
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -122,6 +122,25 @@ describe('screen sync HTTP and SQLite', () => {
|
|||||||
from: '2026-02-31',
|
from: '2026-02-31',
|
||||||
to: '2026-03-01',
|
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',
|
screen: 'stock',
|
||||||
instrumentUid: '00000000-0000-4000-8000-000000000001',
|
instrumentUid: '00000000-0000-4000-8000-000000000001',
|
||||||
@@ -145,6 +164,38 @@ describe('screen sync HTTP and SQLite', () => {
|
|||||||
expect(await prisma.tbankSyncWork.count()).toBe(0);
|
expect(await prisma.tbankSyncWork.count()).toBe(0);
|
||||||
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
|
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 () => {
|
it('keeps failed work terminal, cancels abandoned work without restart, and hides old connection handles', async () => {
|
||||||
upstream.getOpenAccounts.mockRejectedValue(
|
upstream.getOpenAccounts.mockRejectedValue(
|
||||||
new Error('upstream details never exposed'),
|
new Error('upstream details never exposed'),
|
||||||
|
|||||||
@@ -497,4 +497,38 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
|||||||
await get(`brokerage-accounts/account/events?${query}`).expect(400);
|
await get(`brokerage-accounts/account/events?${query}`).expect(400);
|
||||||
expect(upstream.getDividends).not.toHaveBeenCalled();
|
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' },
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user