139 lines
5.0 KiB
TypeScript
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');
|
|
});
|
|
});
|