@yoroi/swap
Version:
The Swap package of Yoroi SDK
468 lines (411 loc) • 14.3 kB
text/typescript
import {fetchData} from '@yoroi/common'
import {Api, Chain, Left} from '@yoroi/types'
import {muesliswapApiMaker, parseMuesliError} from './api-maker'
import {api} from './api.mocks'
import {MuesliswapApiConfig} from './types'
jest.mock('@yoroi/common', () => ({
fetchData: jest.fn(),
isLeft: jest.requireActual('@yoroi/common').isLeft,
difference: jest.requireActual('@yoroi/common').difference,
}))
describe('muesliswapApiMaker', () => {
const mockFetchData = fetchData as jest.MockedFunction<typeof fetchData>
const config: MuesliswapApiConfig = {
addressHex: 'someAddressHex',
address: 'someAddress',
primaryTokenInfo: {} as any,
isPrimaryToken: () => false,
stakingKey: 'someStakingKey',
network: Chain.Network.Mainnet,
partner: 'somePartnerId',
}
afterEach(() => {
mockFetchData.mockReset()
})
it('should return an object with the Swap.Api interface', () => {
const muesliApi = muesliswapApiMaker(config)
expect(muesliApi).toHaveProperty('tokens')
expect(muesliApi).toHaveProperty('orders')
expect(muesliApi).toHaveProperty('estimate')
expect(muesliApi).toHaveProperty('create')
expect(muesliApi).toHaveProperty('cancel')
})
it('should return error if network is not Mainnet', async () => {
const testConfig: MuesliswapApiConfig = {
...config,
network: Chain.Network.Preprod,
}
const muesliApi = muesliswapApiMaker(testConfig)
const result = await muesliApi.tokens()
if (result.tag !== 'left') fail()
expect(result.tag).toBe('left')
expect(result.error.message).toMatch(/works on mainnet/)
})
describe('tokens()', () => {
it('returns a successful response', async () => {
mockFetchData.mockResolvedValueOnce({
tag: 'right',
value: {
status: Api.HttpStatusCode.Ok,
data: api.responses.tokens,
},
})
const muesliApi = muesliswapApiMaker(config)
const result = await muesliApi.tokens()
expect(mockFetchData).toHaveBeenCalledWith({
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
method: 'get',
url: 'https://aggregator-v2.muesliswap.com/tokens',
})
expect(result.tag).toBe('right')
})
it('returns an error (isLeft)', async () => {
mockFetchData.mockResolvedValueOnce({
tag: 'left',
error: {
status: 500,
message: 'Server error',
responseData: {detail: 'something went wrong'},
},
})
const muesliApi = muesliswapApiMaker(config)
const result = await muesliApi.tokens()
if (result.tag !== 'left') fail()
expect(result.tag).toBe('left')
expect(result.error.message).toContain('something went wrong')
})
})
describe('orders()', () => {
it('should return a successful transformed response', async () => {
mockFetchData.mockResolvedValueOnce({
tag: 'right',
value: {
status: Api.HttpStatusCode.Ok,
data: {
...api.responses.orders,
orders: [
...api.responses.orders.orders,
{
dex: 'sundaeswap-v1',
aggregator: null,
fromToken: '.',
toToken:
'4cb48d60d1f7823d1307c61b9ecf472ff78cf22d1ccc5786d59461f8.4144414d4f4f4e',
fromAmount: '0.000036',
toAmount: '10',
paidAmount: '0.000036',
receivedAmount: '11',
batcherFee: '2.500000',
attachedValues: [
{
amount: 4500036,
token: '.',
},
],
sender:
'addr1q9r502tqdksvqmhs3lwlxx5f5cz0c92cftqqludl3r0urtk0ppwv8x4ylafdu84xqmh9sx4vrk4czekksv884xmvanwql6sl74',
beneficiary:
'addr1q9r502tqdksvqmhs3lwlxx5f5cz0c92cftqqludl3r0urtk0ppwv8x4ylafdu84xqmh9sx4vrk4czekksv884xmvanwql6sl74',
txHash:
'29f51a2a9e46ced05f03abc9b419ae57164dc056534121f041d69e307b9722f8',
outputIdx: 0,
deposit: '2.000000',
status: 'matched',
placedAt: undefined,
finalizedAt: undefined,
finalizedTxHash:
'8d3b20bafb8378366f819f506da327a43e94d6948c002bac00a9b1de401bc571',
providerSpecifics: {
poolId: '1701',
swapDirection: 0,
},
},
{
dex: 'minswap-v2',
aggregator: null,
fromToken: '.',
toToken:
'49e423161ef818adc475c783571cb479d5f15ad52a01a240eacc0d3b.434f434b',
fromAmount: '0.008137',
toAmount: '1',
paidAmount: '0.000000',
receivedAmount: '0',
batcherFee: '2.000000',
attachedValues: [
{
amount: 4008137,
token: '.',
},
],
sender:
'addr1q9r502tqdksvqmhs3lwlxx5f5cz0c92cftqqludl3r0urtk0ppwv8x4ylafdu84xqmh9sx4vrk4czekksv884xmvanwql6sl74',
beneficiary:
'addr1q9r502tqdksvqmhs3lwlxx5f5cz0c92cftqqludl3r0urtk0ppwv8x4ylafdu84xqmh9sx4vrk4czekksv884xmvanwql6sl74',
txHash:
'475ffb1f1820eee1790729d86ced473e9f7724ddcd7bf59b477e3293415f16bf',
outputIdx: 0,
deposit: '2.000000',
status: 'canceled',
placedAt: undefined,
finalizedAt: undefined,
finalizedTxHash: null,
providerSpecifics: null,
},
],
},
},
})
const muesliApi = muesliswapApiMaker(config)
const result = await muesliApi.orders()
expect(mockFetchData).toHaveBeenCalledWith(
expect.objectContaining({
url: 'https://aggregator-v2.muesliswap.com/order_history',
method: 'get',
}),
{
params: {
user_address: 'someAddress',
numbers_have_decimals: true,
},
},
)
expect(result.tag).toBe('right')
})
it('should return error (isLeft) when the response is left', async () => {
mockFetchData.mockResolvedValueOnce({
tag: 'left',
error: {
status: 500,
message: 'Some error',
responseData: {detail: 'orderHistory error'},
},
})
const muesliApi = muesliswapApiMaker(config)
const result = await muesliApi.orders()
if (result.tag !== 'left') fail()
expect(result.tag).toBe('left')
expect(result.error.message).toContain('orderHistory error')
})
it('should return left if transformer throws an error', async () => {
mockFetchData.mockResolvedValueOnce({
tag: 'right',
value: {
status: Api.HttpStatusCode.Ok,
data: {},
},
})
const muesliApi = muesliswapApiMaker(config)
const result = await muesliApi.orders()
if (result.tag !== 'left') fail()
expect(result.tag).toBe('left')
expect(result.error.message).toBe('Failed to transform orderHistory')
})
})
describe('estimate()', () => {
it('calls /quote if wantedPrice is undefined', async () => {
mockFetchData.mockResolvedValueOnce({
tag: 'right',
value: {
status: Api.HttpStatusCode.Ok,
data: api.responses.quote,
},
})
const muesliApi = muesliswapApiMaker(config)
// has not wantedPrice
const result = await muesliApi.estimate(api.inputs.quote)
expect(mockFetchData).toHaveBeenCalledWith(
expect.objectContaining({
url: 'https://aggregator-v2.muesliswap.com/quote',
method: 'post',
data: expect.any(Object),
}),
)
expect(result.tag).toBe('right')
})
it('calls /limit_order_quote if wantedPrice is provided', async () => {
mockFetchData.mockResolvedValueOnce({
tag: 'right',
value: {
status: Api.HttpStatusCode.Ok,
data: api.responses.quote,
},
})
const muesliApi = muesliswapApiMaker(config)
// has wantedPrice
const result = await muesliApi.estimate({
slippage: 0.01,
tokenIn: '.',
tokenOut:
'af2e27f580f7f08e93190a81f72462f153026d06450924726645891b.44524950',
protocol: 'minswap-v1',
wantedPrice: 1,
amountOut: undefined,
amountIn: 1,
multiples: 1,
})
expect(mockFetchData).toHaveBeenCalledWith(
expect.objectContaining({
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
url: 'https://aggregator-v2.muesliswap.com/limit_order_quote',
method: 'post',
data: expect.any(Object),
}),
)
expect(result.tag).toBe('right')
})
it('should return error (isLeft) when the response is left', async () => {
mockFetchData.mockResolvedValueOnce({
tag: 'left',
error: {
status: 500,
message: 'Some error',
responseData: {detail: 'random error'},
},
})
const muesliApi = muesliswapApiMaker(config)
const result = await muesliApi.estimate(api.inputs.quote)
if (result.tag !== 'left') fail()
expect(result.tag).toBe('left')
expect(result.error.message).toContain('random error')
})
})
describe('create()', () => {
it('calls /order if no wantedPrice', async () => {
mockFetchData.mockResolvedValueOnce({
tag: 'right',
value: {
status: Api.HttpStatusCode.Ok,
data: api.responses.create,
},
})
const muesliApi = muesliswapApiMaker(config)
const result = await muesliApi.create(api.inputs.create[0]!)
expect(mockFetchData).toHaveBeenCalledWith(
expect.objectContaining({
url: 'https://aggregator-v2.muesliswap.com/order',
method: 'post',
data: expect.any(Object),
}),
)
expect(result.tag).toBe('right')
})
it('calls /limit_order if wantedPrice is provided', async () => {
mockFetchData.mockResolvedValueOnce({
tag: 'right',
value: {
status: Api.HttpStatusCode.Ok,
data: api.responses.createLimit,
},
})
const muesliApi = muesliswapApiMaker(config)
const result = await muesliApi.create(api.inputs.createLimit[0]!)
expect(mockFetchData).toHaveBeenCalledWith(
expect.objectContaining({
url: 'https://aggregator-v2.muesliswap.com/limit_order',
method: 'post',
data: expect.any(Object),
}),
)
expect(result.tag).toBe('right')
})
it('should return error (isLeft) when the response is left', async () => {
mockFetchData.mockResolvedValueOnce({
tag: 'left',
error: {
status: 500,
message: 'Some error',
responseData: {detail: 'random error'},
},
})
const muesliApi = muesliswapApiMaker(config)
const result = await muesliApi.create(api.inputs.createLimit[0]!)
if (result.tag !== 'left') fail()
expect(result.tag).toBe('left')
expect(result.error.message).toContain('random error')
})
})
describe('cancel()', () => {
it('calls /cancel endpoint successfully', async () => {
mockFetchData.mockResolvedValueOnce({
tag: 'right',
value: {
status: Api.HttpStatusCode.Ok,
data: api.responses.cancel,
},
})
const muesliApi = muesliswapApiMaker(config)
const result = await muesliApi.cancel(api.inputs.cancel[0]!)
expect(mockFetchData).toHaveBeenCalledWith(
expect.objectContaining({
url: 'https://aggregator-v2.muesliswap.com/cancel',
method: 'post',
data: expect.any(Object),
}),
)
expect(result.tag).toBe('right')
})
it('handles error response', async () => {
mockFetchData.mockResolvedValueOnce({
tag: 'left',
error: {
status: 500,
message: 'Cancel error',
responseData: {detail: 'could not cancel'},
},
})
const muesliApi = muesliswapApiMaker(config)
const result = await muesliApi.cancel(api.inputs.cancel[1]!)
if (result.tag !== 'left') fail()
expect(result.tag).toBe('left')
expect(result.error.message).toContain('could not cancel')
})
})
})
describe('parseMuesliError', () => {
it('parses error when responseData.detail is present', () => {
const input: Left<Api.ResponseError> = {
tag: 'left',
error: {
status: 500,
message: 'Cancel error',
responseData: {detail: 'could not cancel'},
},
}
const result = parseMuesliError(input)
expect(result.tag).toBe('left')
expect(result.error.message).toBe('could not cancel')
expect(Object.isFrozen(result)).toBe(true)
})
it('falls back to default message when responseData.detail is absent', () => {
const input = {
tag: 'left',
error: {
status: 500,
message: 'Cancel error',
},
} as Left<Api.ResponseError>
const result = parseMuesliError(input)
expect(result.tag).toBe('left')
expect(result.error.message).toBe('Muesliswap API error')
expect(Object.isFrozen(result)).toBe(true)
})
it('strips leading and trailing quotes if detail is a JSON string', () => {
const input: Left<Api.ResponseError> = {
tag: 'left',
error: {
status: 500,
message: 'Cancel error',
responseData: {detail: 'could not "cancel"'},
},
}
const result = parseMuesliError(input)
expect(result.error.message).toBe('could not \\"cancel\\"')
expect(Object.isFrozen(result)).toBe(true)
})
})