refactor: retire legacy tbank-sync status and sync=observe (WealthLedger/backend#196) #197
@@ -53,11 +53,6 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/modules/investments/instruments/application/catalog-discovery.service.ts": {
|
|
||||||
"sonarjs/no-nested-conditional": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/modules/investments/instruments/application/catalog-enrichment.service.ts": {
|
"src/modules/investments/instruments/application/catalog-enrichment.service.ts": {
|
||||||
"sonarjs/no-nested-conditional": {
|
"sonarjs/no-nested-conditional": {
|
||||||
"count": 1
|
"count": 1
|
||||||
@@ -135,11 +130,6 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/modules/investments/tbank-sync/tbank-sync.service.ts": {
|
|
||||||
"sonarjs/no-nested-conditional": {
|
|
||||||
"count": 6
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"test/bank-cards-durability.cjs": {
|
"test/bank-cards-durability.cjs": {
|
||||||
"sonarjs/no-nested-template-literals": {
|
"sonarjs/no-nested-template-literals": {
|
||||||
"count": 1
|
"count": 1
|
||||||
@@ -158,9 +148,6 @@
|
|||||||
"test/tbank-ledger-local.e2e-spec.ts": {
|
"test/tbank-ledger-local.e2e-spec.ts": {
|
||||||
"sonarjs/no-nested-conditional": {
|
"sonarjs/no-nested-conditional": {
|
||||||
"count": 1
|
"count": 1
|
||||||
},
|
|
||||||
"sonarjs/no-nested-template-literals": {
|
|
||||||
"count": 3
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,7 +6,6 @@ import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
|||||||
import { ApiProblemResponses } from '#src/common/decorators/api-safe-error-response.decorator';
|
import { ApiProblemResponses } from '#src/common/decorators/api-safe-error-response.decorator';
|
||||||
|
|
||||||
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
|
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
|
||||||
import { InvestmentReadQueryDto } from '../investment-resource.dto';
|
|
||||||
import {
|
import {
|
||||||
IncomeAnalyticsQueryDto,
|
IncomeAnalyticsQueryDto,
|
||||||
IncomeAnalyticsResourceDto,
|
IncomeAnalyticsResourceDto,
|
||||||
@@ -26,12 +25,10 @@ export class IncomeAnalyticsController {
|
|||||||
@ApiOkResponse({ type: IncomeSummaryResourceDto })
|
@ApiOkResponse({ type: IncomeSummaryResourceDto })
|
||||||
allSummary(
|
allSummary(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Query() query: InvestmentReadQueryDto,
|
|
||||||
) {
|
) {
|
||||||
return this.income.summary(
|
return this.income.summary(
|
||||||
request.user.sub,
|
request.user.sub,
|
||||||
'all',
|
'all',
|
||||||
query.sync === 'observe',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +43,6 @@ export class IncomeAnalyticsController {
|
|||||||
'all',
|
'all',
|
||||||
query.from,
|
query.from,
|
||||||
query.to,
|
query.to,
|
||||||
query.sync === 'observe',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,12 +51,10 @@ export class IncomeAnalyticsController {
|
|||||||
summary(
|
summary(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param('accountId') accountId: string,
|
@Param('accountId') accountId: string,
|
||||||
@Query() query: InvestmentReadQueryDto,
|
|
||||||
) {
|
) {
|
||||||
return this.income.summary(
|
return this.income.summary(
|
||||||
request.user.sub,
|
request.user.sub,
|
||||||
accountId,
|
accountId,
|
||||||
query.sync === 'observe',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +70,6 @@ export class IncomeAnalyticsController {
|
|||||||
accountId,
|
accountId,
|
||||||
query.from,
|
query.from,
|
||||||
query.to,
|
query.to,
|
||||||
query.sync === 'observe',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,14 +5,13 @@ import { MoneyDto } from '#src/common/money.dto';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
InvestmentDependencyDto,
|
InvestmentDependencyDto,
|
||||||
InvestmentReadQueryDto,
|
|
||||||
InvestmentResourceMetaDto,
|
InvestmentResourceMetaDto,
|
||||||
} from '../investment-resource.dto';
|
} from '../investment-resource.dto';
|
||||||
import { BrokerageOperationDto } from '../operations/operations.api';
|
import { BrokerageOperationDto } from '../operations/operations.api';
|
||||||
|
|
||||||
const dateOnly = /^\d{4}-\d{2}-\d{2}$/;
|
const dateOnly = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
|
||||||
export class IncomeAnalyticsQueryDto extends InvestmentReadQueryDto {
|
export class IncomeAnalyticsQueryDto {
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
format: 'date',
|
format: 'date',
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -44,14 +44,12 @@ export class IncomeAnalyticsService {
|
|||||||
async summary(
|
async summary(
|
||||||
userId: string,
|
userId: string,
|
||||||
accountId: string,
|
accountId: string,
|
||||||
observe = false,
|
|
||||||
): Promise<IncomeSummaryResourceDto> {
|
): Promise<IncomeSummaryResourceDto> {
|
||||||
const plan = planIncomePeriod(undefined, undefined);
|
const plan = planIncomePeriod(undefined, undefined);
|
||||||
const [selected] = await this.operations.collectRanges(
|
const [selected] = await this.operations.collectRanges(
|
||||||
userId,
|
userId,
|
||||||
accountId,
|
accountId,
|
||||||
() => [plan.selected],
|
() => [plan.selected],
|
||||||
observe,
|
|
||||||
);
|
);
|
||||||
if (selected.items === null) return { data: null, meta: selected.meta };
|
if (selected.items === null) return { data: null, meta: selected.meta };
|
||||||
assert(selected.meta.lastSuccessfulAt);
|
assert(selected.meta.lastSuccessfulAt);
|
||||||
@@ -71,14 +69,12 @@ export class IncomeAnalyticsService {
|
|||||||
accountId: string,
|
accountId: string,
|
||||||
from: string | undefined,
|
from: string | undefined,
|
||||||
to: string | undefined,
|
to: string | undefined,
|
||||||
observe = false,
|
|
||||||
): Promise<IncomeAnalyticsResourceDto> {
|
): Promise<IncomeAnalyticsResourceDto> {
|
||||||
const plan = planIncomePeriod(from, to);
|
const plan = planIncomePeriod(from, to);
|
||||||
const [current] = await this.operations.collectRanges(
|
const [current] = await this.operations.collectRanges(
|
||||||
userId,
|
userId,
|
||||||
accountId,
|
accountId,
|
||||||
() => [plan.selected],
|
() => [plan.selected],
|
||||||
observe,
|
|
||||||
);
|
);
|
||||||
const dependencies: IncomeDependencyDto[] = [
|
const dependencies: IncomeDependencyDto[] = [
|
||||||
{ ...current.meta, role: 'selected' },
|
{ ...current.meta, role: 'selected' },
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export class CandleService {
|
|||||||
private readonly logger: StructuredLogger,
|
private readonly logger: StructuredLogger,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async read(userId: string, input: CandleInput, observe = false) {
|
async read(userId: string, input: CandleInput) {
|
||||||
const { revision } = await this.access.local(userId);
|
const { revision } = await this.access.local(userId);
|
||||||
const [detail] = await this.sources.readMany(
|
const [detail] = await this.sources.readMany(
|
||||||
userId,
|
userId,
|
||||||
@@ -119,7 +119,7 @@ export class CandleService {
|
|||||||
const hasCompleteRange = Boolean(
|
const hasCompleteRange = Boolean(
|
||||||
range && saved && candleGaps(range, saved.coverage, now).length === NO_CANDLE_GAPS,
|
range && saved && candleGaps(range, saved.coverage, now).length === NO_CANDLE_GAPS,
|
||||||
);
|
);
|
||||||
if (!observe && (!saved || !hasCompleteRange)) {
|
if (!saved || !hasCompleteRange) {
|
||||||
await this.request(userId, revision, input, now);
|
await this.request(userId, revision, input, now);
|
||||||
}
|
}
|
||||||
const work = await this.repository.work(userId, revision, scope(input));
|
const work = await this.repository.work(userId, revision, scope(input));
|
||||||
|
|||||||
@@ -13,12 +13,10 @@ import {
|
|||||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||||
import { InstrumentErrorCode } from '../domain/instrument-error-code';
|
import { InstrumentErrorCode } from '../domain/instrument-error-code';
|
||||||
import {
|
import {
|
||||||
CatalogDiscoveryRepository,
|
|
||||||
catalogQuery,
|
catalogQuery,
|
||||||
type CatalogQuery,
|
type CatalogQuery,
|
||||||
catalogScope,
|
catalogScope,
|
||||||
} from '../infrastructure/catalog-discovery.repository';
|
} from '../infrastructure/catalog-discovery.repository';
|
||||||
import { CatalogEnrichmentService } from './catalog-enrichment.service';
|
|
||||||
import {
|
import {
|
||||||
catalogSearchLimit,
|
catalogSearchLimit,
|
||||||
selectCatalogInstruments,
|
selectCatalogInstruments,
|
||||||
@@ -26,7 +24,6 @@ import {
|
|||||||
import { publicInstrument } from './public-instrument';
|
import { publicInstrument } from './public-instrument';
|
||||||
|
|
||||||
const UNAMBIGUOUS_MATCH_COUNT = 1;
|
const UNAMBIGUOUS_MATCH_COUNT = 1;
|
||||||
const NO_MATCHES = 0;
|
|
||||||
const FIRST_RESULT_OFFSET = 0;
|
const FIRST_RESULT_OFFSET = 0;
|
||||||
|
|
||||||
const tickerAmbiguityLimit = 2;
|
const tickerAmbiguityLimit = 2;
|
||||||
@@ -34,203 +31,56 @@ const tickerAmbiguityLimit = 2;
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class CatalogDiscoveryService {
|
export class CatalogDiscoveryService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly repository: CatalogDiscoveryRepository,
|
|
||||||
private readonly access: TbankAccessService,
|
private readonly access: TbankAccessService,
|
||||||
private readonly tbank: TbankClient,
|
private readonly tbank: TbankClient,
|
||||||
private readonly enrichment: CatalogEnrichmentService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async read(userId: string, input: CatalogQuery) {
|
async search(userId: string, input: CatalogQuery) {
|
||||||
input = catalogQuery(input);
|
return this.onlineSearch(userId, catalogQuery(input));
|
||||||
const connection = await this.access.local(userId);
|
|
||||||
const saved = await this.repository.read(input);
|
|
||||||
return {
|
|
||||||
data: saved.data,
|
|
||||||
meta: investmentResourceMeta({
|
|
||||||
resourceKey: catalogScope(input),
|
|
||||||
dataRevision: saved.dataRevision,
|
|
||||||
availability: saved.data === null ? 'missing' : 'complete',
|
|
||||||
fetchedAt: saved.fetchedAt,
|
|
||||||
freshForMs: Number.MAX_SAFE_INTEGER,
|
|
||||||
work: await this.repository.work(userId, connection.revision, input),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async search(userId: string, input: CatalogQuery, observe = false) {
|
|
||||||
if (!observe) return this.onlineSearch(userId, catalogQuery(input));
|
|
||||||
const discovery = await this.read(userId, input);
|
|
||||||
const sources = discovery.data
|
|
||||||
? await this.enrichment.readMany(
|
|
||||||
userId,
|
|
||||||
input.instrumentType,
|
|
||||||
discovery.data.map((item) => item.uid),
|
|
||||||
observe,
|
|
||||||
)
|
|
||||||
: [];
|
|
||||||
const dependencies = [
|
|
||||||
discovery.meta,
|
|
||||||
...sources.map((source) => source.meta),
|
|
||||||
];
|
|
||||||
return {
|
|
||||||
data:
|
|
||||||
discovery.data?.map((short) => ({
|
|
||||||
...short,
|
|
||||||
detail:
|
|
||||||
sources.find(
|
|
||||||
(source) =>
|
|
||||||
source.input.uid === short.uid &&
|
|
||||||
source.input.kind === 'detail',
|
|
||||||
)?.instrument ?? null,
|
|
||||||
price:
|
|
||||||
sources.find(
|
|
||||||
(source) =>
|
|
||||||
source.input.uid === short.uid &&
|
|
||||||
source.input.kind === 'last-price',
|
|
||||||
)?.price ?? null,
|
|
||||||
})) ?? null,
|
|
||||||
meta: investmentResourceMeta({
|
|
||||||
resourceKey: `search:${catalogScope(catalogQuery(input))}`,
|
|
||||||
dataRevision: dependencies
|
|
||||||
.map((dependency) => dependency.dataRevision)
|
|
||||||
.join('/'),
|
|
||||||
availability:
|
|
||||||
discovery.data === null
|
|
||||||
? 'missing'
|
|
||||||
: sources.every((source) => source.meta.completeness === 'complete')
|
|
||||||
? 'complete'
|
|
||||||
: 'partial',
|
|
||||||
fetchedAt: discovery.meta.lastSuccessfulAt
|
|
||||||
? new Date(discovery.meta.lastSuccessfulAt)
|
|
||||||
: null,
|
|
||||||
freshForMs: Number.MAX_SAFE_INTEGER,
|
|
||||||
work: discovery.meta.sync.id
|
|
||||||
? {
|
|
||||||
id: discovery.meta.sync.id,
|
|
||||||
state: discovery.meta.sync.state,
|
|
||||||
errorCode: discovery.meta.sync.errorCode,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
dependencies,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async detail(
|
async detail(
|
||||||
userId: string,
|
userId: string,
|
||||||
instrumentType: 'bond' | 'share',
|
instrumentType: 'bond' | 'share',
|
||||||
uid: string,
|
uid: string,
|
||||||
observe = false,
|
|
||||||
) {
|
) {
|
||||||
if (!observe) {
|
const instrument = await this.access.online(userId, ({ token }) =>
|
||||||
const instrument = await this.access.online(userId, ({ token }) =>
|
instrumentType === 'share'
|
||||||
instrumentType === 'share'
|
? this.tbank.getShare(token, uid)
|
||||||
? this.tbank.getShare(token, uid)
|
: this.tbank.getBond(token, uid),
|
||||||
: this.tbank.getBond(token, uid),
|
|
||||||
);
|
|
||||||
if (!instrument) throw new NotFoundException();
|
|
||||||
return {
|
|
||||||
data: { ...publicInstrument({ ...instrument, uid }), uid, slug: instrument.ticker?.toUpperCase() ?? uid },
|
|
||||||
meta: investmentResourceMeta({
|
|
||||||
resourceKey: `${instrumentType}/detail/${uid}`,
|
|
||||||
dataRevision: `online:${Date.now()}`,
|
|
||||||
availability: 'complete',
|
|
||||||
fetchedAt: new Date(),
|
|
||||||
freshForMs: tbankSourceTtlMs.portfolio,
|
|
||||||
work: null,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const sources = await this.enrichment.readMany(
|
|
||||||
userId,
|
|
||||||
instrumentType,
|
|
||||||
[uid],
|
|
||||||
observe,
|
|
||||||
['detail'],
|
|
||||||
);
|
);
|
||||||
const source = sources[0];
|
if (!instrument) throw new NotFoundException();
|
||||||
if (source.meta.completeness === 'complete' && !source.instrument) {
|
|
||||||
throw new NotFoundException();
|
|
||||||
}
|
|
||||||
const instrument = source.instrument;
|
|
||||||
let slug = uid;
|
|
||||||
const dependencies = [source.meta];
|
|
||||||
if (
|
|
||||||
instrument?.ticker &&
|
|
||||||
/^[A-Z0-9][\w.@+-]{0,79}$/i.test(instrument.ticker)
|
|
||||||
) {
|
|
||||||
const alias = await this.read(userId, {
|
|
||||||
instrumentType,
|
|
||||||
query: instrument.ticker,
|
|
||||||
exactTicker: true,
|
|
||||||
});
|
|
||||||
if (alias.data?.length === UNAMBIGUOUS_MATCH_COUNT && alias.data[0].uid === uid) {
|
|
||||||
slug = instrument.ticker.toUpperCase();
|
|
||||||
}
|
|
||||||
dependencies.push(alias.meta);
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
data: instrument ? { ...instrument, uid, slug } : null,
|
data: { ...publicInstrument({ ...instrument, uid }), uid, slug: instrument.ticker?.toUpperCase() ?? uid },
|
||||||
meta: investmentResourceMeta({
|
meta: investmentResourceMeta({
|
||||||
resourceKey: `${instrumentType}/detail/${uid}`,
|
resourceKey: `${instrumentType}/detail/${uid}`,
|
||||||
dataRevision: dependencies
|
dataRevision: `online:${Date.now()}`,
|
||||||
.map((dependency) => dependency.dataRevision)
|
availability: 'complete',
|
||||||
.join('/'),
|
fetchedAt: new Date(),
|
||||||
availability: source.meta.availability,
|
|
||||||
fetchedAt: source.meta.lastSuccessfulAt
|
|
||||||
? new Date(source.meta.lastSuccessfulAt)
|
|
||||||
: null,
|
|
||||||
freshForMs: tbankSourceTtlMs.portfolio,
|
freshForMs: tbankSourceTtlMs.portfolio,
|
||||||
work: source.meta.sync.id
|
work: null,
|
||||||
? {
|
|
||||||
id: source.meta.sync.id,
|
|
||||||
state: source.meta.sync.state,
|
|
||||||
errorCode: source.meta.sync.errorCode,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
dependencies,
|
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line sonarjs/cognitive-complexity -- Online and observe paths intentionally preserve distinct error semantics.
|
|
||||||
async byTicker(
|
async byTicker(
|
||||||
userId: string,
|
userId: string,
|
||||||
instrumentType: 'bond' | 'share',
|
instrumentType: 'bond' | 'share',
|
||||||
ticker: string,
|
ticker: string,
|
||||||
observe = false,
|
|
||||||
) {
|
) {
|
||||||
if (!observe) {
|
const query = ticker.trim().toUpperCase();
|
||||||
const query = ticker.trim().toUpperCase();
|
const found = await this.access.online(userId, ({ token }) =>
|
||||||
const found = await this.access.online(userId, ({ token }) =>
|
instrumentType === 'share'
|
||||||
instrumentType === 'share'
|
? this.tbank.findShares(token, query)
|
||||||
? this.tbank.findShares(token, query)
|
: this.tbank.findBonds(token, query),
|
||||||
: this.tbank.findBonds(token, query),
|
);
|
||||||
);
|
const matches = selectCatalogInstruments(
|
||||||
const matches = selectCatalogInstruments(
|
found.filter((item) => item.ticker?.trim().toUpperCase() === query),
|
||||||
found.filter((item) => item.ticker?.trim().toUpperCase() === query),
|
query,
|
||||||
query,
|
|
||||||
instrumentType,
|
|
||||||
).slice(FIRST_RESULT_OFFSET, tickerAmbiguityLimit);
|
|
||||||
if (!matches.length) throw new NotFoundException();
|
|
||||||
if (matches.length > UNAMBIGUOUS_MATCH_COUNT) {
|
|
||||||
throw new ConflictException({
|
|
||||||
code:
|
|
||||||
instrumentType === 'share'
|
|
||||||
? InstrumentErrorCode.StockTickerAmbiguous
|
|
||||||
: InstrumentErrorCode.BondTickerAmbiguous,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const result = await this.detail(userId, instrumentType, matches[0].uid);
|
|
||||||
return { ...result, data: result.data ? { ...result.data, slug: query } : null };
|
|
||||||
}
|
|
||||||
const lookup = await this.read(userId, {
|
|
||||||
instrumentType,
|
instrumentType,
|
||||||
query: ticker,
|
).slice(FIRST_RESULT_OFFSET, tickerAmbiguityLimit);
|
||||||
exactTicker: true,
|
if (!matches.length) throw new NotFoundException();
|
||||||
});
|
if (matches.length > UNAMBIGUOUS_MATCH_COUNT) {
|
||||||
if (lookup.data?.length === NO_MATCHES) throw new NotFoundException();
|
|
||||||
if (lookup.data && lookup.data.length > UNAMBIGUOUS_MATCH_COUNT) {
|
|
||||||
throw new ConflictException({
|
throw new ConflictException({
|
||||||
code:
|
code:
|
||||||
instrumentType === 'share'
|
instrumentType === 'share'
|
||||||
@@ -238,34 +88,8 @@ export class CatalogDiscoveryService {
|
|||||||
: InstrumentErrorCode.BondTickerAmbiguous,
|
: InstrumentErrorCode.BondTickerAmbiguous,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const result = lookup.data
|
const result = await this.detail(userId, instrumentType, matches[0].uid);
|
||||||
? await this.detail(userId, instrumentType, lookup.data[0].uid, observe)
|
return { ...result, data: result.data ? { ...result.data, slug: query } : null };
|
||||||
: null;
|
|
||||||
const dependencies = [lookup.meta, ...(result ? [result.meta] : [])];
|
|
||||||
return {
|
|
||||||
data: result?.data
|
|
||||||
? { ...result.data, slug: ticker.trim().toUpperCase() }
|
|
||||||
: null,
|
|
||||||
meta: investmentResourceMeta({
|
|
||||||
resourceKey: `${instrumentType}/ticker/${ticker.trim().toUpperCase()}`,
|
|
||||||
dataRevision: dependencies
|
|
||||||
.map((dependency) => dependency.dataRevision)
|
|
||||||
.join('/'),
|
|
||||||
availability: result?.meta.availability ?? 'missing',
|
|
||||||
fetchedAt: lookup.meta.lastSuccessfulAt
|
|
||||||
? new Date(lookup.meta.lastSuccessfulAt)
|
|
||||||
: null,
|
|
||||||
freshForMs: tbankSourceTtlMs.portfolio,
|
|
||||||
work: lookup.meta.sync.id
|
|
||||||
? {
|
|
||||||
id: lookup.meta.sync.id,
|
|
||||||
state: lookup.meta.sync.state,
|
|
||||||
errorCode: lookup.meta.sync.errorCode,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
dependencies,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async onlineSearch(userId: string, input: CatalogQuery) {
|
private async onlineSearch(userId: string, input: CatalogQuery) {
|
||||||
|
|||||||
@@ -222,9 +222,8 @@ export class CatalogEnrichmentService {
|
|||||||
instrumentType: 'bond' | 'share',
|
instrumentType: 'bond' | 'share',
|
||||||
uid: string,
|
uid: string,
|
||||||
section: 'company' | 'metrics' | 'quote',
|
section: 'company' | 'metrics' | 'quote',
|
||||||
observe = false,
|
|
||||||
) {
|
) {
|
||||||
if (section === 'company' && !observe) {
|
if (section === 'company') {
|
||||||
const { description, fundamentals, instrument } = await this.access.online(userId, async ({ token }) => {
|
const { description, fundamentals, instrument } = await this.access.online(userId, async ({ token }) => {
|
||||||
const instrument =
|
const instrument =
|
||||||
instrumentType === 'share'
|
instrumentType === 'share'
|
||||||
@@ -254,25 +253,22 @@ export class CatalogEnrichmentService {
|
|||||||
observedAt: meta.lastSuccessfulAt ?? new Date().toISOString(),
|
observedAt: meta.lastSuccessfulAt ?? new Date().toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const kinds: CatalogSource['kind'][] =
|
const kinds: CatalogSource['kind'][] = [
|
||||||
section === 'company'
|
...(instrumentType === 'bond'
|
||||||
? ['detail', 'description', 'fundamentals']
|
? ['bond-current' as const]
|
||||||
: [
|
: ['detail' as const]),
|
||||||
...(instrumentType === 'bond'
|
'last-price',
|
||||||
? ['bond-current' as const]
|
'trading-status',
|
||||||
: ['detail' as const]),
|
'comparison',
|
||||||
'last-price',
|
...(section === 'metrics'
|
||||||
'trading-status',
|
? ['coupons' as const, 'yield' as const, 'rating' as const]
|
||||||
'comparison',
|
: []),
|
||||||
...(section === 'metrics'
|
];
|
||||||
? ['coupons' as const, 'yield' as const, 'rating' as const]
|
|
||||||
: []),
|
|
||||||
];
|
|
||||||
const sources = await this.readMany(
|
const sources = await this.readMany(
|
||||||
userId,
|
userId,
|
||||||
instrumentType,
|
instrumentType,
|
||||||
[uid],
|
[uid],
|
||||||
observe,
|
false,
|
||||||
kinds,
|
kinds,
|
||||||
);
|
);
|
||||||
const first = sources[0];
|
const first = sources[0];
|
||||||
@@ -300,7 +296,7 @@ export class CatalogEnrichmentService {
|
|||||||
fetchedAt: first.meta.lastSuccessfulAt
|
fetchedAt: first.meta.lastSuccessfulAt
|
||||||
? new Date(first.meta.lastSuccessfulAt)
|
? new Date(first.meta.lastSuccessfulAt)
|
||||||
: null,
|
: null,
|
||||||
freshForMs: section === 'company' ? Number.MAX_SAFE_INTEGER : tbankSourceTtlMs.quote,
|
freshForMs: tbankSourceTtlMs.quote,
|
||||||
work: null,
|
work: null,
|
||||||
dependencies,
|
dependencies,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,12 +27,10 @@ export class BondCatalogService {
|
|||||||
async search(
|
async search(
|
||||||
userId: string,
|
userId: string,
|
||||||
query: string,
|
query: string,
|
||||||
observe = false,
|
|
||||||
): Promise<BondSearchResourceDto> {
|
): Promise<BondSearchResourceDto> {
|
||||||
const result = await this.discovery.search(
|
const result = await this.discovery.search(
|
||||||
userId,
|
userId,
|
||||||
{ instrumentType: 'bond', query },
|
{ instrumentType: 'bond', query },
|
||||||
observe,
|
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
@@ -60,9 +58,8 @@ export class BondCatalogService {
|
|||||||
async get(
|
async get(
|
||||||
userId: string,
|
userId: string,
|
||||||
uid: string,
|
uid: string,
|
||||||
observe = false,
|
|
||||||
): Promise<BondResourceDto> {
|
): Promise<BondResourceDto> {
|
||||||
const result = await this.discovery.detail(userId, 'bond', uid, observe);
|
const result = await this.discovery.detail(userId, 'bond', uid);
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
data: result.data
|
data: result.data
|
||||||
@@ -74,13 +71,11 @@ export class BondCatalogService {
|
|||||||
async getByTicker(
|
async getByTicker(
|
||||||
userId: string,
|
userId: string,
|
||||||
ticker: string,
|
ticker: string,
|
||||||
observe = false,
|
|
||||||
): Promise<BondResourceDto> {
|
): Promise<BondResourceDto> {
|
||||||
const result = await this.discovery.byTicker(
|
const result = await this.discovery.byTicker(
|
||||||
userId,
|
userId,
|
||||||
'bond',
|
'bond',
|
||||||
ticker,
|
ticker,
|
||||||
observe,
|
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
|
|||||||
@@ -55,14 +55,12 @@ export class BondIncomeService {
|
|||||||
async metrics(
|
async metrics(
|
||||||
userId: string,
|
userId: string,
|
||||||
uid: string,
|
uid: string,
|
||||||
observe = false,
|
|
||||||
): Promise<BondMetricsResourceDto> {
|
): Promise<BondMetricsResourceDto> {
|
||||||
const result = await this.sources.section(
|
const result = await this.sources.section(
|
||||||
userId,
|
userId,
|
||||||
'bond',
|
'bond',
|
||||||
uid,
|
uid,
|
||||||
'metrics',
|
'metrics',
|
||||||
observe,
|
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
data: result.values.instrument
|
data: result.values.instrument
|
||||||
@@ -83,7 +81,6 @@ export class BondIncomeService {
|
|||||||
async coupons(
|
async coupons(
|
||||||
userId: string,
|
userId: string,
|
||||||
uid: string,
|
uid: string,
|
||||||
observe = false,
|
|
||||||
): Promise<BondCouponsResourceDto> {
|
): Promise<BondCouponsResourceDto> {
|
||||||
const connection = await this.access.local(userId);
|
const connection = await this.access.local(userId);
|
||||||
const today = moscowDay(new Date().toISOString());
|
const today = moscowDay(new Date().toISOString());
|
||||||
@@ -94,7 +91,7 @@ export class BondIncomeService {
|
|||||||
userId,
|
userId,
|
||||||
'bond',
|
'bond',
|
||||||
[uid],
|
[uid],
|
||||||
observe,
|
false,
|
||||||
['detail', 'coupons'],
|
['detail', 'coupons'],
|
||||||
{ from, to },
|
{ from, to },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -39,14 +39,12 @@ export class BondMarketService {
|
|||||||
async quote(
|
async quote(
|
||||||
userId: string,
|
userId: string,
|
||||||
uid: string,
|
uid: string,
|
||||||
observe = false,
|
|
||||||
): Promise<BondQuoteResourceDto> {
|
): Promise<BondQuoteResourceDto> {
|
||||||
const result = await this.sources.section(
|
const result = await this.sources.section(
|
||||||
userId,
|
userId,
|
||||||
'bond',
|
'bond',
|
||||||
uid,
|
uid,
|
||||||
'quote',
|
'quote',
|
||||||
observe,
|
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
data: result.values.instrument
|
data: result.values.instrument
|
||||||
@@ -60,12 +58,10 @@ export class BondMarketService {
|
|||||||
userId: string,
|
userId: string,
|
||||||
uid: string,
|
uid: string,
|
||||||
period: BondCandlesQueryDto['period'] = 'month',
|
period: BondCandlesQueryDto['period'] = 'month',
|
||||||
observe = false,
|
|
||||||
) {
|
) {
|
||||||
return this.history.read(
|
return this.history.read(
|
||||||
userId,
|
userId,
|
||||||
{ uid, instrumentType: 'bond', period },
|
{ uid, instrumentType: 'bond', period },
|
||||||
observe,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
|||||||
|
|
||||||
import { ApiProblemResponses } from '#src/common/decorators/api-safe-error-response.decorator';
|
import { ApiProblemResponses } from '#src/common/decorators/api-safe-error-response.decorator';
|
||||||
import { AccessPayload, JwtAuthGuard } from '#src/modules/auth/auth.api';
|
import { AccessPayload, JwtAuthGuard } from '#src/modules/auth/auth.api';
|
||||||
import { InvestmentReadQueryDto } from '#src/modules/investments/investment-resource.dto';
|
|
||||||
|
|
||||||
import { BondCatalogService } from '../application/bond-catalog.service';
|
import { BondCatalogService } from '../application/bond-catalog.service';
|
||||||
import { BondIncomeService } from '../application/bond-income.service';
|
import { BondIncomeService } from '../application/bond-income.service';
|
||||||
@@ -48,9 +47,8 @@ export class BondController {
|
|||||||
metrics(
|
metrics(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||||
@Query() query: InvestmentReadQueryDto,
|
|
||||||
) {
|
) {
|
||||||
return this.income.metrics(request.user.sub, uid, query.sync === 'observe');
|
return this.income.metrics(request.user.sub, uid);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@@ -62,7 +60,6 @@ export class BondController {
|
|||||||
return this.bonds.search(
|
return this.bonds.search(
|
||||||
request.user.sub,
|
request.user.sub,
|
||||||
query.query,
|
query.query,
|
||||||
query.sync === 'observe',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,12 +68,10 @@ export class BondController {
|
|||||||
getByTicker(
|
getByTicker(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param() params: BondTickerParamsDto,
|
@Param() params: BondTickerParamsDto,
|
||||||
@Query() query: InvestmentReadQueryDto,
|
|
||||||
) {
|
) {
|
||||||
return this.bonds.getByTicker(
|
return this.bonds.getByTicker(
|
||||||
request.user.sub,
|
request.user.sub,
|
||||||
params.ticker,
|
params.ticker,
|
||||||
query.sync === 'observe',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,9 +80,8 @@ export class BondController {
|
|||||||
get(
|
get(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||||
@Query() query: InvestmentReadQueryDto,
|
|
||||||
) {
|
) {
|
||||||
return this.bonds.get(request.user.sub, uid, query.sync === 'observe');
|
return this.bonds.get(request.user.sub, uid);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':uid/quote')
|
@Get(':uid/quote')
|
||||||
@@ -95,9 +89,8 @@ export class BondController {
|
|||||||
quote(
|
quote(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||||
@Query() query: InvestmentReadQueryDto,
|
|
||||||
) {
|
) {
|
||||||
return this.market.quote(request.user.sub, uid, query.sync === 'observe');
|
return this.market.quote(request.user.sub, uid);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':uid/candles')
|
@Get(':uid/candles')
|
||||||
@@ -111,7 +104,6 @@ export class BondController {
|
|||||||
request.user.sub,
|
request.user.sub,
|
||||||
uid,
|
uid,
|
||||||
query.period,
|
query.period,
|
||||||
query.sync === 'observe',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,8 +121,7 @@ export class BondController {
|
|||||||
coupons(
|
coupons(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||||
@Query() query: InvestmentReadQueryDto,
|
|
||||||
) {
|
) {
|
||||||
return this.income.coupons(request.user.sub, uid, query.sync === 'observe');
|
return this.income.coupons(request.user.sub, uid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,14 +13,12 @@ export class GetStockCompanyUseCase {
|
|||||||
async execute(
|
async execute(
|
||||||
userId: string,
|
userId: string,
|
||||||
uid: string,
|
uid: string,
|
||||||
observe = false,
|
|
||||||
): Promise<StockCompanyResourceDto> {
|
): Promise<StockCompanyResourceDto> {
|
||||||
const result = await this.sources.section(
|
const result = await this.sources.section(
|
||||||
userId,
|
userId,
|
||||||
'share',
|
'share',
|
||||||
uid,
|
uid,
|
||||||
'company',
|
'company',
|
||||||
observe,
|
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
data: result.values.instrument
|
data: result.values.instrument
|
||||||
|
|||||||
@@ -23,12 +23,10 @@ export class StockCatalogService {
|
|||||||
async search(
|
async search(
|
||||||
userId: string,
|
userId: string,
|
||||||
query: string,
|
query: string,
|
||||||
observe = false,
|
|
||||||
): Promise<StockSearchResourceDto> {
|
): Promise<StockSearchResourceDto> {
|
||||||
const result = await this.discovery.search(
|
const result = await this.discovery.search(
|
||||||
userId,
|
userId,
|
||||||
{ instrumentType: 'share', query },
|
{ instrumentType: 'share', query },
|
||||||
observe,
|
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
@@ -60,9 +58,8 @@ export class StockCatalogService {
|
|||||||
async get(
|
async get(
|
||||||
userId: string,
|
userId: string,
|
||||||
uid: string,
|
uid: string,
|
||||||
observe = false,
|
|
||||||
): Promise<StockResourceDto> {
|
): Promise<StockResourceDto> {
|
||||||
const result = await this.discovery.detail(userId, 'share', uid, observe);
|
const result = await this.discovery.detail(userId, 'share', uid);
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
data: result.data
|
data: result.data
|
||||||
@@ -74,13 +71,11 @@ export class StockCatalogService {
|
|||||||
async getByTicker(
|
async getByTicker(
|
||||||
userId: string,
|
userId: string,
|
||||||
ticker: string,
|
ticker: string,
|
||||||
observe = false,
|
|
||||||
): Promise<StockResourceDto> {
|
): Promise<StockResourceDto> {
|
||||||
const result = await this.discovery.byTicker(
|
const result = await this.discovery.byTicker(
|
||||||
userId,
|
userId,
|
||||||
'share',
|
'share',
|
||||||
ticker,
|
ticker,
|
||||||
observe,
|
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ export class StockIncomeService {
|
|||||||
async dividends(
|
async dividends(
|
||||||
userId: string,
|
userId: string,
|
||||||
uid: string,
|
uid: string,
|
||||||
observe = false,
|
|
||||||
): Promise<StockDividendsResourceDto> {
|
): Promise<StockDividendsResourceDto> {
|
||||||
const connection = await this.access.local(userId);
|
const connection = await this.access.local(userId);
|
||||||
const today = moscowDay(new Date().toISOString());
|
const today = moscowDay(new Date().toISOString());
|
||||||
@@ -63,7 +62,7 @@ export class StockIncomeService {
|
|||||||
userId,
|
userId,
|
||||||
'share',
|
'share',
|
||||||
[uid],
|
[uid],
|
||||||
observe,
|
false,
|
||||||
['detail', 'dividends'],
|
['detail', 'dividends'],
|
||||||
{ from, to },
|
{ from, to },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -35,14 +35,12 @@ export class StockMarketService {
|
|||||||
async quote(
|
async quote(
|
||||||
userId: string,
|
userId: string,
|
||||||
uid: string,
|
uid: string,
|
||||||
observe = false,
|
|
||||||
): Promise<StockQuoteResourceDto> {
|
): Promise<StockQuoteResourceDto> {
|
||||||
const result = await this.sources.section(
|
const result = await this.sources.section(
|
||||||
userId,
|
userId,
|
||||||
'share',
|
'share',
|
||||||
uid,
|
uid,
|
||||||
'quote',
|
'quote',
|
||||||
observe,
|
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
data: result.values.instrument
|
data: result.values.instrument
|
||||||
@@ -56,12 +54,10 @@ export class StockMarketService {
|
|||||||
userId: string,
|
userId: string,
|
||||||
uid: string,
|
uid: string,
|
||||||
period: StockCandlesQueryDto['period'] = 'month',
|
period: StockCandlesQueryDto['period'] = 'month',
|
||||||
observe = false,
|
|
||||||
) {
|
) {
|
||||||
return this.history.read(
|
return this.history.read(
|
||||||
userId,
|
userId,
|
||||||
{ uid, instrumentType: 'share', period },
|
{ uid, instrumentType: 'share', period },
|
||||||
observe,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swa
|
|||||||
|
|
||||||
import { ApiProblemResponses } from '#src/common/decorators/api-safe-error-response.decorator';
|
import { ApiProblemResponses } from '#src/common/decorators/api-safe-error-response.decorator';
|
||||||
import { AccessPayload, JwtAuthGuard } from '#src/modules/auth/auth.api';
|
import { AccessPayload, JwtAuthGuard } from '#src/modules/auth/auth.api';
|
||||||
import { InvestmentReadQueryDto } from '#src/modules/investments/investment-resource.dto';
|
|
||||||
|
|
||||||
import { GetStockCompanyUseCase } from '../application/get-stock-company.use-case';
|
import { GetStockCompanyUseCase } from '../application/get-stock-company.use-case';
|
||||||
import { StockCatalogService } from '../application/stock-catalog.service';
|
import { StockCatalogService } from '../application/stock-catalog.service';
|
||||||
@@ -65,7 +64,6 @@ export class StockController {
|
|||||||
return this.stocks.search(
|
return this.stocks.search(
|
||||||
request.user.sub,
|
request.user.sub,
|
||||||
query.query,
|
query.query,
|
||||||
query.sync === 'observe',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,12 +72,10 @@ export class StockController {
|
|||||||
getByTicker(
|
getByTicker(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param() params: StockTickerParamsDto,
|
@Param() params: StockTickerParamsDto,
|
||||||
@Query() query: InvestmentReadQueryDto,
|
|
||||||
) {
|
) {
|
||||||
return this.stocks.getByTicker(
|
return this.stocks.getByTicker(
|
||||||
request.user.sub,
|
request.user.sub,
|
||||||
params.ticker,
|
params.ticker,
|
||||||
query.sync === 'observe',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,9 +112,8 @@ export class StockController {
|
|||||||
get(
|
get(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||||
@Query() query: InvestmentReadQueryDto,
|
|
||||||
) {
|
) {
|
||||||
return this.stocks.get(request.user.sub, uid, query.sync === 'observe');
|
return this.stocks.get(request.user.sub, uid);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':uid/quote')
|
@Get(':uid/quote')
|
||||||
@@ -126,9 +121,8 @@ export class StockController {
|
|||||||
quote(
|
quote(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||||
@Query() query: InvestmentReadQueryDto,
|
|
||||||
) {
|
) {
|
||||||
return this.market.quote(request.user.sub, uid, query.sync === 'observe');
|
return this.market.quote(request.user.sub, uid);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':uid/candles')
|
@Get(':uid/candles')
|
||||||
@@ -142,7 +136,6 @@ export class StockController {
|
|||||||
request.user.sub,
|
request.user.sub,
|
||||||
uid,
|
uid,
|
||||||
query.period,
|
query.period,
|
||||||
query.sync === 'observe',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,12 +153,10 @@ export class StockController {
|
|||||||
company(
|
company(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||||
@Query() query: InvestmentReadQueryDto,
|
|
||||||
) {
|
) {
|
||||||
return this.companyInfo.execute(
|
return this.companyInfo.execute(
|
||||||
request.user.sub,
|
request.user.sub,
|
||||||
uid,
|
uid,
|
||||||
query.sync === 'observe',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,12 +165,10 @@ export class StockController {
|
|||||||
dividends(
|
dividends(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||||
@Query() query: InvestmentReadQueryDto,
|
|
||||||
) {
|
) {
|
||||||
return this.income.dividends(
|
return this.income.dividends(
|
||||||
request.user.sub,
|
request.user.sub,
|
||||||
uid,
|
uid,
|
||||||
query.sync === 'observe',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
MIN_INSTRUMENT_QUERY_LENGTH,
|
MIN_INSTRUMENT_QUERY_LENGTH,
|
||||||
} from '#src/modules/investments/instrument-query';
|
} from '#src/modules/investments/instrument-query';
|
||||||
import {
|
import {
|
||||||
InvestmentReadQueryDto,
|
|
||||||
InvestmentResourceMetaDto,
|
InvestmentResourceMetaDto,
|
||||||
} from '#src/modules/investments/investment-resource.dto';
|
} from '#src/modules/investments/investment-resource.dto';
|
||||||
import { PortfolioPositionDto } from '#src/modules/investments/portfolio/portfolio.api';
|
import { PortfolioPositionDto } from '#src/modules/investments/portfolio/portfolio.api';
|
||||||
@@ -104,7 +103,7 @@ export class StockScreenerPageDto {
|
|||||||
@ApiProperty({ type: StockScreenerItemDto, isArray: true, maxItems: 30 }) declare items: StockScreenerItemDto[];
|
@ApiProperty({ type: StockScreenerItemDto, isArray: true, maxItems: 30 }) declare items: StockScreenerItemDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class StockSearchQueryDto extends InvestmentReadQueryDto {
|
export class StockSearchQueryDto {
|
||||||
@ApiProperty({ minLength: MIN_INSTRUMENT_QUERY_LENGTH, maxLength: MAX_INSTRUMENT_QUERY_LENGTH })
|
@ApiProperty({ minLength: MIN_INSTRUMENT_QUERY_LENGTH, maxLength: MAX_INSTRUMENT_QUERY_LENGTH })
|
||||||
@Transform(({ value }: { value: unknown }) =>
|
@Transform(({ value }: { value: unknown }) =>
|
||||||
typeof value === 'string' ? value.trim() : value,
|
typeof value === 'string' ? value.trim() : value,
|
||||||
@@ -126,7 +125,7 @@ export class StockTickerParamsDto {
|
|||||||
@Matches(/^[A-Z0-9][\w.@+-]*$/i)
|
@Matches(/^[A-Z0-9][\w.@+-]*$/i)
|
||||||
declare ticker: string;
|
declare ticker: string;
|
||||||
}
|
}
|
||||||
export class StockCandlesQueryDto extends InvestmentReadQueryDto {
|
export class StockCandlesQueryDto {
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
enum: ['month', 'halfYear', 'year', 'all'],
|
enum: ['month', 'halfYear', 'year', 'all'],
|
||||||
default: 'month',
|
default: 'month',
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsIn, IsOptional } from 'class-validator';
|
|
||||||
|
|
||||||
export const availabilityValues = [
|
export const availabilityValues = [
|
||||||
'available',
|
'available',
|
||||||
@@ -17,13 +16,6 @@ export const attemptStateValues = [
|
|||||||
'failed',
|
'failed',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export class InvestmentReadQueryDto {
|
|
||||||
@ApiPropertyOptional({ enum: ['observe'] })
|
|
||||||
@IsOptional()
|
|
||||||
@IsIn(['observe'])
|
|
||||||
declare sync?: 'observe';
|
|
||||||
}
|
|
||||||
|
|
||||||
export class InvestmentAttemptProblemDto {
|
export class InvestmentAttemptProblemDto {
|
||||||
@ApiProperty({ enum: ['membership', 'portfolio', 'operations', 'events'] })
|
@ApiProperty({ enum: ['membership', 'portfolio', 'operations', 'events'] })
|
||||||
declare section: 'events' | 'membership' | 'operations' | 'portfolio';
|
declare section: 'events' | 'membership' | 'operations' | 'portfolio';
|
||||||
|
|||||||
@@ -238,7 +238,6 @@ export class BrokerageOperationsService {
|
|||||||
userId: string,
|
userId: string,
|
||||||
accountId: string,
|
accountId: string,
|
||||||
ranges: (openedAt: Date | undefined) => DateOnlyRange[],
|
ranges: (openedAt: Date | undefined) => DateOnlyRange[],
|
||||||
observe = false,
|
|
||||||
) {
|
) {
|
||||||
const connection = await this.access.local(userId, accountId);
|
const connection = await this.access.local(userId, accountId);
|
||||||
const input = {
|
const input = {
|
||||||
@@ -252,21 +251,19 @@ export class BrokerageOperationsService {
|
|||||||
periods: ranges,
|
periods: ranges,
|
||||||
};
|
};
|
||||||
let saved = await this.repository.readPage(input);
|
let saved = await this.repository.readPage(input);
|
||||||
if (!observe) {
|
const targets = (saved?.connection.accounts ?? []).flatMap((account) =>
|
||||||
const targets = (saved?.connection.accounts ?? []).flatMap((account) =>
|
(saved?.periods ?? []).map((period) => ({
|
||||||
(saved?.periods ?? []).map((period) => ({
|
accountId: account.accountId,
|
||||||
accountId: account.accountId,
|
...period,
|
||||||
...period,
|
})),
|
||||||
})),
|
);
|
||||||
);
|
if (targets.length) {
|
||||||
if (targets.length) {
|
await this.sync.readPlan(userId, targets, {
|
||||||
await this.sync.readPlan(userId, targets, {
|
movingEnd: saved?.periods?.some((period) => period.movingEnd),
|
||||||
movingEnd: saved?.periods?.some((period) => period.movingEnd),
|
revision: connection.revision,
|
||||||
revision: connection.revision,
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
saved = await this.repository.readPage(input);
|
|
||||||
}
|
}
|
||||||
|
saved = await this.repository.readPage(input);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const hourly = await this.hourlySync.hourly(userId);
|
const hourly = await this.hourlySync.hourly(userId);
|
||||||
const membership = investmentResourceMeta({
|
const membership = investmentResourceMeta({
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
import type { Request } from 'express';
|
|
||||||
|
|
||||||
import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common';
|
|
||||||
import {
|
|
||||||
ApiBearerAuth,
|
|
||||||
ApiExtraModels,
|
|
||||||
ApiOkResponse,
|
|
||||||
ApiTags,
|
|
||||||
} from '@nestjs/swagger';
|
|
||||||
|
|
||||||
import { ApiProblemResponses } from '#src/common/decorators/api-safe-error-response.decorator';
|
|
||||||
|
|
||||||
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
|
|
||||||
import {
|
|
||||||
EventsInvestmentScreenDto,
|
|
||||||
IncomeInvestmentScreenDto,
|
|
||||||
TbankSyncStatusDto,
|
|
||||||
TimestampInvestmentScreenDto,
|
|
||||||
} from './tbank-sync.dto';
|
|
||||||
import { TbankSyncService } from './tbank-sync.service';
|
|
||||||
|
|
||||||
@ApiTags('tbank-sync')
|
|
||||||
@ApiExtraModels(
|
|
||||||
EventsInvestmentScreenDto,
|
|
||||||
IncomeInvestmentScreenDto,
|
|
||||||
TimestampInvestmentScreenDto,
|
|
||||||
TbankSyncStatusDto,
|
|
||||||
)
|
|
||||||
@ApiProblemResponses()
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@Controller('tbank-sync')
|
|
||||||
export class TbankSyncController {
|
|
||||||
constructor(private readonly sync: TbankSyncService) {}
|
|
||||||
@Get(':id')
|
|
||||||
@ApiOkResponse({ type: TbankSyncStatusDto })
|
|
||||||
status(
|
|
||||||
@Req() request: Request & { user: AccessPayload },
|
|
||||||
@Param('id') id: string,
|
|
||||||
) {
|
|
||||||
return this.sync.status(request.user.sub, id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -209,19 +209,6 @@ export const investmentScreenSchema = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export class TbankSyncStatusDto {
|
|
||||||
@ApiProperty({ format: 'uuid' }) declare id: string;
|
|
||||||
@ApiProperty({ type: Object, ...investmentScreenSchema, nullable: true })
|
|
||||||
declare scope: InvestmentScreenDto | null;
|
|
||||||
|
|
||||||
@ApiProperty({ format: 'date-time' }) declare now: string;
|
|
||||||
@ApiProperty({ enum: ['running', 'succeeded', 'failed', 'cancelled'] })
|
|
||||||
declare state: 'cancelled' | 'failed' | 'running' | 'succeeded';
|
|
||||||
|
|
||||||
@ApiProperty({ type: String, nullable: true }) declare errorCode:
|
|
||||||
null | string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class HourlySyncStatusDto {
|
export class HourlySyncStatusDto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
enum: ['running', 'succeeded', 'partial', 'failed', 'cancelled'],
|
enum: ['running', 'succeeded', 'partial', 'failed', 'cancelled'],
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ import { PortfolioModule } from '../portfolio/portfolio.module';
|
|||||||
import { UpcomingEventsModule } from '../upcoming-events/upcoming-events.module';
|
import { UpcomingEventsModule } from '../upcoming-events/upcoming-events.module';
|
||||||
import { TbankHourlyScheduler } from './tbank-hourly.scheduler';
|
import { TbankHourlyScheduler } from './tbank-hourly.scheduler';
|
||||||
import { TbankSyncRepositoryModule } from './tbank-sync-repository.module';
|
import { TbankSyncRepositoryModule } from './tbank-sync-repository.module';
|
||||||
import { TbankSyncController } from './tbank-sync.controller';
|
|
||||||
import { TbankSyncService } from './tbank-sync.service';
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
AuthModule,
|
AuthModule,
|
||||||
@@ -21,8 +19,7 @@ import { TbankSyncService } from './tbank-sync.service';
|
|||||||
UpcomingEventsModule,
|
UpcomingEventsModule,
|
||||||
TbankSyncRepositoryModule,
|
TbankSyncRepositoryModule,
|
||||||
],
|
],
|
||||||
controllers: [TbankSyncController],
|
providers: [TbankHourlyScheduler],
|
||||||
providers: [TbankSyncService, TbankHourlyScheduler],
|
|
||||||
exports: [TbankHourlyScheduler],
|
exports: [TbankHourlyScheduler],
|
||||||
})
|
})
|
||||||
export class TbankSyncModule {}
|
export class TbankSyncModule {}
|
||||||
|
|||||||
@@ -4,19 +4,9 @@ jest.mock('#src/prisma/prisma.service', () => ({
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
hourlyProgress,
|
hourlyProgress,
|
||||||
screenIntent,
|
|
||||||
screenProgress,
|
screenProgress,
|
||||||
} from './tbank-sync.repository';
|
} from './tbank-sync.repository';
|
||||||
|
|
||||||
describe('screen sync intent', () => {
|
|
||||||
it('restores a persisted intent and rejects a corrupted one', () => {
|
|
||||||
const intent = { now: '2026-11-02T00:00:00.000Z', scope: { screen: 'accounts' } };
|
|
||||||
expect(screenIntent(intent)).toEqual(intent);
|
|
||||||
expect(() => screenIntent({ source: 'hourly' })).toThrow('Corrupted screen sync intent');
|
|
||||||
expect(() => screenIntent(null)).toThrow('Corrupted screen sync intent');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('screen sync progress', () => {
|
describe('screen sync progress', () => {
|
||||||
it('restores a persisted progress unchanged', () => {
|
it('restores a persisted progress unchanged', () => {
|
||||||
const progress = { attempts: { 'work-1': 'attempt-1' }, children: ['work-1'], dispatched: true };
|
const progress = { attempts: { 'work-1': 'attempt-1' }, children: ['work-1'], dispatched: true };
|
||||||
|
|||||||
@@ -10,16 +10,6 @@ import { toJson } from '#src/prisma/to-json';
|
|||||||
|
|
||||||
import type { InvestmentAttemptDto } from '../investment-resource.dto';
|
import type { InvestmentAttemptDto } from '../investment-resource.dto';
|
||||||
import type { InvestmentScreenDto } from './tbank-sync.dto';
|
import type { InvestmentScreenDto } from './tbank-sync.dto';
|
||||||
export type ScreenIntent = { now: string; scope: InvestmentScreenDto };
|
|
||||||
|
|
||||||
// Json columns are written only by this repository; a shape check turns corruption into an explicit 500.
|
|
||||||
export function screenIntent(value: Prisma.JsonValue): ScreenIntent {
|
|
||||||
const intent = value as null | Partial<ScreenIntent>;
|
|
||||||
if (typeof intent?.now !== 'string' || typeof intent.scope?.screen !== 'string') {
|
|
||||||
throw new Error('Corrupted screen sync intent');
|
|
||||||
}
|
|
||||||
return intent as ScreenIntent;
|
|
||||||
}
|
|
||||||
export type ScreenProgress = {
|
export type ScreenProgress = {
|
||||||
attempts?: Record<string, string>;
|
attempts?: Record<string, string>;
|
||||||
children: string[];
|
children: string[];
|
||||||
@@ -189,30 +179,6 @@ export class TbankSyncRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
read(userId: string, revision: string, id: string) {
|
|
||||||
return this.prisma.$transaction(async (tx) => {
|
|
||||||
const work = await tx.tbankSyncWork.findFirst({
|
|
||||||
where: { userId, revision, id },
|
|
||||||
});
|
|
||||||
if (!work) return null;
|
|
||||||
const progress = screenProgress(work.progress);
|
|
||||||
const children = await tx.tbankSyncWork.findMany({
|
|
||||||
where: { userId, revision, id: { in: progress.children ?? [] } },
|
|
||||||
select: { id: true, attemptId: true, state: true, errorCode: true },
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
work,
|
|
||||||
children: children.map((child) =>
|
|
||||||
progress.attempts?.[child.id] &&
|
|
||||||
progress.attempts[child.id] !== child.attemptId
|
|
||||||
? { ...child, state: 'cancelled', errorCode: null }
|
|
||||||
: child,
|
|
||||||
),
|
|
||||||
progress,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async progress(
|
async progress(
|
||||||
attempt: TbankSyncAttempt,
|
attempt: TbankSyncAttempt,
|
||||||
children: string[],
|
children: string[],
|
||||||
|
|||||||
@@ -1,103 +0,0 @@
|
|||||||
import {
|
|
||||||
ConflictException,
|
|
||||||
Injectable,
|
|
||||||
NotFoundException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
|
|
||||||
import { TbankAccessService } from '#src/integrations/tbank/tbank.api';
|
|
||||||
|
|
||||||
import { BrokerageOperationsSyncService } from '../operations/operations.api';
|
|
||||||
import { TbankSyncStatusDto } from './tbank-sync.dto';
|
|
||||||
import { screenIntent, TbankSyncRepository } from './tbank-sync.repository';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class TbankSyncService {
|
|
||||||
private readonly runId = randomUUID();
|
|
||||||
private readonly startedAt = new Date();
|
|
||||||
constructor(
|
|
||||||
private readonly repository: TbankSyncRepository,
|
|
||||||
private readonly access: TbankAccessService,
|
|
||||||
private readonly operations: BrokerageOperationsSyncService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async status(userId: string, id: string): Promise<TbankSyncStatusDto> {
|
|
||||||
const connection = await this.access
|
|
||||||
.local(userId)
|
|
||||||
.catch((error: unknown) => {
|
|
||||||
if (error instanceof ConflictException) throw new NotFoundException();
|
|
||||||
throw error;
|
|
||||||
});
|
|
||||||
const saved = await this.repository.read(userId, connection.revision, id);
|
|
||||||
if (!saved) throw new NotFoundException();
|
|
||||||
if (!saved.work.scopeKey.startsWith('screen:')) {
|
|
||||||
return this.sourceStatus(userId, saved.work);
|
|
||||||
}
|
|
||||||
const intent = screenIntent(saved.work.intent);
|
|
||||||
await this.access.local(userId, intent.scope.accountId);
|
|
||||||
const children = await Promise.all(
|
|
||||||
saved.children.map((child) =>
|
|
||||||
child.state === 'superseded'
|
|
||||||
? this.operations.observe(userId, child.id)
|
|
||||||
: Promise.resolve(child),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const running = children.some((child) => child.state === 'running');
|
|
||||||
const failed =
|
|
||||||
saved.work.errorCode ??
|
|
||||||
children.find((child) => child.errorCode)?.errorCode ??
|
|
||||||
null;
|
|
||||||
const state =
|
|
||||||
saved.work.processRunId !== this.runId &&
|
|
||||||
(!saved.progress.dispatched || running)
|
|
||||||
? 'cancelled'
|
|
||||||
: !saved.progress.dispatched || running
|
|
||||||
? 'running'
|
|
||||||
: failed
|
|
||||||
? 'failed'
|
|
||||||
: children.some((child) => child.state === 'cancelled')
|
|
||||||
? 'cancelled'
|
|
||||||
: 'succeeded';
|
|
||||||
if (
|
|
||||||
(await this.access.local(userId, intent.scope.accountId)).revision !==
|
|
||||||
connection.revision
|
|
||||||
) {
|
|
||||||
throw new NotFoundException();
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
...intent,
|
|
||||||
state,
|
|
||||||
errorCode: failed,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private async sourceStatus(
|
|
||||||
userId: string,
|
|
||||||
work: NonNullable<Awaited<ReturnType<TbankSyncRepository['read']>>>['work'],
|
|
||||||
): Promise<TbankSyncStatusDto> {
|
|
||||||
const observed = work.scopeKey.startsWith('operations:')
|
|
||||||
? await this.operations.observe(userId, work.id)
|
|
||||||
: work;
|
|
||||||
const state =
|
|
||||||
observed.state === 'running'
|
|
||||||
? observed.updatedAt < this.startedAt
|
|
||||||
? 'cancelled'
|
|
||||||
: 'running'
|
|
||||||
: observed.state === 'failed'
|
|
||||||
? 'failed'
|
|
||||||
: observed.state === 'cancelled'
|
|
||||||
? 'cancelled'
|
|
||||||
: 'succeeded';
|
|
||||||
if ((await this.access.local(userId)).revision !== work.revision) {
|
|
||||||
throw new NotFoundException();
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
id: work.id,
|
|
||||||
scope: null,
|
|
||||||
now: work.createdAt.toISOString(),
|
|
||||||
state,
|
|
||||||
errorCode: observed.errorCode,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,11 +5,10 @@ import { MoneyDto } from '#src/common/money.dto';
|
|||||||
|
|
||||||
import { AccountReferenceDto } from '../account-reference.dto';
|
import { AccountReferenceDto } from '../account-reference.dto';
|
||||||
import {
|
import {
|
||||||
InvestmentReadQueryDto,
|
|
||||||
InvestmentResourceMetaDto,
|
InvestmentResourceMetaDto,
|
||||||
} from '../investment-resource.dto';
|
} from '../investment-resource.dto';
|
||||||
|
|
||||||
export class UpcomingEventsQueryDto extends InvestmentReadQueryDto {
|
export class UpcomingEventsQueryDto {
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
format: 'date',
|
format: 'date',
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ describe('All accounts HTTP boundary', () => {
|
|||||||
it.each(paths)('requires authentication for /all/%s', async (path) => {
|
it.each(paths)('requires authentication for /all/%s', async (path) => {
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.get(
|
.get(
|
||||||
`/api/v1/brokerage-accounts/all/${path}${path.includes('?') ? '&' : '?'}sync=observe`,
|
`/api/v1/brokerage-accounts/all/${path}`,
|
||||||
)
|
)
|
||||||
.expect(401);
|
.expect(401);
|
||||||
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
|
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
|
||||||
@@ -77,7 +77,7 @@ describe('All accounts HTTP boundary', () => {
|
|||||||
async (path) => {
|
async (path) => {
|
||||||
const response = await request(app.getHttpServer())
|
const response = await request(app.getHttpServer())
|
||||||
.get(
|
.get(
|
||||||
`/api/v1/brokerage-accounts/all/${path}${path.includes('?') ? '&' : '?'}sync=observe`,
|
`/api/v1/brokerage-accounts/all/${path}`,
|
||||||
)
|
)
|
||||||
.set('Authorization', `Bearer ${token}`)
|
.set('Authorization', `Bearer ${token}`)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
@@ -100,7 +100,7 @@ describe('All accounts HTTP boundary', () => {
|
|||||||
])('validates /all/%s in the configured pipeline', async (path) => {
|
])('validates /all/%s in the configured pipeline', async (path) => {
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.get(
|
.get(
|
||||||
`/api/v1/brokerage-accounts/all/${path}${path.includes('?') ? '&' : '?'}sync=observe`,
|
`/api/v1/brokerage-accounts/all/${path}`,
|
||||||
)
|
)
|
||||||
.set('Authorization', `Bearer ${token}`)
|
.set('Authorization', `Bearer ${token}`)
|
||||||
.expect(400);
|
.expect(400);
|
||||||
|
|||||||
@@ -125,14 +125,14 @@ describe('Bond HTTP contract', () => {
|
|||||||
.set('Authorization', `Bearer ${token}`)
|
.set('Authorization', `Bearer ${token}`)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
expect(response.body).toEqual(metrics);
|
expect(response.body).toEqual(metrics);
|
||||||
expect(bonds.metrics).toHaveBeenCalledWith('bond-reader', uid, false);
|
expect(bonds.metrics).toHaveBeenCalledWith('bond-reader', uid);
|
||||||
});
|
});
|
||||||
it('uses verified identity and supports a direct instrument URL', async () => {
|
it('uses verified identity and supports a direct instrument URL', async () => {
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.get(`/api/v1/bonds/${uid}`)
|
.get(`/api/v1/bonds/${uid}`)
|
||||||
.set('Authorization', `Bearer ${token}`)
|
.set('Authorization', `Bearer ${token}`)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
expect(bonds.get).toHaveBeenCalledWith('bond-reader', uid, false);
|
expect(bonds.get).toHaveBeenCalledWith('bond-reader', uid);
|
||||||
});
|
});
|
||||||
it.each(['SU26238RMFS4', 'su26238rmfs4', 'F', 'SU26238RMFS4@GS'])(
|
it.each(['SU26238RMFS4', 'su26238rmfs4', 'F', 'SU26238RMFS4@GS'])(
|
||||||
'resolves ticker %s through the authenticated endpoint',
|
'resolves ticker %s through the authenticated endpoint',
|
||||||
@@ -150,7 +150,6 @@ describe('Bond HTTP contract', () => {
|
|||||||
expect(bonds.getByTicker).toHaveBeenCalledWith(
|
expect(bonds.getByTicker).toHaveBeenCalledWith(
|
||||||
'bond-reader',
|
'bond-reader',
|
||||||
ticker,
|
ticker,
|
||||||
false,
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -185,7 +184,6 @@ describe('Bond HTTP contract', () => {
|
|||||||
expect(bonds.search).toHaveBeenCalledWith(
|
expect(bonds.search).toHaveBeenCalledWith(
|
||||||
'bond-reader',
|
'bond-reader',
|
||||||
'SU26238RMFS4',
|
'SU26238RMFS4',
|
||||||
false,
|
|
||||||
);
|
);
|
||||||
expect(response.body).toEqual({
|
expect(response.body).toEqual({
|
||||||
data: [item],
|
data: [item],
|
||||||
|
|||||||
@@ -28,31 +28,11 @@ describe('OpenAPI contract (integration)', () => {
|
|||||||
const document = createOpenApiDocument(app);
|
const document = createOpenApiDocument(app);
|
||||||
const schemas = document.components?.schemas;
|
const schemas = document.components?.schemas;
|
||||||
expect(document.paths['/api/v1/tbank-sync']).toBeUndefined();
|
expect(document.paths['/api/v1/tbank-sync']).toBeUndefined();
|
||||||
expect(schemas?.TbankSyncStatusDto).toBeDefined();
|
expect(document.paths['/api/v1/tbank-sync/{id}']).toBeUndefined();
|
||||||
for (const [model, screen] of [
|
expect(schemas?.TbankSyncStatusDto).toBeUndefined();
|
||||||
['IncomeInvestmentScreenDto', 'income'],
|
expect(schemas).not.toHaveProperty('IncomeInvestmentScreenDto');
|
||||||
['EventsInvestmentScreenDto', 'events'],
|
expect(schemas).not.toHaveProperty('EventsInvestmentScreenDto');
|
||||||
]) {
|
expect(schemas).not.toHaveProperty('TimestampInvestmentScreenDto');
|
||||||
// eslint-disable-next-line security/detect-object-injection -- Keys come from local test fixtures or numeric iteration, not external input.
|
|
||||||
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 scope of ['all', '{accountId}']) {
|
||||||
for (const [resource, format] of [
|
for (const [resource, format] of [
|
||||||
['income-analytics', 'date'],
|
['income-analytics', 'date'],
|
||||||
@@ -77,6 +57,15 @@ describe('OpenAPI contract (integration)', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not advertise the retired observe query on investment reads', () => {
|
||||||
|
const document = createOpenApiDocument(app);
|
||||||
|
for (const [path, resource] of Object.entries(document.paths)) {
|
||||||
|
if (!path.startsWith('/api/v1/')) continue;
|
||||||
|
const parameters = resource.get?.parameters ?? [];
|
||||||
|
expect(parameters.some((parameter) => 'name' in parameter && parameter.name === 'sync')).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('exposes only the selected income dependency role and no comparison data', () => {
|
it('exposes only the selected income dependency role and no comparison data', () => {
|
||||||
const schemas = createOpenApiDocument(app).components?.schemas;
|
const schemas = createOpenApiDocument(app).components?.schemas;
|
||||||
expect(schemas?.IncomeAnalyticsResourceDto).toMatchObject({
|
expect(schemas?.IncomeAnalyticsResourceDto).toMatchObject({
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ describe('Stock HTTP contract', () => {
|
|||||||
.get(`/api/v1/stocks/${uid}`)
|
.get(`/api/v1/stocks/${uid}`)
|
||||||
.set('Authorization', `Bearer ${token}`)
|
.set('Authorization', `Bearer ${token}`)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
expect(stocks.get).toHaveBeenCalledWith('stock-reader', uid, false);
|
expect(stocks.get).toHaveBeenCalledWith('stock-reader', uid);
|
||||||
});
|
});
|
||||||
it.each(['SBERP', 'sberp', 'F', 'SBER@GS'])(
|
it.each(['SBERP', 'sberp', 'F', 'SBER@GS'])(
|
||||||
'resolves ticker %s through the authenticated endpoint',
|
'resolves ticker %s through the authenticated endpoint',
|
||||||
@@ -154,7 +154,6 @@ describe('Stock HTTP contract', () => {
|
|||||||
expect(stocks.getByTicker).toHaveBeenCalledWith(
|
expect(stocks.getByTicker).toHaveBeenCalledWith(
|
||||||
'stock-reader',
|
'stock-reader',
|
||||||
ticker,
|
ticker,
|
||||||
false,
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -186,7 +185,7 @@ describe('Stock HTTP contract', () => {
|
|||||||
.get('/api/v1/stocks?query=SBER')
|
.get('/api/v1/stocks?query=SBER')
|
||||||
.set('Authorization', `Bearer ${token}`)
|
.set('Authorization', `Bearer ${token}`)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
expect(stocks.search).toHaveBeenCalledWith('stock-reader', 'SBER', false);
|
expect(stocks.search).toHaveBeenCalledWith('stock-reader', 'SBER');
|
||||||
expect(response.body).toEqual({
|
expect(response.body).toEqual({
|
||||||
data: [item],
|
data: [item],
|
||||||
meta: { availability: 'complete' },
|
meta: { availability: 'complete' },
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ describe('atomic shared candle reconciliation (SQLite)', () => {
|
|||||||
).toBe(false);
|
).toBe(false);
|
||||||
expect((await repository.read('uid', range)).coverage).toEqual([]);
|
expect((await repository.read('uid', range)).coverage).toEqual([]);
|
||||||
});
|
});
|
||||||
it('coalesces twenty reads, makes observe passive and reuses closed windows after reopen', async () => {
|
it('coalesces twenty reads and reuses closed windows after reopen', async () => {
|
||||||
const service = createService();
|
const service = createService();
|
||||||
const input = {
|
const input = {
|
||||||
uid: 'uid',
|
uid: 'uid',
|
||||||
@@ -216,7 +216,6 @@ describe('atomic shared candle reconciliation (SQLite)', () => {
|
|||||||
);
|
);
|
||||||
expect(cold.every((row) => row.data === null)).toBe(true);
|
expect(cold.every((row) => row.data === null)).toBe(true);
|
||||||
expect(await prisma.tbankSyncWork.count()).toBe(1);
|
expect(await prisma.tbankSyncWork.count()).toBe(1);
|
||||||
await service.read('owner', input, true);
|
|
||||||
release([]);
|
release([]);
|
||||||
await settled();
|
await settled();
|
||||||
const count = upstream.getCandles.mock.calls.length;
|
const count = upstream.getCandles.mock.calls.length;
|
||||||
@@ -239,7 +238,7 @@ describe('atomic shared candle reconciliation (SQLite)', () => {
|
|||||||
where: { isComplete: false },
|
where: { isComplete: false },
|
||||||
data: { fetchedAt: new Date(0) },
|
data: { fetchedAt: new Date(0) },
|
||||||
});
|
});
|
||||||
const stale = await reopened.read('owner', input, true);
|
const stale = await reopened.read('owner', input);
|
||||||
expect(stale.meta.availability).toBe('available');
|
expect(stale.meta.availability).toBe('available');
|
||||||
expect(stale.meta.completeness).toBe('complete');
|
expect(stale.meta.completeness).toBe('complete');
|
||||||
expect(stale.meta.freshness).toBe('stale');
|
expect(stale.meta.freshness).toBe('stale');
|
||||||
@@ -251,7 +250,7 @@ describe('atomic shared candle reconciliation (SQLite)', () => {
|
|||||||
await settled();
|
await settled();
|
||||||
expect(upstream.getCandles).toHaveBeenCalledTimes(count + 1);
|
expect(upstream.getCandles).toHaveBeenCalledTimes(count + 1);
|
||||||
});
|
});
|
||||||
it('recovers abandoned work only on a new ordinary request', async () => {
|
it('recovers abandoned work on an ordinary request', async () => {
|
||||||
const input = {
|
const input = {
|
||||||
uid: 'uid',
|
uid: 'uid',
|
||||||
instrumentType: 'share' as const,
|
instrumentType: 'share' as const,
|
||||||
@@ -265,12 +264,6 @@ describe('atomic shared candle reconciliation (SQLite)', () => {
|
|||||||
new Set(),
|
new Set(),
|
||||||
);
|
);
|
||||||
const reopened = createService();
|
const reopened = createService();
|
||||||
await reopened.read('owner', input, true);
|
|
||||||
expect(upstream.getCandles).not.toHaveBeenCalled();
|
|
||||||
expect(
|
|
||||||
(await repository.work('owner', 'revision', 'candles:uid:month'))
|
|
||||||
?.attemptId,
|
|
||||||
).toBe(abandoned?.work.attemptId);
|
|
||||||
await reopened.read('owner', input);
|
await reopened.read('owner', input);
|
||||||
await settled();
|
await settled();
|
||||||
expect(upstream.getCandles).toHaveBeenCalledTimes(1);
|
expect(upstream.getCandles).toHaveBeenCalledTimes(1);
|
||||||
@@ -302,7 +295,7 @@ describe('atomic shared candle reconciliation (SQLite)', () => {
|
|||||||
await settled();
|
await settled();
|
||||||
expect(await prisma.tbankCandleCoverage.findMany()).toEqual(before);
|
expect(await prisma.tbankCandleCoverage.findMany()).toEqual(before);
|
||||||
expect(await prisma.tbankCandle.count()).toBe(0);
|
expect(await prisma.tbankCandle.count()).toBe(0);
|
||||||
const read = await service.read('owner', input, true);
|
const read = await service.read('owner', input);
|
||||||
expect(read.meta.lastAttempt.state).toBe('failed');
|
expect(read.meta.lastAttempt.state).toBe('failed');
|
||||||
expect(read.data?.candles).toEqual([]);
|
expect(read.data?.candles).toEqual([]);
|
||||||
});
|
});
|
||||||
@@ -474,7 +467,7 @@ describe('atomic shared candle reconciliation (SQLite)', () => {
|
|||||||
};
|
};
|
||||||
await service.read('owner', input);
|
await service.read('owner', input);
|
||||||
await settled();
|
await settled();
|
||||||
const result = await service.read('owner', input, true);
|
const result = await service.read('owner', input);
|
||||||
expect(result.data?.currency).toBeNull();
|
expect(result.data?.currency).toBeNull();
|
||||||
expect(result.data?.candles).toEqual([
|
expect(result.data?.candles).toEqual([
|
||||||
{ time: now.toISOString(), close: '99.123456789', isComplete: false },
|
{ time: now.toISOString(), close: '99.123456789', isComplete: false },
|
||||||
|
|||||||
@@ -1,138 +0,0 @@
|
|||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
|
|
||||||
import { TbankAccessService } from '../src/integrations/tbank/tbank-access.service';
|
|
||||||
import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service';
|
|
||||||
import { CatalogDiscoveryService } from '../src/modules/investments/instruments/application/catalog-discovery.service';
|
|
||||||
import {
|
|
||||||
CatalogDiscoveryRepository,
|
|
||||||
type CatalogInstrument,
|
|
||||||
type CatalogQuery,
|
|
||||||
} from '../src/modules/investments/instruments/infrastructure/catalog-discovery.repository';
|
|
||||||
import { PrismaService } from '../src/prisma/prisma.service';
|
|
||||||
|
|
||||||
// Real transactions prove query normalization, confirmed-empty identity and cross-user isolation of shared reads.
|
|
||||||
describe('shared catalog discovery (SQLite)', () => {
|
|
||||||
const prisma = new PrismaService(
|
|
||||||
new ConfigService({ DATABASE_URL: process.env.DATABASE_URL }),
|
|
||||||
);
|
|
||||||
const tokens = new TbankTokenService(
|
|
||||||
new ConfigService({
|
|
||||||
TOKEN_ENCRYPTION_KEY: process.env.TOKEN_ENCRYPTION_KEY,
|
|
||||||
}) as never,
|
|
||||||
);
|
|
||||||
const upstream = { findShares: jest.fn(), findBonds: jest.fn() };
|
|
||||||
const access = new TbankAccessService(prisma, tokens);
|
|
||||||
const repository = new CatalogDiscoveryRepository(prisma);
|
|
||||||
const createService = () =>
|
|
||||||
new CatalogDiscoveryService(
|
|
||||||
repository,
|
|
||||||
access,
|
|
||||||
upstream as never,
|
|
||||||
{ requestMany: jest.fn().mockResolvedValue([]) } as never,
|
|
||||||
);
|
|
||||||
let service: CatalogDiscoveryService;
|
|
||||||
const query: CatalogQuery = { instrumentType: 'share', query: 'SBER' };
|
|
||||||
const instrument = {
|
|
||||||
uid: 'uid',
|
|
||||||
name: 'Synthetic share',
|
|
||||||
ticker: 'SBER',
|
|
||||||
isin: 'TEST',
|
|
||||||
instrumentKind: 'INSTRUMENT_TYPE_SHARE',
|
|
||||||
};
|
|
||||||
const seed = (userId: string) =>
|
|
||||||
prisma.tbankConnection.create({
|
|
||||||
data: {
|
|
||||||
userId,
|
|
||||||
revision: `${userId}-revision`,
|
|
||||||
encryptedToken: tokens.encrypt(`synthetic-${userId}`),
|
|
||||||
connectedAt: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
// Writes a finished discovery directly, so the read path is tested without any producer.
|
|
||||||
async function seedCatalog(
|
|
||||||
query: string,
|
|
||||||
instruments: Array<{ payload: CatalogInstrument; uid: string }>,
|
|
||||||
) {
|
|
||||||
for (const item of instruments) {
|
|
||||||
await prisma.tbankInstrument.create({
|
|
||||||
data: {
|
|
||||||
provider: 'tbank',
|
|
||||||
uid: item.uid,
|
|
||||||
instrumentType: 'share',
|
|
||||||
metadata: {},
|
|
||||||
fetchedAt: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await prisma.tbankInstrumentSource.create({
|
|
||||||
data: {
|
|
||||||
provider: 'tbank',
|
|
||||||
instrumentUid: item.uid,
|
|
||||||
source: 'tbank',
|
|
||||||
kind: 'catalog',
|
|
||||||
rangeKey: 'share',
|
|
||||||
payload: item.payload,
|
|
||||||
fetchedAt: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await prisma.tbankCatalogDiscovery.create({
|
|
||||||
data: {
|
|
||||||
provider: 'tbank',
|
|
||||||
instrumentType: 'share',
|
|
||||||
query,
|
|
||||||
instrumentUids: instruments.map((item) => item.uid),
|
|
||||||
fetchedAt: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
beforeAll(async () => {
|
|
||||||
await prisma.$connect();
|
|
||||||
for (const id of ['owner', 'other']) {
|
|
||||||
await prisma.user.create({
|
|
||||||
data: { id, email: `${id}@example.invalid`, passwordHash: 'synthetic' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
afterAll(async () => prisma.$disconnect());
|
|
||||||
beforeEach(async () => {
|
|
||||||
await prisma.tbankConnection.deleteMany();
|
|
||||||
await prisma.tbankCatalogDiscovery.deleteMany();
|
|
||||||
await prisma.tbankInstrumentSource.deleteMany();
|
|
||||||
await prisma.tbankInstrument.deleteMany();
|
|
||||||
await seed('owner');
|
|
||||||
await seed('other');
|
|
||||||
jest.resetAllMocks();
|
|
||||||
service = createService();
|
|
||||||
});
|
|
||||||
it('treats an unknown query as unavailable and a seeded query as available for another user', async () => {
|
|
||||||
const cold = await service.read('owner', { ...query, query: ' sber ' });
|
|
||||||
expect(cold.data).toBeNull();
|
|
||||||
expect(cold.meta.availability).toBe('temporarily-unavailable');
|
|
||||||
await seedCatalog('SBER', [{ uid: 'uid', payload: instrument }]);
|
|
||||||
const warm = await service.read('other', query);
|
|
||||||
expect(warm.data).toEqual([instrument]);
|
|
||||||
expect(warm.meta.availability).toBe('available');
|
|
||||||
expect(upstream.findShares).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
it('keeps a confirmed empty result distinct from an unknown query', async () => {
|
|
||||||
await seedCatalog('GAZP', []);
|
|
||||||
const empty = await service.read('owner', { ...query, query: 'GAZP' });
|
|
||||||
expect(empty.data).toEqual([]);
|
|
||||||
expect(empty.meta.availability).toBe('available');
|
|
||||||
const unknown = await service.read('owner', { ...query, query: 'LKOH' });
|
|
||||||
expect(unknown.data).toBeNull();
|
|
||||||
});
|
|
||||||
it('keeps the stored selection order and does not expose private fields', async () => {
|
|
||||||
const first = { ...instrument, uid: 'first', name: 'First' };
|
|
||||||
const second = { ...instrument, uid: 'second', name: 'Second' };
|
|
||||||
await seedCatalog('SBER', [
|
|
||||||
{ uid: 'second', payload: second },
|
|
||||||
{ uid: 'first', payload: first },
|
|
||||||
]);
|
|
||||||
const result = await service.read('owner', query);
|
|
||||||
assert(result.data);
|
|
||||||
expect(result.data.map((item) => item.uid)).toEqual(['second', 'first']);
|
|
||||||
expect(result.data[0]).not.toHaveProperty('accountId');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -34,12 +34,9 @@ describe('local income completeness and exact money (SQLite)', () => {
|
|||||||
const get = (
|
const get = (
|
||||||
account = 'all',
|
account = 'all',
|
||||||
period = 'from=2025-01-01&to=2025-01-31',
|
period = 'from=2025-01-01&to=2025-01-31',
|
||||||
observe = true,
|
|
||||||
) =>
|
) =>
|
||||||
request(app.getHttpServer())
|
request(app.getHttpServer())
|
||||||
.get(
|
.get(`/api/v1/brokerage-accounts/${account}/income-analytics?${period}`)
|
||||||
`/api/v1/brokerage-accounts/${account}/income-analytics?${period}${observe ? '&sync=observe' : ''}`,
|
|
||||||
)
|
|
||||||
.set('Authorization', authorization)
|
.set('Authorization', authorization)
|
||||||
.timeout(2000);
|
.timeout(2000);
|
||||||
const read = async (account = 'all', period?: string) =>
|
const read = async (account = 'all', period?: string) =>
|
||||||
@@ -127,7 +124,7 @@ describe('local income completeness and exact money (SQLite)', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
it('returns exact selected sums; reopen and observe need no RPC', async () => {
|
it('returns exact selected sums and reuses covered reads without RPC', async () => {
|
||||||
for (let index = 0; index < 2; index++) {
|
for (let index = 0; index < 2; index++) {
|
||||||
const result = await read();
|
const result = await read();
|
||||||
assert(result.data);
|
assert(result.data);
|
||||||
@@ -210,7 +207,9 @@ describe('local income completeness and exact money (SQLite)', () => {
|
|||||||
expect(
|
expect(
|
||||||
(await read('a', 'from=2025-03-01&to=2025-03-31')).data?.totalsByCurrency,
|
(await read('a', 'from=2025-03-01&to=2025-03-31')).data?.totalsByCurrency,
|
||||||
).toEqual([]);
|
).toEqual([]);
|
||||||
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
|
expect(upstream.getOperationsByCursor).toHaveBeenCalledWith(
|
||||||
|
'synthetic', expect.objectContaining({ accountId: 'b' }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
it('counts corrected identities once for the selected period', async () => {
|
it('counts corrected identities once for the selected period', async () => {
|
||||||
await prisma.tbankOperation.updateMany({
|
await prisma.tbankOperation.updateMany({
|
||||||
@@ -274,7 +273,7 @@ describe('local income completeness and exact money (SQLite)', () => {
|
|||||||
expect(after.meta.lastAttempt.state).toBe('none');
|
expect(after.meta.lastAttempt.state).toBe('none');
|
||||||
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
|
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
it('retains current-month observed completeness and does not renew source timestamps', async () => {
|
it('retains current-month completeness and does not renew source timestamps', async () => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const today = moscowDate(now);
|
const today = moscowDate(now);
|
||||||
const start = new Date(`${today.slice(0, 7)}-01T00:00:00+03:00`);
|
const start = new Date(`${today.slice(0, 7)}-01T00:00:00+03:00`);
|
||||||
@@ -283,7 +282,7 @@ describe('local income completeness and exact money (SQLite)', () => {
|
|||||||
await coverage(accountId, start, asOf);
|
await coverage(accountId, start, asOf);
|
||||||
}
|
}
|
||||||
const response = await request(app.getHttpServer())
|
const response = await request(app.getHttpServer())
|
||||||
.get('/api/v1/brokerage-accounts/all/income-summary?sync=observe')
|
.get('/api/v1/brokerage-accounts/all/income-summary')
|
||||||
.set('Authorization', authorization)
|
.set('Authorization', authorization)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
const result = response.body as IncomeSummaryResourceDto;
|
const result = response.body as IncomeSummaryResourceDto;
|
||||||
@@ -302,58 +301,32 @@ describe('local income completeness and exact money (SQLite)', () => {
|
|||||||
where: { userId: 'income-owner' },
|
where: { userId: 'income-owner' },
|
||||||
data: { membershipAt: new Date('2020-01-01Z') },
|
data: { membershipAt: new Date('2020-01-01Z') },
|
||||||
});
|
});
|
||||||
const response = await get('a', undefined, false).expect(200);
|
const response = await get('a').expect(200);
|
||||||
const result = response.body as IncomeAnalyticsResourceDto;
|
const result = response.body as IncomeAnalyticsResourceDto;
|
||||||
expect(result.data).not.toBeNull();
|
expect(result.data).not.toBeNull();
|
||||||
expect(result.meta.completeness).toBe('complete');
|
expect(result.meta.completeness).toBe('complete');
|
||||||
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
|
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
|
||||||
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
|
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
it('cold income imports only the missing requested interval', async () => {
|
it('ignores retired sync=observe on income reads and keeps the cold response incomplete', async () => {
|
||||||
await prisma.tbankConnection.update({
|
await prisma.tbankConnection.update({
|
||||||
where: { userId: 'income-owner' },
|
where: { userId: 'income-owner' },
|
||||||
data: { membershipAt: new Date('2020-01-01Z') },
|
data: { membershipAt: new Date('2020-01-01Z') },
|
||||||
});
|
});
|
||||||
let release: () => void = () => {
|
const response = await request(app.getHttpServer())
|
||||||
throw new Error('Upstream not initialized');
|
.get('/api/v1/brokerage-accounts/a/income-analytics?from=2025-02-01&to=2025-02-28&sync=observe')
|
||||||
};
|
.set('Authorization', authorization)
|
||||||
const blocked = new Promise<{ hasNext: boolean; items: never[] }>((resolve) => {
|
.expect(200);
|
||||||
release = () => resolve({ hasNext: false, items: [] });
|
const result = response.body as IncomeAnalyticsResourceDto;
|
||||||
});
|
expect(result.data).toBeNull();
|
||||||
upstream.getOperationsByCursor.mockReturnValue(blocked);
|
expect(result.meta.completeness).toBe('incomplete');
|
||||||
const first = await get('a', 'from=2025-02-01&to=2025-02-28', false).expect(200);
|
|
||||||
const cold = first.body as IncomeAnalyticsResourceDto;
|
|
||||||
release();
|
|
||||||
expect(cold.data).toBeNull();
|
|
||||||
expect(cold.meta.completeness).toBe('incomplete');
|
|
||||||
let result: IncomeAnalyticsResourceDto = cold;
|
|
||||||
for (let attempt = 0; attempt < 100; attempt++) {
|
|
||||||
const response = await get('a', 'from=2025-02-01&to=2025-02-28').expect(200);
|
|
||||||
result = response.body as IncomeAnalyticsResourceDto;
|
|
||||||
if (result.meta.completeness === 'complete') break;
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
||||||
}
|
|
||||||
expect(result.data).not.toBeNull();
|
|
||||||
expect(result.meta.completeness).toBe('complete');
|
|
||||||
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
|
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
|
||||||
expect(upstream.getOperationsByCursor.mock.calls).toEqual([
|
|
||||||
['synthetic', expect.objectContaining({
|
|
||||||
accountId: 'a',
|
|
||||||
from: { seconds: '1738357199' },
|
|
||||||
to: { seconds: '1738368001' },
|
|
||||||
})],
|
|
||||||
['synthetic', expect.objectContaining({
|
|
||||||
accountId: 'a',
|
|
||||||
from: { seconds: '1738367999' },
|
|
||||||
to: { seconds: '1740776401' },
|
|
||||||
})],
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
it('returns while a cold upstream is blocked and validates access/period before scheduling', async () => {
|
it('returns while a cold upstream is blocked and validates access/period before scheduling', async () => {
|
||||||
await get('absent', undefined, false).expect(404);
|
await get('absent').expect(404);
|
||||||
await get('a', 'from=2025-02-31&to=2025-03-01', false).expect(400);
|
await get('a', 'from=2025-02-31&to=2025-03-01').expect(400);
|
||||||
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
|
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
|
||||||
const response = await get('all', undefined, false).expect(200);
|
const response = await get('all').expect(200);
|
||||||
expect((response.body as IncomeAnalyticsResourceDto).data).not.toBeNull();
|
expect((response.body as IncomeAnalyticsResourceDto).data).not.toBeNull();
|
||||||
});
|
});
|
||||||
it('plans only the selected period for a leap day without scheduling it', async () => {
|
it('plans only the selected period for a leap day without scheduling it', async () => {
|
||||||
@@ -361,7 +334,7 @@ describe('local income completeness and exact money (SQLite)', () => {
|
|||||||
expect(result.meta.dependencies.map(({ role }) => role)).toEqual([
|
expect(result.meta.dependencies.map(({ role }) => role)).toEqual([
|
||||||
'selected',
|
'selected',
|
||||||
]);
|
]);
|
||||||
expect(result.meta.dependencies[0].availability).toBe('temporarily-unavailable');
|
expect(result.meta.dependencies[0].availability).toBe('initial-loading');
|
||||||
expect(result.data).toBeNull();
|
expect(result.data).toBeNull();
|
||||||
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
|
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,10 +30,11 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
|
|||||||
};
|
};
|
||||||
const auth = (owner = 'owner') =>
|
const auth = (owner = 'owner') =>
|
||||||
`Bearer ${new JwtService().sign({ sub: owner, type: 'access' }, { secret: process.env.JWT_SECRET })}`;
|
`Bearer ${new JwtService().sign({ sub: owner, type: 'access' }, { secret: process.env.JWT_SECRET })}`;
|
||||||
|
const cursorParam = (cursor?: null | string) => (cursor ? `&cursor=${encodeURIComponent(cursor)}` : '');
|
||||||
const get = (query = '', account = 'all', owner = 'owner') =>
|
const get = (query = '', account = 'all', owner = 'owner') =>
|
||||||
request(app.getHttpServer())
|
request(app.getHttpServer())
|
||||||
.get(
|
.get(
|
||||||
`/api/v1/brokerage-accounts/${account}/operations?${period}&${query}`,
|
`/api/v1/brokerage-accounts/${account}/operations?${[period, query].filter(Boolean).join('&')}`,
|
||||||
)
|
)
|
||||||
.set('Authorization', auth(owner))
|
.set('Authorization', auth(owner))
|
||||||
.timeout(30_000);
|
.timeout(30_000);
|
||||||
@@ -138,7 +139,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
|
|||||||
let complete = false;
|
let complete = false;
|
||||||
for (let attempt = 0; attempt < 100; attempt++) {
|
for (let attempt = 0; attempt < 100; attempt++) {
|
||||||
const response = await request(app.getHttpServer())
|
const response = await request(app.getHttpServer())
|
||||||
.get('/api/v1/brokerage-accounts/a/operations?from=2025-02-01&to=2025-02-28&sync=observe')
|
.get('/api/v1/brokerage-accounts/a/operations?from=2025-02-01&to=2025-02-28')
|
||||||
.set('Authorization', auth())
|
.set('Authorization', auth())
|
||||||
.expect(200);
|
.expect(200);
|
||||||
const result = response.body as BrokerageOperationsResourceDto;
|
const result = response.body as BrokerageOperationsResourceDto;
|
||||||
@@ -162,7 +163,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
|
|||||||
const ids: string[] = [];
|
const ids: string[] = [];
|
||||||
do {
|
do {
|
||||||
const response = await get(
|
const response = await get(
|
||||||
`limit=2${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`,
|
`limit=2${cursorParam(cursor)}`,
|
||||||
).expect(200);
|
).expect(200);
|
||||||
const result = response.body as BrokerageOperationsResourceDto;
|
const result = response.body as BrokerageOperationsResourceDto;
|
||||||
expect(result.meta.availability).toBe('available');
|
expect(result.meta.availability).toBe('available');
|
||||||
@@ -243,7 +244,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
|
|||||||
do {
|
do {
|
||||||
const response = await request(app.getHttpServer())
|
const response = await request(app.getHttpServer())
|
||||||
.get(
|
.get(
|
||||||
`/api/v1/brokerage-accounts/a/operations?from=${interval.from.toISOString()}&to=2025-01-02T00:00:00.900Z&limit=1&sync=observe${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`,
|
`/api/v1/brokerage-accounts/a/operations?from=${interval.from.toISOString()}&to=2025-01-02T00:00:00.900Z&limit=1${cursorParam(cursor)}`,
|
||||||
)
|
)
|
||||||
.set('Authorization', auth())
|
.set('Authorization', auth())
|
||||||
.expect(200);
|
.expect(200);
|
||||||
@@ -258,7 +259,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
|
|||||||
const income = (
|
const income = (
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.get(
|
.get(
|
||||||
`/api/v1/brokerage-accounts/a/income-analytics?${period}&sync=observe`,
|
`/api/v1/brokerage-accounts/a/income-analytics?${period}`,
|
||||||
)
|
)
|
||||||
.set('Authorization', auth())
|
.set('Authorization', auth())
|
||||||
.expect(200)
|
.expect(200)
|
||||||
@@ -291,10 +292,10 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
|
|||||||
periodKind === 'default'
|
periodKind === 'default'
|
||||||
? ''
|
? ''
|
||||||
: `&to=${observedAt.toISOString().slice(0, 10)}${periodKind === 'today timestamp' ? 'T23:59:59.999Z' : ''}`;
|
: `&to=${observedAt.toISOString().slice(0, 10)}${periodKind === 'today timestamp' ? 'T23:59:59.999Z' : ''}`;
|
||||||
const page = (cursor?: string, observe = false) =>
|
const page = (cursor?: string) =>
|
||||||
request(app.getHttpServer())
|
request(app.getHttpServer())
|
||||||
.get(
|
.get(
|
||||||
`/api/v1/brokerage-accounts/all/operations?${observe ? 'sync=observe&' : ''}limit=1${query}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`,
|
`/api/v1/brokerage-accounts/all/operations?limit=1${query}${cursorParam(cursor)}`,
|
||||||
)
|
)
|
||||||
.set('Authorization', auth());
|
.set('Authorization', auth());
|
||||||
const first = (await page().expect(200))
|
const first = (await page().expect(200))
|
||||||
@@ -307,7 +308,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
|
|||||||
expect(second.meta.availability).toBe('available');
|
expect(second.meta.availability).toBe('available');
|
||||||
expect(second.meta.completeness).toBe('complete');
|
expect(second.meta.completeness).toBe('complete');
|
||||||
expect(second.meta.coverage?.missingRanges).toEqual([]);
|
expect(second.meta.coverage?.missingRanges).toEqual([]);
|
||||||
const observed = (await page(first.data.nextCursor, true).expect(200))
|
const observed = (await page(first.data.nextCursor).expect(200))
|
||||||
.body as BrokerageOperationsResourceDto;
|
.body as BrokerageOperationsResourceDto;
|
||||||
expect(observed.meta.availability).toBe('available');
|
expect(observed.meta.availability).toBe('available');
|
||||||
expect(observed.meta.completeness).toBe('complete');
|
expect(observed.meta.completeness).toBe('complete');
|
||||||
@@ -317,7 +318,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
|
|||||||
it('keeps data revisions stable while observational reads expose current coverage', async () => {
|
it('keeps data revisions stable while observational reads expose current coverage', async () => {
|
||||||
const observe = () =>
|
const observe = () =>
|
||||||
request(app.getHttpServer())
|
request(app.getHttpServer())
|
||||||
.get('/api/v1/brokerage-accounts/all/operations?sync=observe&limit=100')
|
.get('/api/v1/brokerage-accounts/all/operations?limit=100')
|
||||||
.set('Authorization', auth());
|
.set('Authorization', auth());
|
||||||
const first = (await observe().expect(200))
|
const first = (await observe().expect(200))
|
||||||
.body as BrokerageOperationsResourceDto;
|
.body as BrokerageOperationsResourceDto;
|
||||||
@@ -340,7 +341,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
|
|||||||
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
|
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
it('reads shared metadata from SQLite without invalidating a row cursor', async () => {
|
it('reads shared metadata from SQLite without invalidating a row cursor', async () => {
|
||||||
const before = (await get('limit=1&sync=observe').expect(200))
|
const before = (await get('limit=1').expect(200))
|
||||||
.body as BrokerageOperationsResourceDto;
|
.body as BrokerageOperationsResourceDto;
|
||||||
assert(before.data?.nextCursor);
|
assert(before.data?.nextCursor);
|
||||||
const { revision } = await prisma.tbankConnection.findUniqueOrThrow({
|
const { revision } = await prisma.tbankConnection.findUniqueOrThrow({
|
||||||
@@ -371,22 +372,22 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
|
|||||||
new Date(),
|
new Date(),
|
||||||
);
|
);
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
const after = (await get('limit=1&sync=observe').expect(200))
|
const after = (await get('limit=1').expect(200))
|
||||||
.body as BrokerageOperationsResourceDto;
|
.body as BrokerageOperationsResourceDto;
|
||||||
expect(after.data?.items[0].logoName).toBe('saved.png');
|
expect(after.data?.items[0].logoName).toBe('saved.png');
|
||||||
expect(after.meta.dataRevision).toBe(before.meta.dataRevision);
|
expect(after.meta.dataRevision).toBe(before.meta.dataRevision);
|
||||||
await get(
|
await get(
|
||||||
`limit=1&sync=observe&cursor=${encodeURIComponent(before.data.nextCursor)}`,
|
`limit=1&cursor=${encodeURIComponent(before.data.nextCursor)}`,
|
||||||
).expect(200);
|
).expect(200);
|
||||||
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
|
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
it('binds a signed cursor to equivalent filters, user, membership, row revision and connection; status alone does not break it', async () => {
|
it('binds a signed cursor to equivalent filters, user, membership, row revision and connection; status alone does not break it', async () => {
|
||||||
const first = (
|
const first = (
|
||||||
await get('limit=1&categories=trade,income&sync=observe').expect(200)
|
await get('limit=1&categories=trade,income').expect(200)
|
||||||
).body as BrokerageOperationsResourceDto;
|
).body as BrokerageOperationsResourceDto;
|
||||||
assert(first.data?.nextCursor);
|
assert(first.data?.nextCursor);
|
||||||
const cursor = encodeURIComponent(first.data.nextCursor);
|
const cursor = encodeURIComponent(first.data.nextCursor);
|
||||||
const next = `limit=1&categories=income,trade,income&sync=observe&cursor=${cursor}`;
|
const next = `limit=1&categories=income,trade,income&cursor=${cursor}`;
|
||||||
await prisma.tbankOperationCoverage.updateMany({
|
await prisma.tbankOperationCoverage.updateMany({
|
||||||
data: { fetchedAt: new Date(0) },
|
data: { fetchedAt: new Date(0) },
|
||||||
});
|
});
|
||||||
@@ -400,10 +401,10 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
|
|||||||
data: { operationsRevision: { increment: 1 } },
|
data: { operationsRevision: { increment: 1 } },
|
||||||
});
|
});
|
||||||
await get(next).expect(400);
|
await get(next).expect(400);
|
||||||
const single = (await get('limit=1&sync=observe', 'a').expect(200))
|
const single = (await get('limit=1', 'a').expect(200))
|
||||||
.body as BrokerageOperationsResourceDto;
|
.body as BrokerageOperationsResourceDto;
|
||||||
assert(single.data?.nextCursor);
|
assert(single.data?.nextCursor);
|
||||||
const singleNext = `limit=1&sync=observe&cursor=${encodeURIComponent(single.data.nextCursor)}`;
|
const singleNext = `limit=1&cursor=${encodeURIComponent(single.data.nextCursor)}`;
|
||||||
await prisma.tbankConnection.update({
|
await prisma.tbankConnection.update({
|
||||||
where: { userId: 'owner' },
|
where: { userId: 'owner' },
|
||||||
data: { membershipRevision: { increment: 1 } },
|
data: { membershipRevision: { increment: 1 } },
|
||||||
@@ -431,7 +432,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
|
|||||||
});
|
});
|
||||||
const partial = (
|
const partial = (
|
||||||
await get(
|
await get(
|
||||||
`instrumentUid=${uid}&categories=income&limit=100&sync=observe`,
|
`instrumentUid=${uid}&categories=income&limit=100`,
|
||||||
'a',
|
'a',
|
||||||
).expect(200)
|
).expect(200)
|
||||||
).body as BrokerageOperationsResourceDto;
|
).body as BrokerageOperationsResourceDto;
|
||||||
@@ -439,7 +440,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
|
|||||||
expect(partial.meta.completeness).toBe('incomplete');
|
expect(partial.meta.completeness).toBe('incomplete');
|
||||||
expect(partial.data).toBeNull();
|
expect(partial.data).toBeNull();
|
||||||
expect(partial.meta.coverage?.missingRanges.length).toBeGreaterThan(0);
|
expect(partial.meta.coverage?.missingRanges.length).toBeGreaterThan(0);
|
||||||
const whole = (await get('sync=observe', 'a').expect(200))
|
const whole = (await get('', 'a').expect(200))
|
||||||
.body as BrokerageOperationsResourceDto;
|
.body as BrokerageOperationsResourceDto;
|
||||||
expect(whole.meta.availability).toBe('available');
|
expect(whole.meta.availability).toBe('available');
|
||||||
expect(whole.meta.completeness).toBe('complete');
|
expect(whole.meta.completeness).toBe('complete');
|
||||||
@@ -450,7 +451,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
|
|||||||
await prisma.tbankAccount.updateMany({ data: { openedOn: null } });
|
await prisma.tbankAccount.updateMany({ data: { openedOn: null } });
|
||||||
await prisma.tbankOperationCoverage.deleteMany({ where: { accountId: 'a' } });
|
await prisma.tbankOperationCoverage.deleteMany({ where: { accountId: 'a' } });
|
||||||
await prisma.tbankSyncWork.deleteMany({ where: { userId: 'owner' } });
|
await prisma.tbankSyncWork.deleteMany({ where: { userId: 'owner' } });
|
||||||
const result = (await get('sync=observe', 'a').expect(200))
|
const result = (await get('', 'a').expect(200))
|
||||||
.body as BrokerageOperationsResourceDto;
|
.body as BrokerageOperationsResourceDto;
|
||||||
expect(result.meta.coverage?.historyBoundary).toBe('unknown');
|
expect(result.meta.coverage?.historyBoundary).toBe('unknown');
|
||||||
expect(result.meta.coverage?.historyState).toBe('missing');
|
expect(result.meta.coverage?.historyState).toBe('missing');
|
||||||
@@ -474,7 +475,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
|
|||||||
expect(cold.data).toBeNull();
|
expect(cold.data).toBeNull();
|
||||||
expect(cold.meta.availability).toBe('initial-loading');
|
expect(cold.meta.availability).toBe('initial-loading');
|
||||||
expect(cold.meta.completeness).toBe('incomplete');
|
expect(cold.meta.completeness).toBe('incomplete');
|
||||||
await get('sync=observe', 'a').expect(200);
|
await get('', 'a').expect(200);
|
||||||
expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(1);
|
expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(1);
|
||||||
// eslint-disable-next-line sonarjs/no-extra-arguments -- The typed callback is assigned the promise resolver before invocation.
|
// eslint-disable-next-line sonarjs/no-extra-arguments -- The typed callback is assigned the promise resolver before invocation.
|
||||||
unblock({ hasNext: false, items: [] });
|
unblock({ hasNext: false, items: [] });
|
||||||
@@ -485,12 +486,12 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
|
|||||||
await delay(5);
|
await delay(5);
|
||||||
}
|
}
|
||||||
const count = upstream.getOperationsByCursor.mock.calls.length;
|
const count = upstream.getOperationsByCursor.mock.calls.length;
|
||||||
const result = (await get('sync=observe', 'a').expect(200))
|
const result = (await get('', 'a').expect(200))
|
||||||
.body as BrokerageOperationsResourceDto;
|
.body as BrokerageOperationsResourceDto;
|
||||||
expect(result.data?.items).toEqual([]);
|
expect(result.data?.items).toEqual([]);
|
||||||
expect(result.meta.availability).toBe('available');
|
expect(result.meta.availability).toBe('available');
|
||||||
expect(result.meta.completeness).toBe('complete');
|
expect(result.meta.completeness).toBe('complete');
|
||||||
await get('sync=observe', 'a').expect(200);
|
await get('', 'a').expect(200);
|
||||||
expect(upstream.getOperationsByCursor.mock.calls.length).toBeGreaterThanOrEqual(count);
|
expect(upstream.getOperationsByCursor.mock.calls.length).toBeGreaterThanOrEqual(count);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -153,19 +153,19 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
|||||||
encryptedToken: app.get(TbankTokenService).encrypt('synthetic'),
|
encryptedToken: app.get(TbankTokenService).encrypt('synthetic'),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const cold = await get('brokerage-accounts/unknown/portfolio?sync=observe').expect(200);
|
const cold = await get('brokerage-accounts/unknown/portfolio').expect(200);
|
||||||
expect((cold.body as PortfolioResourceDto).data).toBeNull();
|
expect((cold.body as PortfolioResourceDto).data).toBeNull();
|
||||||
expect((cold.body as PortfolioResourceDto).meta.availability).toBe(
|
expect((cold.body as PortfolioResourceDto).meta.availability).toBe(
|
||||||
'temporarily-unavailable',
|
'temporarily-unavailable',
|
||||||
);
|
);
|
||||||
await get('brokerage-accounts?sync=observe').expect(200);
|
await get('brokerage-accounts').expect(200);
|
||||||
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
|
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
|
||||||
expect(upstream.getPortfolio).not.toHaveBeenCalled();
|
expect(upstream.getPortfolio).not.toHaveBeenCalled();
|
||||||
const connection = await prisma.tbankConnection.findUniqueOrThrow({ where: { userId: 'owner' } });
|
const connection = await prisma.tbankConnection.findUniqueOrThrow({ where: { userId: 'owner' } });
|
||||||
await prisma.tbankAccount.create({ data: { userId: 'owner', revision: connection.revision, accountId: 'account', name: 'Synthetic', type: 'ACCOUNT_TYPE_TINKOFF', upstreamOrder: 0 } });
|
await prisma.tbankAccount.create({ data: { userId: 'owner', revision: connection.revision, accountId: 'account', name: 'Synthetic', type: 'ACCOUNT_TYPE_TINKOFF', upstreamOrder: 0 } });
|
||||||
await prisma.tbankConnection.update({ where: { userId: 'owner' }, data: { membershipAt: new Date() } });
|
await prisma.tbankConnection.update({ where: { userId: 'owner' }, data: { membershipAt: new Date() } });
|
||||||
await get('brokerage-accounts/unknown/portfolio?sync=observe').expect(404);
|
await get('brokerage-accounts/unknown/portfolio').expect(404);
|
||||||
await get('brokerage-accounts?sync=observe', 'other').expect(409);
|
await get('brokerage-accounts', 'other').expect(409);
|
||||||
await get('brokerage-accounts?sync=unexpected').expect(200);
|
await get('brokerage-accounts?sync=unexpected').expect(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -192,7 +192,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
|||||||
await seedMembership();
|
await seedMembership();
|
||||||
const blocked = deferred<TbankPortfolio>();
|
const blocked = deferred<TbankPortfolio>();
|
||||||
upstream.getPortfolio.mockReturnValueOnce(blocked.promise);
|
upstream.getPortfolio.mockReturnValueOnce(blocked.promise);
|
||||||
const cold = await get('brokerage-accounts?sync=observe').expect(200);
|
const cold = await get('brokerage-accounts').expect(200);
|
||||||
expect(
|
expect(
|
||||||
(cold.body as BrokerageAccountsResourceDto).data?.[0].portfolioSummary,
|
(cold.body as BrokerageAccountsResourceDto).data?.[0].portfolioSummary,
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
@@ -270,7 +270,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
|||||||
if (work) await work.done;
|
if (work) await work.done;
|
||||||
await settled();
|
await settled();
|
||||||
const before = (
|
const before = (
|
||||||
await get('brokerage-accounts/account/portfolio?sync=observe').expect(200)
|
await get('brokerage-accounts/account/portfolio').expect(200)
|
||||||
).body as PortfolioResourceDto;
|
).body as PortfolioResourceDto;
|
||||||
const fetchedAt = new Date();
|
const fetchedAt = new Date();
|
||||||
await prisma.tbankInstrument.create({
|
await prisma.tbankInstrument.create({
|
||||||
@@ -307,7 +307,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
|||||||
update: { matchedUids: ['enriched-stock'], fetchedAt },
|
update: { matchedUids: ['enriched-stock'], fetchedAt },
|
||||||
});
|
});
|
||||||
const after = (
|
const after = (
|
||||||
await get('brokerage-accounts/account/portfolio?sync=observe').expect(200)
|
await get('brokerage-accounts/account/portfolio').expect(200)
|
||||||
).body as PortfolioResourceDto;
|
).body as PortfolioResourceDto;
|
||||||
expect(after.data?.positions[0]).toMatchObject({
|
expect(after.data?.positions[0]).toMatchObject({
|
||||||
displayName: 'Enriched name',
|
displayName: 'Enriched name',
|
||||||
@@ -333,7 +333,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
|||||||
where: { userId: 'owner', scopeKey: 'portfolio:account' },
|
where: { userId: 'owner', scopeKey: 'portfolio:account' },
|
||||||
data: { state: 'complete' },
|
data: { state: 'complete' },
|
||||||
});
|
});
|
||||||
const stale = await get('brokerage-accounts/account/portfolio?sync=observe').expect(200);
|
const stale = await get('brokerage-accounts/account/portfolio').expect(200);
|
||||||
const previous = (stale.body as PortfolioResourceDto).data;
|
const previous = (stale.body as PortfolioResourceDto).data;
|
||||||
expect(previous?.isStale).toBe(true);
|
expect(previous?.isStale).toBe(true);
|
||||||
expect(previous?.lastSuccessfulAt).toBe(new Date(0).toISOString());
|
expect(previous?.lastSuccessfulAt).toBe(new Date(0).toISOString());
|
||||||
@@ -343,7 +343,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
|||||||
data: { state: 'failed', errorCode: 'TBANK_UNAVAILABLE' },
|
data: { state: 'failed', errorCode: 'TBANK_UNAVAILABLE' },
|
||||||
});
|
});
|
||||||
const failed = await get(
|
const failed = await get(
|
||||||
'brokerage-accounts/account/portfolio?sync=observe',
|
'brokerage-accounts/account/portfolio',
|
||||||
).expect(200);
|
).expect(200);
|
||||||
expect((failed.body as PortfolioResourceDto).data).toEqual(previous);
|
expect((failed.body as PortfolioResourceDto).data).toEqual(previous);
|
||||||
expect((failed.body as PortfolioResourceDto).meta.lastAttempt.state).toBe('none');
|
expect((failed.body as PortfolioResourceDto).meta.lastAttempt.state).toBe('none');
|
||||||
@@ -403,7 +403,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const income = await get(
|
const income = await get(
|
||||||
'brokerage-accounts/account/income-analytics?sync=observe',
|
'brokerage-accounts/account/income-analytics',
|
||||||
).expect(200);
|
).expect(200);
|
||||||
const incomeMeta = (income.body as IncomeAnalyticsResourceDto).meta;
|
const incomeMeta = (income.body as IncomeAnalyticsResourceDto).meta;
|
||||||
expect(incomeMeta.lastAttempt.state).toBe('partial');
|
expect(incomeMeta.lastAttempt.state).toBe('partial');
|
||||||
@@ -443,7 +443,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
|||||||
});
|
});
|
||||||
const blocked = deferred<TbankAccount[]>();
|
const blocked = deferred<TbankAccount[]>();
|
||||||
upstream.getOpenAccounts.mockReturnValueOnce(blocked.promise);
|
upstream.getOpenAccounts.mockReturnValueOnce(blocked.promise);
|
||||||
await get('brokerage-accounts?sync=observe').expect(200);
|
await get('brokerage-accounts').expect(200);
|
||||||
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
|
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
|
||||||
expect(
|
expect(
|
||||||
(
|
(
|
||||||
@@ -468,9 +468,9 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
|||||||
where: { id: 'abandoned-membership' },
|
where: { id: 'abandoned-membership' },
|
||||||
data: { state: 'complete' },
|
data: { state: 'complete' },
|
||||||
});
|
});
|
||||||
expect((await get('brokerage-accounts/account/portfolio?sync=observe').expect(200)).body).toMatchObject({ data: null, meta: { availability: 'temporarily-unavailable', completeness: 'incomplete' } });
|
expect((await get('brokerage-accounts/account/portfolio').expect(200)).body).toMatchObject({ data: null, meta: { availability: 'temporarily-unavailable', completeness: 'incomplete' } });
|
||||||
expect(await prisma.tbankPortfolio.count()).toBe(0);
|
expect(await prisma.tbankPortfolio.count()).toBe(0);
|
||||||
expect((await get('brokerage-accounts?sync=observe')).body).toMatchObject({
|
expect((await get('brokerage-accounts')).body).toMatchObject({
|
||||||
data: [{ id: 'account', portfolioSummary: null }],
|
data: [{ id: 'account', portfolioSummary: null }],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -510,7 +510,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
|||||||
}
|
}
|
||||||
await delay(5);
|
await delay(5);
|
||||||
}
|
}
|
||||||
const summaries = (await get('brokerage-accounts?sync=observe').expect(200))
|
const summaries = (await get('brokerage-accounts').expect(200))
|
||||||
.body as BrokerageAccountsResourceDto;
|
.body as BrokerageAccountsResourceDto;
|
||||||
expect(summaries.data?.[0].portfolioSummary).toMatchObject({
|
expect(summaries.data?.[0].portfolioSummary).toMatchObject({
|
||||||
totalsByCurrency: [{ currency: 'RUB', value: '21.1' }],
|
totalsByCurrency: [{ currency: 'RUB', value: '21.1' }],
|
||||||
@@ -528,10 +528,10 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
|||||||
expect(summaries.meta.availability).toBe('available');
|
expect(summaries.meta.availability).toBe('available');
|
||||||
expect(summaries.meta.completeness).toBe('complete');
|
expect(summaries.meta.completeness).toBe('complete');
|
||||||
expect(
|
expect(
|
||||||
(await get('brokerage-accounts/all/portfolio?sync=observe')).body,
|
(await get('brokerage-accounts/all/portfolio')).body,
|
||||||
).toMatchObject({ data: null, meta: { availability: 'temporarily-unavailable', completeness: 'incomplete' } });
|
).toMatchObject({ data: null, meta: { availability: 'temporarily-unavailable', completeness: 'incomplete' } });
|
||||||
expect(
|
expect(
|
||||||
(await get(`stocks/${uid}/position?sync=observe`).expect(200)).body,
|
(await get(`stocks/${uid}/position`).expect(200)).body,
|
||||||
).toMatchObject({ data: null, meta: { availability: 'temporarily-unavailable', completeness: 'incomplete' } });
|
).toMatchObject({ data: null, meta: { availability: 'temporarily-unavailable', completeness: 'incomplete' } });
|
||||||
expect(upstream.getPortfolio).toHaveBeenCalledTimes(2);
|
expect(upstream.getPortfolio).toHaveBeenCalledTimes(2);
|
||||||
await prisma.tbankAccount.deleteMany({ where: { accountId: 'second' } });
|
await prisma.tbankAccount.deleteMany({ where: { accountId: 'second' } });
|
||||||
@@ -542,7 +542,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
|||||||
await prisma.tbankConnection.updateMany({
|
await prisma.tbankConnection.updateMany({
|
||||||
data: { membershipRevision: { increment: 1 } },
|
data: { membershipRevision: { increment: 1 } },
|
||||||
});
|
});
|
||||||
const position = await get(`stocks/${uid}/position?sync=observe`).expect(
|
const position = await get(`stocks/${uid}/position`).expect(
|
||||||
200,
|
200,
|
||||||
);
|
);
|
||||||
expect(position.body).toMatchObject({
|
expect(position.body).toMatchObject({
|
||||||
@@ -551,12 +551,12 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
|||||||
});
|
});
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
expect(
|
expect(
|
||||||
(await get(`stocks/${uid}/position?sync=observe`).expect(200)).body,
|
(await get(`stocks/${uid}/position`).expect(200)).body,
|
||||||
).toEqual(position.body);
|
).toEqual(position.body);
|
||||||
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
|
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
|
||||||
expect(upstream.getPortfolio).toHaveBeenCalledTimes(2);
|
expect(upstream.getPortfolio).toHaveBeenCalledTimes(2);
|
||||||
await prisma.tbankConnection.updateMany({ data: { invalidToken: true } });
|
await prisma.tbankConnection.updateMany({ data: { invalidToken: true } });
|
||||||
await get(`stocks/${uid}/position?sync=observe`).expect(400);
|
await get(`stocks/${uid}/position`).expect(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses membership TTL for an empty aggregate without starting work through observation', async () => {
|
it('uses membership TTL for an empty aggregate without starting work through observation', async () => {
|
||||||
@@ -570,7 +570,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
|||||||
data: { membershipAt: new Date(Date.now() - 120_000) },
|
data: { membershipAt: new Date(Date.now() - 120_000) },
|
||||||
});
|
});
|
||||||
const fresh = await get(
|
const fresh = await get(
|
||||||
'brokerage-accounts/all/portfolio?sync=observe',
|
'brokerage-accounts/all/portfolio',
|
||||||
).expect(200);
|
).expect(200);
|
||||||
expect((fresh.body as PortfolioResourceDto).meta.freshness).toBe('fresh');
|
expect((fresh.body as PortfolioResourceDto).meta.freshness).toBe('fresh');
|
||||||
expect((fresh.body as PortfolioResourceDto).data).toMatchObject({
|
expect((fresh.body as PortfolioResourceDto).data).toMatchObject({
|
||||||
@@ -580,7 +580,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
|||||||
data: { membershipAt: new Date(Date.now() - 301_000) },
|
data: { membershipAt: new Date(Date.now() - 301_000) },
|
||||||
});
|
});
|
||||||
const stale = await get(
|
const stale = await get(
|
||||||
'brokerage-accounts/all/portfolio?sync=observe',
|
'brokerage-accounts/all/portfolio',
|
||||||
).expect(200);
|
).expect(200);
|
||||||
expect((stale.body as PortfolioResourceDto).meta.freshness).toBe('fresh');
|
expect((stale.body as PortfolioResourceDto).meta.freshness).toBe('fresh');
|
||||||
expect((stale.body as PortfolioResourceDto).data?.isStale).toBe(false);
|
expect((stale.body as PortfolioResourceDto).data?.isStale).toBe(false);
|
||||||
@@ -595,7 +595,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
|||||||
await prisma.tbankConnection.updateMany({
|
await prisma.tbankConnection.updateMany({
|
||||||
data: { membershipAt: new Date() },
|
data: { membershipAt: new Date() },
|
||||||
});
|
});
|
||||||
const empty = await get('brokerage-accounts?sync=observe').expect(200);
|
const empty = await get('brokerage-accounts').expect(200);
|
||||||
expect((empty.body as BrokerageAccountsResourceDto).data).toEqual([]);
|
expect((empty.body as BrokerageAccountsResourceDto).data).toEqual([]);
|
||||||
expect((empty.body as BrokerageAccountsResourceDto).meta.availability).toBe(
|
expect((empty.body as BrokerageAccountsResourceDto).meta.availability).toBe(
|
||||||
'available',
|
'available',
|
||||||
@@ -625,7 +625,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
|||||||
await late.done;
|
await late.done;
|
||||||
expect(await prisma.tbankPortfolio.count()).toBe(0);
|
expect(await prisma.tbankPortfolio.count()).toBe(0);
|
||||||
const replaced = await get(
|
const replaced = await get(
|
||||||
'brokerage-accounts/account/portfolio?sync=observe',
|
'brokerage-accounts/account/portfolio',
|
||||||
).expect(200);
|
).expect(200);
|
||||||
expect((replaced.body as PortfolioResourceDto).data).toBeNull();
|
expect((replaced.body as PortfolioResourceDto).data).toBeNull();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -73,13 +73,12 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
|||||||
.set('Authorization', `Bearer ${auth(userId)}`)
|
.set('Authorization', `Bearer ${auth(userId)}`)
|
||||||
.timeout(1500);
|
.timeout(1500);
|
||||||
const events = (
|
const events = (
|
||||||
observe = true,
|
|
||||||
scope = 'account',
|
scope = 'account',
|
||||||
userId = 'owner',
|
userId = 'owner',
|
||||||
range = window,
|
range = window,
|
||||||
) =>
|
) =>
|
||||||
get(
|
get(
|
||||||
`brokerage-accounts/${scope}/events?from=${range.from}&to=${range.to}${observe ? '&sync=observe' : ''}`,
|
`brokerage-accounts/${scope}/events?from=${range.from}&to=${range.to}`,
|
||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
const refresh = async (
|
const refresh = async (
|
||||||
@@ -278,7 +277,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
|||||||
{ currency: 'RUB', value: '6' },
|
{ currency: 'RUB', value: '6' },
|
||||||
{ currency: 'USD', value: '20.000000002' },
|
{ currency: 'USD', value: '20.000000002' },
|
||||||
]);
|
]);
|
||||||
const all = (await events(true, 'all').expect(200))
|
const all = (await events('all').expect(200))
|
||||||
.body as UpcomingEventsResourceDto;
|
.body as UpcomingEventsResourceDto;
|
||||||
expect(
|
expect(
|
||||||
all.data?.events.every((event) => event.account?.id === 'account'),
|
all.data?.events.every((event) => event.account?.id === 'account'),
|
||||||
@@ -327,7 +326,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
|||||||
const saved = (await events().expect(200))
|
const saved = (await events().expect(200))
|
||||||
.body as UpcomingEventsResourceDto;
|
.body as UpcomingEventsResourceDto;
|
||||||
const newRange = (
|
const newRange = (
|
||||||
await events(true, 'account', 'owner', {
|
await events('account', 'owner', {
|
||||||
from: '2099-03-01',
|
from: '2099-03-01',
|
||||||
to: '2099-03-31',
|
to: '2099-03-31',
|
||||||
}).expect(200)
|
}).expect(200)
|
||||||
@@ -346,7 +345,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
|||||||
expect(
|
expect(
|
||||||
((await events().expect(200)).body as UpcomingEventsResourceDto).data,
|
((await events().expect(200)).body as UpcomingEventsResourceDto).data,
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
await events(true, 'foreign').expect(404);
|
await events('foreign').expect(404);
|
||||||
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
|
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -389,7 +388,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
|||||||
await get(`stocks/${shareUid}/dividends`).expect(200);
|
await get(`stocks/${shareUid}/dividends`).expect(200);
|
||||||
await settled();
|
await settled();
|
||||||
const before = (
|
const before = (
|
||||||
await get(`stocks/${shareUid}/dividends?sync=observe`).expect(200)
|
await get(`stocks/${shareUid}/dividends`).expect(200)
|
||||||
).body as StockDividendsResourceDto;
|
).body as StockDividendsResourceDto;
|
||||||
expect(before.data?.dividends[0].estimatedAmount).toEqual({
|
expect(before.data?.dividends[0].estimatedAmount).toEqual({
|
||||||
currency: 'RUB',
|
currency: 'RUB',
|
||||||
@@ -418,7 +417,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
|||||||
await settled();
|
await settled();
|
||||||
expect(
|
expect(
|
||||||
(
|
(
|
||||||
(await get(`stocks/${shareUid}/dividends?sync=observe`).expect(200))
|
(await get(`stocks/${shareUid}/dividends`).expect(200))
|
||||||
.body as StockDividendsResourceDto
|
.body as StockDividendsResourceDto
|
||||||
).data,
|
).data,
|
||||||
).toEqual(before.data);
|
).toEqual(before.data);
|
||||||
@@ -433,7 +432,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
|||||||
await get(`bonds/${bondUid}/coupons`).expect(200);
|
await get(`bonds/${bondUid}/coupons`).expect(200);
|
||||||
await settled();
|
await settled();
|
||||||
const coupons = (
|
const coupons = (
|
||||||
await get(`bonds/${bondUid}/coupons?sync=observe`).expect(200)
|
await get(`bonds/${bondUid}/coupons`).expect(200)
|
||||||
).body as BondCouponsResourceDto;
|
).body as BondCouponsResourceDto;
|
||||||
expect(coupons.data?.coupons[0].estimatedAmount).toEqual({
|
expect(coupons.data?.coupons[0].estimatedAmount).toEqual({
|
||||||
currency: 'RUB',
|
currency: 'RUB',
|
||||||
@@ -467,7 +466,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
|||||||
await portfolio([position(uid, '3', type)]);
|
await portfolio([position(uid, '3', type)]);
|
||||||
const path = `${kind}/${uid}/${payout}`;
|
const path = `${kind}/${uid}/${payout}`;
|
||||||
const read = async () =>
|
const read = async () =>
|
||||||
(await get(`${path}?sync=observe`).expect(200)).body as
|
(await get(`${path}`).expect(200)).body as
|
||||||
BondCouponsResourceDto | StockDividendsResourceDto;
|
BondCouponsResourceDto | StockDividendsResourceDto;
|
||||||
await get(path).expect(200);
|
await get(path).expect(200);
|
||||||
await settled();
|
await settled();
|
||||||
@@ -614,7 +613,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
|||||||
await settled();
|
await settled();
|
||||||
const result = (
|
const result = (
|
||||||
await get(
|
await get(
|
||||||
'brokerage-accounts/account/events?from=2099-02-01&sync=observe',
|
'brokerage-accounts/account/events?from=2099-02-01',
|
||||||
).expect(200)
|
).expect(200)
|
||||||
).body as UpcomingEventsResourceDto;
|
).body as UpcomingEventsResourceDto;
|
||||||
expect(result.data?.events.map((event) => event.date)).toEqual([
|
expect(result.data?.events.map((event) => event.date)).toEqual([
|
||||||
@@ -636,7 +635,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
|||||||
const calculatedAt = full.data?.calculatedAt;
|
const calculatedAt = full.data?.calculatedAt;
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
|
||||||
const narrow = (await events(false, 'account', 'owner', {
|
const narrow = (await events('account', 'owner', {
|
||||||
from: '2099-02-01',
|
from: '2099-02-01',
|
||||||
to: '2099-02-10',
|
to: '2099-02-10',
|
||||||
}).expect(200)).body as UpcomingEventsResourceDto;
|
}).expect(200)).body as UpcomingEventsResourceDto;
|
||||||
@@ -656,7 +655,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
|||||||
const narrowRange = { from: '2099-02-01', to: '2099-02-10' };
|
const narrowRange = { from: '2099-02-01', to: '2099-02-10' };
|
||||||
await refresh(narrowRange);
|
await refresh(narrowRange);
|
||||||
|
|
||||||
const narrow = (await events(true, 'account', 'owner', narrowRange).expect(
|
const narrow = (await events('account', 'owner', narrowRange).expect(
|
||||||
200,
|
200,
|
||||||
)).body as UpcomingEventsResourceDto;
|
)).body as UpcomingEventsResourceDto;
|
||||||
|
|
||||||
@@ -679,7 +678,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
|||||||
dividendNet: { units: '10', nano: 1, currency: 'usd' },
|
dividendNet: { units: '10', nano: 1, currency: 'usd' },
|
||||||
});
|
});
|
||||||
const defaultEvents = async () =>
|
const defaultEvents = async () =>
|
||||||
(await get('brokerage-accounts/account/events?sync=observe').expect(200))
|
(await get('brokerage-accounts/account/events').expect(200))
|
||||||
.body as UpcomingEventsResourceDto;
|
.body as UpcomingEventsResourceDto;
|
||||||
// Only Date is faked: the app and SQLite helpers need real timers.
|
// Only Date is faked: the app and SQLite helpers need real timers.
|
||||||
const clock = (date: string) => {
|
const clock = (date: string) => {
|
||||||
@@ -724,12 +723,12 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
|||||||
expect(second.data?.incomeTotalsByCurrency).toEqual([
|
expect(second.data?.incomeTotalsByCurrency).toEqual([
|
||||||
{ currency: 'USD', value: '100.00000001' },
|
{ currency: 'USD', value: '100.00000001' },
|
||||||
]);
|
]);
|
||||||
const stale = (await events(true, 'account', 'owner', w0).expect(200))
|
const stale = (await events('account', 'owner', w0).expect(200))
|
||||||
.body as UpcomingEventsResourceDto;
|
.body as UpcomingEventsResourceDto;
|
||||||
expect(stale.data?.calculatedAt).toBe(second.data?.calculatedAt);
|
expect(stale.data?.calculatedAt).toBe(second.data?.calculatedAt);
|
||||||
expect(stale.data?.window).toEqual(w1);
|
expect(stale.data?.window).toEqual(w1);
|
||||||
const disjoint = (
|
const disjoint = (
|
||||||
await events(true, 'account', 'owner', {
|
await events('account', 'owner', {
|
||||||
from: '2100-01-01',
|
from: '2100-01-01',
|
||||||
to: '2100-01-31',
|
to: '2100-01-31',
|
||||||
}).expect(200)
|
}).expect(200)
|
||||||
@@ -794,7 +793,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
|||||||
await settled();
|
await settled();
|
||||||
};
|
};
|
||||||
const latest = async () =>
|
const latest = async () =>
|
||||||
(await get('brokerage-accounts/account/events?sync=observe').expect(200))
|
(await get('brokerage-accounts/account/events').expect(200))
|
||||||
.body as UpcomingEventsResourceDto;
|
.body as UpcomingEventsResourceDto;
|
||||||
const dividendRow = () =>
|
const dividendRow = () =>
|
||||||
prisma.tbankInstrumentSource.findFirstOrThrow({
|
prisma.tbankInstrumentSource.findFirstOrThrow({
|
||||||
@@ -924,12 +923,16 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
|||||||
'to=2099-13-01',
|
'to=2099-13-01',
|
||||||
'from=2099-02-01T00:00:00Z',
|
'from=2099-02-01T00:00:00Z',
|
||||||
'from=2099-02-28&to=2099-02-01',
|
'from=2099-02-28&to=2099-02-01',
|
||||||
'sync=invalid',
|
|
||||||
])('rejects invalid query before source reads: %s', async (query) => {
|
])('rejects invalid query before source reads: %s', async (query) => {
|
||||||
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('ignores the retired sync query parameter', async () => {
|
||||||
|
const response = await get(`brokerage-accounts/account/events?from=2099-02-01&to=2099-02-28&sync=invalid`).expect(200);
|
||||||
|
expect(response.body).toHaveProperty('meta');
|
||||||
|
});
|
||||||
|
|
||||||
it('uses UTC today for an end-only range and includes the entire leap day', async () => {
|
it('uses UTC today for an end-only range and includes the entire leap day', async () => {
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
await refresh({ from: today, to: '2096-02-29' });
|
await refresh({ from: today, to: '2096-02-29' });
|
||||||
@@ -954,7 +957,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
|||||||
const range = { from: '2096-02-29', to: '2096-02-29' };
|
const range = { from: '2096-02-29', to: '2096-02-29' };
|
||||||
await refresh(range);
|
await refresh(range);
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
const result = (await events(true, 'account', 'owner', range).expect(200))
|
const result = (await events('account', 'owner', range).expect(200))
|
||||||
.body as UpcomingEventsResourceDto;
|
.body as UpcomingEventsResourceDto;
|
||||||
expect(result.data?.events).toHaveLength(1);
|
expect(result.data?.events).toHaveLength(1);
|
||||||
expect(result.data?.events[0]).toMatchObject({
|
expect(result.data?.events[0]).toMatchObject({
|
||||||
|
|||||||
Reference in New Issue
Block a user