// SPDX-FileCopyrightText: © 2024 LEDGER SAS // SPDX-License-Identifier: Apache-2.0 import type { CurrencyConfig, Context } from '../../config' import type { CoinModuleImpl } from '../impl' import type { BlockInfo, ListOperationsOptions, TransactionIntent } from '../types' import { withDefaults } from '../withDefaults' import { withLogging } from './withLogging' type Cfg = CurrencyConfig type Ctx = Context const block: BlockInfo = { height: 1, hash: '0xabc', time: new Date(0) } const ctx: Ctx = { config: () => Promise.reject(new Error('stub')), logger: () => undefined, } const minimalModule: CoinModuleImpl = { lastBlock: (_ctx: Ctx): Promise => Promise.reject(new Error('stub')), getBalance: (_ctx: Ctx, _addr: string) => Promise.resolve([]), listOperations: (_ctx: Ctx, _addr: string, _opts: ListOperationsOptions) => Promise.resolve({ items: [] }), craftTransaction: (_ctx: Ctx, _intent: TransactionIntent) => Promise.reject(new Error('stub')), estimateFees: (_ctx: Ctx, _intent: TransactionIntent) => Promise.reject(new Error('stub')), combine: (_ctx: Ctx, tx: string, _sig: string[]): string => tx, broadcast: (_ctx: Ctx, _tx: string) => Promise.reject(new Error('stub')), craftTransactionData: (_ctx: Ctx, _intent: TransactionIntent) => ({ type: 'none' }) as const, } describe('withLogging', () => { it('logs call and ok on a successful async method', async () => { const logger = jest.fn() const logCtx: Ctx = { ...ctx, logger } const impl: CoinModuleImpl = { ...minimalModule, lastBlock: (_c: Ctx) => Promise.resolve(block), } const logged = withLogging(withDefaults(impl)) await logged.lastBlock(logCtx) expect(logger).toHaveBeenNthCalledWith(1, '[coin-module] lastBlock: call') expect(logger).toHaveBeenNthCalledWith(2, '[coin-module] lastBlock: ok') }) it('logs call and error on a rejected async method and re-throws', async () => { const logger = jest.fn() const logCtx: Ctx = { ...ctx, logger } const error = new Error('net-fail') const impl: CoinModuleImpl = { ...minimalModule, lastBlock: (_c: Ctx) => Promise.reject(error), } const logged = withLogging(withDefaults(impl)) await expect(logged.lastBlock(logCtx)).rejects.toThrow(error) expect(logger).toHaveBeenNthCalledWith(1, '[coin-module] lastBlock: call') expect(logger).toHaveBeenNthCalledWith(2, '[coin-module] lastBlock: error', error) }) it('logs call and ok on a synchronous method', () => { const logger = jest.fn() const logCtx: Ctx = { ...ctx, logger } const logged = withLogging(withDefaults(minimalModule)) const result = logged.combine(logCtx, 'tx', []) expect(result).toBe('tx') expect(logger).toHaveBeenNthCalledWith(1, '[coin-module] combine: call') expect(logger).toHaveBeenNthCalledWith(2, '[coin-module] combine: ok') }) it('logs call and error on a synchronous throw and re-throws', () => { const logger = jest.fn() const logCtx: Ctx = { ...ctx, logger } const error = new Error('sync-fail') const impl: CoinModuleImpl = { ...minimalModule, combine: (_c: Ctx, _tx: string, _sig: string[]): string => { throw error }, } const logged = withLogging(withDefaults(impl)) expect(() => logged.combine(logCtx, 'tx', [])).toThrow(error) expect(logger).toHaveBeenNthCalledWith(1, '[coin-module] combine: call') expect(logger).toHaveBeenNthCalledWith(2, '[coin-module] combine: error', error) }) it('forwards the context object unchanged to the underlying method', async () => { let capturedCtx: unknown const logger = jest.fn() const logCtx: Ctx = { ...ctx, logger } const impl: CoinModuleImpl = { ...minimalModule, lastBlock: (c: Ctx) => { capturedCtx = c return Promise.resolve(block) }, } const logged = withLogging(withDefaults(impl)) await logged.lastBlock(logCtx) expect(capturedCtx).toBe(logCtx) }) it('applies logging twice when composed withLogging(withLogging(api))', async () => { const logger = jest.fn() const logCtx: Ctx = { ...ctx, logger } const impl: CoinModuleImpl = { ...minimalModule, lastBlock: (_c: Ctx) => Promise.resolve(block), } const doubly = withLogging(withLogging(withDefaults(impl))) await doubly.lastBlock(logCtx) // Each layer emits call + ok: 4 total calls expect(logger).toHaveBeenCalledTimes(4) expect(logger.mock.calls[0][0]).toBe('[coin-module] lastBlock: call') expect(logger.mock.calls[3][0]).toBe('[coin-module] lastBlock: ok') }) // The consumer-side shape this whole chain exists for: Ledger Live's resolve point hands out // `CoinModuleApi & BridgeApi`, so the members that are not part of the API must survive // withDefaults + withLogging — at runtime AND in the type. `bridgeOnly` below is only reachable // if both wrappers preserve the extra half, so this test doubles as a type-level guard: an // erasing signature makes it a compile error, not a silent behavior change. it('preserves non-API members through withDefaults + withLogging (consumer intersection)', async () => { const logger = jest.fn() const logCtx: Ctx = { ...ctx, logger } const impl = { ...minimalModule, lastBlock: (_c: Ctx) => Promise.resolve(block), bridgeOnly: (label: string): string => `bridge:${label}`, } const wired = withLogging(withDefaults(impl)) expect(wired.bridgeOnly('x')).toBe('bridge:x') await wired.lastBlock(logCtx) expect(logger).toHaveBeenNthCalledWith(1, '[coin-module] lastBlock: call') }) })