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:
@@ -0,0 +1,113 @@
|
||||
jest.mock('../infrastructure/candle.repository', () => ({
|
||||
CandleRepository: class {},
|
||||
}));
|
||||
jest.mock('./catalog-enrichment.service', () => ({
|
||||
CatalogEnrichmentService: class {},
|
||||
}));
|
||||
jest.mock('../../../../integrations/tbank/tbank.api', () => ({
|
||||
TbankAccessService: class {},
|
||||
TbankClient: class {},
|
||||
}));
|
||||
|
||||
import { CandleService } from './candle.service';
|
||||
|
||||
describe('CandleService forced demand', () => {
|
||||
it.each([false, true])(
|
||||
'reconciles the full range through the latest forced timestamp while force=%s is loading',
|
||||
async (initialForce) => {
|
||||
let entered = () => {};
|
||||
let release = () => {};
|
||||
let complete = () => {};
|
||||
const started = new Promise<void>((resolve) => (entered = resolve));
|
||||
const blocked = new Promise<void>((resolve) => (release = resolve));
|
||||
const finished = new Promise<void>((resolve) => (complete = resolve));
|
||||
let completed = 0;
|
||||
const work = { id: 'work', attemptId: 'attempt' };
|
||||
const repository = {
|
||||
reserve: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ work, launch: true })
|
||||
.mockResolvedValueOnce({
|
||||
work: { id: 'other', attemptId: 'other-attempt' },
|
||||
launch: true,
|
||||
})
|
||||
.mockResolvedValue({ work, launch: false }),
|
||||
read: jest.fn().mockResolvedValue({
|
||||
coverage: [
|
||||
{
|
||||
from: new Date('2026-01-01Z'),
|
||||
to: new Date('2026-02-01Z'),
|
||||
isComplete: true,
|
||||
fetchedAt: new Date('2026-02-01Z'),
|
||||
},
|
||||
],
|
||||
}),
|
||||
stage: jest.fn().mockResolvedValue(true),
|
||||
publish: jest.fn((_attempt, _intent, done = true) => {
|
||||
if (done && ++completed === 2) complete();
|
||||
return Promise.resolve(true);
|
||||
}),
|
||||
fail: jest.fn(),
|
||||
};
|
||||
const sources = {
|
||||
ensureDetail: jest.fn().mockResolvedValue({
|
||||
first_1dayCandleDate: {
|
||||
seconds: String(Date.parse('2026-01-01Z') / 1000),
|
||||
},
|
||||
}),
|
||||
};
|
||||
const upstream = { getCandles: jest.fn().mockResolvedValue([]) };
|
||||
upstream.getCandles.mockImplementationOnce(async () => {
|
||||
entered();
|
||||
await blocked;
|
||||
return [];
|
||||
});
|
||||
const service = new CandleService(
|
||||
repository as never,
|
||||
sources as never,
|
||||
{ tokenForAttempt: jest.fn().mockResolvedValue('token') } as never,
|
||||
upstream as never,
|
||||
{ event: jest.fn() } as never,
|
||||
);
|
||||
const input = {
|
||||
uid: 'uid',
|
||||
instrumentType: 'share' as const,
|
||||
period: 'all' as const,
|
||||
};
|
||||
await service.request(
|
||||
'owner',
|
||||
'revision',
|
||||
input,
|
||||
new Date('2026-02-02T12:00:00Z'),
|
||||
initialForce,
|
||||
2,
|
||||
);
|
||||
await started;
|
||||
const latest = new Date('2026-02-02T13:00:00Z');
|
||||
await service.request(
|
||||
'owner',
|
||||
'revision',
|
||||
{ ...input, uid: 'other' },
|
||||
latest,
|
||||
false,
|
||||
1,
|
||||
);
|
||||
expect(
|
||||
await service.request('owner', 'revision', input, latest, true, 0),
|
||||
).toBe('work');
|
||||
release();
|
||||
await finished;
|
||||
|
||||
expect(upstream.getCandles).toHaveBeenCalledWith(
|
||||
'token',
|
||||
'uid',
|
||||
new Date('2026-01-01Z'),
|
||||
latest,
|
||||
);
|
||||
expect(repository.fail).not.toHaveBeenCalled();
|
||||
expect(
|
||||
upstream.getCandles.mock.calls.map((call: unknown[]) => call[1]),
|
||||
).toEqual(['uid', 'uid', 'other']);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -13,8 +13,8 @@ import {
|
||||
TbankAccessService,
|
||||
TbankClient,
|
||||
type TbankShare,
|
||||
type TbankSyncAttempt,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank-access.service';
|
||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||
import { CatalogEnrichmentService } from './catalog-enrichment.service';
|
||||
import { CandleRepository } from '../infrastructure/candle.repository';
|
||||
@@ -39,8 +39,12 @@ type Producer = {
|
||||
force: boolean;
|
||||
priority: number;
|
||||
observers: TbankSyncAttempt[];
|
||||
ranges?: CandleRange[];
|
||||
nextWindow?: number;
|
||||
pass?: {
|
||||
now: Date;
|
||||
force: boolean;
|
||||
ranges: CandleRange[];
|
||||
nextWindow: number;
|
||||
};
|
||||
};
|
||||
const months = { month: 1, halfYear: 6, year: 12 };
|
||||
const tailFreshForMs = 60_000;
|
||||
@@ -323,9 +327,14 @@ export class CandleService {
|
||||
id: reserved.work.id,
|
||||
attemptId: reserved.work.attemptId,
|
||||
};
|
||||
const producer = this.producers.get(scope(input));
|
||||
if (producer) {
|
||||
producer.force ||= force;
|
||||
producer.priority = Math.min(producer.priority, priority);
|
||||
if (force && now > producer.now) producer.now = now;
|
||||
}
|
||||
if (reserved.launch) {
|
||||
this.active.add(attempt.attemptId);
|
||||
const producer = this.producers.get(scope(input));
|
||||
if (producer) producer.observers.push(attempt);
|
||||
else {
|
||||
const next = { input, attempt, now, force, priority, observers: [] };
|
||||
@@ -346,7 +355,7 @@ export class CandleService {
|
||||
}
|
||||
|
||||
private async load(producer: Producer) {
|
||||
const { input, attempt, now } = producer;
|
||||
const { input, attempt } = producer;
|
||||
const instrument = await this.sources.ensureDetail(
|
||||
attempt.userId,
|
||||
attempt.revision,
|
||||
@@ -354,20 +363,24 @@ export class CandleService {
|
||||
input.uid,
|
||||
);
|
||||
if (!instrument) throw new NotFoundException();
|
||||
const range = rangeFor(input, instrument, now);
|
||||
if (!producer.ranges) {
|
||||
let pass = producer.pass;
|
||||
if (!pass || (producer.force && (!pass.force || producer.now > pass.now))) {
|
||||
const { now, force } = producer;
|
||||
const range = rangeFor(input, instrument, now);
|
||||
const saved = await this.repository.read(input.uid, range);
|
||||
producer.ranges = (
|
||||
producer.force ? [range] : candleGaps(range, saved.coverage, now)
|
||||
)
|
||||
.flatMap(candleWindows)
|
||||
.reverse();
|
||||
producer.nextWindow = 0;
|
||||
pass = producer.pass = {
|
||||
now,
|
||||
force,
|
||||
ranges: (force ? [range] : candleGaps(range, saved.coverage, now))
|
||||
.flatMap(candleWindows)
|
||||
.reverse(),
|
||||
nextWindow: 0,
|
||||
};
|
||||
}
|
||||
const ranges = producer.ranges;
|
||||
const index = producer.nextWindow ?? 0;
|
||||
const { ranges, now, force } = pass;
|
||||
const index = pass.nextWindow;
|
||||
let window = ranges[index];
|
||||
if (window && !producer.force) {
|
||||
if (window && !force) {
|
||||
const latest = await this.repository.read(input.uid, window);
|
||||
const gaps = candleGaps(window, latest.coverage, now)
|
||||
.flatMap(candleWindows)
|
||||
@@ -398,7 +411,7 @@ export class CandleService {
|
||||
if (!(await this.repository.stage(attempt, window, rows, now)))
|
||||
return false;
|
||||
if (
|
||||
!producer.force &&
|
||||
!force &&
|
||||
!(await this.repository.publish(
|
||||
attempt,
|
||||
{ uid: input.uid, ranges: [window] },
|
||||
@@ -406,13 +419,10 @@ export class CandleService {
|
||||
))
|
||||
)
|
||||
return false;
|
||||
producer.nextWindow = index + 1;
|
||||
if (producer.nextWindow < ranges.length) return null;
|
||||
pass.nextWindow = index + 1;
|
||||
if (pass.nextWindow < ranges.length) return null;
|
||||
}
|
||||
return this.repository.publish(attempt, {
|
||||
uid: input.uid,
|
||||
ranges: producer.force ? ranges : [],
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
private async drain() {
|
||||
@@ -443,20 +453,43 @@ export class CandleService {
|
||||
code = response.code;
|
||||
}
|
||||
}
|
||||
if (!success) await this.repository.fail(producer.attempt, code);
|
||||
this.active.delete(producer.attempt.attemptId);
|
||||
for (const observer of producer.observers) {
|
||||
if (
|
||||
!success ||
|
||||
!(await this.repository.completeObserver(
|
||||
observer,
|
||||
producer.attempt,
|
||||
))
|
||||
)
|
||||
await this.repository.fail(observer, code);
|
||||
this.active.delete(observer.attemptId);
|
||||
// Seal publication with reservations so a late forced join cannot be
|
||||
// acknowledged after the final demand check but before work completion.
|
||||
const finished = this.reservations.then(async () => {
|
||||
const pass = producer.pass;
|
||||
if (success && pass) {
|
||||
if (producer.force && (!pass.force || producer.now > pass.now))
|
||||
return false;
|
||||
try {
|
||||
success = await this.repository.publish(producer.attempt, {
|
||||
uid: producer.input.uid,
|
||||
ranges: pass.force ? pass.ranges : [],
|
||||
});
|
||||
} catch {
|
||||
success = false;
|
||||
}
|
||||
}
|
||||
if (!success) await this.repository.fail(producer.attempt, code);
|
||||
this.active.delete(producer.attempt.attemptId);
|
||||
for (const observer of producer.observers) {
|
||||
if (
|
||||
!success ||
|
||||
!(await this.repository.completeObserver(
|
||||
observer,
|
||||
producer.attempt,
|
||||
))
|
||||
)
|
||||
await this.repository.fail(observer, code);
|
||||
this.active.delete(observer.attemptId);
|
||||
}
|
||||
this.producers.delete(scope(producer.input));
|
||||
return true;
|
||||
});
|
||||
this.reservations = finished.catch(() => undefined);
|
||||
if (!(await finished)) {
|
||||
this.queue.push(producer);
|
||||
continue;
|
||||
}
|
||||
this.producers.delete(scope(producer.input));
|
||||
if (success && !producer.force && producer.input.period !== 'all')
|
||||
await this.request(
|
||||
producer.attempt.userId,
|
||||
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
TbankClient,
|
||||
tbankSourceNeedsRefresh,
|
||||
tbankSourceTtlMs,
|
||||
type TbankSyncAttempt,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank-access.service';
|
||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||
import {
|
||||
CatalogDiscoveryRepository,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { TbankDividend } from '../../../../integrations/tbank/tbank.api';
|
||||
import type { MoexBondEvent } from '../../../../integrations/moex/moex.api';
|
||||
import { CandleRepository } from '../infrastructure/candle.repository';
|
||||
import { candleGaps, utcDay } from '../domain/candle-coverage';
|
||||
@@ -14,11 +13,6 @@ import {
|
||||
} from '../../../../common/time';
|
||||
import { MoexClient } from '../../../../integrations/moex/moex.api';
|
||||
import { NcrRatingsClient } from '../../../../integrations/ncr/ncr.api';
|
||||
import type {
|
||||
TbankCandle,
|
||||
TbankFundamentals,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
import type { TbankCoupon } from '../../../../integrations/tbank/tbank-market.types';
|
||||
import { previousClose, timestamp } from './instrument-market';
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
@@ -29,10 +23,14 @@ import {
|
||||
tbankSourceNeedsRefresh,
|
||||
tbankSourceTtlMs,
|
||||
type TbankBond,
|
||||
type TbankCandle,
|
||||
type TbankCoupon,
|
||||
type TbankDividend,
|
||||
type TbankFundamentals,
|
||||
type TbankLastPrice,
|
||||
type TbankShare,
|
||||
type TbankSyncAttempt,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
import type { TbankLastPrice } from '../../../../integrations/tbank/tbank-market.types';
|
||||
import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank-access.service';
|
||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||
import {
|
||||
CatalogDiscoveryRepository,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
|
||||
import { ServiceUnavailableException } from '@nestjs/common';
|
||||
import type {
|
||||
TbankCandle,
|
||||
TbankTimestamp,
|
||||
@@ -36,16 +35,3 @@ export function previousClose(
|
||||
null
|
||||
);
|
||||
}
|
||||
export async function optional<T>(
|
||||
operation: () => Promise<T>,
|
||||
name: string,
|
||||
unavailable: string[],
|
||||
): Promise<T | null> {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
if (!(error instanceof ServiceUnavailableException)) throw error;
|
||||
unavailable.push(name);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
+23
-14
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
PortfolioDto,
|
||||
PortfolioPositionDto,
|
||||
} from '../../../portfolio/presentation/portfolio.dto';
|
||||
import { stockPosition } from './stock-position';
|
||||
} from '../../portfolio/presentation/portfolio.dto';
|
||||
import { instrumentPosition } from './instrument-position';
|
||||
|
||||
const money = (value: string, currency = 'RUB') => ({ value, currency });
|
||||
const row = (
|
||||
@@ -34,13 +34,14 @@ const portfolio = (positions: PortfolioPositionDto[]): PortfolioDto => ({
|
||||
|
||||
describe('stock position', () => {
|
||||
it('weights costs exactly and keeps preferred shares separate', () => {
|
||||
const result = stockPosition(
|
||||
const result = instrumentPosition(
|
||||
portfolio([
|
||||
row(),
|
||||
row('3', '20', '70'),
|
||||
row('100', '1', '100', 'preferred'),
|
||||
]),
|
||||
'common',
|
||||
'STOCK',
|
||||
);
|
||||
expect(result.quantity).toBe('5');
|
||||
expect(result.averagePurchasePrice).toEqual(money('16'));
|
||||
@@ -49,14 +50,18 @@ describe('stock position', () => {
|
||||
expect(result.returnPercent).toBe('25');
|
||||
expect(result.accounts[0].returnPercent).toBe('50');
|
||||
});
|
||||
it('does not round large amounts through floating point', () => {
|
||||
const result = stockPosition(
|
||||
portfolio([row('9007199254740993', '0.1', '900719925474099.3')]),
|
||||
'common',
|
||||
);
|
||||
expect(result.quantity).toBe('9007199254740993');
|
||||
expect(result.averagePurchasePrice?.value).toBe('0.1');
|
||||
});
|
||||
it.each(['9007199254740993', '0.25'])(
|
||||
'preserves exact quantity %s',
|
||||
(quantity) => {
|
||||
const result = instrumentPosition(
|
||||
portfolio([row(quantity, '0.1', '900719925474099.3')]),
|
||||
'common',
|
||||
'STOCK',
|
||||
);
|
||||
expect(result.quantity).toBe(quantity);
|
||||
expect(result.averagePurchasePrice?.value).toBe('0.1');
|
||||
},
|
||||
);
|
||||
it('does not sum currencies or treat unknown values as zero', () => {
|
||||
const second = {
|
||||
...row(),
|
||||
@@ -64,7 +69,11 @@ describe('stock position', () => {
|
||||
averagePurchasePrice: null,
|
||||
absoluteReturn: null,
|
||||
};
|
||||
const result = stockPosition(portfolio([row(), second]), 'common');
|
||||
const result = instrumentPosition(
|
||||
portfolio([row(), second]),
|
||||
'common',
|
||||
'STOCK',
|
||||
);
|
||||
expect(result.quantity).toBe('4');
|
||||
expect(result.currentValue).toBeNull();
|
||||
expect(result.averagePurchasePrice).toBeNull();
|
||||
@@ -72,14 +81,14 @@ describe('stock position', () => {
|
||||
expect(result.returnPercent).toBeNull();
|
||||
});
|
||||
it('keeps no-position and offsetting long/short holdings honest', () => {
|
||||
expect(stockPosition(portfolio([]), 'common')).toMatchObject({
|
||||
expect(instrumentPosition(portfolio([]), 'common', 'STOCK')).toMatchObject({
|
||||
quantity: '0',
|
||||
accounts: [],
|
||||
currentValue: null,
|
||||
returnPercent: null,
|
||||
});
|
||||
expect(
|
||||
stockPosition(portfolio([row(), row('-2')]), 'common')
|
||||
instrumentPosition(portfolio([row(), row('-2')]), 'common', 'STOCK')
|
||||
.averagePurchasePrice,
|
||||
).toBeNull();
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto';
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { Prisma } from '../../../../../generated/prisma/client';
|
||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||
import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank-access.service';
|
||||
import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank.api';
|
||||
import {
|
||||
confirmedCandleCoverage,
|
||||
type CandleRange,
|
||||
@@ -78,8 +78,37 @@ export class CandleRepository {
|
||||
previous?.state === 'running' &&
|
||||
previous.processRunId === runId &&
|
||||
active.has(previous.attemptId)
|
||||
)
|
||||
return { work: previous, launch: false };
|
||||
) {
|
||||
const previousIntent = previous.intent as Prisma.JsonObject;
|
||||
const previousNow =
|
||||
typeof previousIntent.now === 'string' ? previousIntent.now : '';
|
||||
const nextPriority = Math.min(previous.priority, priority);
|
||||
if (
|
||||
nextPriority === previous.priority &&
|
||||
(!intent.force ||
|
||||
(previousIntent.force === true &&
|
||||
(!intent.now || intent.now <= previousNow)))
|
||||
)
|
||||
return { work: previous, launch: false };
|
||||
const work = await tx.tbankSyncWork.update({
|
||||
where: { id: previous.id },
|
||||
data: {
|
||||
priority: nextPriority,
|
||||
intent: {
|
||||
...previousIntent,
|
||||
...(intent.force
|
||||
? {
|
||||
force: true,
|
||||
...(intent.now && intent.now > previousNow
|
||||
? { now: intent.now }
|
||||
: {}),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
return { work, launch: false };
|
||||
}
|
||||
if (previous)
|
||||
await tx.tbankSyncStage.deleteMany({ where: { workId: previous.id } });
|
||||
const data = {
|
||||
|
||||
@@ -3,10 +3,10 @@ import { randomUUID } from 'node:crypto';
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
import type { Prisma } from '../../../../../generated/prisma/client';
|
||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||
import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank-access.service';
|
||||
import type {
|
||||
TbankBond,
|
||||
TbankShare,
|
||||
TbankSyncAttempt,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
|
||||
export type CatalogQuery = {
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import type { PortfolioDto } from '../../../portfolio/portfolio.api';
|
||||
import { instrumentPosition } from '../../application/instrument-position';
|
||||
export function stockPosition(portfolio: PortfolioDto, uid: string) {
|
||||
return instrumentPosition(portfolio, uid, 'STOCK');
|
||||
}
|
||||
Reference in New Issue
Block a user