// 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` * - `Horizon.AxiosClient` is a fresh instance shared consistently within the isolated * module registry (stellar-sdk is re-resolved but cached for all requires inside the * same isolateModules block) * * Since ADR-019, the config and logger are provided via the {@link StellarContext} passed to * `registerHorizonInterceptors`; there is no module-level singleton to initialise, and logging * goes through `context.logger` rather than the global `@ledgerhq/logs` `log`. */ import type { StellarCoinConfig, StellarContext } from '../../config' import type * as HorizonModule from '../horizon' import type { Logger } from '@ledgerhq/coin-module-framework/config' import type * as StellarSdk from '@stellar/stellar-sdk' // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- type IsolatedHorizon = typeof StellarSdk type IsolatedHorizonModule = typeof HorizonModule function isolatedRequire(): { Horizon: IsolatedHorizon['Horizon'] registerHorizonInterceptors: IsolatedHorizonModule['registerHorizonInterceptors'] } { // `require` here runs inside the jest.isolateModules callback supplied by each test /* eslint-disable @typescript-eslint/no-require-imports */ const stellarSdkModule: IsolatedHorizon = require('@stellar/stellar-sdk') const { Horizon } = stellarSdkModule const horizonModule: IsolatedHorizonModule = require('../horizon') const { registerHorizonInterceptors } = horizonModule /* eslint-enable @typescript-eslint/no-require-imports */ return { Horizon, registerHorizonInterceptors } } /** Returns the last element of an array without relying on Array.prototype.at (es2022). */ function last(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 { return { status: { type: 'active' }, name: 'Stellar', unit: { name: 'Lumen', code: 'XLM', magnitude: 7 }, explorer: { url: 'https://horizon.test.invalid' }, ...overrides, } } function makeContext( overrides: Partial = {}, logger: Logger = jest.fn() ): StellarContext { return { config: async () => makeConfig(overrides), logger, } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe('registerHorizonInterceptors', () => { describe('idempotency', () => { it('registers each interceptor exactly once even when called multiple times', async () => { await jest.isolateModulesAsync(async () => { const { Horizon, registerHorizonInterceptors } = isolatedRequire() const context = makeContext() const reqSpy = jest.spyOn(Horizon.AxiosClient.interceptors.request, 'use') const resSpy = jest.spyOn(Horizon.AxiosClient.interceptors.response, 'use') registerHorizonInterceptors(context) registerHorizonInterceptors(context) registerHorizonInterceptors(context) expect(reqSpy).toHaveBeenCalledTimes(1) expect(resSpy).toHaveBeenCalledTimes(1) reqSpy.mockRestore() resSpy.mockRestore() }) }) it('does not throw when called multiple times', async () => { await jest.isolateModulesAsync(async () => { const { registerHorizonInterceptors } = isolatedRequire() const context = makeContext() expect(() => { registerHorizonInterceptors(context) registerHorizonInterceptors(context) }).not.toThrow() }) }) }) describe('pass-through behaviour when logging disabled and no links', () => { it('registerHorizonInterceptors itself does not throw', async () => { await jest.isolateModulesAsync(async () => { const { registerHorizonInterceptors } = isolatedRequire() expect(() => registerHorizonInterceptors(makeContext())).not.toThrow() }) }) it('request interceptor returns config unchanged and does not throw', async () => { await jest.isolateModulesAsync(async () => { const { Horizon, registerHorizonInterceptors } = isolatedRequire() registerHorizonInterceptors(makeContext()) 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', } await expect(handler!.fulfilled(mockConfig)).resolves.toBe(mockConfig) }) }) it('response interceptor returns response unchanged and does not throw', async () => { await jest.isolateModulesAsync(async () => { const { Horizon, registerHorizonInterceptors } = isolatedRequire() registerHorizonInterceptors(makeContext()) const handler = last(Horizon.AxiosClient.interceptors.response.handlers) expect(handler).toEqual(expect.objectContaining({ fulfilled: expect.any(Function) })) const mockResponse = makeResponse() await expect(handler!.fulfilled(mockResponse)).resolves.toBe(mockResponse) }) }) }) describe('URL rewriting (always active, independent of logging)', () => { it('rewrites _links.next.href to use the configured host', async () => { await jest.isolateModulesAsync(async () => { const { Horizon, registerHorizonInterceptors } = isolatedRequire() registerHorizonInterceptors(makeContext()) const handler = last(Horizon.AxiosClient.interceptors.response.handlers) const data = { _links: { next: { href: 'https://some-internal-node.stellar.org/operations?cursor=123' }, }, } await 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', async () => { await jest.isolateModulesAsync(async () => { const { Horizon, registerHorizonInterceptors } = isolatedRequire() registerHorizonInterceptors(makeContext()) 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' } }, }, }, ], }, } await 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', async () => { await jest.isolateModulesAsync(async () => { const logger = jest.fn() const { Horizon, registerHorizonInterceptors } = isolatedRequire() registerHorizonInterceptors(makeContext({ enableNetworkLogs: true }, logger)) const handler = last(Horizon.AxiosClient.interceptors.request.handlers) await handler!.fulfilled({ url: '/accounts/G123', method: 'GET', baseURL: 'https://horizon.test.invalid', }) expect(logger).toHaveBeenCalledWith( 'network', expect.stringContaining('/accounts/G123'), expect.anything() ) }) }) it('emits network-success log for responses with elapsed time when enableNetworkLogs is true', async () => { await jest.isolateModulesAsync(async () => { const logger = jest.fn() const { Horizon, registerHorizonInterceptors } = isolatedRequire() registerHorizonInterceptors(makeContext({ enableNetworkLogs: true }, logger)) 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', } await reqHandler!.fulfilled(config) const mockResponse = makeResponse({ status: 200, config }) await resHandler!.fulfilled(mockResponse) expect(logger).toHaveBeenCalledWith( 'network-success', expect.stringMatching(/200 GET.*\/accounts\/G123.*\d+ms/), expect.anything() ) }) }) it('does not emit any log when enableNetworkLogs is false', async () => { await jest.isolateModulesAsync(async () => { const logger = jest.fn() const { Horizon, registerHorizonInterceptors } = isolatedRequire() registerHorizonInterceptors(makeContext({ enableNetworkLogs: false }, logger)) 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', } await reqHandler!.fulfilled(config) await resHandler!.fulfilled(makeResponse({ config })) expect(logger).not.toHaveBeenCalled() }) }) }) })