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

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:
2026-09-27 14:09:05 +03:00
parent 5d3835e796
commit 4796d9d3f0
55 changed files with 1954 additions and 569 deletions
+125 -11
View File
@@ -1,6 +1,7 @@
import { INestApplication } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Test, TestingModule } from '@nestjs/testing';
import { PinoLogger } from 'nestjs-pino';
import request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from '../src/app.module';
@@ -28,7 +29,10 @@ describe('Authentication (e2e)', () => {
await app.get(PrismaService).user.deleteMany();
});
afterEach(() => app.close());
afterEach(async () => {
await app.close();
jest.restoreAllMocks();
});
it('registers, guards profile, rotates refresh and logs out', async () => {
const agent = request.agent(app.getHttpServer());
@@ -50,14 +54,94 @@ describe('Authentication (e2e)', () => {
);
const firstCookie = registration.headers['set-cookie'][0];
await agent.post('/api/v1/auth/refresh').expect(200);
const refreshed = await agent.post('/api/v1/auth/refresh').expect(200);
await request(app.getHttpServer())
.post('/api/v1/auth/refresh')
.set('Cookie', firstCookie)
.expect(401)
.expect('Content-Type', /application\/problem\+json/);
const logout = await agent.post('/api/v1/auth/logout').expect(204);
expect(logout.headers['set-cookie'][0]).toContain('refresh_token=;');
await request(app.getHttpServer())
.post('/api/v1/auth/refresh')
.set('Cookie', refreshed.headers['set-cookie'][0])
.expect(401)
.expect(({ body }: { body: unknown }) =>
expect(body).toEqual({ status: 401, code: 'UNAUTHORIZED' }),
);
await request(app.getHttpServer())
.post('/api/v1/auth/logout')
.set('Cookie', refreshed.headers['set-cookie'][0])
.expect(204);
await agent.post('/api/v1/auth/logout').expect(204);
await agent.post('/api/v1/auth/logout').expect(204);
});
it('keeps logout idempotent for an invalid refresh token', async () => {
const logout = await request(app.getHttpServer())
.post('/api/v1/auth/logout')
.set('Cookie', 'refresh_token=invalid')
.expect(204);
expect(logout.headers['set-cookie'][0]).toContain('refresh_token=;');
});
it('reports a failed revocation and preserves the refresh cookie for retry', async () => {
const registration = await request(app.getHttpServer())
.post('/api/v1/auth/register')
.send({ email: 'logout@example.test', password: 'a secure password' })
.expect(200);
const cookie = registration.headers['set-cookie'][0];
const revoke = jest
.spyOn(app.get(PrismaService).session, 'update')
.mockRejectedValueOnce(new Error('Storage unavailable'));
const failed = await request(app.getHttpServer())
.post('/api/v1/auth/logout')
.set('Cookie', cookie)
.expect(500)
.expect('Content-Type', /application\/problem\+json/);
revoke.mockRestore();
expect(failed.body).toEqual({
status: 500,
code: 'INTERNAL_SERVER_ERROR',
});
expect(failed.headers['set-cookie']).toBeUndefined();
expect(
await app.get(PrismaService).session.findFirstOrThrow(),
).toMatchObject({ revokedAt: null });
await request(app.getHttpServer())
.post('/api/v1/auth/refresh')
.set('Cookie', cookie)
.expect(200);
});
it('preserves a valid refresh cookie during a storage failure', async () => {
const registration = await request(app.getHttpServer())
.post('/api/v1/auth/register')
.send({ email: 'refresh@example.test', password: 'a secure password' })
.expect(200);
const cookie = registration.headers['set-cookie'][0];
const read = jest
.spyOn(app.get(PrismaService).session, 'findUnique')
.mockRejectedValueOnce(new Error('Storage unavailable'));
const failed = await request(app.getHttpServer())
.post('/api/v1/auth/refresh')
.set('Cookie', cookie)
.expect(500)
.expect('Content-Type', /application\/problem\+json/);
read.mockRestore();
expect(failed.body).toEqual({
status: 500,
code: 'INTERNAL_SERVER_ERROR',
});
expect(failed.headers['set-cookie']).toBeUndefined();
await request(app.getHttpServer())
.post('/api/v1/auth/refresh')
.set('Cookie', cookie)
.expect(200);
});
it('rotates one concurrent refresh on SQLite and keeps the winner usable', async () => {
@@ -141,6 +225,26 @@ describe('Authentication (e2e)', () => {
);
});
it('returns field errors for invalid credentials without exposing input', async () => {
const response = await request(app.getHttpServer())
.post('/api/v1/auth/register')
.send({ email: 'SYNTHETIC_VALIDATION_SECRET' })
.expect(400)
.expect('Content-Type', /application\/problem\+json/);
expect(response.body).toEqual({
status: 400,
code: 'VALIDATION_ERROR',
errors: expect.arrayContaining([
{ pointer: '#/email', detail: 'must be a valid email address' },
{ pointer: '#/password', detail: 'password must be a string' },
]) as unknown,
});
expect(JSON.stringify(response.body).toLowerCase()).not.toContain(
'synthetic_validation_secret',
);
expect(await app.get(PrismaService).user.count()).toBe(0);
});
it('guards T-Bank connection and never returns a token', async () => {
const registration = await request(app.getHttpServer())
.post('/api/v1/auth/register')
@@ -218,27 +322,37 @@ describe('Authentication (e2e)', () => {
);
});
it('records the public code and stack for an unhandled error', async () => {
const logger = app.get(StructuredLogger);
const event = jest.spyOn(logger, 'event');
it('logs an unhandled error with safe public context and correlation', async () => {
const error = jest.spyOn(PinoLogger.prototype, 'error');
const clientRequestId = 'a2dc6fbb-5a48-4c3c-9e3e-4fbf4d638a43';
jest
.spyOn(app.get(AuthService), 'login')
.mockRejectedValue(new Error('password: secret'));
.mockRejectedValue(
new Error('operation failed\npassword: SYNTHETIC_AUDIT_SECRET'),
);
await request(app.getHttpServer())
const response = await request(app.getHttpServer())
.post('/api/v1/auth/login')
.set('X-Request-ID', clientRequestId)
.send({ email: 'user@example.com', password: 'a secure password' })
.expect(500)
.expect('Content-Type', /application\/problem\+json/)
.expect(({ body }: { body: unknown }) =>
expect(body).toEqual({ status: 500, code: 'INTERNAL_SERVER_ERROR' }),
);
expect(event).toHaveBeenCalledWith(
'error',
'http.request.completed',
expect(error).toHaveBeenCalledTimes(1);
expect(error).toHaveBeenCalledWith(
expect.objectContaining({
requestId: response.headers['x-request-id'],
clientRequestId,
status: 500,
code: 'INTERNAL_SERVER_ERROR',
}),
'http.request.completed',
);
expect(JSON.stringify(error.mock.calls)).not.toContain(
'SYNTHETIC_AUDIT_SECRET',
);
});
});
+34
View File
@@ -275,6 +275,40 @@ describe('Manual bank cards configured HTTP and real SQLite', () => {
})
.expect(400);
});
it('returns nested money field errors without exposing input or writing a card', async () => {
const before = await db.bankCard.count();
const response = await auth('post', '')
.send({
id: randomUUID(),
name: 'Card',
openingDate: '2026-01-01',
openingBalance: {
currency: 'USD',
value: 'SYNTHETIC_VALIDATION_SECRET',
},
})
.expect(400)
.expect('Content-Type', /application\/problem\+json/);
expect(response.body).toEqual({
status: 400,
code: 'VALIDATION_ERROR',
errors: [
{
pointer: '#/openingBalance/currency',
detail: expect.stringContaining('RUB') as unknown,
},
{
pointer: '#/openingBalance/value',
detail: expect.any(String) as unknown,
},
],
});
expect(JSON.stringify(response.body)).not.toContain(
'SYNTHETIC_VALIDATION_SECRET',
);
expect(await db.bankCard.count()).toBe(before);
});
it('scopes every card/category/expense link and edit to owner', async () => {
const own = await createCard(),
foreign = await createCard('0', stranger);
+1 -1
View File
@@ -57,7 +57,7 @@ repository.publish = async (...args) => {
};
const service = new BrokerageOperationsSyncService(
repository,
new TbankAccessService(prisma, tokens, upstream),
new TbankAccessService(prisma, tokens),
upstream,
{
event() {
+17
View File
@@ -127,6 +127,23 @@ describe('OpenAPI contract (integration)', () => {
});
it('generates the runtime contract without server startup or deployment configuration', async () => {
expect(
createOpenApiDocument(app).components?.schemas?.ProblemDetailsDto,
).toMatchObject({
properties: {
errors: {
type: 'array',
items: {
type: 'object',
required: ['pointer', 'detail'],
properties: {
pointer: { type: 'string' },
detail: { type: 'string' },
},
},
},
},
});
const directory = await mkdtemp(resolve(tmpdir(), 'wealthledger-openapi-'));
try {
const networkGuard = resolve(directory, 'no-network.cjs');
+147 -3
View File
@@ -22,7 +22,7 @@ describe('atomic shared candle reconciliation (SQLite)', () => {
}) as never,
);
const upstream = { getCandles: jest.fn() };
const access = new TbankAccessService(prisma, tokens, upstream as never);
const access = new TbankAccessService(prisma, tokens);
const instrument = {
uid: 'uid',
currency: 'rub',
@@ -46,10 +46,10 @@ describe('atomic shared candle reconciliation (SQLite)', () => {
},
]),
};
const createService = () =>
const createService = (selectedSources = sources) =>
new CandleService(
repository,
sources as never,
selectedSources as never,
access,
upstream as never,
{ event: jest.fn() } as never,
@@ -112,6 +112,7 @@ describe('atomic shared candle reconciliation (SQLite)', () => {
});
});
afterAll(async () => prisma.$disconnect());
afterEach(() => jest.restoreAllMocks());
it('deduplicates seams, excludes outside rows, persists exact decimals and closed empty coverage after reopen', async () => {
const attempt = await reserve();
await repository.stage(
@@ -302,6 +303,149 @@ describe('atomic shared candle reconciliation (SQLite)', () => {
expect(read.meta.sync.state).toBe('failed');
expect(read.data?.candles).toEqual([]);
});
it.each([
['owner', 'before loading', false],
['observer', 'before loading', false],
['owner', 'after ordinary publication', false],
['observer', 'after ordinary publication', false],
['observer', 'after ordinary publication', true],
] as const)(
'honors force from %s joining %s (later failure=%s)',
async (userId, phase, laterFailure) => {
if (userId !== owner.userId) {
await prisma.user.upsert({
where: { id: userId },
create: {
id: userId,
email: `${userId}@example.invalid`,
passwordHash: 'synthetic',
},
update: {},
});
await prisma.tbankConnection.create({
data: {
userId,
revision: 'revision',
encryptedToken: tokens.encrypt('synthetic'),
connectedAt: new Date(),
},
});
}
const history = {
...instrument,
first_1dayCandleDate: {
seconds: String(Date.parse('2024-01-01Z') / 1000),
},
};
const covered = {
from: new Date('2024-01-01Z'),
to: new Date('2025-01-01Z'),
};
const old = { ...candle, time: '2024-01-02T00:00:00.000Z' };
const initial = await reserve([covered]);
await repository.stage(initial, covered, [old], new Date('2025-01-02Z'));
await repository.publish(initial, { uid: 'uid', ranges: [covered] });
let entered = () => {};
let release = () => {};
const started = new Promise<void>((resolve) => (entered = resolve));
const blocked = new Promise<void>((resolve) => (release = resolve));
const selectedSources = {
...sources,
ensureDetail: jest.fn().mockResolvedValue(history),
};
if (phase === 'before loading')
selectedSources.ensureDetail.mockImplementationOnce(async () => {
entered();
await blocked;
return history;
});
else {
const publish = repository.publish.bind(repository);
jest
.spyOn(repository, 'publish')
.mockImplementationOnce(async (...args) => {
const result = await publish(...args);
entered();
await blocked;
return result;
});
}
const forcedAt = new Date('2026-02-03T13:00:00Z');
const forcedStates: string[] = [];
const fullRange = { from: covered.from, to: new Date('2026-02-04Z') };
let beforeForced:
Awaited<ReturnType<CandleRepository['read']>> | undefined;
upstream.getCandles.mockImplementation(
async (_token, _uid, _from, to: Date) => {
if (laterFailure && forcedStates.length)
throw new Error('Forced reconciliation second window unavailable');
if (to.valueOf() === forcedAt.valueOf()) {
beforeForced = await repository.read('uid', fullRange);
const work = await repository.work(
userId,
'revision',
'candles:uid:all',
);
forcedStates.push(work?.state ?? 'missing');
}
return [
{
time: { seconds: String(Date.parse(old.time) / 1000) },
close: { units: '200', nano: 1 },
isComplete: true,
},
];
},
);
const service = createService(selectedSources);
const input = {
uid: 'uid',
instrumentType: 'share' as const,
period: 'all' as const,
};
const ordinaryId = await service.request(
'owner',
'revision',
input,
new Date('2026-02-02T12:00:00Z'),
false,
2,
);
await started;
const forcedId = await service.request(
userId,
'revision',
input,
forcedAt,
true,
0,
);
release();
await settled();
if (laterFailure) {
expect(beforeForced).toBeDefined();
expect(await repository.read('uid', fullRange)).toEqual(beforeForced);
} else
expect((await repository.read('uid', covered)).data).toEqual([
{ ...old, close: '200.000000001' },
]);
expect(forcedStates).toEqual(['running']);
expect(
await repository.work(userId, 'revision', 'candles:uid:all'),
).toMatchObject({
id: forcedId,
state: laterFailure ? 'failed' : 'complete',
priority: 0,
intent: {
force: true,
now: forcedAt.toISOString(),
},
});
if (userId === owner.userId) expect(forcedId).toBe(ordinaryId);
else expect(forcedId).not.toBe(ordinaryId);
},
);
it('preserves source incompleteness and bond percentage precision without nominal conversion', async () => {
const now = new Date();
now.setUTCHours(0, 0, 0, 0);
+1 -1
View File
@@ -22,7 +22,7 @@ describe('shared catalog discovery (SQLite)', () => {
}) as never,
);
const upstream = { findShares: jest.fn(), findBonds: jest.fn() };
const access = new TbankAccessService(prisma, tokens, upstream as never);
const access = new TbankAccessService(prisma, tokens);
const repository = new CatalogDiscoveryRepository(prisma);
const createService = () =>
new CatalogDiscoveryService(
+93
View File
@@ -11,6 +11,8 @@ import { PrismaService } from '../src/prisma/prisma.service';
import { InstrumentMetadataRepository } from '../src/integrations/tbank/instrument-metadata.repository';
import { TbankClient } from '../src/integrations/tbank/tbank.client';
import { BrokerageOperationsResourceDto } from '../src/modules/investments/operations/presentation/brokerage-operation.dto';
import { BrokerageOperationsRepository } from '../src/modules/investments/operations/infrastructure/brokerage-operations.repository';
import type { IncomeAnalyticsResourceDto } from '../src/modules/investments/income/income-analytics.dto';
const uid = '11111111-1111-4111-8111-111111111111';
const period = 'from=2025-01-01&to=2025-01-31';
@@ -127,6 +129,97 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
});
it('uses the persisted subsecond timestamp for filtering, cursor order and income entries', async () => {
await prisma.tbankOperation.deleteMany();
const { revision } = await prisma.tbankConnection.findUniqueOrThrow({
where: { userId: 'owner' },
});
const repository = app.get(BrokerageOperationsRepository);
const interval = {
accountId: 'a',
from: new Date('2025-01-02T00:00:00.500Z'),
to: new Date('2025-01-02T00:00:01.000Z'),
};
const reserved = await repository.request(
'owner',
revision,
[interval],
'subsecond-test',
true,
new Set(),
1,
);
assert(reserved);
const attempt = {
userId: 'owner',
revision,
id: reserved.work.id,
attemptId: reserved.work.attemptId,
};
await expect(
repository.stagePage(attempt, interval, undefined, {
hasNext: false,
items: [{ id: 'invalid', date: {} }],
}),
).rejects.toThrow('Invalid operation timestamp');
await repository.stagePage(attempt, interval, undefined, {
hasNext: false,
items: [400, 600, 800].map((milliseconds) => ({
id: String(milliseconds),
date: { seconds: '1735776000', nanos: milliseconds * 1_000_000 },
type: 'OPERATION_TYPE_COUPON',
payment: { currency: 'rub', units: '1', nano: 1 },
})),
});
await repository.publish(attempt, [interval], new Date(), true);
const saved = await prisma.tbankOperation.findMany({
orderBy: { occurredAt: 'desc' },
});
const expected = [
{ id: '800', occurredAt: '2025-01-02T00:00:00.800Z' },
{ id: '600', occurredAt: '2025-01-02T00:00:00.600Z' },
];
expect(
saved.map((row) => ({
id: row.brokerOperationId,
occurredAt: row.occurredAt.toISOString(),
})),
).toEqual(expected);
const items: { id: string; occurredAt: string }[] = [];
let cursor: string | null = null;
do {
const response = await request(app.getHttpServer())
.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)}` : ''}`,
)
.set('Authorization', auth())
.expect(200);
const result = response.body as BrokerageOperationsResourceDto;
assert(result.data);
expect(result.data.items).toHaveLength(1);
items.push(
...result.data.items.map(({ id, occurredAt }) => ({ id, occurredAt })),
);
cursor = result.data.nextCursor;
} while (cursor);
const income = (
await request(app.getHttpServer())
.get(
`/api/v1/brokerage-accounts/a/income-analytics?${period}&sync=observe`,
)
.set('Authorization', auth())
.expect(200)
).body as IncomeAnalyticsResourceDto;
expect(items).toEqual(expected);
expect(
income.data?.entries.map(({ id, occurredAt }) => ({ id, occurredAt })),
).toEqual(expected);
expect(income.data?.totalsByCurrency[0].total).toEqual({
currency: 'RUB',
value: '2.000000002',
});
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
});
it.each(['default', 'today', 'today timestamp'])(
'keeps observed current coverage complete through %s period pagination without RPC',
async (periodKind) => {
+1 -1
View File
@@ -25,7 +25,7 @@ describe('durable shared instrument metadata (SQLite)', () => {
getInstrumentByUid: jest.fn(),
getInstrumentByFigi: jest.fn(),
};
const access = new TbankAccessService(prisma, tokens, upstream as never);
const access = new TbankAccessService(prisma, tokens);
const repository = new InstrumentMetadataRepository(prisma);
const logger = { event: jest.fn() };
const createService = () =>
+1 -1
View File
@@ -42,7 +42,7 @@ describe('Request-driven operations synchronization (SQLite)', () => {
TOKEN_ENCRYPTION_KEY: process.env.TOKEN_ENCRYPTION_KEY,
}),
);
const access = new TbankAccessService(prisma, tokens, upstream as never);
const access = new TbankAccessService(prisma, tokens);
const logger = { event: jest.fn() };
const createService = () =>
new BrokerageOperationsSyncService(
+113 -1
View File
@@ -144,7 +144,12 @@ describe('screen sync HTTP and SQLite', () => {
{
screen: 'stock',
instrumentUid: '00000000-0000-4000-8000-000000000001',
candleRange: { from: '2026-02-01', to: '2026-01-01', jobs: [] },
candleRange: {
from: '2026-02-01',
to: '2026-01-01',
jobs: [],
'~bad/path': 'SYNTHETIC_VALIDATION_SECRET',
},
},
])('rejects malformed scope without reserving work: %j', async (scope) => {
const response = await post(scope).expect(400);
@@ -161,6 +166,20 @@ describe('screen sync HTTP and SQLite', () => {
expect(typeof error.pointer).toBe('string');
expect(typeof error.detail).toBe('string');
}
if ('candleRange' in scope) {
expect(
problem.errors.filter(
(error) => error.pointer === '#/candleRange/jobs',
),
).toHaveLength(1);
expect(problem.errors).toContainEqual({
pointer: '#/candleRange/~0bad~1path',
detail: expect.any(String) as unknown,
});
expect(JSON.stringify(response.body)).not.toContain(
'SYNTHETIC_VALIDATION_SECRET',
);
}
expect(await prisma.tbankSyncWork.count()).toBe(0);
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
});
@@ -287,6 +306,99 @@ describe('screen sync HTTP and SQLite', () => {
await status(last.meta.sync.id).expect(200);
expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(1);
});
it.each(['succeeded', 'failed'] as const)(
'observes the operations successor until it is %s',
async (outcome) => {
const accounts = [
{
id: 'a',
name: 'Synthetic',
type: 'ACCOUNT_TYPE_TINKOFF',
openedDate: { seconds: '1577836800' },
},
];
let releaseMembership: (value: typeof accounts) => void = () => {};
upstream.getOpenAccounts.mockReturnValue(
new Promise((resolve) => {
releaseMembership = resolve;
}),
);
const connection = await prisma.tbankConnection.findUniqueOrThrow({
where: { userId: 'owner' },
});
await prisma.tbankAccount.create({
data: {
userId: 'owner',
revision: connection.revision,
accountId: 'a',
name: 'Synthetic',
type: 'ACCOUNT_TYPE_TINKOFF',
openedOn: '2020-01-01',
upstreamOrder: 0,
},
});
let release: (value: {
hasNext: boolean;
items: never[];
}) => void = () => {};
let reject: (error: Error) => void = () => {};
upstream.getOperationsByCursor
.mockReturnValueOnce(
new Promise((resolve, rejectResult) => {
release = resolve;
reject = rejectResult;
}),
)
.mockImplementation(() =>
outcome === 'failed'
? Promise.reject(new Error('Synthetic outage'))
: Promise.resolve({ hasNext: false, items: [] }),
);
const accept = async (from: string, to: string) => {
const response = await post({
screen: 'operations',
accountId: 'a',
from,
to,
}).expect(202);
return (response.body as { id: string }).id;
};
const dispatched = async (id: string) => {
for (let i = 0; i < 300; i++) {
const work = await prisma.tbankSyncWork.findUniqueOrThrow({
where: { id },
});
if ((work.progress as { dispatched: boolean }).dispatched) return id;
await delay(5);
}
throw new Error('Screen work did not dispatch');
};
const first = await accept('2026-01-01', '2026-01-10');
const second = await accept('2026-01-20', '2026-01-31');
const merged = await accept('2026-01-01', '2026-01-31');
releaseMembership(accounts);
await Promise.all([first, second, merged].map(dispatched));
try {
expect((await status(second).expect(200)).body).toMatchObject({
state: 'running',
progress: { completed: 1, total: 2, discovering: false },
});
await status(second, 'other').expect(404);
} finally {
if (outcome === 'failed') reject(new Error('Synthetic outage'));
else release({ hasNext: false, items: [] });
await settled(merged);
await settled(first);
}
expect(await settled(second)).toMatchObject({
state: outcome,
errorCode: outcome === 'failed' ? 'TBANK_UNAVAILABLE' : null,
progress: { completed: 2, total: 2, discovering: false },
});
await prisma.tbankConnection.delete({ where: { userId: 'owner' } });
await status(second).expect(404);
},
);
it('rejects known foreign accounts locally and requires authentication', async () => {
await post({ screen: 'operations', accountId: 'foreign' }).expect(404);
await request(app.getHttpServer())
+153 -5
View File
@@ -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 = (