diff --git a/src/graphql/public/root/mutation/ln-invoice-payment-send.ts b/src/graphql/public/root/mutation/ln-invoice-payment-send.ts index 3f9763cfb..ba46f5b88 100644 --- a/src/graphql/public/root/mutation/ln-invoice-payment-send.ts +++ b/src/graphql/public/root/mutation/ln-invoice-payment-send.ts @@ -10,7 +10,7 @@ import dedent from "dedent" // FLASH FORK: import ibex dependencies import { PaymentSendStatus } from "@domain/bitcoin/lightning" import Ibex from "@services/ibex/client" -import { IbexError } from "@services/ibex/errors" +import { IbexError, InsufficientIbexBalance } from "@services/ibex/errors" import { withPaymentIdempotency } from "@app/payments/idempotency" const LnInvoicePaymentInput = GT.Input({ @@ -129,7 +129,14 @@ const LnInvoicePaymentSendMutation = GT.Field< // } // } - // Preserve the existing generic IBEX-failure message. + // Insufficient balance is actionable for the caller — surface the typed + // INSUFFICIENT_BALANCE error via the error map instead of the generic + // catch-all below (issue #93). + if (status instanceof InsufficientIbexBalance) { + return { status: "failed", errors: [mapAndParseErrorForGqlResponse(status)] } + } + + // Preserve the existing generic IBEX-failure message for other IBEX errors. if (status instanceof IbexError) { return { status: "failed", diff --git a/src/services/ibex/client.ts b/src/services/ibex/client.ts index cf5944c9d..90819695c 100644 --- a/src/services/ibex/client.ts +++ b/src/services/ibex/client.ts @@ -50,7 +50,13 @@ import { UsdWalletAmount, } from "./types" -import { errorHandler, IbexError, ParseError, UnexpectedIbexResponse } from "./errors" +import { + errorHandler, + httpErrorHandler, + IbexError, + ParseError, + UnexpectedIbexResponse, +} from "./errors" import { ibexWebhookEndpoints, ibexWebhookSecret } from "./webhook-config" const Ibex = new IbexClient( @@ -241,7 +247,15 @@ const payInvoice = async ( webhookSecret: ibexWebhookSecret, } as PayInvoiceV2BodyParam addAttributesToCurrentSpan({ "request.params": JSON.stringify(bodyWithHooks) }) - return Ibex.payInvoiceV2(bodyWithHooks).then(errorHandler) + // Call the generated SDK through withAuth directly (instead of + // Ibex.payInvoiceV2) so a failed payment's FetchError — which carries the + // parsed IBEX error body on `.data` — reaches httpErrorHandler intact. + // ibex-client@3.2.0's own ApiError wrapper discards that body, which is what + // made "insufficient balance" 400s unclassifiable (lnflash/ibex-client#6). + return Ibex.authentication + .withAuth(() => Ibex.ibex.payInvoiceV2(bodyWithHooks)) + .then(errorHandler) + .catch(httpErrorHandler) } // onchain transactions are typically high-value diff --git a/src/services/ibex/errors.ts b/src/services/ibex/errors.ts index c34d6c9be..d6c5c42e2 100644 --- a/src/services/ibex/errors.ts +++ b/src/services/ibex/errors.ts @@ -27,17 +27,113 @@ export class UnexpectedIbexResponse extends IbexError { export class ParseError extends IbexError {} -export class InsufficientIbexBalance extends IbexError {} +export class InsufficientIbexBalance extends IbexError { + // Full vendor detail, preserved verbatim for logs/spans (pino's error + // serializer picks up own enumerable properties). + readonly detail?: string + + constructor(err: Error, level: ErrorLevel = ErrorLevel.Info, detail?: string) { + super(err, level) + this.detail = detail + // error-map forwards `message` to the GraphQL client verbatim (the + // INSUFFICIENT_BALANCE case sets message = error.message). Keep it the + // human-readable IBEX detail — never the wrapped error's stack trace — + // but strip the trailing internal IBEX account UUID ("... account: "): + // end users must never see vendor-internal identifiers on the failure + // screen. The unstripped detail stays on `this.detail`. + this.message = + detail?.replace(/[.,]?\s*account:\s*\S+\s*$/i, "") ?? "insufficient balance" + } +} export class CompletedInvoice extends IbexError {} +/** + * Best-effort extraction of the IBEX error text from a failed call. + * Shapes handled: + * - ibex-client > 3.2.0 (lnflash/ibex-client#6): ApiError carries the parsed + * body on `ibexResponse` and the extracted text on `ibexMessage` + * - the generated api SDK's FetchError: parsed JSON body on `.data`, + * typically `{ "error": "..." }`, sometimes `{ "message": "..." }` or text + */ +export const ibexErrorDetail = (e: unknown): string | undefined => { + if (typeof e !== "object" || e === null) return undefined + const { ibexMessage, data } = e as { ibexMessage?: unknown; data?: unknown } + if (typeof ibexMessage === "string" && ibexMessage !== "") return ibexMessage + if (typeof data === "string" && data !== "") return data + if (typeof data === "object" && data !== null) { + const record = data as Record + if (typeof record.error === "string" && record.error !== "") return record.error + if (typeof record.message === "string" && record.message !== "") return record.message + } + return undefined +} + +// The single needle list mapping IBEX error prose to a typed error class. +// Matching is case-insensitive (the haystack is lowercased once here) so a +// vendor rewording like "Insufficient Balance" cannot silently revert +// classification to the generic path. Both errorHandler and httpErrorHandler +// classify through this helper — never add a needle anywhere else. +const classifyIbexErrorText = ( + text: string, +): typeof InsufficientIbexBalance | typeof CompletedInvoice | undefined => { + const haystack = text.toLowerCase() + if (haystack.includes("insufficient balance")) return InsufficientIbexBalance + if (haystack.includes("payment already prepared")) return CompletedInvoice + return undefined +} + export const errorHandler = ( e: T | IbexClientError | AuthenticationError | ApiError, ): T | IbexError => { if (e instanceof AuthenticationError) return new IbexError(e, ErrorLevel.Critical) - else if (e instanceof ApiError && e.message.includes("insufficient balance")) - return new InsufficientIbexBalance(e, ErrorLevel.Info) - else if (e instanceof ApiError && e.message.includes("payment already prepared")) - return new CompletedInvoice(e, ErrorLevel.Info) - else if (e instanceof IbexClientError) return new IbexError(e, ErrorLevel.Warn) - else return e + if (e instanceof ApiError) { + // Classify against the structured body detail when the error carries one, + // and against `message` otherwise (flash's raw-fetch path embeds the body + // text in the message; ibex-client@3.2.0's ApiError message is only the + // wrapped stack, which is why body-carrying shapes are checked first). + const detail = ibexErrorDetail(e) + const classified = classifyIbexErrorText(detail ?? e.message) + if (classified === InsufficientIbexBalance) + return new InsufficientIbexBalance(e, ErrorLevel.Info, detail) + if (classified === CompletedInvoice) return new CompletedInvoice(e, ErrorLevel.Info) + // Unclassified path: mirror httpErrorHandler's carry — an unrecognized + // IBEX 400 whose ApiError has a stack-only message must still log what + // IBEX actually said. Build a new IbexError rather than mutating `e`, + // which the caller may still hold. + if (detail !== undefined) { + const generic = new IbexError(e, ErrorLevel.Warn) + generic.message = `${detail}\n${generic.message}` + return generic + } + } + if (e instanceof IbexClientError) return new IbexError(e, ErrorLevel.Warn) + return e +} + +/** + * Classify a raw error thrown by the generated IBEX SDK (or fetch) before + * ibex-client's ApiError wrapper can discard the response body. With the + * pinned ibex-client@3.2.0, ApiError keeps only `httpCode` — the JSON error + * body that distinguishes e.g. "insufficient balance" from any other 400 only + * exists on the underlying FetchError's `.data` (lnflash/ibex-client#6). + * Call sites that need body-level classification invoke the SDK through + * `Ibex.authentication.withAuth` themselves and route the caught error here. + */ +export const httpErrorHandler = (e: unknown): IbexError => { + const raw = e instanceof Error ? e : new Error(String(e)) + if (raw instanceof AuthenticationError) return new IbexError(raw, ErrorLevel.Critical) + const detail = ibexErrorDetail(raw) + // ApiError's constructor keeps `.status` as httpCode, which IbexError reads. + const wrapped = raw instanceof IbexClientError ? raw : new ApiError(raw) + const classified = classifyIbexErrorText(detail ?? raw.message) + if (classified === InsufficientIbexBalance) + return new InsufficientIbexBalance(wrapped, ErrorLevel.Info, detail) + if (classified === CompletedInvoice) + return new CompletedInvoice(wrapped, ErrorLevel.Info) + // Unclassified path: ApiError's message is only the wrapped stack, so carry + // the extracted body detail into it — an unrecognized IBEX 400 must still + // log what IBEX actually said, not just "FetchError: Bad Request" + stack. + if (detail !== undefined && !(raw instanceof IbexClientError)) + wrapped.message = `${detail}\n${wrapped.message}` + return new IbexError(wrapped, ErrorLevel.Warn) } diff --git a/test/flash/unit/graphql/error-map.spec.ts b/test/flash/unit/graphql/error-map.spec.ts index 4cdb7c3ff..988dd6aa5 100644 --- a/test/flash/unit/graphql/error-map.spec.ts +++ b/test/flash/unit/graphql/error-map.spec.ts @@ -1,10 +1,12 @@ -import { mapError } from "@graphql/error-map" +import { ErrorLevel } from "@domain/shared" +import { mapAndParseErrorForGqlResponse, mapError } from "@graphql/error-map" import { PhoneAccountAlreadyExistsCannotUpgradeError } from "@services/kratos" import { BridgeWithdrawalNotFoundError, BridgeWithdrawalAlreadyInitiatedError, BridgeDepositInstructionsMissingError, } from "@services/bridge/errors" +import { IbexError, InsufficientIbexBalance } from "@services/ibex/errors" describe("error-map", () => { it("maps BridgeWithdrawalNotFoundError to BRIDGE_WITHDRAWAL_NOT_FOUND", () => { @@ -36,4 +38,58 @@ describe("error-map", () => { expect(result.message).toContain("already registered") expect(result.extensions.code).toBe("PHONE_ALREADY_REGISTERED_TO_ANOTHER_USER") }) + + describe("IBEX payment errors (issue #93)", () => { + const insufficientDetail = + "insufficient balance. Current Balance: 5.000000. Estimated Fee: 0.001109. invoice amount: 5.042164. account: 39c6e986-979b-40ab-9e7b-df18a9277a84" + // client-facing message strips IBEX's trailing internal account UUID + const insufficientDetailStripped = + "insufficient balance. Current Balance: 5.000000. Estimated Fee: 0.001109. invoice amount: 5.042164" + + it("maps InsufficientIbexBalance to INSUFFICIENT_BALANCE with the IBEX detail", () => { + const input = new InsufficientIbexBalance( + new Error("Bad Request"), + ErrorLevel.Info, + insufficientDetail, + ) + const result = mapError(input) + + expect(result.extensions.code).toBe("INSUFFICIENT_BALANCE") + expect(result.message).toBe(insufficientDetailStripped) + // never the internal IBEX account UUID + expect(result.message).not.toContain("account:") + expect(result.message).not.toContain("39c6e986-979b-40ab-9e7b-df18a9277a84") + }) + + it("maps InsufficientIbexBalance without detail to a clean fallback message", () => { + const input = new InsufficientIbexBalance(new Error("Bad Request")) + const result = mapError(input) + + expect(result.extensions.code).toBe("INSUFFICIENT_BALANCE") + expect(result.message).toBe("insufficient balance") + // never a stack trace + expect(result.message).not.toContain(" at ") + }) + + it("surfaces INSUFFICIENT_BALANCE through mapAndParseErrorForGqlResponse", () => { + const input = new InsufficientIbexBalance( + new Error("Bad Request"), + ErrorLevel.Info, + insufficientDetail, + ) + const result = mapAndParseErrorForGqlResponse(input) + + expect(result).toMatchObject({ + code: "INSUFFICIENT_BALANCE", + message: insufficientDetailStripped, + }) + }) + + it("keeps other IBEX errors mapped to the generic IBEX_ERROR", () => { + const result = mapError(new IbexError(new Error("some other 400"))) + + expect(result.extensions.code).toBe("IBEX_ERROR") + expect(result.message).toContain("An error occurred") + }) + }) }) diff --git a/test/flash/unit/graphql/ln-invoice-payment-send.spec.ts b/test/flash/unit/graphql/ln-invoice-payment-send.spec.ts new file mode 100644 index 000000000..e681ee0e9 --- /dev/null +++ b/test/flash/unit/graphql/ln-invoice-payment-send.spec.ts @@ -0,0 +1,90 @@ +const mockPayInvoice = jest.fn() + +jest.mock("@services/ibex/client", () => ({ + __esModule: true, + default: { payInvoice: (...args: unknown[]) => mockPayInvoice(...args) }, +})) + +// Run the resolver's execute() directly — idempotency plumbing is not under test +jest.mock("@app/payments/idempotency", () => ({ + withPaymentIdempotency: async ({ execute }: { execute: () => Promise }) => + execute(), +})) + +import { ErrorLevel } from "@domain/shared" +import LnInvoicePaymentSendMutation from "@graphql/public/root/mutation/ln-invoice-payment-send" +import { IbexError, InsufficientIbexBalance } from "@services/ibex/errors" + +const insufficientDetail = + "insufficient balance. Current Balance: 5.000000. Estimated Fee: 0.001109. invoice amount: 5.042164. account: 39c6e986-979b-40ab-9e7b-df18a9277a84" +// client-facing message strips IBEX's trailing internal account UUID +const insufficientDetailStripped = + "insufficient balance. Current Balance: 5.000000. Estimated Fee: 0.001109. invoice amount: 5.042164" + +type PaymentSendResult = { + status?: string + errors: { message: string; code?: string }[] +} + +const resolvePayment = async (): Promise => { + const resolve = LnInvoicePaymentSendMutation.resolve as unknown as ( + source: null, + args: { input: Record }, + ctx: { domainAccount: Record }, + ) => Promise + + return resolve( + null, + { input: { walletId: "wallet-1", paymentRequest: "lnbc1" } }, + { domainAccount: { id: "account-1" } }, + ) +} + +describe("lnInvoicePaymentSend IBEX error surfacing (issue #93)", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it("returns a typed INSUFFICIENT_BALANCE error for insufficient-balance failures", async () => { + mockPayInvoice.mockResolvedValue( + new InsufficientIbexBalance( + new Error("Bad Request"), + ErrorLevel.Info, + insufficientDetail, + ), + ) + + const result = await resolvePayment() + + expect(result.status).toBe("failed") + expect(result.errors[0]).toMatchObject({ + code: "INSUFFICIENT_BALANCE", + message: insufficientDetailStripped, + }) + // never the internal IBEX account UUID + expect(result.errors[0].message).not.toContain("account:") + }) + + it("keeps the generic message for other IBEX failures", async () => { + mockPayInvoice.mockResolvedValue(new IbexError(new Error("some other 400"))) + + const result = await resolvePayment() + + expect(result.status).toBe("failed") + expect(result.errors[0].message).toBe( + "An unexpected error occurred. Please try again later.", + ) + expect(result.errors[0].code).toBeUndefined() + }) + + it("returns success for a settled payment", async () => { + mockPayInvoice.mockResolvedValue({ + transaction: { payment: { status: { id: 2 } } }, + }) + + const result = await resolvePayment() + + expect(result.status).toBe("success") + expect(result.errors).toEqual([]) + }) +}) diff --git a/test/flash/unit/services/ibex/errors.spec.ts b/test/flash/unit/services/ibex/errors.spec.ts new file mode 100644 index 000000000..fa9283bb4 --- /dev/null +++ b/test/flash/unit/services/ibex/errors.spec.ts @@ -0,0 +1,248 @@ +import { ErrorLevel } from "@domain/shared" +import { ApiError, AuthenticationError } from "ibex-client" + +import { + CompletedInvoice, + errorHandler, + httpErrorHandler, + IbexError, + ibexErrorDetail, + InsufficientIbexBalance, +} from "@services/ibex/errors" + +const insufficientDetail = + "insufficient balance. Current Balance: 5.000000. Estimated Fee: 0.001109. invoice amount: 5.042164. account: 39c6e986-979b-40ab-9e7b-df18a9277a84" + +// The client-facing message must strip IBEX's trailing internal account UUID. +const insufficientDetailStripped = + "insufficient balance. Current Balance: 5.000000. Estimated Fee: 0.001109. invoice amount: 5.042164" + +// The generated api SDK throws FetchError: message = statusText, `.status` = +// HTTP status, `.data` = parsed response body. Reproduce that shape without +// depending on the api package internals. +const fetchErrorShaped = (status: number, data: unknown): Error => { + const err = new Error("Bad Request") as Error & { status: number; data: unknown } + err.name = "FetchError" + err.status = status + err.data = data + return err +} + +describe("ibexErrorDetail", () => { + it("extracts the `error` field from a FetchError-shaped body", () => { + const err = fetchErrorShaped(400, { error: insufficientDetail }) + expect(ibexErrorDetail(err)).toBe(insufficientDetail) + }) + + it("falls back to the `message` field", () => { + const err = fetchErrorShaped(400, { message: "invalid invoice" }) + expect(ibexErrorDetail(err)).toBe("invalid invoice") + }) + + it("returns a plain-text (non-JSON) body verbatim", () => { + const err = fetchErrorShaped(400, "upstream said no") + expect(ibexErrorDetail(err)).toBe("upstream said no") + }) + + it("prefers a structured ibexMessage (ibex-client > 3.2.0)", () => { + const apiErr = new ApiError(new Error("Bad Request")) + Object.assign(apiErr, { ibexMessage: insufficientDetail }) + expect(ibexErrorDetail(apiErr)).toBe(insufficientDetail) + }) + + it("returns undefined for errors without a body", () => { + expect(ibexErrorDetail(new TypeError("fetch failed"))).toBeUndefined() + expect(ibexErrorDetail(fetchErrorShaped(400, { code: 42 }))).toBeUndefined() + }) +}) + +describe("errorHandler", () => { + it("classifies an ApiError whose message carries the insufficient-balance text", () => { + // flash's raw-fetch path embeds the body text in the wrapped message + const apiErr = new ApiError( + new Error(`IBEX /pay failed: 400 — {"error":"${insufficientDetail}"}`), + ) + + const result = errorHandler(apiErr) + + expect(result).toBeInstanceOf(InsufficientIbexBalance) + const err = result as InsufficientIbexBalance + expect(err.level).toBe(ErrorLevel.Info) + // client-facing message is clean — never the wrapped stack trace + expect(err.message).toBe("insufficient balance") + expect(err.message).not.toContain(" at ") + }) + + it("classifies a structured ibexMessage (ibex-client > 3.2.0), stripping the account id", () => { + const apiErr = new ApiError(new Error("Bad Request")) + Object.assign(apiErr, { ibexMessage: insufficientDetail }) + + const result = errorHandler(apiErr) + + expect(result).toBeInstanceOf(InsufficientIbexBalance) + const err = result as InsufficientIbexBalance + // client-facing message never exposes IBEX's internal account UUID + expect(err.message).toBe(insufficientDetailStripped) + expect(err.message).not.toContain("account:") + expect(err.message).not.toContain("39c6e986-979b-40ab-9e7b-df18a9277a84") + // the unstripped vendor text is preserved for logs/spans + expect(err.detail).toBe(insufficientDetail) + }) + + it("carries the body detail on the unclassified ApiError fall-through", () => { + // future body-carrying ApiError shape (lnflash/ibex-client#12) whose text + // matches no needle — the detail must still reach the logged message + const apiErr = new ApiError(new Error("Bad Request")) + Object.assign(apiErr, { ibexMessage: "invalid parameters" }) + const originalMessage = apiErr.message + + const result = errorHandler(apiErr) + + expect(result).toBeInstanceOf(IbexError) + expect(result).not.toBeInstanceOf(InsufficientIbexBalance) + const err = result as IbexError + expect(err.level).toBe(ErrorLevel.Warn) + expect(err.message).toContain("invalid parameters") + // the caller's error is not mutated — a fresh IbexError carries the detail + expect(apiErr.message).toBe(originalMessage) + }) + + it("maps a pinned-version SDK-path ApiError (stack-only message) to a generic IbexError", () => { + // ibex-client@3.2.0 discards the response body: message is only the + // wrapped FetchError stack ("FetchError: Bad Request\n at ...") + const apiErr = new ApiError(fetchErrorShaped(400, undefined)) + + const result = errorHandler(apiErr) + + expect(result).toBeInstanceOf(IbexError) + expect(result).not.toBeInstanceOf(InsufficientIbexBalance) + }) + + it("classifies payment-already-prepared as CompletedInvoice", () => { + const apiErr = new ApiError(new Error("payment already prepared")) + + const result = errorHandler(apiErr) + + expect(result).toBeInstanceOf(CompletedInvoice) + expect((result as CompletedInvoice).level).toBe(ErrorLevel.Info) + }) + + it("classifies case-insensitively — a vendor rewording must not revert to the generic path", () => { + const insufficientErr = new ApiError(new Error("Bad Request")) + Object.assign(insufficientErr, { ibexMessage: "Insufficient Balance" }) + expect(errorHandler(insufficientErr)).toBeInstanceOf(InsufficientIbexBalance) + + const preparedErr = new ApiError(new Error("Payment Already Prepared")) + expect(errorHandler(preparedErr)).toBeInstanceOf(CompletedInvoice) + }) + + it("maps AuthenticationError to a critical IbexError", () => { + const result = errorHandler(new AuthenticationError("auth failed")) + + expect(result).toBeInstanceOf(IbexError) + expect((result as IbexError).level).toBe(ErrorLevel.Critical) + }) + + it("passes successful responses through untouched", () => { + const response = { transaction: { id: "t-1" } } + expect(errorHandler(response)).toBe(response) + }) +}) + +describe("httpErrorHandler", () => { + it("classifies a 400 with a JSON insufficient-balance body", () => { + const raw = fetchErrorShaped(400, { error: insufficientDetail }) + + const result = httpErrorHandler(raw) + + expect(result).toBeInstanceOf(InsufficientIbexBalance) + const err = result as InsufficientIbexBalance + expect(err.httpCode).toBe(400) + expect(err.level).toBe(ErrorLevel.Info) + // client-facing message keeps the IBEX detail minus the internal account UUID + expect(err.message).toBe(insufficientDetailStripped) + expect(err.message).not.toContain("account:") + // the unstripped vendor text is preserved for logs/spans + expect(err.detail).toBe(insufficientDetail) + }) + + it("classifies a 400 with a non-JSON insufficient-balance body", () => { + const raw = fetchErrorShaped(400, "insufficient balance (text body)") + + const result = httpErrorHandler(raw) + + expect(result).toBeInstanceOf(InsufficientIbexBalance) + expect((result as InsufficientIbexBalance).message).toBe( + "insufficient balance (text body)", + ) + }) + + it("maps other 400s to a generic IbexError, keeping the status", () => { + const raw = fetchErrorShaped(400, { error: "invalid parameters" }) + + const result = httpErrorHandler(raw) + + expect(result).toBeInstanceOf(IbexError) + expect(result).not.toBeInstanceOf(InsufficientIbexBalance) + const err = result as IbexError + expect(err.httpCode).toBe(400) + expect(err.level).toBe(ErrorLevel.Warn) + // the extracted body detail must survive the generic path — an + // unrecognized IBEX 400 that logs only "FetchError: Bad Request" is the + // debugging blindness this module exists to fix + expect(err.message).toContain("invalid parameters") + }) + + it("keeps the generic path unchanged when the error carries no body detail", () => { + const raw = fetchErrorShaped(500, undefined) + + const result = httpErrorHandler(raw) + + expect(result).toBeInstanceOf(IbexError) + expect((result as IbexError).httpCode).toBe(500) + }) + + it("classifies insufficient-balance case-insensitively", () => { + const raw = fetchErrorShaped(400, { + error: "Insufficient Balance. Current Balance: 5.000000", + }) + + const result = httpErrorHandler(raw) + + expect(result).toBeInstanceOf(InsufficientIbexBalance) + // the client-facing detail keeps IBEX's original casing + expect((result as InsufficientIbexBalance).message).toBe( + "Insufficient Balance. Current Balance: 5.000000", + ) + }) + + it("classifies payment-already-prepared case-insensitively", () => { + const raw = fetchErrorShaped(400, { error: "Payment Already Prepared" }) + + const result = httpErrorHandler(raw) + + expect(result).toBeInstanceOf(CompletedInvoice) + }) + + it("classifies payment-already-prepared as CompletedInvoice", () => { + const raw = fetchErrorShaped(400, { error: "payment already prepared" }) + + const result = httpErrorHandler(raw) + + expect(result).toBeInstanceOf(CompletedInvoice) + }) + + it("maps network-level failures to a generic IbexError without a status", () => { + const result = httpErrorHandler(new TypeError("fetch failed")) + + expect(result).toBeInstanceOf(IbexError) + expect(result).not.toBeInstanceOf(InsufficientIbexBalance) + expect((result as IbexError).httpCode).toBeUndefined() + }) + + it("tolerates non-Error throwables", () => { + const result = httpErrorHandler("boom") + + expect(result).toBeInstanceOf(IbexError) + }) +}) diff --git a/test/flash/unit/services/ibex/pay-invoice.spec.ts b/test/flash/unit/services/ibex/pay-invoice.spec.ts new file mode 100644 index 000000000..3c4bb2530 --- /dev/null +++ b/test/flash/unit/services/ibex/pay-invoice.spec.ts @@ -0,0 +1,140 @@ +const mockPayInvoiceV2 = jest.fn() +const mockWithAuth = jest.fn() +const mockGetAccessToken = jest.fn() +const mockSetAccessToken = jest.fn() + +jest.mock("@services/ibex/cache", () => ({ + Redis: { + get: jest.fn(), + set: jest.fn(), + delete: jest.fn(), + }, +})) + +jest.mock("@services/ibex/webhook-server", () => ({ + __esModule: true, + default: { + endpoints: { + onReceive: { + invoice: "https://flash.test/ibex/receive/invoice", + lnurl: "https://flash.test/ibex/receive/lnurl", + onchain: "https://flash.test/ibex/receive/onchain", + }, + onPay: { + invoice: "https://flash.test/ibex/pay/invoice", + lnurl: "https://flash.test/ibex/pay/lnurl", + onchain: "https://flash.test/ibex/pay/onchain", + }, + }, + secret: "test-secret", + }, +})) + +// Keep the REAL ibex-client error classes (errorHandler/httpErrorHandler rely +// on instanceof against them); mock only the client instance so we can drive +// the SDK call underneath payInvoice. +jest.mock("ibex-client", () => ({ + ...jest.requireActual("ibex-client"), + __esModule: true, + default: jest.fn().mockImplementation(() => ({ + authentication: { + storage: { + getAccessToken: (...args: unknown[]) => mockGetAccessToken(...args), + setAccessToken: (...args: unknown[]) => mockSetAccessToken(...args), + setRefreshToken: jest.fn(), + }, + withAuth: (...args: unknown[]) => mockWithAuth(...args), + }, + ibex: { + payInvoiceV2: (...args: unknown[]) => mockPayInvoiceV2(...args), + }, + })), +})) + +import Ibex from "@services/ibex/client" +import { IbexError, InsufficientIbexBalance } from "@services/ibex/errors" + +const insufficientDetail = + "insufficient balance. Current Balance: 5.000000. Estimated Fee: 0.001109. invoice amount: 5.042164. account: 39c6e986-979b-40ab-9e7b-df18a9277a84" + +const fetchErrorShaped = (status: number, data: unknown): Error => { + const err = new Error("Bad Request") as Error & { status: number; data: unknown } + err.name = "FetchError" + err.status = status + err.data = data + return err +} + +describe("Ibex.payInvoice error classification", () => { + beforeEach(() => { + jest.clearAllMocks() + // mirror the real withAuth: run the SDK call, unwrap .data, rethrow errors + mockWithAuth.mockImplementation( + async (apiCall: () => Promise<{ data: unknown }>) => (await apiCall()).data, + ) + }) + + const payArgs = { + invoice: "lnbc1" as Bolt11, + accountId: "wallet-1" as IbexAccountId, + } + + it("returns InsufficientIbexBalance for a 400 with an insufficient-balance body", async () => { + mockPayInvoiceV2.mockRejectedValue( + fetchErrorShaped(400, { error: insufficientDetail }), + ) + + const result = await Ibex.payInvoice(payArgs) + + expect(result).toBeInstanceOf(InsufficientIbexBalance) + const err = result as InsufficientIbexBalance + expect(err.httpCode).toBe(400) + // client-facing message strips IBEX's trailing internal account UUID + expect(err.message).toBe( + "insufficient balance. Current Balance: 5.000000. Estimated Fee: 0.001109. invoice amount: 5.042164", + ) + expect(err.message).not.toContain("account:") + // the unstripped vendor text is preserved for logs/spans + expect(err.detail).toBe(insufficientDetail) + }) + + it("returns a generic IbexError for other 400s", async () => { + mockPayInvoiceV2.mockRejectedValue( + fetchErrorShaped(400, { error: "invalid parameters" }), + ) + + const result = await Ibex.payInvoice(payArgs) + + expect(result).toBeInstanceOf(IbexError) + expect(result).not.toBeInstanceOf(InsufficientIbexBalance) + expect((result as IbexError).httpCode).toBe(400) + }) + + it("returns a generic IbexError for network-level failures", async () => { + mockPayInvoiceV2.mockRejectedValue(new TypeError("fetch failed")) + + const result = await Ibex.payInvoice(payArgs) + + expect(result).toBeInstanceOf(IbexError) + expect(result).not.toBeInstanceOf(InsufficientIbexBalance) + expect((result as IbexError).httpCode).toBeUndefined() + }) + + it("passes successful payments through with the webhook body", async () => { + const response = { + transaction: { payment: { status: { id: 2 } } }, + } + mockPayInvoiceV2.mockResolvedValue({ data: response, status: 200 }) + + const result = await Ibex.payInvoice(payArgs) + + expect(result).toBe(response) + expect(mockPayInvoiceV2).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: "wallet-1", + bolt11: "lnbc1", + webhookUrl: expect.stringContaining("/pay/invoice"), + }), + ) + }) +})