@ledgerhq/coin-stellar
Version:
Ledger Stellar Coin integration
277 lines (230 loc) • 10 kB
text/typescript
// SPDX-FileCopyrightText: © 2026 LEDGER SAS
// SPDX-License-Identifier: Apache-2.0
/**
* Unit tests for registerHorizonInterceptors.
*
* Each test uses jest.isolateModules so that:
* - the `interceptorsRegistered` flag inside horizon.ts starts at `false`
* - `coinConfig` and `Horizon.AxiosClient` are fresh instances shared consistently
* within the isolated module registry (stellar-sdk is re-resolved but cached for
* all requires inside the same isolateModules block)
*/
import type { StellarCoinConfig } from '../config'
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
type IsolatedHorizon = typeof import('@stellar/stellar-sdk')
type IsolatedHorizonModule = typeof import('./horizon')
type IsolatedConfigModule = typeof import('../config')
function isolatedRequire(): {
Horizon: IsolatedHorizon['Horizon']
registerHorizonInterceptors: IsolatedHorizonModule['registerHorizonInterceptors']
coinConfig: IsolatedConfigModule['default']
} {
// `require` here runs inside the jest.isolateModules callback supplied by each test
/* eslint-disable @typescript-eslint/no-require-imports */
const { Horizon } = require('@stellar/stellar-sdk') as IsolatedHorizon
const { registerHorizonInterceptors } = require('./horizon') as IsolatedHorizonModule
const coinConfig = (require('../config') as IsolatedConfigModule).default
/* eslint-enable @typescript-eslint/no-require-imports */
return { Horizon, registerHorizonInterceptors, coinConfig }
}
/** Returns the last element of an array without relying on Array.prototype.at (es2022). */
function last<T>(arr: T[]): T | undefined {
return arr[arr.length - 1]
}
/** Builds a minimal HttpClientResponse-compatible mock object. */
function makeResponse(overrides: { status?: number; config?: object; data?: unknown } = {}) {
return {
status: 200,
statusText: 'OK',
headers: {},
config: {},
data: {},
...overrides,
}
}
function makeConfig(overrides: Partial<StellarCoinConfig> = {}): StellarCoinConfig {
return {
status: { type: 'active' },
explorer: { url: 'https://horizon.test.invalid' },
...overrides,
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('registerHorizonInterceptors', () => {
describe('idempotency', () => {
it('registers each interceptor exactly once even when called multiple times', () => {
jest.isolateModules(() => {
const { Horizon, registerHorizonInterceptors } = isolatedRequire()
const reqSpy = jest.spyOn(Horizon.AxiosClient.interceptors.request, 'use')
const resSpy = jest.spyOn(Horizon.AxiosClient.interceptors.response, 'use')
registerHorizonInterceptors()
registerHorizonInterceptors()
registerHorizonInterceptors()
expect(reqSpy).toHaveBeenCalledTimes(1)
expect(resSpy).toHaveBeenCalledTimes(1)
reqSpy.mockRestore()
resSpy.mockRestore()
})
})
it('does not throw when called multiple times', () => {
jest.isolateModules(() => {
const { registerHorizonInterceptors } = isolatedRequire()
expect(() => {
registerHorizonInterceptors()
registerHorizonInterceptors()
}).not.toThrow()
})
})
})
describe('resilience when coinConfig is not initialised', () => {
it('registerHorizonInterceptors itself does not throw without coinConfig', () => {
jest.isolateModules(() => {
const { registerHorizonInterceptors } = isolatedRequire()
expect(() => registerHorizonInterceptors()).not.toThrow()
})
})
it('request interceptor returns config unchanged and does not throw', () => {
jest.isolateModules(() => {
const { Horizon, registerHorizonInterceptors } = isolatedRequire()
registerHorizonInterceptors()
const handler = last(Horizon.AxiosClient.interceptors.request.handlers)
expect(handler).toEqual(expect.objectContaining({ fulfilled: expect.any(Function) }))
const mockConfig = {
url: '/accounts/G123',
method: 'GET',
baseURL: 'https://horizon.test.invalid',
}
expect(() => handler!.fulfilled(mockConfig)).not.toThrow()
expect(handler!.fulfilled(mockConfig)).toBe(mockConfig)
})
})
it('response interceptor returns response unchanged and does not throw', () => {
jest.isolateModules(() => {
const { Horizon, registerHorizonInterceptors } = isolatedRequire()
registerHorizonInterceptors()
const handler = last(Horizon.AxiosClient.interceptors.response.handlers)
expect(handler).toEqual(expect.objectContaining({ fulfilled: expect.any(Function) }))
const mockResponse = makeResponse()
expect(() => handler!.fulfilled(mockResponse)).not.toThrow()
expect(handler!.fulfilled(mockResponse)).toBe(mockResponse)
})
})
})
describe('URL rewriting (always active, independent of logging)', () => {
it('rewrites _links.next.href to use the configured host', () => {
jest.isolateModules(() => {
const { Horizon, registerHorizonInterceptors, coinConfig } = isolatedRequire()
coinConfig.setCoinConfig(() => makeConfig())
registerHorizonInterceptors()
const handler = last(Horizon.AxiosClient.interceptors.response.handlers)
const data = {
_links: {
next: { href: 'https://some-internal-node.stellar.org/operations?cursor=123' },
},
}
handler!.fulfilled(makeResponse({ data }))
expect(data._links.next.href).toBe('https://horizon.test.invalid/operations?cursor=123')
})
})
it('rewrites _embedded.records[].transaction._links.ledger.href to use the configured host', () => {
jest.isolateModules(() => {
const { Horizon, registerHorizonInterceptors, coinConfig } = isolatedRequire()
coinConfig.setCoinConfig(() => makeConfig())
registerHorizonInterceptors()
const handler = last(Horizon.AxiosClient.interceptors.response.handlers)
const data = {
_embedded: {
records: [
{
transaction: {
_links: { ledger: { href: 'https://some-internal-node.stellar.org/ledgers/55' } },
},
},
{
transaction: {
_links: { ledger: { href: 'https://some-internal-node.stellar.org/ledgers/56' } },
},
},
],
},
}
handler!.fulfilled(makeResponse({ data }))
expect(data._embedded.records[0].transaction._links.ledger.href).toBe(
'https://horizon.test.invalid/ledgers/55'
)
expect(data._embedded.records[1].transaction._links.ledger.href).toBe(
'https://horizon.test.invalid/ledgers/56'
)
})
})
})
describe('network logging (gated on enableNetworkLogs)', () => {
it('emits network log for requests when enableNetworkLogs is true', () => {
jest.isolateModules(() => {
const logMock = jest.fn()
jest.mock('@ledgerhq/logs', () => ({ log: logMock }))
const { Horizon, registerHorizonInterceptors, coinConfig } = isolatedRequire()
coinConfig.setCoinConfig(() => makeConfig({ enableNetworkLogs: true }))
registerHorizonInterceptors()
const handler = last(Horizon.AxiosClient.interceptors.request.handlers)
handler!.fulfilled({
url: '/accounts/G123',
method: 'GET',
baseURL: 'https://horizon.test.invalid',
})
expect(logMock).toHaveBeenCalledWith(
'network',
expect.stringContaining('/accounts/G123'),
expect.anything()
)
})
})
it('emits network-success log for responses with elapsed time when enableNetworkLogs is true', () => {
jest.isolateModules(() => {
const logMock = jest.fn()
jest.mock('@ledgerhq/logs', () => ({ log: logMock }))
const { Horizon, registerHorizonInterceptors, coinConfig } = isolatedRequire()
coinConfig.setCoinConfig(() => makeConfig({ enableNetworkLogs: true }))
registerHorizonInterceptors()
const reqHandler = last(Horizon.AxiosClient.interceptors.request.handlers)
const resHandler = last(Horizon.AxiosClient.interceptors.response.handlers)
const config = {
url: '/accounts/G123',
method: 'GET',
baseURL: 'https://horizon.test.invalid',
}
reqHandler!.fulfilled(config)
const mockResponse = makeResponse({ status: 200, config })
resHandler!.fulfilled(mockResponse)
expect(logMock).toHaveBeenCalledWith(
'network-success',
expect.stringMatching(/200 GET.*\/accounts\/G123.*\d+ms/),
expect.anything()
)
})
})
it('does not emit any log when enableNetworkLogs is false', () => {
jest.isolateModules(() => {
const logMock = jest.fn()
jest.mock('@ledgerhq/logs', () => ({ log: logMock }))
const { Horizon, registerHorizonInterceptors, coinConfig } = isolatedRequire()
coinConfig.setCoinConfig(() => makeConfig({ enableNetworkLogs: false }))
registerHorizonInterceptors()
const reqHandler = last(Horizon.AxiosClient.interceptors.request.handlers)
const resHandler = last(Horizon.AxiosClient.interceptors.response.handlers)
const config = {
url: '/accounts/G123',
method: 'GET',
baseURL: 'https://horizon.test.invalid',
}
reqHandler!.fulfilled(config)
resHandler!.fulfilled(makeResponse({ config }))
expect(logMock).not.toHaveBeenCalled()
})
})
})
})