This commit was merged in pull request #154.
This commit is contained in:
@@ -218,6 +218,18 @@ describe('persistent portfolio projection', () => {
|
|||||||
expect(snapshot.structure.currencies[0].totalValue).toBe('121.49');
|
expect(snapshot.structure.currencies[0].totalValue).toBe('121.49');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Test intent: a corrupted Json payload must fail loudly instead of being spread into the API response.
|
||||||
|
it('rejects a stored snapshot payload without the persisted shape', async () => {
|
||||||
|
repository.read.mockResolvedValue({
|
||||||
|
membershipAt,
|
||||||
|
membershipRevision: 1,
|
||||||
|
accounts: [{ accountId: 'a', name: 'A', type: 'BROKERAGE', openedOn: null,
|
||||||
|
portfolio: { payload: { positions: 'broken' }, fetchedAt: membershipAt, dataRevision: 1 } }],
|
||||||
|
work: [],
|
||||||
|
});
|
||||||
|
await expect(service.read('user', 'all')).rejects.toThrow('Corrupted portfolio snapshot payload');
|
||||||
|
});
|
||||||
|
|
||||||
it('does not turn a missing account snapshot into aggregate zero; summaries stay independent', async () => {
|
it('does not turn a missing account snapshot into aggregate zero; summaries stay independent', async () => {
|
||||||
const snapshot: PortfolioDto = {
|
const snapshot: PortfolioDto = {
|
||||||
lastSuccessfulAt: membershipAt.toISOString(),
|
lastSuccessfulAt: membershipAt.toISOString(),
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
import type { Prisma } from '../../../../../generated/prisma/client';
|
||||||
|
|
||||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||||
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||||
import {
|
import {
|
||||||
@@ -26,6 +28,7 @@ import { sumMoneyByCurrency } from '../domain/portfolio-totals';
|
|||||||
import {
|
import {
|
||||||
portfolioFreshForMs,
|
portfolioFreshForMs,
|
||||||
PortfolioRepository,
|
PortfolioRepository,
|
||||||
|
portfolioSnapshot,
|
||||||
portfolioWorkKey,
|
portfolioWorkKey,
|
||||||
} from '../infrastructure/portfolio.repository';
|
} from '../infrastructure/portfolio.repository';
|
||||||
import { mapPortfolio } from '../infrastructure/tbank-portfolio.mapper';
|
import { mapPortfolio } from '../infrastructure/tbank-portfolio.mapper';
|
||||||
@@ -88,18 +91,7 @@ export class PortfolioService {
|
|||||||
type: account.type,
|
type: account.type,
|
||||||
openedOn: account.openedOn,
|
openedOn: account.openedOn,
|
||||||
snapshot: account.portfolio
|
snapshot: account.portfolio
|
||||||
? {
|
? savedSnapshot(account.portfolio)
|
||||||
...(account.portfolio.payload as unknown as PortfolioDto),
|
|
||||||
positions: (account.portfolio.payload as unknown as PortfolioDto).positions.map((position) => ({
|
|
||||||
...position,
|
|
||||||
sector: position.sector ?? null,
|
|
||||||
issuer: position.issuer ?? null,
|
|
||||||
})),
|
|
||||||
lastSuccessfulAt: account.portfolio.fetchedAt.toISOString(),
|
|
||||||
isStale:
|
|
||||||
Date.now() - account.portfolio.fetchedAt.valueOf() >=
|
|
||||||
portfolioFreshForMs,
|
|
||||||
}
|
|
||||||
: null,
|
: null,
|
||||||
meta: investmentResourceMeta({
|
meta: investmentResourceMeta({
|
||||||
resourceKey: `portfolio/${encodeURIComponent(account.accountId)}`,
|
resourceKey: `portfolio/${encodeURIComponent(account.accountId)}`,
|
||||||
@@ -326,3 +318,17 @@ export class PortfolioService {
|
|||||||
return mapPortfolio(upstream, money, savedMetadata, lastSuccessfulAt, classifications);
|
return mapPortfolio(upstream, money, savedMetadata, lastSuccessfulAt, classifications);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function savedSnapshot(saved: { fetchedAt: Date; payload: Prisma.JsonValue }): PortfolioDto {
|
||||||
|
const snapshot = portfolioSnapshot(saved.payload);
|
||||||
|
return {
|
||||||
|
...snapshot,
|
||||||
|
positions: snapshot.positions.map((position) => ({
|
||||||
|
...position,
|
||||||
|
sector: position.sector ?? null,
|
||||||
|
issuer: position.issuer ?? null,
|
||||||
|
})),
|
||||||
|
lastSuccessfulAt: saved.fetchedAt.toISOString(),
|
||||||
|
isStale: Date.now() - saved.fetchedAt.valueOf() >= portfolioFreshForMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -200,3 +200,16 @@ export class PortfolioRepository {
|
|||||||
export function portfolioWorkKey(accountId: string): string {
|
export function portfolioWorkKey(accountId: string): string {
|
||||||
return `portfolio:${JSON.stringify(accountId)}`;
|
return `portfolio:${JSON.stringify(accountId)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Json payload is written only by publish(); a shape check turns corruption into an explicit 500.
|
||||||
|
export function portfolioSnapshot(value: Prisma.JsonValue): PortfolioDto {
|
||||||
|
const snapshot = value as null | Partial<PortfolioDto>;
|
||||||
|
if (
|
||||||
|
!Array.isArray(snapshot?.positions) ||
|
||||||
|
!Array.isArray(snapshot.totalsByCurrency) ||
|
||||||
|
!Array.isArray(snapshot.cashBalances)
|
||||||
|
) {
|
||||||
|
throw new Error('Corrupted portfolio snapshot payload');
|
||||||
|
}
|
||||||
|
return snapshot as PortfolioDto;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
jest.mock('../../../prisma/prisma.service', () => ({
|
||||||
|
PrismaService: class {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { screenIntent } 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,15 @@ import type { InvestmentScreenDto } from './tbank-sync.dto';
|
|||||||
|
|
||||||
import { PrismaService } from '../../../prisma/prisma.service';
|
import { PrismaService } from '../../../prisma/prisma.service';
|
||||||
export type ScreenIntent = { now: string; scope: InvestmentScreenDto };
|
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[];
|
||||||
@@ -226,10 +235,8 @@ export class TbankSyncRepository {
|
|||||||
processRunId: runId,
|
processRunId: runId,
|
||||||
state: 'running',
|
state: 'running',
|
||||||
priority: 0,
|
priority: 0,
|
||||||
intent: JSON.parse(
|
intent: { source: 'hourly' },
|
||||||
JSON.stringify({ source: 'hourly' }),
|
progress: { sections: [] },
|
||||||
) as Prisma.InputJsonObject,
|
|
||||||
progress: { sections: [] } as unknown as Prisma.InputJsonValue,
|
|
||||||
errorCode: null,
|
errorCode: null,
|
||||||
};
|
};
|
||||||
const work = await tx.tbankSyncWork.upsert({
|
const work = await tx.tbankSyncWork.upsert({
|
||||||
|
|||||||
@@ -8,10 +8,7 @@ import { randomUUID } from 'node:crypto';
|
|||||||
import { TbankAccessService } from '../../../integrations/tbank/tbank.api';
|
import { TbankAccessService } from '../../../integrations/tbank/tbank.api';
|
||||||
import { BrokerageOperationsSyncService } from '../operations/operations.api';
|
import { BrokerageOperationsSyncService } from '../operations/operations.api';
|
||||||
import { TbankSyncStatusDto } from './tbank-sync.dto';
|
import { TbankSyncStatusDto } from './tbank-sync.dto';
|
||||||
import {
|
import { screenIntent, TbankSyncRepository } from './tbank-sync.repository';
|
||||||
type ScreenIntent,
|
|
||||||
TbankSyncRepository,
|
|
||||||
} from './tbank-sync.repository';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TbankSyncService {
|
export class TbankSyncService {
|
||||||
@@ -35,7 +32,7 @@ export class TbankSyncService {
|
|||||||
if (!saved.work.scopeKey.startsWith('screen:')) {
|
if (!saved.work.scopeKey.startsWith('screen:')) {
|
||||||
return this.sourceStatus(userId, saved.work);
|
return this.sourceStatus(userId, saved.work);
|
||||||
}
|
}
|
||||||
const intent = saved.work.intent as unknown as ScreenIntent;
|
const intent = screenIntent(saved.work.intent);
|
||||||
await this.access.local(userId, intent.scope.accountId);
|
await this.access.local(userId, intent.scope.accountId);
|
||||||
const children = await Promise.all(
|
const children = await Promise.all(
|
||||||
saved.children.map((child) =>
|
saved.children.map((child) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user