import { NotFoundException } from '@nestjs/common'; jest.mock('../../../../prisma/prisma.service', () => ({ PrismaService: class {} })); import { TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code'; import { InstrumentIdType } from '../../../../integrations/tbank/tbank-protocol'; import { PositionClassificationService } from './position-classification.service'; describe('PositionClassificationService', () => { const position = { instrumentType: 'share', instrumentUid: 'uid', figi: 'figi', quantity: { units: 1 } }; const saved = { sector: 'Old', sectorFetchedAt: new Date('2026-10-30T12:00:00Z'), assetUid: 'asset', brandUid: 'brand', brandName: 'Old brand', brandFetchedAt: new Date('2026-10-30T12:00:00Z') }; const tbank = { getShare: jest.fn(), getAssetBrand: jest.fn(), getBond: jest.fn(), getEtf: jest.fn() }; const repository = { read: jest.fn(), save: jest.fn() }; const logger = { event: jest.fn() }; const service = new PositionClassificationService(tbank as never, repository as never, logger as never); beforeEach(() => { jest.resetAllMocks(); repository.read.mockResolvedValue(saved); repository.save.mockImplementation((_key: string, update: Record) => Promise.resolve({ ...saved, ...update })); tbank.getAssetBrand.mockResolvedValue({ assetUid: 'asset', brand: { uid: 'new-brand', name: 'New brand' } }); }); it('does not fetch a value at 23:59, and refreshes it at 24:00', async () => { await service.refresh('token', [position], new Date('2026-10-31T11:59:00Z')); expect(tbank.getShare).not.toHaveBeenCalled(); tbank.getShare.mockResolvedValue({ uid: 'uid', figi: 'figi', sector: ' Tech ', assetUid: 'asset' }); const result = await service.refresh('token', [position], new Date('2026-10-31T12:00:00Z')); expect(result.values().next().value).toEqual({ sector: 'Tech', issuer: { id: 'new-brand', name: 'New brand' } }); expect(repository.save).toHaveBeenCalledWith(expect.any(String), { sector: 'Tech', sectorFetchedAt: new Date('2026-10-31T12:00:00Z'), brandUid: 'new-brand', brandName: 'New brand', brandFetchedAt: new Date('2026-10-31T12:00:00Z'), }); }); it('accepts a matching FIGI after UID NOT_FOUND', async () => { tbank.getShare.mockRejectedValueOnce(new NotFoundException()).mockResolvedValueOnce({ uid: 'different', figi: 'figi', sector: 'Energy', assetUid: 'asset' }); const result = await service.refresh('token', [position], new Date('2026-10-31T12:00:00Z')); expect(tbank.getShare).toHaveBeenNthCalledWith(2, 'token', 'figi', InstrumentIdType.Figi, true); expect(result.values().next().value?.sector).toBe('Energy'); }); it('preserves saved values on mismatched instrument and returns refreshed values on database failure', async () => { tbank.getShare.mockResolvedValue({ uid: 'wrong', sector: 'Energy' }); const result = await service.refresh('token', [position], new Date('2026-10-31T12:00:00Z')); expect(result.values().next().value?.sector).toBe('Old'); repository.save.mockRejectedValue(new Error('db unavailable')); tbank.getShare.mockResolvedValue({ uid: 'uid', sector: 'Energy', assetUid: 'asset' }); const fallback = await service.refresh('token', [position], new Date('2026-10-31T12:00:00Z')); expect(fallback.values().next().value?.sector).toBe('Energy'); expect(logger.event).toHaveBeenCalled(); }); it('does not retain an issuer from a previous asset when the asset changes', async () => { repository.read.mockResolvedValue({ ...saved, brandFetchedAt: new Date('2026-10-31T12:00:00Z') }); tbank.getShare.mockResolvedValue({ uid: 'uid', sector: 'Energy', assetUid: 'other' }); tbank.getAssetBrand.mockResolvedValue({ assetUid: 'other', brand: { uid: 'other-brand', name: 'Other brand' } }); const result = await service.refresh('token', [position], new Date('2026-10-31T12:00:00Z')); expect(tbank.getAssetBrand).toHaveBeenCalledWith('token', 'other'); expect(result.values().next().value?.issuer).toEqual({ id: 'other-brand', name: 'Other brand' }); }); it('does not restart the brand clock when the brand fetch fails after an asset change', async () => { repository.read.mockResolvedValue({ ...saved, brandFetchedAt: new Date('2026-10-31T12:00:00Z') }); tbank.getShare.mockResolvedValue({ uid: 'uid', sector: 'Energy', assetUid: 'other' }); tbank.getAssetBrand.mockRejectedValue(new Error('upstream unavailable')); const result = await service.refresh('token', [position], new Date('2026-10-31T12:00:00Z')); expect(result.values().next().value?.issuer).toBeNull(); expect(repository.save).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ assetUid: 'other', brandUid: null, brandName: null, brandFetchedAt: null, })); }); it('does not return the previous asset brand when saving a changed asset fails', async () => { repository.save.mockRejectedValue(new Error('db unavailable')); tbank.getShare.mockResolvedValue({ uid: 'uid', sector: 'Energy', assetUid: 'other' }); tbank.getAssetBrand.mockRejectedValue(new Error('upstream unavailable')); const result = await service.refresh('token', [position], new Date('2026-10-31T12:00:00Z')); expect(result.values().next().value).toEqual({ sector: 'Energy', issuer: null }); expect(repository.save).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ assetUid: 'other', brandUid: null, brandName: null, brandFetchedAt: null, })); expect(logger.event).toHaveBeenCalled(); }); it('records a fetched brand as null when a fresh instrument has no asset_uid', async () => { repository.read.mockResolvedValue(null); tbank.getShare.mockResolvedValue({ uid: 'uid', sector: 'Energy' }); await service.refresh('token', [position], new Date('2026-10-31T12:00:00Z')); expect(repository.save).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ brandUid: null, brandName: null, brandFetchedAt: new Date('2026-10-31T12:00:00Z'), })); }); it('treats a NOT_FOUND answer as fetched and does not refetch it an hour later', async () => { tbank.getShare.mockRejectedValue(new NotFoundException()); await service.refresh('token', [position], new Date('2026-10-31T12:00:00Z')); expect(repository.save).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ sector: null, sectorFetchedAt: new Date('2026-10-31T12:00:00Z'), })); repository.read.mockResolvedValue({ ...saved, sector: null, sectorFetchedAt: new Date('2026-10-31T12:00:00Z'), assetUid: null, brandUid: null, brandName: null, brandFetchedAt: new Date('2026-10-31T12:00:00Z') }); tbank.getShare.mockClear(); await service.refresh('token', [position], new Date('2026-10-31T13:00:00Z')); expect(tbank.getShare).not.toHaveBeenCalled(); }); it('records a missing brand and restarts its clock when NOT_FOUND has no saved asset UID', async () => { repository.read.mockResolvedValue(null); tbank.getShare.mockRejectedValue(new NotFoundException()); const startedAt = new Date('2026-10-31T12:00:00Z'); const result = await service.refresh('token', [position], startedAt); expect(result.values().next().value).toEqual({ sector: null, issuer: null }); expect(repository.save).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ brandUid: null, brandName: null, brandFetchedAt: startedAt, })); expect(tbank.getAssetBrand).not.toHaveBeenCalled(); }); it('does not leak one refresh call\'s invalid token into another call', async () => { tbank.getShare.mockRejectedValueOnce(new Error(TbankErrorCode.InvalidToken)) .mockResolvedValueOnce({ uid: 'uid', figi: 'figi', sector: 'Energy', assetUid: 'asset' }); await expect(service.refresh('first', [position], new Date('2026-10-31T12:00:00Z'))).rejects.toThrow(); const result = await service.refresh('second', [position], new Date('2026-10-31T12:00:00Z')); expect(result.values().next().value?.sector).toBe('Energy'); }); it('propagates an invalid token before publishing a classification', async () => { tbank.getShare.mockRejectedValue(new Error(TbankErrorCode.InvalidToken)); const result = service.refresh('token', [position], new Date('2026-10-31T12:00:00Z')); await expect(result).rejects.toThrow(TbankErrorCode.InvalidToken); expect(repository.save).not.toHaveBeenCalled(); }); it('leaves a fresh brand untouched when the sector refresh finds the same asset', async () => { repository.read.mockResolvedValue({ ...saved, brandFetchedAt: new Date('2026-10-31T12:00:00Z') }); tbank.getShare.mockResolvedValue({ uid: 'uid', sector: 'Energy', assetUid: 'asset' }); await service.refresh('token', [position], new Date('2026-10-31T12:00:00Z')); expect(tbank.getAssetBrand).not.toHaveBeenCalled(); }); it('skips unsupported types, zero-quantity positions, and shares a brand lookup across instruments', async () => { repository.read.mockResolvedValue(null); tbank.getShare.mockResolvedValue({ uid: 'uid', sector: 'Energy', assetUid: 'asset' }); tbank.getBond.mockResolvedValue({ uid: 'bond', sector: 'Energy', assetUid: 'asset' }); const result = await service.refresh('token', [ { instrumentType: 'currency', instrumentUid: 'cash', figi: 'cash', quantity: { units: 1 } }, { instrumentType: 'share', instrumentUid: 'empty', figi: 'empty', quantity: {} }, { instrumentType: 'Share', instrumentUid: 'upper', figi: 'upper', quantity: { units: 1 } }, position, { instrumentType: 'bond', instrumentUid: 'bond', figi: 'bond-figi', quantity: { units: 1 } }, ], new Date('2026-10-31T12:00:00Z')); expect(result.size).toBe(3); expect(tbank.getAssetBrand).toHaveBeenCalledTimes(1); expect(tbank.getEtf).not.toHaveBeenCalled(); }); it('skips duplicate instrument keys and retries a failed asset lookup for another instrument in the same walk', async () => { repository.read.mockResolvedValue(null); tbank.getShare.mockResolvedValue({ uid: 'uid', sector: 'Energy', assetUid: 'asset' }); tbank.getBond.mockResolvedValue({ uid: 'bond', sector: 'Energy', assetUid: 'asset' }); tbank.getAssetBrand.mockRejectedValueOnce(new Error('upstream unavailable')) .mockResolvedValueOnce({ assetUid: 'asset', brand: { uid: 'new-brand', name: 'New brand' } }); const result = await service.refresh('token', [position, position, { instrumentType: 'bond', instrumentUid: 'bond', figi: 'bond-figi', quantity: { units: 1 } }, ], new Date('2026-10-31T12:00:00Z')); expect(result.size).toBe(2); expect(tbank.getShare).toHaveBeenCalledTimes(1); expect(tbank.getAssetBrand).toHaveBeenCalledTimes(2); expect([...result.values()].map(({ issuer }) => issuer)).toEqual([ null, { id: 'new-brand', name: 'New brand' }, ]); }); });