Files
backend/test/tbank-catalog-discovery.e2e-spec.ts
T
ksv741 60f4ca6b80
Backend verify / commits (pull_request) Failing after 27s
Backend verify / security (pull_request) Skipped
Backend verify / verify (pull_request) Skipped
refactor: remove unreachable catalog producer and sync screen orchestration (WealthLedger/backend#62)
2026-10-15 07:37:17 +03:00

139 lines
5.0 KiB
TypeScript

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');
});
});