fix: harden auth and investment synchronization
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Successful in 11m9s
Conventional commits / commits (pull_request) Successful in 28s
Backend security / security (pull_request) Successful in 7s
Backend verify / verify (pull_request) Successful in 10m38s
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Successful in 11m9s
Conventional commits / commits (pull_request) Successful in 28s
Backend security / security (pull_request) Successful in 7s
Backend verify / verify (pull_request) Successful in 10m38s
Resolve A01-A10 and C01-C07 from the backend audit. Preserve auth failures, forced candle reconciliation and forecast freshness. Validate MOEX responses and nested HTTP errors; remove unused legacy paths. Verified: 209 unit tests, 262 SQLite e2e tests on Linux, OpenAPI and durability. Record the remaining macOS native SQLite abort in the audit.
This commit is contained in:
@@ -10,6 +10,7 @@ import { PrismaService } from '../src/prisma/prisma.service';
|
||||
import {
|
||||
TbankClient,
|
||||
TbankTokenService,
|
||||
tbankSourceTtlMs,
|
||||
} from '../src/integrations/tbank/tbank.api';
|
||||
import { MoexClient } from '../src/integrations/moex/moex.api';
|
||||
import { CatalogEnrichmentService } from '../src/modules/investments/instruments/application/catalog-enrichment.service';
|
||||
@@ -423,6 +424,95 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
expect(moex.bondEvents).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['stocks', shareUid, 'dividends', 'STOCK'],
|
||||
['bonds', bondUid, 'coupons', 'BOND'],
|
||||
])(
|
||||
'ages the saved %s payout position without RPC or a financial revision, including incompatible fresh candidates',
|
||||
async (kind, uid, payout, type) => {
|
||||
const date = new Date(Date.now() + 7 * 86400000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
upstream.getDividends.mockResolvedValue([
|
||||
{
|
||||
recordDate: time(date),
|
||||
dividendNet: { units: '10', nano: 1, currency: 'rub' },
|
||||
},
|
||||
]);
|
||||
upstream.getBondCoupons.mockResolvedValue([
|
||||
{
|
||||
couponDate: time(date),
|
||||
fixDate: time(date),
|
||||
payOneBond: { units: '2', currency: 'rub' },
|
||||
},
|
||||
]);
|
||||
await portfolio([position(uid, '3', type)]);
|
||||
const path = `${kind}/${uid}/${payout}`;
|
||||
const read = async () =>
|
||||
(await get(`${path}?sync=observe`).expect(200)).body as
|
||||
StockDividendsResourceDto | BondCouponsResourceDto;
|
||||
await get(path).expect(200);
|
||||
await settled();
|
||||
const before = await read();
|
||||
if (!before.data?.positionAt)
|
||||
throw new Error('Expected a saved position');
|
||||
expect(before.data.positionIsStale).toBe(false);
|
||||
const saved = await prisma.tbankForecast.findFirstOrThrow();
|
||||
const expiresAt =
|
||||
Date.parse(before.data.positionAt) + tbankSourceTtlMs.portfolio;
|
||||
const clock = jest.spyOn(Date, 'now').mockReturnValue(expiresAt - 1);
|
||||
jest.clearAllMocks();
|
||||
try {
|
||||
expect((await read()).data).toEqual(before.data);
|
||||
clock.mockReturnValue(expiresAt);
|
||||
const aged = await read();
|
||||
expect(aged.data).toEqual({ ...before.data, positionIsStale: true });
|
||||
expect(aged.meta.freshness).toBe('stale');
|
||||
expect(aged.meta.dataRevision).toBe(before.meta.dataRevision);
|
||||
expect(aged.meta.lastSuccessfulAt).toBe(before.meta.lastSuccessfulAt);
|
||||
expect(await prisma.tbankForecast.findFirstOrThrow()).toEqual(saved);
|
||||
|
||||
await portfolio([position(uid, '9', type)]);
|
||||
await prisma.tbankPortfolio.updateMany({
|
||||
where: { userId: 'owner' },
|
||||
data: { fetchedAt: new Date(expiresAt) },
|
||||
});
|
||||
const repository = app.get(ForecastRepository);
|
||||
const resolve = repository.resolve.bind(repository);
|
||||
const publish = jest
|
||||
.spyOn(repository, 'resolve')
|
||||
.mockImplementationOnce(async (input) => {
|
||||
expect(input.candidate).toMatchObject({
|
||||
positionAt: new Date(expiresAt).toISOString(),
|
||||
positionIsStale: false,
|
||||
});
|
||||
await prisma.tbankPortfolio.updateMany({
|
||||
where: { userId: 'owner' },
|
||||
data: { dataRevision: { increment: 1 } },
|
||||
});
|
||||
return resolve(input);
|
||||
});
|
||||
try {
|
||||
const fallback = await read();
|
||||
expect(fallback.data).toEqual(aged.data);
|
||||
expect(fallback.meta.freshness).toBe('stale');
|
||||
expect(fallback.meta.dataRevision).toBe(before.meta.dataRevision);
|
||||
expect(fallback.meta.lastSuccessfulAt).toBe(
|
||||
before.meta.lastSuccessfulAt,
|
||||
);
|
||||
expect(await prisma.tbankForecast.findFirstOrThrow()).toEqual(saved);
|
||||
} finally {
|
||||
publish.mockRestore();
|
||||
}
|
||||
for (const rpc of Object.values(upstream))
|
||||
expect(rpc).not.toHaveBeenCalled();
|
||||
expect(moex.bondEvents).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
clock.mockRestore();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps optional metadata failure separate from calendar completeness and uses portfolio ticker', async () => {
|
||||
upstream.getBond.mockRejectedValue(new Error('metadata unavailable'));
|
||||
await portfolio([position(bondUid, '3', 'BOND')]);
|
||||
@@ -436,14 +526,72 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
expect(moex.bondEvents).toHaveBeenCalledWith('BOND');
|
||||
});
|
||||
|
||||
it('does not turn a missing MOEX block into a complete empty forecast and defaults to a 30-day window', async () => {
|
||||
it.each([
|
||||
['missing block', { coupons: undefined }],
|
||||
[
|
||||
'invalid cursor',
|
||||
{ 'coupons.cursor': { columns: ['INDEX', 'TOTAL'], data: [[0, 1]] } },
|
||||
],
|
||||
])(
|
||||
'preserves the saved calendar and forecast when real MOEX parsing rejects %s',
|
||||
async (_name, invalid) => {
|
||||
await portfolio([position(bondUid, '3', 'BOND')]);
|
||||
const response = {
|
||||
coupons: {
|
||||
columns: ['coupondate', 'value', 'faceunit'],
|
||||
data: [['2099-02-01', '2', 'RUB']],
|
||||
},
|
||||
offers: { columns: ['offerdate'], data: [] },
|
||||
description: { columns: ['name', 'value'], data: [] },
|
||||
};
|
||||
const fetchMock = jest
|
||||
.spyOn(global, 'fetch')
|
||||
.mockImplementation(() => Promise.resolve(Response.json(response)));
|
||||
moex.bondEvents.mockImplementation((ticker: string) =>
|
||||
new MoexClient().bondEvents(ticker),
|
||||
);
|
||||
try {
|
||||
await events(false).expect(200);
|
||||
await settled();
|
||||
const before = (await events().expect(200))
|
||||
.body as UpcomingEventsResourceDto;
|
||||
expect(before.data?.incomeTotalsByCurrency).toEqual([
|
||||
{ currency: 'RUB', value: '6' },
|
||||
]);
|
||||
const calendar = await prisma.tbankInstrumentSource.findFirstOrThrow({
|
||||
where: { kind: 'bond-events' },
|
||||
});
|
||||
const forecast = await prisma.tbankForecast.findFirstOrThrow();
|
||||
fetchMock.mockImplementation(() =>
|
||||
Promise.resolve(Response.json({ ...response, ...invalid })),
|
||||
);
|
||||
await app
|
||||
.get(CatalogEnrichmentService)
|
||||
.requestMany('owner', 'owner-revision', 'bond', [bondUid], true, [
|
||||
'bond-events',
|
||||
]);
|
||||
await settled();
|
||||
const after = (await events().expect(200))
|
||||
.body as UpcomingEventsResourceDto;
|
||||
expect(after.data).toEqual(before.data);
|
||||
expect(after.meta.lastSuccessfulAt).toBe(before.meta.lastSuccessfulAt);
|
||||
expect(after.meta.sync.state).toBe('failed');
|
||||
expect(
|
||||
await prisma.tbankInstrumentSource.findFirstOrThrow({
|
||||
where: { kind: 'bond-events' },
|
||||
}),
|
||||
).toEqual(calendar);
|
||||
expect(await prisma.tbankForecast.findFirstOrThrow()).toEqual(forecast);
|
||||
} finally {
|
||||
fetchMock.mockRestore();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('defaults to a 30-day window', async () => {
|
||||
await portfolio([position(bondUid, '3', 'BOND')]);
|
||||
moex.bondEvents.mockRejectedValueOnce(new Error('missing MOEX block'));
|
||||
await events(false).expect(200);
|
||||
await settled();
|
||||
expect(
|
||||
((await events().expect(200)).body as UpcomingEventsResourceDto).data,
|
||||
).toBeNull();
|
||||
await get('brokerage-accounts/account/events?from=2099-02-01').expect(200);
|
||||
await settled();
|
||||
const result = (
|
||||
|
||||
Reference in New Issue
Block a user