UNPKG

@ledgerhq/coin-stellar

Version:
277 lines (230 loc) 10 kB
// 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() }) }) }) })