397 lines
16 KiB
TypeScript
397 lines
16 KiB
TypeScript
import { ConfigService } from '@nestjs/config';
|
|
import assert from 'node:assert/strict';
|
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
|
|
import type { TbankInstrument } from '../src/integrations/tbank/tbank.client';
|
|
|
|
import {
|
|
InstrumentMetadataRepository,
|
|
metadataKey,
|
|
} from '../src/integrations/tbank/instrument-metadata.repository';
|
|
import { InstrumentMetadataService } from '../src/integrations/tbank/instrument-metadata.service';
|
|
import { TbankAccessService } from '../src/integrations/tbank/tbank-access.service';
|
|
import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service';
|
|
import { PrismaService } from '../src/prisma/prisma.service';
|
|
|
|
// Shared publication is proven against real FK/transaction/connection boundaries.
|
|
describe('durable shared instrument metadata (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 = {
|
|
getInstrumentByUid: jest.fn(),
|
|
getInstrumentByFigi: jest.fn(),
|
|
};
|
|
const access = new TbankAccessService(prisma, tokens);
|
|
const repository = new InstrumentMetadataRepository(prisma);
|
|
const logger = { event: jest.fn() };
|
|
const createService = () =>
|
|
new InstrumentMetadataService(
|
|
upstream as never,
|
|
logger as never,
|
|
repository,
|
|
access,
|
|
);
|
|
let service: InstrumentMetadataService;
|
|
const identity = { instrumentUid: 'uid', figi: 'FIGI' };
|
|
const known = {
|
|
uid: 'uid',
|
|
figi: 'FIGI',
|
|
ticker: 'TEST',
|
|
name: 'Synthetic instrument',
|
|
brand: { logoName: 'test.png' },
|
|
};
|
|
const seedConnection = (userId: string) =>
|
|
prisma.tbankConnection.create({
|
|
data: {
|
|
userId,
|
|
revision: `${userId}-revision`,
|
|
encryptedToken: tokens.encrypt(`synthetic-${userId}`),
|
|
connectedAt: new Date(),
|
|
},
|
|
});
|
|
async function settled() {
|
|
for (let index = 0; index < 300; index++) {
|
|
if (!(await prisma.tbankSyncWork.count({ where: { state: 'running' } }))) {
|
|
return;
|
|
}
|
|
await delay(5);
|
|
}
|
|
throw new Error('Metadata work did not settle');
|
|
}
|
|
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.tbankInstrumentLookup.deleteMany();
|
|
await prisma.tbankInstrument.deleteMany();
|
|
await seedConnection('owner');
|
|
await seedConnection('other');
|
|
jest.resetAllMocks();
|
|
upstream.getInstrumentByUid.mockResolvedValue(known);
|
|
upstream.getInstrumentByFigi.mockResolvedValue(known);
|
|
service = createService();
|
|
});
|
|
it('reuses durable successful metadata across users and reopen without decrypt or RPC', async () => {
|
|
const cold = await service.read('owner', identity);
|
|
expect(cold?.data).toBeNull();
|
|
expect(cold?.work?.state).toBe('running');
|
|
await settled();
|
|
await prisma.$disconnect();
|
|
service = createService();
|
|
const decrypt = jest.spyOn(tokens, 'decrypt');
|
|
const other = await service.read('other', identity);
|
|
expect(other?.data).toEqual(known);
|
|
expect(other?.fresh).toBe(true);
|
|
expect(other?.work).toBeNull();
|
|
expect((await service.read('other', { figi: 'FIGI' }))?.data).toEqual(
|
|
known,
|
|
);
|
|
expect(upstream.getInstrumentByUid).toHaveBeenCalledTimes(1);
|
|
expect(upstream.getInstrumentByFigi).not.toHaveBeenCalled();
|
|
expect(decrypt).not.toHaveBeenCalled();
|
|
decrypt.mockRestore();
|
|
const rows = await prisma.tbankInstrument.findMany();
|
|
expect(JSON.stringify(rows)).not.toMatch(
|
|
/synthetic-owner|owner-revision|quantity|estimatedAmount/,
|
|
);
|
|
});
|
|
it('persists UID miss separately from successful FIGI fallback and confirmed empty lookups', async () => {
|
|
upstream.getInstrumentByUid.mockResolvedValue(null);
|
|
await service.read('owner', identity);
|
|
await settled();
|
|
const result = await service.read('other', identity);
|
|
expect(result?.data).toEqual(known);
|
|
expect(result?.complete).toBe(true);
|
|
expect(upstream.getInstrumentByUid).toHaveBeenCalledTimes(1);
|
|
expect(upstream.getInstrumentByFigi).toHaveBeenCalledTimes(1);
|
|
upstream.getInstrumentByFigi.mockResolvedValue(null);
|
|
const missing = { figi: 'UNKNOWN' };
|
|
await service.read('owner', missing);
|
|
await settled();
|
|
const empty = await service.read('other', missing);
|
|
expect(empty?.data).toBeNull();
|
|
expect(empty?.complete).toBe(true);
|
|
expect(empty?.fresh).toBe(true);
|
|
expect(upstream.getInstrumentByFigi).toHaveBeenCalledTimes(2);
|
|
});
|
|
it('coalesces twenty requests across users while giving each user a private handle', async () => {
|
|
const blocked = deferred<TbankInstrument>();
|
|
upstream.getInstrumentByUid.mockReturnValue(blocked.promise);
|
|
const responses = await Promise.all(
|
|
Array.from({ length: 20 }, (_, i) =>
|
|
service.read(i % 2 ? 'owner' : 'other', identity),
|
|
),
|
|
);
|
|
await delay(10);
|
|
expect(upstream.getInstrumentByUid).toHaveBeenCalledTimes(1);
|
|
const ids = new Set(responses.map((response) => response?.work?.id));
|
|
expect(ids.size).toBe(2);
|
|
expect(responses[0]?.work?.id).not.toBe(responses[1]?.work?.id);
|
|
blocked.resolve(known);
|
|
await settled();
|
|
expect(await prisma.tbankInstrument.count()).toBe(1);
|
|
expect(
|
|
await prisma.tbankSyncWork.count({ where: { state: 'complete' } }),
|
|
).toBe(2);
|
|
});
|
|
it.each(['delete', 'replace'])(
|
|
'discards a producer after %s, including shared freshness, without borrowing another user token',
|
|
async (action) => {
|
|
const blocked = deferred<TbankInstrument>();
|
|
upstream.getInstrumentByUid.mockReturnValueOnce(blocked.promise);
|
|
await service.read('owner', identity);
|
|
await delay(10);
|
|
await service.read('other', identity);
|
|
await prisma.tbankConnection.delete({ where: { userId: 'owner' } });
|
|
if (action === 'replace') {
|
|
await prisma.tbankConnection.create({
|
|
data: {
|
|
userId: 'owner',
|
|
revision: 'replacement',
|
|
encryptedToken: tokens.encrypt('replacement-token'),
|
|
connectedAt: new Date(),
|
|
},
|
|
});
|
|
}
|
|
blocked.resolve(known);
|
|
await settled();
|
|
expect(await prisma.tbankInstrument.count()).toBe(0);
|
|
expect(await prisma.tbankInstrumentLookup.count()).toBe(0);
|
|
expect(
|
|
(await service.read('other', identity, true))?.work?.errorCode,
|
|
).toBe('TBANK_UNAVAILABLE');
|
|
expect(upstream.getInstrumentByUid).toHaveBeenCalledTimes(1);
|
|
await service.read('other', identity);
|
|
await settled();
|
|
expect((await service.read('other', identity, true))?.data).toEqual(
|
|
known,
|
|
);
|
|
expect(upstream.getInstrumentByUid).toHaveBeenLastCalledWith(
|
|
'synthetic-other',
|
|
'uid',
|
|
);
|
|
},
|
|
);
|
|
it('does not turn initiating credentials failure into a shared negative lookup or a foreign credentials error', async () => {
|
|
const blocked = deferred<TbankInstrument>();
|
|
upstream.getInstrumentByUid.mockReturnValueOnce(blocked.promise);
|
|
await service.read('owner', identity);
|
|
await delay(10);
|
|
await service.read('other', identity);
|
|
blocked.reject(new Error('INVALID_TBANK_TOKEN'));
|
|
await settled();
|
|
await expect(service.read('owner', identity, true)).rejects.toMatchObject({
|
|
response: { code: 'INVALID_TBANK_TOKEN' },
|
|
});
|
|
expect((await service.read('other', identity, true))?.work?.errorCode).toBe(
|
|
'TBANK_UNAVAILABLE',
|
|
);
|
|
expect(await prisma.tbankInstrumentLookup.count()).toBe(0);
|
|
await service.read('other', identity);
|
|
await settled();
|
|
expect((await service.read('other', identity, true))?.data).toEqual(known);
|
|
});
|
|
it('keeps last good metadata and source time after refresh failure; observation does not restart work', async () => {
|
|
await service.read('owner', identity);
|
|
await settled();
|
|
const old = new Date(Date.now() - 10 * 86_400_000);
|
|
await prisma.tbankInstrumentLookup.updateMany({ data: { fetchedAt: old } });
|
|
await prisma.tbankInstrument.updateMany({ data: { fetchedAt: old } });
|
|
const calls = upstream.getInstrumentByUid.mock.calls.length;
|
|
expect((await service.read('owner', identity, true))?.fresh).toBe(true);
|
|
expect(upstream.getInstrumentByUid).toHaveBeenCalledTimes(calls);
|
|
const failedHandle = await service.request('owner', 'owner-revision', identity, true);
|
|
upstream.getInstrumentByUid.mockRejectedValueOnce(
|
|
new Error('upstream unavailable'),
|
|
);
|
|
upstream.getInstrumentByFigi.mockRejectedValueOnce(
|
|
new Error('upstream unavailable'),
|
|
);
|
|
await settled();
|
|
const failed = await service.read('owner', identity, true);
|
|
expect(failed?.data).toEqual(known);
|
|
expect(failed?.fetchedAt).toEqual(old);
|
|
if (!failedHandle) throw new Error('Expected forced refresh work');
|
|
expect((await prisma.tbankSyncWork.findUniqueOrThrow({ where: { id: failedHandle } })).state).toBe('failed');
|
|
await delay(20);
|
|
expect(upstream.getInstrumentByUid).toHaveBeenCalledTimes(calls + 1);
|
|
});
|
|
it('coalesces forced refresh and retains freshness/timestamp when it fails before the TTL', async () => {
|
|
await service.read('owner', identity);
|
|
await settled();
|
|
const before = await service.read('owner', identity, true);
|
|
const blocked = deferred<TbankInstrument>();
|
|
upstream.getInstrumentByUid.mockReturnValueOnce(blocked.promise);
|
|
upstream.getInstrumentByFigi.mockRejectedValueOnce(
|
|
new Error('also unavailable'),
|
|
);
|
|
const handles = await Promise.all(
|
|
Array.from({ length: 20 }, () =>
|
|
service.request('owner', 'owner-revision', identity, true),
|
|
),
|
|
);
|
|
expect(new Set(handles).size).toBe(1);
|
|
blocked.reject(new Error('unavailable'));
|
|
await settled();
|
|
const after = await service.read('owner', identity, true);
|
|
expect(after?.data).toEqual(before?.data);
|
|
expect(after?.fetchedAt).toEqual(before?.fetchedAt);
|
|
expect(after?.fresh).toBe(true);
|
|
expect(after?.work?.state).toBe('failed');
|
|
expect(upstream.getInstrumentByUid).toHaveBeenCalledTimes(2);
|
|
});
|
|
it('keeps saved metadata fresh and does not count another instrument as covered', async () => {
|
|
await service.read('owner', identity);
|
|
await settled();
|
|
const timestamp = new Date('2026-09-01Z');
|
|
await prisma.tbankInstrumentLookup.updateMany({
|
|
data: { fetchedAt: timestamp },
|
|
});
|
|
const clock = jest.spyOn(Date, 'now').mockReturnValue(timestamp.valueOf());
|
|
try {
|
|
expect((await service.read('owner', identity, true))?.fresh).toBe(true);
|
|
clock.mockReturnValue(timestamp.valueOf() + 10 * 86_400_000);
|
|
expect((await service.read('owner', identity, true))?.fresh).toBe(true);
|
|
expect(
|
|
(await service.read('owner', { instrumentUid: 'other-uid' }, true))
|
|
?.complete,
|
|
).toBe(false);
|
|
expect(upstream.getInstrumentByUid).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
clock.mockRestore();
|
|
}
|
|
});
|
|
it('recovers abandoned work only on a new request after reopen, with a new attempt', async () => {
|
|
const old = await repository.reserve(
|
|
{ userId: 'owner', revision: 'owner-revision' },
|
|
identity,
|
|
'previous-process',
|
|
new Set(),
|
|
);
|
|
assert(old);
|
|
await prisma.$disconnect();
|
|
service = createService();
|
|
await service.read('owner', identity, true);
|
|
await delay(10);
|
|
expect(upstream.getInstrumentByUid).not.toHaveBeenCalled();
|
|
await service.read('owner', identity);
|
|
await settled();
|
|
const work = await prisma.tbankSyncWork.findUniqueOrThrow({
|
|
where: { id: old.work.id },
|
|
});
|
|
expect(work.attemptId).not.toBe(old.work.attemptId);
|
|
expect(work.state).toBe('complete');
|
|
expect(upstream.getInstrumentByUid).toHaveBeenCalledTimes(1);
|
|
});
|
|
it('rejects publication by a superseded attempt without changing payload or successful timestamp', async () => {
|
|
const owner = { userId: 'owner', revision: 'owner-revision' };
|
|
const old = await repository.reserve(owner, identity, 'old', new Set());
|
|
assert(old);
|
|
const current = await repository.reserve(
|
|
owner,
|
|
identity,
|
|
'current',
|
|
new Set(),
|
|
);
|
|
assert(current);
|
|
const fence = (work: typeof old.work) => ({
|
|
...owner,
|
|
id: work.id,
|
|
attemptId: work.attemptId,
|
|
});
|
|
expect(
|
|
await repository.publish(
|
|
fence(current.work),
|
|
[{ type: 'uid', id: 'uid', data: known }],
|
|
new Date(1000),
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
await repository.publish(
|
|
fence(old.work),
|
|
[{ type: 'uid', id: 'uid', data: { ...known, name: 'late' } }],
|
|
new Date(2000),
|
|
),
|
|
).toBe(false);
|
|
const saved = (await repository.readMany([identity])).get(
|
|
metadataKey(identity),
|
|
);
|
|
expect(saved?.data).toEqual(known);
|
|
expect(saved?.fetchedAt).toEqual(new Date(1000));
|
|
});
|
|
it('uses a verified FIGI fallback after transient UID failure without persisting a false UID miss', async () => {
|
|
upstream.getInstrumentByUid.mockRejectedValueOnce(
|
|
new Error('temporarily unavailable'),
|
|
);
|
|
await service.read('owner', identity);
|
|
await settled();
|
|
const result = await service.read('other', identity, true);
|
|
expect(result?.data).toEqual(known);
|
|
expect(result?.complete).toBe(true);
|
|
const lookup = await prisma.tbankInstrumentLookup.findFirstOrThrow({
|
|
where: { identifierType: 'uid', identifier: 'uid' },
|
|
});
|
|
expect(lookup.matchedUids).toEqual(['uid']);
|
|
});
|
|
it('reads a full 1000-row ledger metadata batch without SQLite expression-depth overflow', async () => {
|
|
await service.read('owner', identity);
|
|
await settled();
|
|
const identities = Array.from({ length: 1000 }, (_, index) =>
|
|
index
|
|
? { instrumentUid: `uid-${index}`, figi: `FIGI-${index}` }
|
|
: identity,
|
|
);
|
|
const saved = await service.savedMany(identities);
|
|
expect(saved.size).toBe(1000);
|
|
expect(saved.get(metadataKey(identity))?.data).toEqual(known);
|
|
expect(upstream.getInstrumentByUid).toHaveBeenCalledTimes(1);
|
|
});
|
|
it('does not cache a sparse malformed UID result as complete metadata', async () => {
|
|
upstream.getInstrumentByUid.mockResolvedValue({ uid: 'uid', name: ' ' });
|
|
await service.read('owner', { instrumentUid: 'uid' });
|
|
await settled();
|
|
const result = await service.read('owner', { instrumentUid: 'uid' }, true);
|
|
expect(result?.complete).toBe(false);
|
|
expect(result?.work?.state).toBe('failed');
|
|
expect(await prisma.tbankInstrumentLookup.count()).toBe(0);
|
|
});
|
|
it('does not fabricate a provider UID from a malformed FIGI response', async () => {
|
|
upstream.getInstrumentByFigi.mockResolvedValue({
|
|
name: 'No provider identity',
|
|
});
|
|
await service.read('owner', { figi: 'FIGI' });
|
|
await settled();
|
|
expect(await prisma.tbankInstrument.count()).toBe(0);
|
|
expect(await prisma.tbankInstrumentLookup.count()).toBe(0);
|
|
expect(
|
|
(await service.read('owner', { figi: 'FIGI' }, true))?.work?.state,
|
|
).toBe('failed');
|
|
});
|
|
});
|
|
function deferred<T>() {
|
|
let resolve: (value: T) => void = () => {
|
|
throw new Error('Uninitialized deferred');
|
|
};
|
|
let reject: (error: Error) => void = () => {
|
|
throw new Error('Uninitialized deferred');
|
|
};
|
|
const promise = new Promise<T>((yes, no) => {
|
|
resolve = yes;
|
|
reject = no;
|
|
});
|
|
return { promise, resolve, reject };
|
|
}
|