From 15b7d7efeceec024e4b583965cc5356f09c962a9 Mon Sep 17 00:00:00 2001 From: Nas Kavian Date: Wed, 10 Jun 2026 12:59:33 -0700 Subject: [PATCH] test: cover x402 flows and login-api-key, raise coverage thresholds to 91% --- .../unit/commands/auth/login-api-key.test.tsx | 129 ++++++ .../unit/commands/x402/index-tty.test.tsx | 304 +++++++++++++ packages/cli/vitest.config.ts | 8 +- .../flows/inflow-flows-augmented-ops.test.ts | 107 +++++ .../unit/flows/x402-decode-header.test.ts | 50 +++ .../unit/flows/x402-inspect-accepts.test.ts | 146 +++++++ .../test/unit/flows/x402-pay-pipeline.test.ts | 406 ++++++++++++++++++ .../unit/flows/x402-status-polling.test.ts | 58 +++ packages/core/vitest.config.ts | 8 +- 9 files changed, 1208 insertions(+), 8 deletions(-) create mode 100644 packages/cli/test/unit/commands/auth/login-api-key.test.tsx create mode 100644 packages/cli/test/unit/commands/x402/index-tty.test.tsx create mode 100644 packages/core/test/unit/flows/inflow-flows-augmented-ops.test.ts create mode 100644 packages/core/test/unit/flows/x402-decode-header.test.ts create mode 100644 packages/core/test/unit/flows/x402-inspect-accepts.test.ts create mode 100644 packages/core/test/unit/flows/x402-pay-pipeline.test.ts create mode 100644 packages/core/test/unit/flows/x402-status-polling.test.ts diff --git a/packages/cli/test/unit/commands/auth/login-api-key.test.tsx b/packages/cli/test/unit/commands/auth/login-api-key.test.tsx new file mode 100644 index 0000000..b4c4840 --- /dev/null +++ b/packages/cli/test/unit/commands/auth/login-api-key.test.tsx @@ -0,0 +1,129 @@ +import { + augmentAuth, + type AuthTokens, + type DeviceAuthRequest, + type IAuth, + type IAuthResource, + type IUserResource, + InflowApiError, + MemoryStorage, + type User, +} from '@inflowpayai/inflow-core'; +import { render } from 'ink-testing-library'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { LoginApiKey } from '../../../../src/commands/auth/login-api-key.js'; + +const sampleUser: User = { + userId: 'u-1', + email: 'ada@example.test', + firstName: 'Ada', + lastName: 'Lovelace', + username: 'ada', + mobile: null, + locale: 'EN_US', + timezone: 'UTC', + created: '2026-01-01T00:00:00Z', + updated: '2026-01-01T00:00:00Z', +}; + +const STUB_DEVICE: DeviceAuthRequest = { + device_code: 'dc', + user_code: 'UC', + verification_url: '', + verification_url_complete: '', + expires_in: 0, + interval: 0, +}; + +function authResourceStub(): IAuthResource { + return { + initiateDeviceAuth: vi.fn(() => Promise.resolve(STUB_DEVICE)), + pollDeviceAuth: vi.fn(() => Promise.resolve(null)), + refreshToken: vi.fn(() => Promise.reject(new Error('unused'))), + revokeToken: vi.fn(() => Promise.resolve()), + }; +} + +function makeAuth(storage: MemoryStorage, retrieve: () => Promise): IAuth { + const userResource: IUserResource = { retrieve: vi.fn(retrieve) }; + return augmentAuth(authResourceStub(), userResource, storage); +} + +describe('LoginApiKey', () => { + it('renders the validating spinner while the probe is in flight', () => { + const storage = new MemoryStorage(); + const auth = makeAuth(storage, () => new Promise(() => {})); // never settles + const onComplete = vi.fn(); + + const { lastFrame, unmount } = render( + , + ); + + expect(lastFrame()).toContain('Validating API key...'); + expect(onComplete).not.toHaveBeenCalled(); + unmount(); + }); + + it('persists the key, clears prior tokens, and renders the authenticated user on success', async () => { + const storage = new MemoryStorage({ + access_token: 'old-access-token', + refresh_token: 'old-refresh-token', + token_type: 'Bearer', + expires_in: 3600, + }); + const auth = makeAuth(storage, () => Promise.resolve(sampleUser)); + const onComplete = vi.fn(); + + const { lastFrame, unmount } = render( + , + ); + + await vi.waitFor(() => { + expect(lastFrame()).toContain('Saved API key'); + }); + expect(lastFrame()).toContain('Authenticated as:'); + expect(lastFrame()).toContain('ada@example.test'); + expect(storage.getApiKey()).toBe('ifk-valid'); + expect(storage.getAuth()).toBeNull(); + expect(storage.getConnection()).toEqual({ environment: 'sandbox' }); + expect(onComplete).toHaveBeenCalledOnce(); + unmount(); + }); + + it('renders the rejection message for a server-side 401 without persisting the key', async () => { + const storage = new MemoryStorage(); + const rejected = new InflowApiError('Unauthorized', { status: 401 }); + const auth = makeAuth(storage, () => Promise.reject(rejected)); + const onComplete = vi.fn(); + + const { lastFrame, unmount } = render( + , + ); + + await vi.waitFor(() => { + expect(lastFrame()).toContain('API key not accepted'); + }); + expect(lastFrame()).toContain('API key was rejected by the server (HTTP 401)'); + expect(storage.getApiKey()).toBeNull(); + expect(onComplete).toHaveBeenCalledOnce(); + unmount(); + }); + + it('surfaces a generic error message verbatim in the failed branch', async () => { + const storage = new MemoryStorage(); + const auth = makeAuth(storage, () => Promise.reject(new Error('network down'))); + const onComplete = vi.fn(); + + const { lastFrame, unmount } = render( + , + ); + + await vi.waitFor(() => { + expect(lastFrame()).toContain('API key not accepted'); + }); + expect(lastFrame()).toContain('network down'); + expect(storage.getApiKey()).toBeNull(); + unmount(); + }); +}); diff --git a/packages/cli/test/unit/commands/x402/index-tty.test.tsx b/packages/cli/test/unit/commands/x402/index-tty.test.tsx new file mode 100644 index 0000000..a613eea --- /dev/null +++ b/packages/cli/test/unit/commands/x402/index-tty.test.tsx @@ -0,0 +1,304 @@ +import type { AuthStorage } from '@inflowpayai/inflow-core'; +import { Inflow, MemoryStorage } from '@inflowpayai/inflow-core'; +import type { + EncodedPayment, + InflowClient as X402InflowClient, + PreparedPayment, + X402PayloadResponse, +} from '@inflowpayai/x402-buyer'; +import { encodePaymentRequiredHeader } from '@x402/core/http'; +import type { PaymentRequired } from '@x402/core/types'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { __testing, createX402Cli } from '../../../../src/commands/x402/index.js'; + +const { runPayCommand, runStatusCommand, runCancelCommand, runSupportedCommand, runInspectCommand } = __testing; + +function makePaymentRequired(): PaymentRequired { + return { + x402Version: 2, + resource: { url: 'https://seller/api', mimeType: 'application/json' }, + accepts: [ + { + scheme: 'balance', + network: 'inflow:1', + amount: '500', + payTo: 'inflow:abc', + maxTimeoutSeconds: 60, + asset: 'USDC', + extra: {}, + }, + ], + }; +} + +const encodedPayment: EncodedPayment = { + encodedPayload: 'enc', + paymentPayload: { + x402Version: 2, + accepted: { + scheme: 'balance', + network: 'inflow:1', + amount: '500', + payTo: 'inflow:abc', + maxTimeoutSeconds: 60, + asset: 'USDC', + extra: {}, + }, + payload: {}, + }, + transactionId: 'txn_1', +}; + +function makePrepared(): PreparedPayment { + return { + transactionId: 'txn_1', + approvalId: 'appr_1', + awaitPayload: () => Promise.resolve(encodedPayment), + status: () => Promise.resolve('INITIATED'), + cancel: () => Promise.resolve(), + }; +} + +function makeClient(overrides: Partial = {}): X402InflowClient { + const base = { + selectInflowRequirement: vi.fn(() => + Promise.resolve({ + scheme: 'balance', + network: 'inflow:1', + amount: '500', + payTo: 'inflow:abc', + maxTimeoutSeconds: 60, + asset: 'USDC', + extra: {}, + }), + ), + prepareInflowPayment: vi.fn(() => Promise.resolve(makePrepared())), + getSupported: vi.fn(() => + Promise.resolve({ + kinds: [{ scheme: 'balance', network: 'inflow:1', x402Version: 2 }], + }), + ), + getX402Payload: vi.fn(() => Promise.resolve({ status: 'INITIATED' })), + cancelApproval: vi.fn(() => Promise.resolve(undefined)), + }; + return { ...base, ...overrides } as unknown as X402InflowClient; +} + +function authedResources(client: X402InflowClient): { inflow: Inflow; storage: AuthStorage } { + const storage = new MemoryStorage({ + access_token: 'a', + refresh_token: 'r', + token_type: 'Bearer', + expires_in: 3600, + expires_at: Date.now() + 3600 * 1000, + }); + const inflow = new Inflow({ authStorage: storage, environment: 'sandbox', cliClientId: 'test' }); + (inflow.x402 as unknown as { cached: Promise }).cached = Promise.resolve(client); + return { inflow, storage }; +} + +function ttyCtx(args: A, options: O) { + return { + agent: false, + formatExplicit: false, + args, + options, + error: vi.fn((err: { code: string; message: string }): never => { + throw new Error(`c.error: ${err.code}`); + }), + }; +} + +function agentCtx(args: A, options: O) { + return { + agent: true, + formatExplicit: true, + args, + options, + error: vi.fn((err: { code: string; message: string }): never => { + throw new Error(`c.error: ${err.code}: ${err.message}`); + }), + }; +} + +async function drain(gen: AsyncGenerator): Promise { + const out: T[] = []; + for await (const v of gen) out.push(v); + return out; +} + +const PAY_OPTIONS = { method: 'GET', header: [], interval: 5, maxAttempts: 0, timeout: 900, showBody: true }; + +// PayView activates `useInput` during the awaiting-approval phase; Ink throws unless stdin claims raw-mode support. +// Mirror a real TTY by stubbing `isTTY` / `setRawMode` on the shared stdin for the duration of this file. +const stdinAsTty = process.stdin as unknown as { + isTTY?: boolean | undefined; + setRawMode?: ((mode: boolean) => unknown) | undefined; +}; +let originalIsTTY: boolean | undefined; +let originalSetRawMode: ((mode: boolean) => unknown) | undefined; + +beforeAll(() => { + originalIsTTY = stdinAsTty.isTTY; + originalSetRawMode = stdinAsTty.setRawMode; + stdinAsTty.isTTY = true; + stdinAsTty.setRawMode = () => process.stdin; +}); + +afterAll(() => { + stdinAsTty.isTTY = originalIsTTY; + stdinAsTty.setRawMode = originalSetRawMode; +}); + +afterEach(() => vi.restoreAllMocks()); + +describe('x402 TTY runners (renderInkUntilExit paths)', () => { + it('runPayCommand renders to completion on a successful pay and never calls c.error', async () => { + const header = encodePaymentRequiredHeader(makePaymentRequired()); + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy.mockResolvedValueOnce( + new Response('payment required', { status: 402, headers: { 'PAYMENT-REQUIRED': header } }), + ); + fetchSpy.mockResolvedValueOnce(new Response('ok-body', { status: 200, headers: { 'content-type': 'text/plain' } })); + const { inflow, storage } = authedResources(makeClient()); + const ctx = ttyCtx({ url: 'https://seller/api' }, PAY_OPTIONS); + const yields = await drain(runPayCommand(ctx as never, inflow, storage, 'https://api.inflowpay.ai')); + expect(yields).toHaveLength(0); + expect(ctx.error).not.toHaveBeenCalled(); + }); + + it('runPayCommand calls c.error with PAYMENT_NOT_ACCEPTED when the seller rejects the replay', async () => { + const header = encodePaymentRequiredHeader(makePaymentRequired()); + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy.mockResolvedValueOnce( + new Response('payment required', { status: 402, headers: { 'PAYMENT-REQUIRED': header } }), + ); + fetchSpy.mockResolvedValueOnce(new Response('still payment required', { status: 402 })); + const { inflow, storage } = authedResources(makeClient()); + const ctx = ttyCtx({ url: 'https://seller/api' }, PAY_OPTIONS); + await expect(drain(runPayCommand(ctx as never, inflow, storage, 'https://api.inflowpay.ai'))).rejects.toThrow( + 'c.error: PAYMENT_NOT_ACCEPTED', + ); + expect(ctx.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('Seller rejected the signed payment with status 402') as string, + }), + ); + }); + + it('runPayCommand forwards a pipeline error phase to c.error', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response('not found', { status: 404 })); + const { inflow, storage } = authedResources(makeClient()); + const ctx = ttyCtx({ url: 'https://seller/api' }, PAY_OPTIONS); + await expect(drain(runPayCommand(ctx as never, inflow, storage, 'https://api.inflowpay.ai'))).rejects.toThrow( + 'c.error: UNEXPECTED_PROBE_STATUS', + ); + }); + + it('runStatusCommand renders the status view to completion on a signed payload', async () => { + const getX402Payload = vi.fn(() => + Promise.resolve({ + status: 'APPROVED', + encodedPayload: 'enc', + paymentPayload: encodedPayment.paymentPayload, + }), + ); + const client = makeClient({ getX402Payload }); + const { inflow, storage } = authedResources(client); + const ctx = ttyCtx({ transactionId: 'txn_1' }, { interval: 0.01, maxAttempts: 0, timeout: 60 }); + const yields = await drain(runStatusCommand(ctx as never, inflow, storage)); + expect(yields).toHaveLength(0); + expect(getX402Payload).toHaveBeenCalledWith('txn_1'); + expect(ctx.error).not.toHaveBeenCalled(); + }); + + it('runCancelCommand renders the cancel view and returns the best-effort envelope', async () => { + const cancelApproval = vi.fn(() => Promise.resolve(undefined)); + const { inflow, storage } = authedResources(makeClient({ cancelApproval })); + const ctx = ttyCtx({ approvalId: 'appr_1' }, {}); + const result = await runCancelCommand(ctx, inflow, storage); + expect(result).toEqual({ + approval_id: 'appr_1', + cancelled: true, + note: 'best-effort; server-side state not verified', + }); + expect(cancelApproval).toHaveBeenCalledWith('appr_1'); + }); + + it('runSupportedCommand renders the supported view and returns undefined', async () => { + const getSupported = vi.fn(() => + Promise.resolve({ + kinds: [{ scheme: 'balance', network: 'inflow:1', x402Version: 2 }], + }), + ); + const { inflow, storage } = authedResources(makeClient({ getSupported })); + const ctx = ttyCtx({}, {}); + const result = await runSupportedCommand(ctx, inflow, storage); + expect(result).toBeUndefined(); + expect(getSupported).toHaveBeenCalled(); + }); + + it('runInspectCommand renders the inspect view to completion on a 402 probe', async () => { + const header = encodePaymentRequiredHeader(makePaymentRequired()); + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response('payment required', { status: 402, headers: { 'PAYMENT-REQUIRED': header } }), + ); + const ctx = ttyCtx({ url: 'https://seller/api' }, { method: 'GET', header: [] }); + const result = await runInspectCommand(ctx); + expect(result).toBeUndefined(); + expect(ctx.error).not.toHaveBeenCalled(); + }); + + it('runInspectCommand forwards an inspect error phase to c.error', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response('not found', { status: 404 })); + const ctx = ttyCtx({ url: 'https://seller/api' }, { method: 'GET', header: [] }); + await expect(runInspectCommand(ctx as never)).rejects.toThrow('c.error: UNEXPECTED_PROBE_STATUS'); + }); +}); + +describe('runStatusCommand (agent-mode polling details)', () => { + it('dedupes consecutive identical pending frames via isEqual before the signed terminal', async () => { + const responses: X402PayloadResponse[] = [ + { status: 'INITIATED' }, + { status: 'INITIATED' }, + { + status: 'APPROVED', + encodedPayload: 'enc', + paymentPayload: encodedPayment.paymentPayload, + }, + ]; + const client = makeClient({ + getX402Payload: vi.fn(() => Promise.resolve(responses.shift() ?? { status: 'INITIATED' })), + }); + const { inflow, storage } = authedResources(client); + const ctx = agentCtx({ transactionId: 'txn_1' }, { interval: 0.01, maxAttempts: 0, timeout: 60 }); + const yields = await drain(runStatusCommand(ctx as never, inflow, storage)); + // The duplicate INITIATED frame is suppressed; only the first pending frame and the terminal frame surface. + expect(yields).toHaveLength(2); + expect(yields[0]).toMatchObject({ transaction_id: 'txn_1', status: 'INITIATED' }); + expect(yields[1]).toMatchObject({ transaction_id: 'txn_1', status: 'APPROVED' }); + expect(ctx.error).not.toHaveBeenCalled(); + }); + + it('emits the timeout-flavoured POLLING_TIMEOUT message when the deadline elapses', async () => { + const client = makeClient({ + getX402Payload: vi.fn(() => Promise.resolve({ status: 'INITIATED' })), + }); + const { inflow, storage } = authedResources(client); + const ctx = agentCtx({ transactionId: 'txn_1' }, { interval: 0.01, maxAttempts: 0, timeout: 0.02 }); + await expect(drain(runStatusCommand(ctx as never, inflow, storage))).rejects.toThrow( + 'Polling timed out before the transaction reached a signed state.', + ); + expect(ctx.error).toHaveBeenCalledWith(expect.objectContaining({ code: 'POLLING_TIMEOUT', retryable: true })); + }); +}); + +describe('createX402Cli', () => { + it('registers the full x402 command group under the x402 namespace', () => { + const { inflow, storage } = authedResources(makeClient()); + const cli = createX402Cli(inflow, storage, 'https://app.inflowpay.ai'); + expect(cli).toBeDefined(); + expect(cli.name).toBe('x402'); + expect(cli.description).toContain('x402 payment commands'); + }); +}); diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index d19e338..7af13e1 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -12,10 +12,10 @@ export default defineConfig({ exclude: ['src/**/*.d.ts', 'src/cli.tsx'], reporter: ['text', 'lcov'], thresholds: { - lines: 75, - functions: 75, - statements: 80, - branches: 65, + lines: 91, + functions: 80, + statements: 91, + branches: 80, }, }, }, diff --git a/packages/core/test/unit/flows/inflow-flows-augmented-ops.test.ts b/packages/core/test/unit/flows/inflow-flows-augmented-ops.test.ts new file mode 100644 index 0000000..adc82d6 --- /dev/null +++ b/packages/core/test/unit/flows/inflow-flows-augmented-ops.test.ts @@ -0,0 +1,107 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { http, HttpResponse } from 'msw'; +import type * as x402Buyer from '@inflowpayai/x402-buyer'; +import { Inflow, MemoryStorage } from '../../../src/index.js'; +import type { AuthStatusFrame } from '../../../src/auth/poll.js'; +import { BASE_URL, userHappy } from '../fixtures/handlers.js'; +import { makeServer } from '../fixtures/server.js'; + +vi.mock('@inflowpayai/x402-buyer', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createInflowClient: vi.fn(() => + Promise.resolve({ + getSupported: vi.fn(() => Promise.resolve({ kinds: [] })), + selectInflowRequirement: () => null, + getX402Payload: vi.fn(() => Promise.resolve({ status: 'INITIATED' as const })), + cancelApproval: vi.fn(() => Promise.resolve(undefined)), + prepareInflowPayment: vi.fn(), + }), + ), + }; +}); + +const server = makeServer(); +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +async function drainEvents(iterable: AsyncIterable): Promise { + const out: E[] = []; + for await (const event of iterable) out.push(event); + return out; +} + +describe('Inflow.auth augmented operations (with storage configured)', () => { + it('auth.snapshot composes the unauthenticated frame from empty storage', () => { + const client = new Inflow({ apiBaseUrl: BASE_URL, authStorage: new MemoryStorage() }); + expect(client.auth.snapshot()).toEqual({ authenticated: false }); + }); + + it('auth.loginApiKey validates against the user endpoint and persists the key', async () => { + server.use(userHappy); + const storage = new MemoryStorage(); + const client = new Inflow({ apiBaseUrl: BASE_URL, apiKey: 'inflow_test_key', authStorage: storage }); + const events = await drainEvents( + client.auth.loginApiKey({ apiKey: 'inflow_test_key', connection: { environment: 'sandbox' } }).events, + ); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ type: 'validated', user: { userId: 'u-1' } }); + expect(storage.getApiKey()).toBe('inflow_test_key'); + expect(storage.getConnection()).toEqual({ environment: 'sandbox' }); + }); + + it('auth.probeStatus reports unauthenticated without a server probe when storage is empty', async () => { + const client = new Inflow({ apiBaseUrl: BASE_URL, authStorage: new MemoryStorage() }); + const out = await client.auth.probeStatus(); + expect(out.kind).toBe('unauthenticated'); + if (out.kind === 'unauthenticated') { + expect(out.frame).toEqual({ authenticated: false }); + } + }); + + it('auth.pollStatus terminates with a max_attempts reason when nothing authenticates', async () => { + const client = new Inflow({ apiBaseUrl: BASE_URL, authStorage: new MemoryStorage() }); + const frames = await drainEvents( + client.auth.pollStatus({ interval: 0.01, maxAttempts: 1, timeout: 30 }), + ); + expect(frames.length).toBeGreaterThan(0); + const terminal = frames.at(-1) as unknown as Record; + expect(terminal.authenticated).toBe(false); + expect(terminal.reason).toBe('max_attempts'); + }); +}); + +describe('Inflow.x402 augmented operations', () => { + it('x402.pay drains the async-iterable to a short-circuited terminal using the resolved apiBaseUrl', async () => { + server.use(http.get('https://seller.test/free', () => new HttpResponse('FREE', { status: 200 }))); + const client = new Inflow({ apiBaseUrl: BASE_URL, accessToken: 'tk' }); + const events = await drainEvents( + client.x402.pay({ + url: 'https://seller.test/free', + probeOptions: { method: 'GET', headers: {} }, + signOptions: {}, + showBody: true, + }).events, + ); + expect(events).toHaveLength(1); + const terminal = events[0]; + expect(terminal?.type).toBe('short-circuited'); + if (terminal?.type === 'short-circuited') { + expect(terminal.result.outcome).toBe('no-payment-required'); + expect(terminal.result.body).toBe('FREE'); + } + }); + + it('x402.status polls the lazy buyer client and times out on a never-signed payload', async () => { + const client = new Inflow({ apiBaseUrl: BASE_URL, accessToken: 'tk' }); + const run = client.x402.status({ transactionId: 'tx-77', interval: 0.01, maxAttempts: 1, timeout: 30 }); + const events = await drainEvents(run.events); + expect(events.at(-1)?.type).toBe('timedOut'); + const terminal = events.at(-1); + if (terminal?.type === 'timedOut') { + expect(terminal.response).toMatchObject({ status: 'INITIATED' }); + } + }); +}); diff --git a/packages/core/test/unit/flows/x402-decode-header.test.ts b/packages/core/test/unit/flows/x402-decode-header.test.ts new file mode 100644 index 0000000..db49472 --- /dev/null +++ b/packages/core/test/unit/flows/x402-decode-header.test.ts @@ -0,0 +1,50 @@ +import { encodePaymentRequiredHeader } from '@x402/core/http'; +import type { PaymentRequired } from '@x402/core/types'; +import { describe, expect, it } from 'vitest'; +import { decodeHeader } from '../../../src/flows/x402-decode.js'; + +const ACCEPTS = [ + { + scheme: 'balance', + network: 'inflow:1', + asset: '', + amount: '10', + payTo: 'acct_1', + maxTimeoutSeconds: 60, + extra: { assetName: 'USDC' }, + }, +]; + +describe('decodeHeader', () => { + it('decodes a minimal PAYMENT-REQUIRED header and omits absent optional fields', () => { + const raw = encodePaymentRequiredHeader({ + x402Version: 2, + resource: { url: 'https://seller.test/api', method: 'GET' }, + accepts: ACCEPTS, + } as unknown as PaymentRequired); + const out = decodeHeader(raw); + expect(out.x402Version).toBe(2); + expect(out.resource).toEqual({ url: 'https://seller.test/api', method: 'GET' }); + expect(out.accepts).toHaveLength(1); + expect(out.accepts[0]).toMatchObject({ scheme: 'balance', network: 'inflow:1' }); + expect(out).not.toHaveProperty('extensions'); + expect(out).not.toHaveProperty('error'); + }); + + it('carries extensions and error through when the header includes them', () => { + const raw = encodePaymentRequiredHeader({ + x402Version: 2, + resource: { url: 'https://seller.test/api', method: 'GET' }, + accepts: ACCEPTS, + extensions: { foo: 'bar' }, + error: 'try-again', + } as unknown as PaymentRequired); + const out = decodeHeader(raw); + expect(out.extensions).toEqual({ foo: 'bar' }); + expect(out.error).toBe('try-again'); + }); + + it('throws on a malformed header value', () => { + expect(() => decodeHeader('%%%not-a-header%%%')).toThrow(); + }); +}); diff --git a/packages/core/test/unit/flows/x402-inspect-accepts.test.ts b/packages/core/test/unit/flows/x402-inspect-accepts.test.ts new file mode 100644 index 0000000..4207e82 --- /dev/null +++ b/packages/core/test/unit/flows/x402-inspect-accepts.test.ts @@ -0,0 +1,146 @@ +import { HEADERS } from '@inflowpayai/x402'; +import { encodePaymentRequiredHeader } from '@x402/core/http'; +import type { PaymentRequired } from '@x402/core/types'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + type InspectEvent, + type InspectResultAccepts, + type InspectResultNoPayment, + reduceX402Inspect, + runInspectPipeline, +} from '../../../src/flows/x402-inspect.js'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const SELLER = 'https://seller.test/api'; + +function paymentRequired(): PaymentRequired { + return { + x402Version: 2, + resource: { url: SELLER, method: 'GET' }, + accepts: [ + { + scheme: 'balance', + network: 'inflow:1', + asset: '', + amount: '10', + payTo: 'acct_1', + maxTimeoutSeconds: 60, + extra: { assetName: 'USDC' }, + }, + { + scheme: 'exact', + network: 'eip155:84532', + asset: '0xabc', + amount: '10', + payTo: '0xdef', + maxTimeoutSeconds: 60, + extra: { assetName: 'USDT' }, + }, + ], + extensions: { foo: 'bar' }, + } as unknown as PaymentRequired; +} + +function mock402(headerValue = encodePaymentRequiredHeader(paymentRequired())): void { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response('payment required', { status: 402, headers: { [HEADERS.PAYMENT_REQUIRED]: headerValue } }), + ); +} + +async function collect(deps: Parameters[0]): Promise { + const events: InspectEvent[] = []; + await runInspectPipeline(deps, (e) => events.push(e)); + return events; +} + +describe('runInspectPipeline — accepts decoding', () => { + it('emits the decoded accepts list with resource, version and extensions', async () => { + mock402(); + const events = await collect({ url: SELLER, probeOptions: { method: 'GET', headers: {} } }); + expect(events).toHaveLength(1); + const ev = events[0]; + expect(ev?.type).toBe('accepts'); + if (ev?.type === 'accepts') { + expect(ev.result.outcome).toBe('accepts'); + expect(ev.result.url).toBe(SELLER); + expect(ev.result.method).toBe('GET'); + expect(ev.result.resource).toBe(SELLER); + expect(ev.result.x402Version).toBe(2); + expect(ev.result.extensions).toEqual({ foo: 'bar' }); + expect(ev.result.accepts).toHaveLength(2); + expect(ev.result.accepts[0]?.scheme).toBe('balance'); + expect(ev.result.accepts[1]?.network).toBe('eip155:84532'); + } + }); + + it('narrows the rendered accepts when scheme/network/asset/asset-name filters all match one entry', async () => { + mock402(); + const events = await collect({ + url: SELLER, + probeOptions: { method: 'GET', headers: {} }, + schemeFilter: 'exact', + networkFilter: 'eip155:84532', + assetFilter: '0xabc', + assetNameFilter: 'USDT', + }); + const ev = events[0]; + expect(ev?.type).toBe('accepts'); + if (ev?.type === 'accepts') { + expect(ev.result.accepts).toHaveLength(1); + expect(ev.result.accepts[0]?.scheme).toBe('exact'); + } + }); + + it('errors NO_FILTERED_MATCH with the available pairs when filters match nothing', async () => { + mock402(); + const events = await collect({ + url: SELLER, + probeOptions: { method: 'GET', headers: {} }, + schemeFilter: 'bogus', + }); + expect(events).toHaveLength(1); + const ev = events[0]; + expect(ev?.type).toBe('errored'); + if (ev?.type === 'errored') { + expect(ev.code).toBe('NO_FILTERED_MATCH'); + expect(ev.message).toContain('--scheme=bogus'); + expect(ev.message).toContain('balance/inflow:1'); + expect(ev.message).toContain('exact/eip155:84532'); + } + }); + + it('errors DECODE_FAILED when the PAYMENT-REQUIRED header does not decode', async () => { + mock402('%%%not-a-header%%%'); + const events = await collect({ url: SELLER, probeOptions: { method: 'GET', headers: {} } }); + expect(events).toHaveLength(1); + const ev = events[0]; + expect(ev?.type).toBe('errored'); + if (ev?.type === 'errored') { + expect(ev.code).toBe('DECODE_FAILED'); + expect(ev.message.length).toBeGreaterThan(0); + } + }); +}); + +describe('reduceX402Inspect — remaining transitions', () => { + it('accepts → accepts phase carrying the result', () => { + const result = { outcome: 'accepts', url: SELLER } as unknown as InspectResultAccepts; + expect(reduceX402Inspect({ kind: 'probing' }, { type: 'accepts', result })).toEqual({ kind: 'accepts', result }); + }); + + it('no-payment → no-payment phase carrying the result', () => { + const result = { outcome: 'no-payment-required', url: SELLER } as unknown as InspectResultNoPayment; + expect(reduceX402Inspect({ kind: 'probing' }, { type: 'no-payment', result })).toEqual({ + kind: 'no-payment', + result, + }); + }); + + it('returns the prior state for an unrecognised event (default branch)', () => { + const prior = { kind: 'probing' } as const; + expect(reduceX402Inspect(prior, { type: 'bogus' } as never)).toBe(prior); + }); +}); diff --git a/packages/core/test/unit/flows/x402-pay-pipeline.test.ts b/packages/core/test/unit/flows/x402-pay-pipeline.test.ts new file mode 100644 index 0000000..ea56992 --- /dev/null +++ b/packages/core/test/unit/flows/x402-pay-pipeline.test.ts @@ -0,0 +1,406 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve as resolvePath } from 'node:path'; +import { HEADERS, type PaymentRequirements } from '@inflowpayai/x402'; +import { type PreparedPayment, X402ApprovalCancelledError, X402ApprovalTimeoutError } from '@inflowpayai/x402-buyer'; +import { encodePaymentRequiredHeader, encodePaymentResponseHeader } from '@x402/core/http'; +import type { PaymentRequired } from '@x402/core/types'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + buildBodyAttachment, + buildSettledMeta, + type PayEvent, + type PayPipelineDeps, + type PayResultReplayRejected, + type PayResultSuccess, + reducePay, + runPayPipeline, +} from '../../../src/flows/x402-pay.js'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const SELLER = 'https://seller.test/api'; + +function paymentRequired(): PaymentRequired { + return { + x402Version: 2, + resource: { url: SELLER, method: 'GET' }, + accepts: [ + { + scheme: 'balance', + network: 'inflow:1', + asset: '', + amount: '10', + payTo: 'acct_1', + maxTimeoutSeconds: 60, + extra: { assetName: 'USDC' }, + }, + { + scheme: 'exact', + network: 'eip155:84532', + asset: '0xabc', + amount: '10', + payTo: '0xdef', + maxTimeoutSeconds: 60, + extra: { assetName: 'USDT' }, + }, + ], + extensions: { foo: 'bar' }, + error: 'payment required', + } as unknown as PaymentRequired; +} + +interface SellerOptions { + paidStatus?: number; + paidBody?: string; + paidHeaders?: Record; + requiredHeader?: string; +} + +/** + * Mock fetch as an x402 seller: every request without a PAYMENT-SIGNATURE header gets a 402 carrying the + * PAYMENT-REQUIRED header; the replay (signature present) gets the paid response. + */ +function mockSeller(options: SellerOptions = {}) { + const required = options.requiredHeader ?? encodePaymentRequiredHeader(paymentRequired()); + return vi.spyOn(globalThis, 'fetch').mockImplementation((_input, init) => { + const headers = new Headers(init?.headers); + if (headers.get(HEADERS.PAYMENT_SIGNATURE) !== null) { + return Promise.resolve( + new Response(options.paidBody ?? 'PAID-BODY', { + status: options.paidStatus ?? 200, + headers: { 'content-type': 'text/plain', ...(options.paidHeaders ?? {}) }, + }), + ); + } + return Promise.resolve( + new Response('payment required', { + status: 402, + headers: { [HEADERS.PAYMENT_REQUIRED]: required }, + }), + ); + }); +} + +function preparedPayment(payload = 'ENC-PAYLOAD'): PreparedPayment { + return { + transactionId: 'tx-1', + approvalId: 'appr_1', + awaitPayload: vi.fn(() => Promise.resolve({ encodedPayload: payload })), + } as unknown as PreparedPayment; +} + +function payingClient(overrides: Record = {}): unknown { + return { + selectInflowRequirement: vi.fn((filtered: PaymentRequired) => Promise.resolve(filtered.accepts[0] ?? null)), + prepareInflowPayment: vi.fn(() => Promise.resolve(preparedPayment())), + getX402Payload: vi.fn(), + cancelApproval: vi.fn(), + getSupported: vi.fn(), + ...overrides, + }; +} + +function deps(overrides: Partial = {}): PayPipelineDeps { + return { + client: payingClient() as never, + apiBaseUrl: 'https://api.test', + probeOptions: { method: 'GET', headers: {} }, + url: SELLER, + signOptions: {}, + showBody: true, + ...overrides, + }; +} + +async function collect(d: PayPipelineDeps): Promise { + const events: PayEvent[] = []; + await runPayPipeline(d, (e) => events.push(e)); + return events; +} + +describe('runPayPipeline — full lifecycle', () => { + it('drives decoded → matched → prepared → awaited → replayed and settles with the seller body', async () => { + const fetchSpy = mockSeller({ + paidHeaders: { + [HEADERS.PAYMENT_RESPONSE]: encodePaymentResponseHeader({ + success: true, + network: 'eip155:84532', + transaction: '0xtx99', + } as never), + }, + }); + const events = await collect(deps()); + expect(events.map((e) => e.type)).toEqual(['decoded', 'matched', 'prepared', 'awaited', 'replayed']); + + const decoded = events[0]; + if (decoded?.type === 'decoded') { + expect(decoded.decoded.extensions).toEqual({ foo: 'bar' }); + expect(decoded.decoded.error).toBe('payment required'); + expect(decoded.decoded.accepts).toHaveLength(2); + } + + const prepared = events[2]; + if (prepared?.type === 'prepared') { + expect(prepared.approvalUrl).toBe('https://api.test/approvals/appr_1/view/'); + expect(prepared.requirement.scheme).toBe('balance'); + } + + const terminal = events.at(-1); + expect(terminal?.type).toBe('replayed'); + if (terminal?.type === 'replayed') { + expect(terminal.result.outcome).toBe('paid'); + expect(terminal.result.transactionId).toBe('tx-1'); + expect(terminal.result.approvalId).toBe('appr_1'); + expect(terminal.result.approvalUrl).toBe('https://api.test/approvals/appr_1/view/'); + expect(terminal.result.scheme).toBe('balance'); + expect(terminal.result.network).toBe('inflow:1'); + expect(terminal.result.encodedPayload).toBe('ENC-PAYLOAD'); + expect(terminal.result.responseStatus).toBe(200); + expect(terminal.result.body).toBe('PAID-BODY'); + expect(terminal.result.settled).toEqual({ network: 'eip155:84532', transaction: '0xtx99' }); + } + + const replayInit = fetchSpy.mock.calls.at(-1)?.[1]; + expect(new Headers(replayInit?.headers).get(HEADERS.PAYMENT_SIGNATURE)).toBe('ENC-PAYLOAD'); + }); + + it('passes the decoded signing context (resource + version + extensions) to prepareInflowPayment', async () => { + mockSeller(); + const client = payingClient() as { + prepareInflowPayment: ReturnType; + }; + await collect(deps({ client: client as never })); + expect(client.prepareInflowPayment).toHaveBeenCalledTimes(1); + const [, signingContext, signOptions] = client.prepareInflowPayment.mock.calls[0] as [ + unknown, + { resource: { url: string }; x402Version: number; extensions?: Record }, + unknown, + ]; + expect(signingContext.resource.url).toBe(SELLER); + expect(signingContext.x402Version).toBe(2); + expect(signingContext.extensions).toEqual({ foo: 'bar' }); + expect(signOptions).toEqual({}); + }); + + it('emits rejected when the seller answers the replay with a non-2xx status', async () => { + mockSeller({ paidStatus: 402, paidBody: 'still want money' }); + const events = await collect(deps()); + expect(events.map((e) => e.type)).toEqual(['decoded', 'matched', 'prepared', 'awaited', 'rejected']); + const terminal = events.at(-1); + if (terminal?.type === 'rejected') { + expect(terminal.result.outcome).toBe('replay-rejected'); + expect(terminal.result.responseStatus).toBe(402); + expect(terminal.result.transactionId).toBe('tx-1'); + expect(terminal.result.body).toBe('still want money'); + expect(terminal.result).not.toHaveProperty('encodedPayload'); + } + }); + + it('stops after prepared (no awaitPayload, no replay) when awaitPayment is false', async () => { + const fetchSpy = mockSeller(); + const prepared = preparedPayment(); + const client = payingClient({ prepareInflowPayment: vi.fn(() => Promise.resolve(prepared)) }); + const events = await collect(deps({ client: client as never, awaitPayment: false })); + expect(events.map((e) => e.type)).toEqual(['decoded', 'matched', 'prepared']); + expect((prepared as unknown as { awaitPayload: ReturnType }).awaitPayload).not.toHaveBeenCalled(); + // only the probe hit the network — no replay + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('narrows accepts with --scheme so the second entry is matched', async () => { + mockSeller(); + const events = await collect(deps({ schemeFilter: 'exact' })); + const matched = events.find((e) => e.type === 'matched'); + expect(matched).toBeDefined(); + if (matched?.type === 'matched') { + expect(matched.requirement.scheme).toBe('exact'); + expect(matched.requirement.network).toBe('eip155:84532'); + } + expect(events.at(-1)?.type).toBe('replayed'); + }); + + it('applies network + asset + asset-name filters together', async () => { + mockSeller(); + const events = await collect( + deps({ networkFilter: 'eip155:84532', assetFilter: '0xabc', assetNameFilter: 'USDT' }), + ); + const matched = events.find((e) => e.type === 'matched'); + if (matched?.type === 'matched') { + expect(matched.requirement.scheme).toBe('exact'); + } + expect(events.at(-1)?.type).toBe('replayed'); + }); + + it('errors NO_FILTERED_MATCH listing the available pairs when filters match nothing', async () => { + mockSeller(); + const events = await collect(deps({ assetNameFilter: 'EUR' })); + expect(events.map((e) => e.type)).toEqual(['decoded', 'errored']); + const terminal = events.at(-1); + if (terminal?.type === 'errored') { + expect(terminal.code).toBe('NO_FILTERED_MATCH'); + expect(terminal.message).toContain('--asset-name=EUR'); + expect(terminal.message).toContain('balance/inflow:1'); + expect(terminal.message).toContain('assetName=USDT'); + } + }); + + it('errors NO_INFLOW_MATCH when the client cannot sign any accepts entry', async () => { + mockSeller(); + const client = payingClient({ selectInflowRequirement: vi.fn(() => Promise.resolve(null)) }); + const events = await collect(deps({ client: client as never })); + const terminal = events.at(-1); + expect(terminal).toMatchObject({ type: 'errored', code: 'NO_INFLOW_MATCH' }); + }); + + it('errors DECODE_FAILED when the PAYMENT-REQUIRED header is malformed', async () => { + mockSeller({ requiredHeader: '%%%not-a-header%%%' }); + const events = await collect(deps()); + const terminal = events.at(-1); + expect(terminal?.type).toBe('errored'); + if (terminal?.type === 'errored') { + expect(terminal.code).toBe('DECODE_FAILED'); + } + }); + + it('maps a prepare-time SDK error (approval cancelled) into its canonical code', async () => { + mockSeller(); + const client = payingClient({ + prepareInflowPayment: vi.fn(() => Promise.reject(new X402ApprovalCancelledError('appr_1'))), + }); + const events = await collect(deps({ client: client as never })); + expect(events.map((e) => e.type)).toEqual(['decoded', 'matched', 'errored']); + expect(events.at(-1)).toMatchObject({ type: 'errored', code: 'APPROVAL_CANCELLED' }); + }); + + it('maps an awaitPayload timeout into APPROVAL_TIMEOUT after prepared', async () => { + mockSeller(); + const prepared = { + transactionId: 'tx-1', + approvalId: 'appr_1', + awaitPayload: vi.fn(() => Promise.reject(new X402ApprovalTimeoutError('appr_1', 1000))), + } as unknown as PreparedPayment; + const client = payingClient({ prepareInflowPayment: vi.fn(() => Promise.resolve(prepared)) }); + const events = await collect(deps({ client: client as never })); + expect(events.map((e) => e.type)).toEqual(['decoded', 'matched', 'prepared', 'errored']); + expect(events.at(-1)).toMatchObject({ type: 'errored', code: 'APPROVAL_TIMEOUT' }); + }); + + it('collapses a probe-time network failure into the generic PAYMENT_FAILED frame', async () => { + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('network down')); + const events = await collect(deps()); + expect(events).toEqual([{ type: 'errored', code: 'PAYMENT_FAILED', message: 'network down' }]); + }); +}); + +describe('buildSettledMeta', () => { + it('returns undefined when the PAYMENT-RESPONSE header is absent', () => { + expect(buildSettledMeta(new Headers())).toBeUndefined(); + }); + + it('returns undefined when the header is not decodable', () => { + expect(buildSettledMeta(new Headers({ [HEADERS.PAYMENT_RESPONSE]: '%%%' }))).toBeUndefined(); + }); + + it('returns undefined when the decoded response carries neither network nor transaction', () => { + const header = encodePaymentResponseHeader({ success: true } as never); + expect(buildSettledMeta(new Headers({ [HEADERS.PAYMENT_RESPONSE]: header }))).toBeUndefined(); + }); + + it('projects network + transaction from a settled response header', () => { + const header = encodePaymentResponseHeader({ + success: true, + network: 'eip155:84532', + transaction: '0xtx42', + } as never); + expect(buildSettledMeta(new Headers({ [HEADERS.PAYMENT_RESPONSE]: header }))).toEqual({ + network: 'eip155:84532', + transaction: '0xtx42', + }); + }); +}); + +describe('buildBodyAttachment — outputFile', () => { + it('writes the bytes to the file and reports the absolute path instead of an inline body', async () => { + const dir = await mkdtemp(join(tmpdir(), 'x402-pay-test-')); + const target = join(dir, 'out.bin'); + try { + const out = await buildBodyAttachment(new TextEncoder().encode('saved!'), true, target); + expect(out.outputSavedTo).toBe(resolvePath(target)); + expect(out.body).toBeUndefined(); + expect(out.bodyBase64).toBeUndefined(); + expect(out.bodySizeBytes).toBe(6); + await expect(readFile(target, 'utf8')).resolves.toBe('saved!'); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); + +describe('reducePay — remaining transitions', () => { + const requirement = { scheme: 'balance', network: 'inflow:1' } as unknown as PaymentRequirements; + const decoded = { + x402Version: 2, + resource: { url: SELLER, method: 'GET' }, + accepts: [], + }; + const probe = { + status: 200, + headers: new Headers(), + bytes: new Uint8Array(), + contentType: undefined, + }; + + it('probed → no-payment', () => { + expect(reducePay({ kind: 'probing' }, { type: 'probed', probe })).toEqual({ kind: 'no-payment', probe }); + }); + + it('decoded → matching', () => { + expect(reducePay({ kind: 'probing' }, { type: 'decoded', decoded })).toEqual({ kind: 'matching', decoded }); + }); + + it('matched → preparing', () => { + expect(reducePay({ kind: 'probing' }, { type: 'matched', decoded, requirement })).toEqual({ + kind: 'preparing', + decoded, + requirement, + }); + }); + + it('prepared → awaiting-approval', () => { + const prepared = preparedPayment(); + expect( + reducePay({ kind: 'probing' }, { type: 'prepared', decoded, requirement, prepared, approvalUrl: 'https://a/' }), + ).toEqual({ kind: 'awaiting-approval', decoded, requirement, prepared, approvalUrl: 'https://a/' }); + }); + + it('awaited → replaying', () => { + const encoded = { encodedPayload: 'ENC' } as never; + expect( + reducePay( + { kind: 'probing' }, + { type: 'awaited', encoded, approvalUrl: 'https://a/', scheme: 'balance', network: 'inflow:1' }, + ), + ).toEqual({ kind: 'replaying', encoded, approvalUrl: 'https://a/', scheme: 'balance', network: 'inflow:1' }); + }); + + it('replayed → success', () => { + const result = { outcome: 'paid' } as unknown as PayResultSuccess; + expect(reducePay({ kind: 'probing' }, { type: 'replayed', result })).toEqual({ kind: 'success', result }); + }); + + it('rejected → replay-rejected', () => { + const result = { outcome: 'replay-rejected' } as unknown as PayResultReplayRejected; + expect(reducePay({ kind: 'probing' }, { type: 'rejected', result })).toEqual({ + kind: 'replay-rejected', + result, + }); + }); + + it('returns the prior state for an unrecognised event (default branch)', () => { + const prior = { kind: 'probing' } as const; + expect(reducePay(prior, { type: 'bogus' } as never)).toBe(prior); + }); +}); diff --git a/packages/core/test/unit/flows/x402-status-polling.test.ts b/packages/core/test/unit/flows/x402-status-polling.test.ts new file mode 100644 index 0000000..65de061 --- /dev/null +++ b/packages/core/test/unit/flows/x402-status-polling.test.ts @@ -0,0 +1,58 @@ +import type { X402PayloadResponse } from '@inflowpayai/x402-buyer'; +import { describe, expect, it, vi } from 'vitest'; +import { reduceX402Status, runX402Status, type X402StatusEvent } from '../../../src/flows/x402-status.js'; + +async function drain(iterable: AsyncIterable): Promise { + const out: T[] = []; + for await (const v of iterable) out.push(v); + return out; +} + +const pending = { status: 'INITIATED' } as unknown as X402PayloadResponse; +const signed = { status: 'SIGNED', encodedPayload: 'ep', paymentPayload: 'pp' } as unknown as X402PayloadResponse; + +describe('reduceX402Status — remaining transitions', () => { + it('failed transitions to the failed phase carrying the response', () => { + const response = { status: 'DECLINED' } as unknown as X402PayloadResponse; + expect(reduceX402Status({ kind: 'polling' }, { type: 'failed', response })).toEqual({ kind: 'failed', response }); + }); + + it('timedOut with a response keeps the last snapshot on the timeout phase', () => { + expect(reduceX402Status({ kind: 'polling' }, { type: 'timedOut', response: pending })).toEqual({ + kind: 'timeout', + response: pending, + }); + }); + + it('returns the prior state for an unrecognised event (default branch)', () => { + const prior = { kind: 'polling' } as const; + expect(reduceX402Status(prior, { type: 'bogus' } as never)).toBe(prior); + }); +}); + +describe('runX402Status — polling paths', () => { + it('yields a snapshot for the pending tick, dedups the repeat, then settles', async () => { + const fetchOnce = vi + .fn<() => Promise>() + .mockResolvedValueOnce(pending) + .mockResolvedValueOnce(pending) + .mockResolvedValue(signed); + const events = await drain(runX402Status({ fetchOnce, interval: 0.01, maxAttempts: 10, timeout: 30 }).events); + expect(events.map((e) => e.type)).toEqual(['snapshot', 'settled']); + const settledEvent = events.at(-1) as X402StatusEvent; + if (settledEvent.type === 'settled') { + expect(settledEvent.response).toBe(signed); + } + expect(fetchOnce).toHaveBeenCalledTimes(3); + }); + + it('emits timedOut carrying the last pending response when maxAttempts is exhausted', async () => { + const fetchOnce = vi.fn<() => Promise>().mockResolvedValue(pending); + const events = await drain(runX402Status({ fetchOnce, interval: 0.01, maxAttempts: 2, timeout: 30 }).events); + expect(events.map((e) => e.type)).toEqual(['snapshot', 'timedOut']); + const terminal = events.at(-1) as X402StatusEvent; + if (terminal.type === 'timedOut') { + expect(terminal.response).toBe(pending); + } + }); +}); diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts index 8a12baa..6e2c180 100644 --- a/packages/core/vitest.config.ts +++ b/packages/core/vitest.config.ts @@ -12,10 +12,10 @@ export default defineConfig({ reporter: ['text', 'lcov'], reportsDirectory: process.env.COVERAGE_DIR ?? 'coverage', thresholds: { - statements: 80, - branches: 75, - functions: 80, - lines: 80, + statements: 91, + branches: 85, + functions: 91, + lines: 91, }, }, },