fix: harden auth and investment synchronization
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Successful in 11m9s
Conventional commits / commits (pull_request) Successful in 28s
Backend security / security (pull_request) Successful in 7s
Backend verify / verify (pull_request) Successful in 10m38s
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Successful in 11m9s
Conventional commits / commits (pull_request) Successful in 28s
Backend security / security (pull_request) Successful in 7s
Backend verify / verify (pull_request) Successful in 10m38s
Resolve A01-A10 and C01-C07 from the backend audit. Preserve auth failures, forced candle reconciliation and forecast freshness. Validate MOEX responses and nested HTTP errors; remove unused legacy paths. Verified: 209 unit tests, 262 SQLite e2e tests on Linux, OpenAPI and durability. Record the remaining macOS native SQLite abort in the audit.
This commit is contained in:
@@ -4,6 +4,17 @@ import {
|
||||
} from './brokerage-operation.mapper';
|
||||
|
||||
describe('mapBrokerageOperation', () => {
|
||||
it('keeps subsecond timestamps at persisted millisecond precision and the standalone epoch default', () => {
|
||||
expect(
|
||||
mapBrokerageOperation({
|
||||
date: { seconds: '1768000000', nanos: 600999999 },
|
||||
}).occurredAt,
|
||||
).toBe('2026-01-09T23:06:40.600Z');
|
||||
expect(mapBrokerageOperation({}).occurredAt).toBe(
|
||||
'1970-01-01T00:00:00.000Z',
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['OPERATION_TYPE_COUPON', 'income'],
|
||||
['OPERATION_TYPE_DIVIDEND', 'income'],
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
OperationCategory,
|
||||
} from '../presentation/brokerage-operation.dto';
|
||||
|
||||
const NANOSECONDS_PER_MILLISECOND = 1_000_000;
|
||||
|
||||
const types: Record<string, OperationCategory> = {
|
||||
OPERATION_TYPE_BUY: 'trade',
|
||||
OPERATION_TYPE_BUY_CARD: 'trade',
|
||||
@@ -42,10 +44,11 @@ export function operationCategory(type: string | undefined): OperationCategory {
|
||||
|
||||
export function mapBrokerageOperation(
|
||||
operation: TbankOperationItem,
|
||||
occurredAt = operationTimestamp(operation.date),
|
||||
): BrokerageOperationDto {
|
||||
return {
|
||||
id: operation.id ?? operation.cursor ?? '',
|
||||
occurredAt: timestamp(operation.date),
|
||||
occurredAt: occurredAt.toISOString(),
|
||||
type: operation.type ?? 'OPERATION_TYPE_UNSPECIFIED',
|
||||
category: operationCategory(operation.type),
|
||||
description: operation.description?.trim() || operation.name?.trim() || '',
|
||||
@@ -82,10 +85,11 @@ function money(value: TbankMoney | undefined) {
|
||||
: null;
|
||||
}
|
||||
|
||||
function timestamp(
|
||||
export function operationTimestamp(
|
||||
value: { seconds?: string | number; nanos?: number } | undefined,
|
||||
) {
|
||||
return new Date(
|
||||
Number(value?.seconds ?? 0) * MILLISECONDS_PER_SECOND,
|
||||
).toISOString();
|
||||
Number(value?.seconds ?? 0) * MILLISECONDS_PER_SECOND +
|
||||
Math.floor((value?.nanos ?? 0) / NANOSECONDS_PER_MILLISECOND),
|
||||
);
|
||||
}
|
||||
|
||||
+10
-16
@@ -5,7 +5,6 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
|
||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||
import type { Prisma } from '../../../../../generated/prisma/client';
|
||||
import type {
|
||||
@@ -17,7 +16,10 @@ import {
|
||||
dateOnlyBounds,
|
||||
moscowDate,
|
||||
} from '../../investment-calendar';
|
||||
import { operationCategory } from './brokerage-operation.mapper';
|
||||
import {
|
||||
operationCategory,
|
||||
operationTimestamp,
|
||||
} from './brokerage-operation.mapper';
|
||||
import {
|
||||
type OperationInterval,
|
||||
operationIntervals,
|
||||
@@ -45,7 +47,6 @@ type PageProgress = {
|
||||
pages: number;
|
||||
};
|
||||
const progressItem = '';
|
||||
const NANOSECONDS_PER_MILLISECOND = 1_000_000;
|
||||
|
||||
@Injectable()
|
||||
export class BrokerageOperationsRepository {
|
||||
@@ -650,13 +651,11 @@ export class BrokerageOperationsRepository {
|
||||
for (const operation of response.items ?? []) {
|
||||
if (!operation.id?.trim())
|
||||
throw new Error('Missing broker operation identity');
|
||||
const occurredAt = new Date(
|
||||
Number(operation.date?.seconds) * MILLISECONDS_PER_SECOND +
|
||||
Math.floor(
|
||||
(operation.date?.nanos ?? 0) / NANOSECONDS_PER_MILLISECOND,
|
||||
),
|
||||
);
|
||||
if (!Number.isFinite(occurredAt.valueOf()))
|
||||
const occurredAt = operationTimestamp(operation.date);
|
||||
if (
|
||||
operation.date?.seconds === undefined ||
|
||||
!Number.isFinite(occurredAt.valueOf())
|
||||
)
|
||||
throw new Error('Invalid operation timestamp');
|
||||
if (
|
||||
occurredAt < interval.from ||
|
||||
@@ -793,12 +792,7 @@ export class BrokerageOperationsRepository {
|
||||
if (previous && isDeepStrictEqual(previous.payload, operation))
|
||||
continue;
|
||||
const data = {
|
||||
occurredAt: new Date(
|
||||
Number(operation.date?.seconds) * MILLISECONDS_PER_SECOND +
|
||||
Math.floor(
|
||||
(operation.date?.nanos ?? 0) / NANOSECONDS_PER_MILLISECOND,
|
||||
),
|
||||
),
|
||||
occurredAt: operationTimestamp(operation.date),
|
||||
instrumentUid: operation.instrumentUid ?? null,
|
||||
category: operationCategory(operation.type),
|
||||
payload: row.payload as Prisma.InputJsonObject,
|
||||
|
||||
Reference in New Issue
Block a user