@yoroi/portfolio
Version:
The Portfolio package of Yoroi SDK
694 lines (600 loc) • 18.4 kB
text/typescript
import {Portfolio} from '@yoroi/types'
import {BehaviorSubject} from 'rxjs'
import {mountMMKVStorage, observableStorageMaker} from '@yoroi/common'
import {portfolioBalanceManagerMaker} from './balance-manager'
import {tokenBalanceMocks} from './adapters/token-balance.mocks'
import {sortTokenAmountsByInfo} from './helpers/sorting'
import {tokenMocks} from './adapters/token.mocks'
import {portfolioBalanceStorageMaker} from './adapters/mmkv-storage/balance-storage-maker'
import {tokenInfoMocks} from './adapters/token-info.mocks'
import {isFt} from './helpers/is-ft'
import {isNft} from './helpers/is-nft'
import {createTokenManagerMock} from './token-manager.mock'
const tokenInfoStorage = observableStorageMaker(
mountMMKVStorage<Portfolio.Token.Id>({
path: `/test/token-info/`,
}),
)
const primaryTokenId = tokenMocks.primaryETH.info.id
const sourceId = 'sourceId'
describe('portfolioBalanceManagerMaker', () => {
const balanceStorage = observableStorageMaker(
mountMMKVStorage<Portfolio.Token.Id>({
path: '/tmp/balance/',
id: 'balance',
}),
)
const primaryBreakdownStorage = observableStorageMaker(
mountMMKVStorage<Portfolio.Token.Id>({
path: '/tmp/primary-balance-breakdown/',
id: 'primary-balance-breakdown',
}),
)
const storage: Portfolio.Storage.Balance = portfolioBalanceStorageMaker({
balanceStorage,
primaryBreakdownStorage,
primaryTokenId,
})
const tokenManager = createTokenManagerMock()
it('should be instantiated', () => {
const manager = portfolioBalanceManagerMaker({
tokenManager,
storage,
primaryTokenInfo: tokenMocks.primaryETH.info,
sourceId,
})
expect(manager).toBeDefined()
})
})
describe('hydrate', () => {
const balanceStorage = observableStorageMaker(
mountMMKVStorage<Portfolio.Token.Id>({
path: '/tmp/balance/',
id: 'balance',
}),
)
const primaryBreakdownStorage = observableStorageMaker(
mountMMKVStorage<Portfolio.Token.Id>({
path: '/tmp/primary-balance-breakdown/',
id: 'primary-balance-breakdown',
}),
)
const storage: Portfolio.Storage.Balance = portfolioBalanceStorageMaker({
balanceStorage,
primaryBreakdownStorage,
primaryTokenId,
})
const tokenManager = createTokenManagerMock()
afterEach(() => {
storage.clear()
tokenInfoStorage.clear()
})
const primaryStated: Readonly<Portfolio.PrimaryBreakdown> = {
availableRewards: 1000000n,
lockedAsStorageCost: 0n,
totalFromTxs: 0n,
}
storage.primaryBreakdown.save(primaryStated)
storage.balances.save(tokenBalanceMocks.storage.entries1)
it('should hydrate data', async () => {
tokenInfoStorage.multiSet(tokenInfoMocks.storage.entries1)
const manager = portfolioBalanceManagerMaker({
tokenManager,
storage,
primaryTokenInfo: tokenMocks.primaryETH.info,
sourceId,
})
const subscriber = jest.fn()
manager.subscribe(subscriber)
const sorted = sortTokenAmountsByInfo({
primaryTokenInfo: tokenMocks.primaryETH.info,
amounts: [
...new Map(tokenBalanceMocks.storage.entries1WithPrimary).values(),
],
})
const sortedBalances = {
records: new Map(sorted.map((amount) => [amount.info.id, amount])),
all: sorted,
fts: sorted.filter(({info}) => isFt(info)),
nfts: sorted.filter(({info}) => isNft(info)),
}
manager.hydrate()
expect(manager.getPrimaryBreakdown()).toEqual(primaryStated)
expect(manager.getBalances()).toEqual(sortedBalances)
expect(manager.getPrimaryBalance()).toEqual({
info: tokenMocks.primaryETH.info,
quantity: primaryStated.totalFromTxs + primaryStated.availableRewards,
})
expect(subscriber).toHaveBeenCalledTimes(1)
expect(manager.getHasOnlyPrimary()).toBe(false)
expect(manager.getIsEmpty()).toBe(false)
})
})
describe('destroy', () => {
const balanceStorage = observableStorageMaker(
mountMMKVStorage<Portfolio.Token.Id>({
path: '/tmp/balance/',
id: 'balance',
}),
)
const primaryBreakdownStorage = observableStorageMaker(
mountMMKVStorage<Portfolio.Token.Id>({
path: '/tmp/primary-balance-breakdown/',
id: 'primary-balance-breakdown',
}),
)
const storage: Portfolio.Storage.Balance = portfolioBalanceStorageMaker({
balanceStorage,
primaryBreakdownStorage,
primaryTokenId,
})
const tokenManager = createTokenManagerMock()
const queueDestroy = jest.fn()
const observerDestroy = jest.fn()
afterEach(() => {
storage.clear()
tokenInfoStorage.clear()
})
it('should tear down observers', async () => {
tokenManager.sync.mockResolvedValue(
new Map(tokenInfoMocks.storage.entries1),
)
const tasks: any = []
const enqueueMock = jest.fn((task) => tasks.push(task))
const mockedNotify = jest.fn()
const manager = portfolioBalanceManagerMaker(
{
tokenManager,
storage,
primaryTokenInfo: tokenMocks.primaryETH.info,
sourceId,
},
{
observer: {
destroy: observerDestroy,
notify: mockedNotify,
subscribe: jest.fn(),
unsubscribe: jest.fn(),
observable: new BehaviorSubject({} as any).asObservable(),
},
queue: {
enqueue: enqueueMock,
destroy: queueDestroy,
observable: new BehaviorSubject({}).asObservable(),
} as any,
},
)
manager.destroy()
expect(observerDestroy).toHaveBeenCalled()
expect(queueDestroy).toHaveBeenCalled()
expect(tokenManager.unsubscribe).toHaveBeenCalled()
})
})
describe('primary updates', () => {
const balanceStorage = observableStorageMaker(
mountMMKVStorage<Portfolio.Token.Id>({
path: '/tmp/balance/',
id: 'balance',
}),
)
const primaryBreakdownStorage = observableStorageMaker(
mountMMKVStorage<Portfolio.Token.Id>({
path: '/tmp/primary-balance-breakdown/',
id: 'primary-balance-breakdown',
}),
)
const storage: Portfolio.Storage.Balance = portfolioBalanceStorageMaker({
balanceStorage,
primaryBreakdownStorage,
primaryTokenId,
})
const tokenManager = createTokenManagerMock()
afterEach(() => {
storage.clear()
tokenInfoStorage.clear()
})
it('should update primary stated', () => {
const tasks: any = []
const enqueueMock = jest.fn((task) => tasks.push(task))
const mockedNotify = jest.fn()
const manager = portfolioBalanceManagerMaker(
{
tokenManager,
storage,
primaryTokenInfo: tokenMocks.primaryETH.info,
sourceId,
},
{
observer: {
destroy: jest.fn(),
notify: mockedNotify,
subscribe: jest.fn(),
unsubscribe: jest.fn(),
observable: new BehaviorSubject({} as any).asObservable(),
},
queue: {
enqueue: enqueueMock,
destroy: jest.fn(),
observable: new BehaviorSubject({}).asObservable(),
} as any,
},
)
const subscriber = jest.fn()
manager.subscribe(subscriber)
manager.hydrate()
manager.updatePrimaryStated({
totalFromTxs: 1000001n,
lockedAsStorageCost: 10n,
})
expect(manager.getPrimaryBreakdown()).toEqual({
totalFromTxs: 1000001n,
availableRewards: 0n,
lockedAsStorageCost: 10n,
})
expect(manager.getPrimaryBalance()).toEqual({
info: tokenMocks.primaryETH.info,
quantity: 1000001n,
})
expect(mockedNotify).toHaveBeenCalledTimes(2)
expect(mockedNotify).toHaveBeenCalledWith({
on: Portfolio.Event.ManagerOn.Sync,
sourceId,
mode: 'primary-stated',
})
})
it('should update primary derived', () => {
const tasks: any = []
const enqueueMock = jest.fn((task) => tasks.push(task))
const mockedNotify = jest.fn()
const manager = portfolioBalanceManagerMaker(
{
tokenManager,
storage,
primaryTokenInfo: tokenMocks.primaryETH.info,
sourceId,
},
{
observer: {
destroy: jest.fn(),
notify: mockedNotify,
subscribe: jest.fn(),
unsubscribe: jest.fn(),
observable: new BehaviorSubject({} as any).asObservable(),
},
queue: {
enqueue: enqueueMock,
destroy: jest.fn(),
observable: new BehaviorSubject({}).asObservable(),
} as any,
},
)
const primaryStated: Readonly<Portfolio.PrimaryBreakdown> = {
availableRewards: 0n,
lockedAsStorageCost: 0n,
totalFromTxs: 1000001n,
}
storage.primaryBreakdown.save(primaryStated)
const subscriber = jest.fn()
manager.subscribe(subscriber)
manager.hydrate()
manager.updatePrimaryDerived({
availableRewards: 1000001n,
})
expect(manager.getPrimaryBreakdown()).toEqual({
totalFromTxs: 1000001n,
availableRewards: 1000001n,
lockedAsStorageCost: 0n,
})
expect(manager.getPrimaryBalance()).toEqual({
info: tokenMocks.primaryETH.info,
quantity: 2000002n,
})
expect(mockedNotify).toHaveBeenCalledTimes(2)
expect(mockedNotify).toHaveBeenCalledWith({
on: Portfolio.Event.ManagerOn.Sync,
sourceId,
mode: 'primary-derived',
})
})
})
describe('sync & refresh', () => {
const balanceStorage = observableStorageMaker(
mountMMKVStorage<Portfolio.Token.Id>({
path: '/tmp/balance/',
id: 'balance',
}),
)
const primaryBreakdownStorage = observableStorageMaker(
mountMMKVStorage<Portfolio.Token.Id>({
path: '/tmp/primary-balance-breakdown/',
id: 'primary-balance-breakdown',
}),
)
const storage: Portfolio.Storage.Balance = portfolioBalanceStorageMaker({
balanceStorage,
primaryBreakdownStorage,
primaryTokenId,
})
const tokenManagerObservable = new BehaviorSubject({} as any)
const tokenManager = createTokenManagerMock(
tokenManagerObservable.asObservable(),
)
afterEach(() => {
storage.clear()
tokenInfoStorage.clear()
})
const primaryStated: Readonly<Portfolio.PrimaryBreakdown> = {
totalFromTxs: BigInt(1000000),
availableRewards: BigInt(0),
lockedAsStorageCost: BigInt(0),
}
it('should sync and respond to token event', async () => {
tokenManager.sync.mockResolvedValue(
new Map(tokenInfoMocks.storage.entries1),
)
const tasks: any = []
const enqueueMock = jest.fn((task) => tasks.push(task))
const mockedNotify = jest.fn()
const manager = portfolioBalanceManagerMaker(
{
tokenManager,
storage,
primaryTokenInfo: tokenMocks.primaryETH.info,
sourceId,
},
{
observer: {
destroy: jest.fn(),
notify: mockedNotify,
subscribe: jest.fn(),
unsubscribe: jest.fn(),
observable: new BehaviorSubject({} as any).asObservable(),
},
queue: {
enqueue: enqueueMock,
destroy: jest.fn(),
observable: new BehaviorSubject({}).asObservable(),
} as any,
},
)
const subscriber = jest.fn()
manager.subscribe(subscriber)
manager.hydrate()
const secondaryBalances = new Map(
tokenBalanceMocks.storage.entries1.slice(0, -1),
)
manager.syncBalances({
primaryStated,
secondaryBalances,
})
expect(enqueueMock).toHaveBeenCalled()
expect(tasks.length).toBe(1)
for (const task of tasks) {
await task()
}
tasks.length = 0
expect(manager.getPrimaryBreakdown()).toEqual(expect.anything())
expect(manager.getBalances()).toEqual(expect.anything())
expect(mockedNotify).toHaveBeenCalledTimes(2)
tokenManager.sync.mockResolvedValue(
new Map(tokenInfoMocks.storage.entries1.slice(0, 1)),
)
// simulate an update coming from another sync on token manager
tokenManagerObservable.next({
on: Portfolio.Event.ManagerOn.Sync,
sourceId: 'another-source',
ids: [tokenInfoMocks.nftCryptoKitty.id, tokenInfoMocks.rnftWhatever.id],
})
expect(tasks.length).toBeGreaterThan(0)
for (const task of tasks) {
await task()
}
})
it('should refresh', async () => {
tokenManager.sync.mockResolvedValue(
new Map(tokenInfoMocks.storage.entries1),
)
const tasks: any = []
const enqueueMock = jest.fn((task) => tasks.push(task))
const mockedNotify = jest.fn()
const manager = portfolioBalanceManagerMaker(
{
tokenManager,
storage,
primaryTokenInfo: tokenMocks.primaryETH.info,
sourceId,
},
{
observer: {
destroy: jest.fn(),
notify: mockedNotify,
subscribe: jest.fn(),
unsubscribe: jest.fn(),
observable: new BehaviorSubject({} as any).asObservable(),
},
queue: {
enqueue: enqueueMock,
destroy: jest.fn(),
observable: new BehaviorSubject({}).asObservable(),
} as any,
},
)
const subscriber = jest.fn()
manager.subscribe(subscriber)
const secondaryBalances = new Map(
tokenBalanceMocks.storage.entries1.slice(0, -1),
)
manager.syncBalances({
primaryStated,
secondaryBalances,
})
manager.refresh()
expect(enqueueMock).toHaveBeenCalled()
expect(tasks.length).toBe(2)
for (const task of tasks) {
await task()
}
tasks.length = 0
expect(manager.getPrimaryBreakdown()).toEqual(expect.anything())
expect(manager.getBalances()).toEqual(expect.anything())
expect(mockedNotify).toHaveBeenCalledTimes(2)
})
it('should sync', async () => {
tokenManager.sync.mockResolvedValue(
new Map(tokenInfoMocks.storage.entries1),
)
const tasks: any = []
const enqueueMock = jest.fn((task) => tasks.push(task))
const mockedNotify = jest.fn()
const manager = portfolioBalanceManagerMaker(
{
tokenManager,
storage,
primaryTokenInfo: tokenMocks.primaryETH.info,
sourceId,
},
{
observer: {
destroy: jest.fn(),
notify: mockedNotify,
subscribe: jest.fn(),
unsubscribe: jest.fn(),
observable: new BehaviorSubject({} as any).asObservable(),
},
queue: {
enqueue: enqueueMock,
destroy: jest.fn(),
observable: new BehaviorSubject({}).asObservable(),
} as any,
},
)
const subscriber = jest.fn()
manager.subscribe(subscriber)
const secondaryBalances = new Map(
tokenBalanceMocks.storage.entries1.slice(0, -1),
)
manager.hydrate()
manager.syncBalances({
primaryStated,
secondaryBalances,
})
expect(enqueueMock).toHaveBeenCalled()
expect(tasks.length).toBeGreaterThan(0)
for (const task of tasks) {
await task()
}
expect(manager.getPrimaryBreakdown()).toEqual(expect.anything())
expect(manager.getBalances()).toEqual(expect.anything())
expect(mockedNotify).toHaveBeenCalledTimes(2) // Hydrate + Sync
})
it('should throw error if token manager miss a token', async () => {
// empty map, should never happen
tokenManager.sync.mockResolvedValue(new Map())
const tasks: any = []
const enqueueMock = jest.fn((task) => tasks.push(task))
const mockedNotify = jest.fn()
const manager = portfolioBalanceManagerMaker(
{
tokenManager,
storage,
primaryTokenInfo: tokenMocks.primaryETH.info,
sourceId,
},
{
observer: {
destroy: jest.fn(),
notify: mockedNotify,
subscribe: jest.fn(),
unsubscribe: jest.fn(),
observable: new BehaviorSubject({} as any).asObservable(),
},
queue: {
enqueue: enqueueMock,
destroy: jest.fn(),
observable: new BehaviorSubject({}).asObservable(),
} as any,
},
)
const secondaryBalances: Readonly<
Map<Portfolio.Token.Id, Portfolio.Token.Amount>
> = new Map(tokenBalanceMocks.storage.entries1)
manager.syncBalances({
primaryStated,
secondaryBalances,
})
expect(enqueueMock).toHaveBeenCalled()
expect(tasks.length).toBeGreaterThan(0)
for (const task of tasks) {
await expect(() => task()).rejects.toThrow()
}
})
})
describe('clear', () => {
const balanceStorage = observableStorageMaker(
mountMMKVStorage<Portfolio.Token.Id>({
path: '/tmp/balance/',
id: 'balance',
}),
)
const primaryBreakdownStorage = observableStorageMaker(
mountMMKVStorage<Portfolio.Token.Id>({
path: '/tmp/primary-balance-breakdown/',
id: 'primary-breakdown',
}),
)
const storage: Portfolio.Storage.Balance = portfolioBalanceStorageMaker({
balanceStorage,
primaryBreakdownStorage,
primaryTokenId,
})
const tokenManager = createTokenManagerMock()
afterEach(() => {
storage.clear()
tokenInfoStorage.clear()
})
it('should clear all data after syncing operations are complete', async () => {
const tasks: any = []
const enqueueMock = jest.fn((task) => tasks.push(task))
const mockedNotify = jest.fn()
const manager = portfolioBalanceManagerMaker(
{
tokenManager,
storage,
primaryTokenInfo: tokenMocks.primaryETH.info,
sourceId,
},
{
queue: {
enqueue: enqueueMock,
destroy: jest.fn(),
observable: new BehaviorSubject({} as any).asObservable(),
} as any,
observer: {
notify: mockedNotify,
subscribe: jest.fn(),
unsubscribe: jest.fn(),
destroy: jest.fn(),
observable: new BehaviorSubject({} as any).asObservable(),
},
},
)
manager.syncBalances({
primaryStated: {
totalFromTxs: 500n,
lockedAsStorageCost: 100n,
},
secondaryBalances: new Map(),
})
manager.clear()
expect(enqueueMock).toHaveBeenCalledTimes(2)
for (const task of tasks) {
await task()
}
expect(storage.balances.all()).toEqual([])
expect(storage.primaryBreakdown.read()).toBeNull()
expect(mockedNotify).toHaveBeenCalledWith({
on: Portfolio.Event.ManagerOn.Clear,
sourceId,
})
})
})