@bsv/sdk
Version:
BSV Blockchain Software Development Kit
263 lines (232 loc) • 8.38 kB
text/typescript
import { jest } from '@jest/globals'
import { AuthFetch } from '../AuthFetch.js'
import { Utils, PrivateKey } from '../../../primitives/index.js'
jest.mock('../../utils/createNonce.js', () => ({
createNonce: jest.fn()
}))
import { createNonce } from '../../utils/createNonce.js'
type Mutable<T> = {
-readonly [P in keyof T]: T[P]
}
interface TestPaymentContext {
satoshisRequired: number
transactionBase64: string
derivationPrefix: string
derivationSuffix: string
serverIdentityKey: string
clientIdentityKey: string
attempts: number
maxAttempts: number
errors: Array<{
attempt: number
timestamp: string
message: string
stack?: string
}>
requestSummary: {
url: string
method: string
headers: Record<string, string>
bodyType: string
bodyByteLength: number
}
}
const createNonceMock = createNonce as jest.MockedFunction<typeof createNonce>
function createWalletStub (): any {
const identityKey = new PrivateKey(10).toPublicKey().toString()
const derivedKey = new PrivateKey(11).toPublicKey().toString()
return {
getPublicKey: jest.fn(async (options: Record<string, any>) => {
if (options?.identityKey === true) {
return { publicKey: identityKey }
}
return { publicKey: derivedKey }
}),
createAction: jest.fn(async () => ({
tx: Utils.toArray('mock-transaction', 'utf8')
})),
createHmac: jest.fn(async () => ({
hmac: new Array(32).fill(7)
}))
}
}
function createPaymentRequiredResponse (overrides: Record<string, string> = {}): Response {
const headers: Record<string, string> = {
'x-bsv-payment-version': '1.0',
'x-bsv-payment-satoshis-required': '5',
'x-bsv-auth-identity-key': 'server-key',
'x-bsv-payment-derivation-prefix': 'prefix',
...overrides
}
return new Response('', { status: 402, headers })
}
afterEach(() => {
jest.restoreAllMocks()
createNonceMock.mockReset()
})
describe('AuthFetch payment handling', () => {
test('createPaymentContext builds a complete retry context', async () => {
const wallet = createWalletStub()
const authFetch = new AuthFetch(wallet as any)
createNonceMock.mockResolvedValueOnce('suffix-from-test')
const config = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: { hello: 'world' }
}
const context = await (authFetch as any).createPaymentContext(
'https://api.example.com/resource',
config,
42,
'remote-identity-key',
'test-prefix'
) as TestPaymentContext
expect(context.satoshisRequired).toBe(42)
expect(context.serverIdentityKey).toBe('remote-identity-key')
expect(context.derivationPrefix).toBe('test-prefix')
expect(context.derivationSuffix).toBe('suffix-from-test')
expect(context.transactionBase64).toBe(Utils.toBase64(Utils.toArray('mock-transaction', 'utf8')))
expect(context.clientIdentityKey).toEqual(expect.any(String))
expect(context.attempts).toBe(0)
expect(context.maxAttempts).toBe(3)
expect(context.errors).toEqual([])
expect(context.requestSummary).toMatchObject({
url: 'https://api.example.com/resource',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
bodyType: 'object'
})
expect(context.requestSummary.bodyByteLength).toBe(
Utils.toArray(JSON.stringify(config.body), 'utf8').length
)
expect(wallet.createAction).toHaveBeenCalledWith(
expect.objectContaining({
description: expect.stringContaining('https://api.example.com'),
outputs: [
expect.objectContaining({
satoshis: 42,
customInstructions: expect.stringContaining('remote-identity-key')
})
]
}),
undefined
)
})
test('handlePaymentAndRetry reuses compatible contexts and adds payment header', async () => {
const wallet = createWalletStub()
const authFetch = new AuthFetch(wallet as any)
const paymentContext: TestPaymentContext = {
satoshisRequired: 5,
transactionBase64: Utils.toBase64([1, 2, 3]),
derivationPrefix: 'prefix',
derivationSuffix: 'suffix',
serverIdentityKey: 'server-key',
clientIdentityKey: 'client-key',
attempts: 0,
maxAttempts: 3,
errors: [],
requestSummary: {
url: 'https://api.example.com/resource',
method: 'POST',
headers: { 'X-Test': '1' },
bodyType: 'none',
bodyByteLength: 0
}
}
const fetchSpy = jest.spyOn(authFetch, 'fetch').mockResolvedValue({ status: 200 } as Response)
jest.spyOn(authFetch as any, 'logPaymentAttempt').mockImplementation(() => {})
const createPaymentContextSpy = jest.spyOn(authFetch as any, 'createPaymentContext')
const config: Mutable<any> = {
headers: { 'x-custom': 'value' },
paymentContext
}
const response = createPaymentRequiredResponse()
const result = await (authFetch as any).handlePaymentAndRetry(
'https://api.example.com/resource',
config,
response
)
expect(result).toEqual({ status: 200 })
expect(paymentContext.attempts).toBe(1)
expect(fetchSpy).toHaveBeenCalledTimes(1)
const callArgs = fetchSpy.mock.calls[0] as [string, any]
const nextConfig = callArgs?.[1]
expect(nextConfig).toBeDefined()
expect(nextConfig.paymentContext).toBe(paymentContext)
expect(nextConfig.retryCounter).toBe(3)
const paymentHeader = JSON.parse(nextConfig.headers['x-bsv-payment'])
expect(paymentHeader).toEqual({
derivationPrefix: 'prefix',
derivationSuffix: 'suffix',
transaction: Utils.toBase64([1, 2, 3])
})
expect(createPaymentContextSpy).not.toHaveBeenCalled()
})
test('handlePaymentAndRetry exhausts attempts and throws detailed error', async () => {
const wallet = createWalletStub()
const authFetch = new AuthFetch(wallet as any)
jest.spyOn(authFetch as any, 'logPaymentAttempt').mockImplementation(() => {})
jest.spyOn(authFetch as any, 'wait').mockResolvedValue(undefined)
const firstError = new Error('payment attempt 1 failed')
const secondError = new Error('payment attempt 2 failed')
jest.spyOn(authFetch, 'fetch')
.mockRejectedValueOnce(firstError)
.mockRejectedValueOnce(secondError)
const paymentContext: TestPaymentContext = {
satoshisRequired: 5,
transactionBase64: Utils.toBase64([9, 9, 9]),
derivationPrefix: 'prefix',
derivationSuffix: 'suffix',
serverIdentityKey: 'server-key',
clientIdentityKey: 'client-key',
attempts: 0,
maxAttempts: 2,
errors: [],
requestSummary: {
url: 'https://api.example.com/resource',
method: 'GET',
headers: {},
bodyType: 'none',
bodyByteLength: 0
}
}
const config: Mutable<any> = { paymentContext }
const response = createPaymentRequiredResponse()
await expect((async () => {
try {
await (authFetch as any).handlePaymentAndRetry(
'https://api.example.com/resource',
config,
response
)
} catch (error) {
const err = error as any
expect(err.message).toBe(
'Paid request to https://api.example.com/resource failed after 2/2 attempts. Sent 5 satoshis to server-key.'
)
expect(err.details).toMatchObject({
attempts: { used: 2, max: 2 },
payment: expect.objectContaining({
satoshis: 5,
serverIdentityKey: 'server-key',
clientIdentityKey: 'client-key'
})
})
expect(err.details.errors).toHaveLength(2)
expect(err.details.errors[0]).toEqual(expect.objectContaining({
attempt: 1,
message: 'payment attempt 1 failed'
}))
expect(err.details.errors[1]).toEqual(expect.objectContaining({
attempt: 2,
message: 'payment attempt 2 failed'
}))
expect(typeof err.details.errors[0].timestamp).toBe('string')
expect(err.cause).toBe(secondError)
throw error
}
})()).rejects.toThrow('Paid request to https://api.example.com/resource failed after 2/2 attempts. Sent 5 satoshis to server-key.')
expect(paymentContext.attempts).toBe(2)
expect(paymentContext.errors).toHaveLength(2)
})
})