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:
+125
-11
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Vendored
+1
-1
@@ -57,7 +57,7 @@ repository.publish = async (...args) => {
|
||||
};
|
||||
const service = new BrokerageOperationsSyncService(
|
||||
repository,
|
||||
new TbankAccessService(prisma, tokens, upstream),
|
||||
new TbankAccessService(prisma, tokens),
|
||||
upstream,
|
||||
{
|
||||
event() {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 = () =>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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