@bsv/sdk 2.0.11 → 2.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/dist/cjs/package.json +1 -1
  2. package/dist/cjs/src/auth/clients/__tests__/AuthFetch.additional.test.js +827 -0
  3. package/dist/cjs/src/auth/clients/__tests__/AuthFetch.additional.test.js.map +1 -0
  4. package/dist/cjs/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.js +654 -0
  5. package/dist/cjs/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.js.map +1 -0
  6. package/dist/cjs/src/overlay-tools/HostReputationTracker.js +21 -13
  7. package/dist/cjs/src/overlay-tools/HostReputationTracker.js.map +1 -1
  8. package/dist/cjs/src/primitives/PrivateKey.js +3 -3
  9. package/dist/cjs/src/primitives/PrivateKey.js.map +1 -1
  10. package/dist/cjs/src/script/Spend.js +17 -9
  11. package/dist/cjs/src/script/Spend.js.map +1 -1
  12. package/dist/cjs/src/storage/StorageDownloader.js +6 -6
  13. package/dist/cjs/src/storage/StorageDownloader.js.map +1 -1
  14. package/dist/cjs/src/storage/StorageUtils.js +1 -1
  15. package/dist/cjs/src/storage/StorageUtils.js.map +1 -1
  16. package/dist/cjs/src/transaction/MerklePath.js +168 -27
  17. package/dist/cjs/src/transaction/MerklePath.js.map +1 -1
  18. package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
  19. package/dist/esm/src/auth/clients/__tests__/AuthFetch.additional.test.js +825 -0
  20. package/dist/esm/src/auth/clients/__tests__/AuthFetch.additional.test.js.map +1 -0
  21. package/dist/esm/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.js +619 -0
  22. package/dist/esm/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.js.map +1 -0
  23. package/dist/esm/src/overlay-tools/HostReputationTracker.js +21 -13
  24. package/dist/esm/src/overlay-tools/HostReputationTracker.js.map +1 -1
  25. package/dist/esm/src/primitives/PrivateKey.js +3 -3
  26. package/dist/esm/src/primitives/PrivateKey.js.map +1 -1
  27. package/dist/esm/src/script/Spend.js +17 -9
  28. package/dist/esm/src/script/Spend.js.map +1 -1
  29. package/dist/esm/src/storage/StorageDownloader.js +6 -6
  30. package/dist/esm/src/storage/StorageDownloader.js.map +1 -1
  31. package/dist/esm/src/storage/StorageUtils.js +1 -1
  32. package/dist/esm/src/storage/StorageUtils.js.map +1 -1
  33. package/dist/esm/src/transaction/MerklePath.js +168 -27
  34. package/dist/esm/src/transaction/MerklePath.js.map +1 -1
  35. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  36. package/dist/types/src/auth/clients/__tests__/AuthFetch.additional.test.d.ts +21 -0
  37. package/dist/types/src/auth/clients/__tests__/AuthFetch.additional.test.d.ts.map +1 -0
  38. package/dist/types/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.d.ts +2 -0
  39. package/dist/types/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.d.ts.map +1 -0
  40. package/dist/types/src/overlay-tools/HostReputationTracker.d.ts.map +1 -1
  41. package/dist/types/src/script/Spend.d.ts.map +1 -1
  42. package/dist/types/src/transaction/MerklePath.d.ts +27 -0
  43. package/dist/types/src/transaction/MerklePath.d.ts.map +1 -1
  44. package/dist/types/tsconfig.types.tsbuildinfo +1 -1
  45. package/dist/umd/bundle.js +3 -3
  46. package/dist/umd/bundle.js.map +1 -1
  47. package/docs/reference/storage.md +1 -1
  48. package/docs/reference/transaction.md +40 -0
  49. package/package.json +1 -1
  50. package/src/auth/clients/__tests__/AuthFetch.additional.test.ts +1131 -0
  51. package/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.ts +770 -0
  52. package/src/auth/utils/__tests/validateCertificates.test.ts +12 -9
  53. package/src/compat/__tests/Mnemonic.additional.test.ts +64 -0
  54. package/src/identity/__tests/IdentityClient.additional.test.ts +767 -0
  55. package/src/kvstore/__tests/LocalKVStore.additional.test.ts +611 -0
  56. package/src/kvstore/__tests/LocalKVStore.test.ts +4 -6
  57. package/src/kvstore/__tests/kvStoreInterpreter.test.ts +327 -0
  58. package/src/overlay-tools/HostReputationTracker.ts +17 -14
  59. package/src/overlay-tools/__tests/HostReputationTracker.additional.test.ts +561 -0
  60. package/src/overlay-tools/__tests/LookupResolver.additional.test.ts +612 -0
  61. package/src/overlay-tools/__tests/withDoubleSpendRetry.test.ts +278 -0
  62. package/src/primitives/PrivateKey.ts +3 -3
  63. package/src/primitives/__tests/BigNumber.additional.test.ts +79 -0
  64. package/src/primitives/__tests/Curve.additional.test.ts +208 -0
  65. package/src/primitives/__tests/ECDSA.additional.test.ts +122 -0
  66. package/src/primitives/__tests/Hash.additional.test.ts +59 -0
  67. package/src/primitives/__tests/JacobianPoint.test.ts +308 -0
  68. package/src/primitives/__tests/Point.additional.test.ts +503 -0
  69. package/src/primitives/__tests/PublicKey.additional.test.ts +383 -0
  70. package/src/primitives/__tests/Random.additional.test.ts +262 -0
  71. package/src/primitives/__tests/Signature.test.ts +333 -0
  72. package/src/primitives/__tests/TransactionSignature.additional.test.ts +241 -0
  73. package/src/registry/__tests/RegistryClient.additional.test.ts +750 -0
  74. package/src/remittance/__tests/BasicBRC29.additional.test.ts +657 -0
  75. package/src/remittance/__tests/RemittanceManager.additional.test.ts +1272 -0
  76. package/src/script/Spend.ts +19 -11
  77. package/src/script/__tests/LockingUnlockingScript.test.ts +79 -0
  78. package/src/script/__tests/Script.additional.test.ts +100 -0
  79. package/src/script/__tests/ScriptEvaluationError.test.ts +98 -0
  80. package/src/script/__tests/Spend.additional.test.ts +837 -0
  81. package/src/script/templates/__tests/RPuzzle.test.ts +134 -0
  82. package/src/storage/StorageDownloader.ts +6 -6
  83. package/src/storage/StorageUtils.ts +1 -1
  84. package/src/transaction/MerklePath.ts +196 -36
  85. package/src/transaction/__tests/BeefParty.additional.test.ts +22 -0
  86. package/src/transaction/__tests/Broadcaster.test.ts +159 -0
  87. package/src/transaction/__tests/MerklePath.bench.test.ts +105 -0
  88. package/src/transaction/__tests/MerklePath.test.ts +232 -21
  89. package/src/transaction/__tests/Transaction.additional.test.ts +225 -0
  90. package/src/transaction/broadcasters/__tests/ARC.additional.test.ts +585 -0
  91. package/src/transaction/broadcasters/__tests/Teranode.test.ts +349 -0
  92. package/src/transaction/chaintrackers/__tests/BlockHeadersService.test.ts +253 -0
  93. package/src/transaction/chaintrackers/__tests/DefaultChainTracker.test.ts +44 -0
  94. package/src/transaction/chaintrackers/__tests/WhatsOnChain.additional.test.ts +193 -0
  95. package/src/transaction/fee-models/__tests/SatoshisPerKilobyte.test.ts +262 -0
  96. package/src/transaction/http/__tests/BinaryFetchClient.test.ts +212 -0
  97. package/src/transaction/http/__tests/DefaultHttpClient.additional.test.ts +192 -0
  98. package/src/transaction/http/__tests/DefaultHttpClient.test.ts +71 -0
  99. package/src/wallet/__tests/ProtoWallet.additional.test.ts +134 -0
  100. package/src/wallet/__tests/WERR.test.ts +212 -0
  101. package/src/wallet/__tests/WalletClient.additional.test.ts +699 -0
  102. package/src/wallet/__tests/WalletClient.substrate.test.ts +759 -0
  103. package/src/wallet/__tests/WalletError.test.ts +290 -0
  104. package/src/wallet/__tests/validationHelpers.test.ts +1218 -0
  105. package/src/wallet/substrates/__tests/HTTPWalletJSON.test.ts +496 -0
  106. package/src/wallet/substrates/__tests/HTTPWalletWire.test.ts +273 -0
@@ -0,0 +1,825 @@
1
+ /**
2
+ * AuthFetch additional tests.
3
+ *
4
+ * Covers branches not exercised by the primary AuthFetch.test.ts:
5
+ * - fetch(): retryCounter exhaustion, non-auth fallback path, stale-session retry
6
+ * - serializeRequest(): all header/body/URL variants
7
+ * - handleFetchAndValidate(): success, x-bsv header spoofing, non-ok response
8
+ * - handlePaymentAndRetry(): missing/invalid headers, incompatible context regeneration
9
+ * - describeRequestBodyForLogging(): all body types
10
+ * - getMaxPaymentAttempts(): edge cases
11
+ * - getPaymentRetryDelay(): values at different attempt counts
12
+ * - wait(): zero / positive
13
+ * - isPaymentContextCompatible(): match / mismatch branches
14
+ * - consumeReceivedCertificates(): drains the internal buffer
15
+ * - sendCertificateRequest(): creates new peer when none exists
16
+ * - logPaymentAttempt(): all three log levels
17
+ * - createPaymentErrorEntry(): Error vs non-Error values
18
+ * - buildPaymentFailureError(): shapes the error correctly
19
+ */
20
+ import { jest } from '@jest/globals';
21
+ import { AuthFetch } from '../AuthFetch.js';
22
+ import { Utils, PrivateKey } from '../../../primitives/index.js';
23
+ // ---------------------------------------------------------------------------
24
+ // Module mock for createNonce (matches what primary test does)
25
+ // ---------------------------------------------------------------------------
26
+ jest.mock('../../utils/createNonce.js', () => ({
27
+ createNonce: jest.fn()
28
+ }));
29
+ import { createNonce } from '../../utils/createNonce.js';
30
+ const createNonceMock = createNonce;
31
+ // ---------------------------------------------------------------------------
32
+ // Helpers
33
+ // ---------------------------------------------------------------------------
34
+ function buildWallet() {
35
+ const identityKey = new PrivateKey(10).toPublicKey().toString();
36
+ const derivedKey = new PrivateKey(11).toPublicKey().toString();
37
+ return {
38
+ getPublicKey: jest.fn(async (opts) => opts?.identityKey === true ? { publicKey: identityKey } : { publicKey: derivedKey }),
39
+ createAction: jest.fn(async () => ({
40
+ tx: Utils.toArray('mock-tx', 'utf8')
41
+ })),
42
+ createHmac: jest.fn(async () => ({ hmac: new Array(32).fill(0) }))
43
+ };
44
+ }
45
+ function make402Response(overrides = {}) {
46
+ const headers = {
47
+ 'x-bsv-payment-version': '1.0',
48
+ 'x-bsv-payment-satoshis-required': '10',
49
+ 'x-bsv-auth-identity-key': 'srv-key',
50
+ 'x-bsv-payment-derivation-prefix': 'pfx',
51
+ ...overrides
52
+ };
53
+ return new Response('', { status: 402, headers });
54
+ }
55
+ afterEach(() => {
56
+ jest.restoreAllMocks();
57
+ createNonceMock.mockReset();
58
+ });
59
+ // ---------------------------------------------------------------------------
60
+ // 1. fetch() – retryCounter exhaustion
61
+ // ---------------------------------------------------------------------------
62
+ describe('AuthFetch.fetch – retryCounter', () => {
63
+ it('throws when retryCounter reaches 0', async () => {
64
+ const authFetch = new AuthFetch(buildWallet());
65
+ await expect(authFetch.fetch('https://example.com', { retryCounter: 0 })).rejects.toThrow('Request failed after maximum number of retries.');
66
+ });
67
+ it('decrements retryCounter before making the request', async () => {
68
+ // Verify that the stale-session retry path calls fetch() again, which means
69
+ // retryCounter gets decremented. We intercept the recursive fetch() call using
70
+ // a spy so a real Peer is never constructed inside the unit-test environment.
71
+ const authFetch = new AuthFetch(buildWallet());
72
+ let fetchCallCount = 0;
73
+ const originalFetch = authFetch.fetch.bind(authFetch);
74
+ jest.spyOn(authFetch, 'fetch').mockImplementation(async (url, config) => {
75
+ fetchCallCount++;
76
+ if (fetchCallCount === 1) {
77
+ // First call: run the real code path so the stale-session branch triggers
78
+ return originalFetch(url, config);
79
+ }
80
+ // Subsequent calls (recursive retry after stale-session): throw to prove
81
+ // the retry occurred with a decremented retryCounter.
82
+ throw new Error('second call');
83
+ });
84
+ // Inject a stub peer that throws a stale-session error on toPeer()
85
+ const peerStub = {
86
+ listenForCertificatesReceived: jest.fn(),
87
+ listenForCertificatesRequested: jest.fn(),
88
+ listenForGeneralMessages: jest.fn(() => 1),
89
+ stopListeningForGeneralMessages: jest.fn(),
90
+ toPeer: jest.fn(async () => {
91
+ throw new Error('Session not found for nonce xyz');
92
+ })
93
+ };
94
+ authFetch.peers['https://example.com'] = {
95
+ peer: peerStub,
96
+ identityKey: 'some-key',
97
+ supportsMutualAuth: true,
98
+ pendingCertificateRequests: []
99
+ };
100
+ // With retryCounter: 2, the stale-session branch retries once; the spy
101
+ // intercepts the recursive call and throws 'second call'.
102
+ await expect(authFetch.fetch('https://example.com/path', { retryCounter: 2 })).rejects.toThrow('second call');
103
+ expect(fetchCallCount).toBe(2);
104
+ });
105
+ });
106
+ // ---------------------------------------------------------------------------
107
+ // 2. fetch() – supportsMutualAuth === false fallback
108
+ // ---------------------------------------------------------------------------
109
+ describe('AuthFetch.fetch – non-auth fallback (supportsMutualAuth=false)', () => {
110
+ it('falls back to handleFetchAndValidate when peer does not support mutual auth', async () => {
111
+ const authFetch = new AuthFetch(buildWallet());
112
+ const handleFetchSpy = jest
113
+ .spyOn(authFetch, 'handleFetchAndValidate')
114
+ .mockResolvedValue(new Response('ok', { status: 200 }));
115
+ const peerStub = {
116
+ peer: { toPeer: jest.fn() },
117
+ supportsMutualAuth: false,
118
+ pendingCertificateRequests: []
119
+ };
120
+ authFetch.peers['https://example.com'] = peerStub;
121
+ const result = await authFetch.fetch('https://example.com/resource');
122
+ expect(handleFetchSpy).toHaveBeenCalledTimes(1);
123
+ expect(result.status).toBe(200);
124
+ });
125
+ it('rejects when handleFetchAndValidate throws in non-auth fallback', async () => {
126
+ const authFetch = new AuthFetch(buildWallet());
127
+ jest
128
+ .spyOn(authFetch, 'handleFetchAndValidate')
129
+ .mockRejectedValue(new Error('fetch validation failed'));
130
+ const peerStub = {
131
+ peer: { toPeer: jest.fn() },
132
+ supportsMutualAuth: false,
133
+ pendingCertificateRequests: []
134
+ };
135
+ authFetch.peers['https://example.com'] = peerStub;
136
+ await expect(authFetch.fetch('https://example.com/resource')).rejects.toThrow('fetch validation failed');
137
+ });
138
+ });
139
+ // ---------------------------------------------------------------------------
140
+ // 3. handleFetchAndValidate
141
+ // ---------------------------------------------------------------------------
142
+ describe('AuthFetch.handleFetchAndValidate (private)', () => {
143
+ it('returns the response when fetch succeeds with no x-bsv headers', async () => {
144
+ const authFetch = new AuthFetch(buildWallet());
145
+ const mockResponse = new Response('body', {
146
+ status: 200,
147
+ headers: { 'Content-Type': 'text/plain' }
148
+ });
149
+ jest.spyOn(global, 'fetch').mockResolvedValue(mockResponse);
150
+ const peerToUse = { supportsMutualAuth: undefined };
151
+ const response = await authFetch.handleFetchAndValidate('https://example.com', { method: 'GET' }, peerToUse);
152
+ expect(response.status).toBe(200);
153
+ expect(peerToUse.supportsMutualAuth).toBe(false);
154
+ });
155
+ it('throws when response contains an x-bsv header (spoofing detection)', async () => {
156
+ const authFetch = new AuthFetch(buildWallet());
157
+ // The source iterates response.headers.forEach((value, name) => ...)
158
+ // and checks if the VALUE starts with 'x-bsv'. To trigger spoofing
159
+ // detection we need a header whose value starts with 'x-bsv'.
160
+ const mockResponse = new Response('', {
161
+ status: 200,
162
+ headers: { 'x-custom-header': 'x-bsv-auth-identity-key' }
163
+ });
164
+ jest.spyOn(global, 'fetch').mockResolvedValue(mockResponse);
165
+ await expect(authFetch.handleFetchAndValidate('https://example.com', {}, { supportsMutualAuth: undefined })).rejects.toThrow('The server is trying to claim it has been authenticated');
166
+ });
167
+ it('throws when response is not ok', async () => {
168
+ const authFetch = new AuthFetch(buildWallet());
169
+ const mockResponse = new Response('Not Found', { status: 404 });
170
+ jest.spyOn(global, 'fetch').mockResolvedValue(mockResponse);
171
+ await expect(authFetch.handleFetchAndValidate('https://example.com', {}, { supportsMutualAuth: undefined })).rejects.toThrow('Request failed with status: 404');
172
+ });
173
+ });
174
+ // ---------------------------------------------------------------------------
175
+ // 4. handlePaymentAndRetry – missing/invalid headers
176
+ // ---------------------------------------------------------------------------
177
+ describe('AuthFetch.handlePaymentAndRetry – header validation', () => {
178
+ it('throws when x-bsv-payment-version header is missing', async () => {
179
+ const authFetch = new AuthFetch(buildWallet());
180
+ const response = new Response('', { status: 402 }); // no payment headers
181
+ await expect(authFetch.handlePaymentAndRetry('https://example.com', {}, response)).rejects.toThrow('Unsupported x-bsv-payment-version response header');
182
+ });
183
+ it('throws when x-bsv-payment-version header has wrong value', async () => {
184
+ const authFetch = new AuthFetch(buildWallet());
185
+ const response = make402Response({ 'x-bsv-payment-version': '2.0' });
186
+ await expect(authFetch.handlePaymentAndRetry('https://example.com', {}, response)).rejects.toThrow('Unsupported x-bsv-payment-version response header');
187
+ });
188
+ it('throws when x-bsv-payment-satoshis-required header is missing', async () => {
189
+ const authFetch = new AuthFetch(buildWallet());
190
+ const response = make402Response({ 'x-bsv-payment-satoshis-required': '' });
191
+ // Force re-check: create a response without the satoshis header entirely
192
+ const headersRaw = {
193
+ 'x-bsv-payment-version': '1.0',
194
+ 'x-bsv-auth-identity-key': 'srv-key',
195
+ 'x-bsv-payment-derivation-prefix': 'pfx'
196
+ // satoshis-required intentionally omitted
197
+ };
198
+ const respNoSatoshis = new Response('', { status: 402, headers: headersRaw });
199
+ await expect(authFetch.handlePaymentAndRetry('https://example.com', {}, respNoSatoshis)).rejects.toThrow('Missing x-bsv-payment-satoshis-required response header');
200
+ });
201
+ it('throws when satoshis value is NaN', async () => {
202
+ const authFetch = new AuthFetch(buildWallet());
203
+ const response = make402Response({ 'x-bsv-payment-satoshis-required': 'not-a-number' });
204
+ await expect(authFetch.handlePaymentAndRetry('https://example.com', {}, response)).rejects.toThrow('Invalid x-bsv-payment-satoshis-required response header value');
205
+ });
206
+ it('throws when satoshis value is zero', async () => {
207
+ const authFetch = new AuthFetch(buildWallet());
208
+ const response = make402Response({ 'x-bsv-payment-satoshis-required': '0' });
209
+ await expect(authFetch.handlePaymentAndRetry('https://example.com', {}, response)).rejects.toThrow('Invalid x-bsv-payment-satoshis-required response header value');
210
+ });
211
+ it('throws when x-bsv-auth-identity-key header is missing', async () => {
212
+ const authFetch = new AuthFetch(buildWallet());
213
+ const headersRaw = {
214
+ 'x-bsv-payment-version': '1.0',
215
+ 'x-bsv-payment-satoshis-required': '10',
216
+ 'x-bsv-payment-derivation-prefix': 'pfx'
217
+ // identity-key omitted
218
+ };
219
+ const response = new Response('', { status: 402, headers: headersRaw });
220
+ await expect(authFetch.handlePaymentAndRetry('https://example.com', {}, response)).rejects.toThrow('Missing x-bsv-auth-identity-key response header');
221
+ });
222
+ it('throws when x-bsv-payment-derivation-prefix header is missing', async () => {
223
+ const authFetch = new AuthFetch(buildWallet());
224
+ const headersRaw = {
225
+ 'x-bsv-payment-version': '1.0',
226
+ 'x-bsv-payment-satoshis-required': '10',
227
+ 'x-bsv-auth-identity-key': 'srv-key'
228
+ // derivation-prefix omitted
229
+ };
230
+ const response = new Response('', { status: 402, headers: headersRaw });
231
+ await expect(authFetch.handlePaymentAndRetry('https://example.com', {}, response)).rejects.toThrow('Missing x-bsv-payment-derivation-prefix response header');
232
+ });
233
+ it('throws when derivation-prefix is an empty string', async () => {
234
+ const authFetch = new AuthFetch(buildWallet());
235
+ const response = make402Response({ 'x-bsv-payment-derivation-prefix': '' });
236
+ await expect(authFetch.handlePaymentAndRetry('https://example.com', {}, response)).rejects.toThrow('Missing x-bsv-payment-derivation-prefix response header');
237
+ });
238
+ });
239
+ // ---------------------------------------------------------------------------
240
+ // 5. handlePaymentAndRetry – incompatible context triggers new context creation
241
+ // ---------------------------------------------------------------------------
242
+ describe('AuthFetch.handlePaymentAndRetry – context compatibility', () => {
243
+ it('regenerates context when server changes payment requirements', async () => {
244
+ const authFetch = new AuthFetch(buildWallet());
245
+ jest.spyOn(authFetch, 'logPaymentAttempt').mockImplementation(() => { });
246
+ jest.spyOn(authFetch, 'wait').mockResolvedValue(undefined);
247
+ createNonceMock.mockResolvedValue('new-suffix');
248
+ const existingContext = {
249
+ satoshisRequired: 5, // server now asks for 10
250
+ transactionBase64: Utils.toBase64([1, 2, 3]),
251
+ derivationPrefix: 'pfx',
252
+ derivationSuffix: 'old-suffix',
253
+ serverIdentityKey: 'srv-key',
254
+ clientIdentityKey: 'client-key',
255
+ attempts: 0,
256
+ maxAttempts: 3,
257
+ errors: [],
258
+ requestSummary: {
259
+ url: 'https://example.com',
260
+ method: 'GET',
261
+ headers: {},
262
+ bodyType: 'none',
263
+ bodyByteLength: 0
264
+ }
265
+ };
266
+ const fetchSpy = jest
267
+ .spyOn(authFetch, 'fetch')
268
+ .mockResolvedValue(new Response('ok', { status: 200 }));
269
+ const response = make402Response({ 'x-bsv-payment-satoshis-required': '10' }); // changed from 5
270
+ await authFetch.handlePaymentAndRetry('https://example.com', { paymentContext: existingContext }, response);
271
+ // createNonce should have been called because the context was regenerated
272
+ expect(createNonceMock).toHaveBeenCalled();
273
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
274
+ });
275
+ });
276
+ // ---------------------------------------------------------------------------
277
+ // 6. handlePaymentAndRetry – maxAttempts exceeded before first try
278
+ // ---------------------------------------------------------------------------
279
+ describe('AuthFetch.handlePaymentAndRetry – maxAttempts exceeded pre-check', () => {
280
+ it('throws immediately when attempts >= maxAttempts', async () => {
281
+ const authFetch = new AuthFetch(buildWallet());
282
+ jest.spyOn(authFetch, 'logPaymentAttempt').mockImplementation(() => { });
283
+ const exhaustedContext = {
284
+ satoshisRequired: 10,
285
+ transactionBase64: Utils.toBase64([1]),
286
+ derivationPrefix: 'pfx',
287
+ derivationSuffix: 'sfx',
288
+ serverIdentityKey: 'srv-key',
289
+ clientIdentityKey: 'client-key',
290
+ attempts: 3,
291
+ maxAttempts: 3,
292
+ errors: [],
293
+ requestSummary: {
294
+ url: 'https://example.com',
295
+ method: 'GET',
296
+ headers: {},
297
+ bodyType: 'none',
298
+ bodyByteLength: 0
299
+ }
300
+ };
301
+ const response = make402Response();
302
+ await expect(authFetch.handlePaymentAndRetry('https://example.com', { paymentContext: exhaustedContext }, response)).rejects.toThrow('Paid request to https://example.com failed after 3/3 attempts');
303
+ });
304
+ });
305
+ // ---------------------------------------------------------------------------
306
+ // 7. serializeRequest – header and body branches
307
+ // ---------------------------------------------------------------------------
308
+ describe('AuthFetch.serializeRequest (private)', () => {
309
+ it('serializes a GET request with no body or headers', async () => {
310
+ const authFetch = new AuthFetch(buildWallet());
311
+ const nonce = new Array(32).fill(0);
312
+ const writer = await authFetch.serializeRequest('GET', {}, undefined, new URL('https://example.com/path'), nonce);
313
+ expect(writer).toBeDefined();
314
+ expect(writer.toArray().length).toBeGreaterThan(32);
315
+ });
316
+ it('serializes a POST request with a JSON body', async () => {
317
+ const authFetch = new AuthFetch(buildWallet());
318
+ const nonce = new Array(32).fill(1);
319
+ const writer = await authFetch.serializeRequest('POST', { 'content-type': 'application/json' }, { hello: 'world' }, new URL('https://example.com/api'), nonce);
320
+ expect(writer.toArray().length).toBeGreaterThan(32);
321
+ });
322
+ it('serializes a request with search params', async () => {
323
+ const authFetch = new AuthFetch(buildWallet());
324
+ const nonce = new Array(32).fill(2);
325
+ const writer = await authFetch.serializeRequest('GET', {}, undefined, new URL('https://example.com/api?q=hello'), nonce);
326
+ expect(writer.toArray().length).toBeGreaterThan(32);
327
+ });
328
+ it('includes x-bsv-* custom headers', async () => {
329
+ const authFetch = new AuthFetch(buildWallet());
330
+ const nonce = new Array(32).fill(3);
331
+ const writer = await authFetch.serializeRequest('GET', { 'x-bsv-custom': 'value123' }, undefined, new URL('https://example.com/'), nonce);
332
+ expect(writer.toArray().length).toBeGreaterThan(32);
333
+ });
334
+ it('includes authorization header', async () => {
335
+ const authFetch = new AuthFetch(buildWallet());
336
+ const nonce = new Array(32).fill(4);
337
+ const writer = await authFetch.serializeRequest('GET', { authorization: 'Bearer token123' }, undefined, new URL('https://example.com/'), nonce);
338
+ expect(writer.toArray().length).toBeGreaterThan(32);
339
+ });
340
+ it('throws for x-bsv-auth-* headers', async () => {
341
+ const authFetch = new AuthFetch(buildWallet());
342
+ const nonce = new Array(32).fill(5);
343
+ await expect(authFetch.serializeRequest('GET', { 'x-bsv-auth-identity-key': 'spoofed' }, undefined, new URL('https://example.com/'), nonce)).rejects.toThrow('No BSV auth headers allowed here!');
344
+ });
345
+ it('throws for unsupported headers', async () => {
346
+ const authFetch = new AuthFetch(buildWallet());
347
+ const nonce = new Array(32).fill(6);
348
+ await expect(authFetch.serializeRequest('GET', { 'accept': 'application/json' }, undefined, new URL('https://example.com/'), nonce)).rejects.toThrow('Unsupported header');
349
+ });
350
+ it('normalizes content-type by stripping parameters', async () => {
351
+ const authFetch = new AuthFetch(buildWallet());
352
+ const nonce = new Array(32).fill(7);
353
+ // Should not throw — content-type is allowed but parameters are stripped
354
+ const writer = await authFetch.serializeRequest('POST', { 'content-type': 'application/json; charset=utf-8' }, '{"x":1}', new URL('https://example.com/api'), nonce);
355
+ expect(writer.toArray().length).toBeGreaterThan(32);
356
+ });
357
+ it('defaults POST body to {} when content-type is application/json and body is undefined', async () => {
358
+ const authFetch = new AuthFetch(buildWallet());
359
+ const nonce = new Array(32).fill(8);
360
+ // Should not throw
361
+ const writer = await authFetch.serializeRequest('POST', { 'content-type': 'application/json' }, undefined, new URL('https://example.com/api'), nonce);
362
+ expect(writer.toArray().length).toBeGreaterThan(32);
363
+ });
364
+ it('defaults DELETE body to empty string when no content-type and body is undefined', async () => {
365
+ const authFetch = new AuthFetch(buildWallet());
366
+ const nonce = new Array(32).fill(9);
367
+ const writer = await authFetch.serializeRequest('DELETE', {}, undefined, new URL('https://example.com/resource'), nonce);
368
+ expect(writer.toArray().length).toBeGreaterThan(32);
369
+ });
370
+ });
371
+ // ---------------------------------------------------------------------------
372
+ // 8. describeRequestBodyForLogging – all body types
373
+ // ---------------------------------------------------------------------------
374
+ describe('AuthFetch.describeRequestBodyForLogging (private)', () => {
375
+ let authFetch;
376
+ beforeEach(() => {
377
+ authFetch = new AuthFetch(buildWallet());
378
+ });
379
+ it('returns type=none for null body', () => {
380
+ const result = authFetch.describeRequestBodyForLogging(null);
381
+ expect(result).toEqual({ type: 'none', byteLength: 0 });
382
+ });
383
+ it('returns type=none for undefined body', () => {
384
+ const result = authFetch.describeRequestBodyForLogging(undefined);
385
+ expect(result).toEqual({ type: 'none', byteLength: 0 });
386
+ });
387
+ it('returns type=string with correct byteLength', () => {
388
+ const result = authFetch.describeRequestBodyForLogging('hello');
389
+ expect(result.type).toBe('string');
390
+ expect(result.byteLength).toBe(5);
391
+ });
392
+ it('returns type=number[] for number array', () => {
393
+ const result = authFetch.describeRequestBodyForLogging([1, 2, 3]);
394
+ expect(result).toEqual({ type: 'number[]', byteLength: 3 });
395
+ });
396
+ it('returns type=array for non-number array', () => {
397
+ const result = authFetch.describeRequestBodyForLogging(['a', 'b']);
398
+ expect(result).toEqual({ type: 'array', byteLength: 2 });
399
+ });
400
+ it('returns type=ArrayBuffer for ArrayBuffer', () => {
401
+ const buf = new ArrayBuffer(8);
402
+ const result = authFetch.describeRequestBodyForLogging(buf);
403
+ expect(result).toEqual({ type: 'ArrayBuffer', byteLength: 8 });
404
+ });
405
+ it('returns typed array name for Uint8Array', () => {
406
+ const arr = new Uint8Array([1, 2, 3, 4]);
407
+ const result = authFetch.describeRequestBodyForLogging(arr);
408
+ expect(result.type).toBe('Uint8Array');
409
+ expect(result.byteLength).toBe(4);
410
+ });
411
+ it('returns type=Blob for Blob', () => {
412
+ const blob = new Blob(['hello world']);
413
+ const result = authFetch.describeRequestBodyForLogging(blob);
414
+ expect(result.type).toBe('Blob');
415
+ expect(result.byteLength).toBeGreaterThan(0);
416
+ });
417
+ it('returns type=URLSearchParams for URLSearchParams', () => {
418
+ const params = new URLSearchParams({ key: 'value' });
419
+ const result = authFetch.describeRequestBodyForLogging(params);
420
+ expect(result.type).toBe('URLSearchParams');
421
+ expect(result.byteLength).toBeGreaterThan(0);
422
+ });
423
+ it('returns type=FormData for FormData', () => {
424
+ const fd = new FormData();
425
+ fd.append('field', 'value');
426
+ const result = authFetch.describeRequestBodyForLogging(fd);
427
+ expect(result.type).toBe('FormData');
428
+ expect(result.byteLength).toBe(0);
429
+ });
430
+ it('returns type=object for a plain object', () => {
431
+ const result = authFetch.describeRequestBodyForLogging({ a: 1 });
432
+ expect(result.type).toBe('object');
433
+ expect(result.byteLength).toBeGreaterThan(0);
434
+ });
435
+ it('returns type=ReadableStream for ReadableStream', () => {
436
+ const stream = new ReadableStream();
437
+ const result = authFetch.describeRequestBodyForLogging(stream);
438
+ expect(result).toEqual({ type: 'ReadableStream', byteLength: 0 });
439
+ });
440
+ it('falls back to typeof for an unrecognised type', () => {
441
+ // A Symbol cannot be JSON-stringified, triggering the fallback
442
+ const sym = Symbol('test');
443
+ const result = authFetch.describeRequestBodyForLogging(sym);
444
+ expect(result.type).toBe('symbol');
445
+ expect(result.byteLength).toBe(0);
446
+ });
447
+ });
448
+ // ---------------------------------------------------------------------------
449
+ // 9. normalizeBodyToNumberArray – edge cases
450
+ // ---------------------------------------------------------------------------
451
+ describe('AuthFetch.normalizeBodyToNumberArray (private)', () => {
452
+ let authFetch;
453
+ beforeEach(() => {
454
+ authFetch = new AuthFetch(buildWallet());
455
+ });
456
+ it('returns [] for null', async () => {
457
+ const result = await authFetch.normalizeBodyToNumberArray(null);
458
+ expect(result).toEqual([]);
459
+ });
460
+ it('returns [] for undefined', async () => {
461
+ const result = await authFetch.normalizeBodyToNumberArray(undefined);
462
+ expect(result).toEqual([]);
463
+ });
464
+ it('converts a string to a number array', async () => {
465
+ const result = await authFetch.normalizeBodyToNumberArray('abc');
466
+ expect(result.length).toBe(3);
467
+ });
468
+ it('converts a number[] to a JSON-encoded number array', async () => {
469
+ // Arrays are objects, so they hit the typeof === 'object' branch first
470
+ // and are serialized via JSON.stringify before the number[] guard runs.
471
+ const input = [1, 2, 3];
472
+ const result = await authFetch.normalizeBodyToNumberArray(input);
473
+ // '[1,2,3]' encoded as UTF-8 bytes
474
+ const expected = Utils.toArray(JSON.stringify(input), 'utf8');
475
+ expect(result).toEqual(expected);
476
+ });
477
+ it('converts ArrayBuffer to a JSON-encoded number array', async () => {
478
+ // ArrayBuffer is an object, so it hits the typeof === 'object' branch first.
479
+ const buf = new Uint8Array([10, 20, 30]).buffer;
480
+ const result = await authFetch.normalizeBodyToNumberArray(buf);
481
+ // JSON.stringify of an ArrayBuffer produces '{}'
482
+ const expected = Utils.toArray(JSON.stringify(buf), 'utf8');
483
+ expect(result).toEqual(expected);
484
+ });
485
+ it('converts Uint8Array to a JSON-encoded number array', async () => {
486
+ // Uint8Array is an object, so it hits the typeof === 'object' branch first.
487
+ const arr = new Uint8Array([5, 6, 7]);
488
+ const result = await authFetch.normalizeBodyToNumberArray(arr);
489
+ // JSON.stringify of a Uint8Array produces e.g. '{"0":5,"1":6,"2":7}'
490
+ const expected = Utils.toArray(JSON.stringify(arr), 'utf8');
491
+ expect(result).toEqual(expected);
492
+ });
493
+ it('converts Blob via JSON.stringify (object branch)', async () => {
494
+ // Blob is an object — hits the typeof === 'object' branch before the Blob check.
495
+ const blob = new Blob(['hi']);
496
+ const result = await authFetch.normalizeBodyToNumberArray(blob);
497
+ // JSON.stringify(new Blob(...)) → '{}'
498
+ const expected = Utils.toArray(JSON.stringify(blob), 'utf8');
499
+ expect(result).toEqual(expected);
500
+ });
501
+ it('converts FormData via JSON.stringify (object branch)', async () => {
502
+ // FormData is an object — hits the typeof === 'object' branch before the FormData check.
503
+ const fd = new FormData();
504
+ fd.append('name', 'alice');
505
+ const result = await authFetch.normalizeBodyToNumberArray(fd);
506
+ // JSON.stringify(FormData) → '{}'
507
+ const expected = Utils.toArray(JSON.stringify(fd), 'utf8');
508
+ expect(result).toEqual(expected);
509
+ });
510
+ it('converts URLSearchParams via JSON.stringify (object branch)', async () => {
511
+ // URLSearchParams is an object — hits typeof === 'object' branch first.
512
+ const params = new URLSearchParams({ q: 'hello' });
513
+ const result = await authFetch.normalizeBodyToNumberArray(params);
514
+ // JSON.stringify(URLSearchParams) → '{}'
515
+ const expected = Utils.toArray(JSON.stringify(params), 'utf8');
516
+ expect(result).toEqual(expected);
517
+ });
518
+ it('converts ReadableStream via JSON.stringify (object branch)', async () => {
519
+ // ReadableStream is an object, so it hits the typeof === 'object' branch first
520
+ // and is serialized via JSON.stringify (produces '{}') rather than throwing.
521
+ const stream = new ReadableStream();
522
+ const result = await authFetch.normalizeBodyToNumberArray(stream);
523
+ const expected = Utils.toArray(JSON.stringify(stream), 'utf8');
524
+ expect(result).toEqual(expected);
525
+ });
526
+ it('converts a plain object via JSON.stringify', async () => {
527
+ const obj = { key: 'value' };
528
+ const result = await authFetch.normalizeBodyToNumberArray(obj);
529
+ expect(result.length).toBeGreaterThan(0);
530
+ });
531
+ });
532
+ // ---------------------------------------------------------------------------
533
+ // 10. getMaxPaymentAttempts
534
+ // ---------------------------------------------------------------------------
535
+ describe('AuthFetch.getMaxPaymentAttempts (private)', () => {
536
+ let authFetch;
537
+ beforeEach(() => {
538
+ authFetch = new AuthFetch(buildWallet());
539
+ });
540
+ it('returns 3 by default', () => {
541
+ expect(authFetch.getMaxPaymentAttempts({})).toBe(3);
542
+ });
543
+ it('returns the configured positive integer', () => {
544
+ expect(authFetch.getMaxPaymentAttempts({ paymentRetryAttempts: 5 })).toBe(5);
545
+ });
546
+ it('floors the value for a float', () => {
547
+ expect(authFetch.getMaxPaymentAttempts({ paymentRetryAttempts: 4.9 })).toBe(4);
548
+ });
549
+ it('returns 3 when paymentRetryAttempts is 0', () => {
550
+ expect(authFetch.getMaxPaymentAttempts({ paymentRetryAttempts: 0 })).toBe(3);
551
+ });
552
+ it('returns 3 when paymentRetryAttempts is negative', () => {
553
+ expect(authFetch.getMaxPaymentAttempts({ paymentRetryAttempts: -1 })).toBe(3);
554
+ });
555
+ it('returns 3 when paymentRetryAttempts is a string', () => {
556
+ expect(authFetch.getMaxPaymentAttempts({ paymentRetryAttempts: 'five' })).toBe(3);
557
+ });
558
+ });
559
+ // ---------------------------------------------------------------------------
560
+ // 11. getPaymentRetryDelay
561
+ // ---------------------------------------------------------------------------
562
+ describe('AuthFetch.getPaymentRetryDelay (private)', () => {
563
+ let authFetch;
564
+ beforeEach(() => {
565
+ authFetch = new AuthFetch(buildWallet());
566
+ });
567
+ it('returns 250 for attempt 1 (250 * 1)', () => {
568
+ expect(authFetch.getPaymentRetryDelay(1)).toBe(250);
569
+ });
570
+ it('returns 500 for attempt 2', () => {
571
+ expect(authFetch.getPaymentRetryDelay(2)).toBe(500);
572
+ });
573
+ it('caps multiplier at 5 for attempt >= 5', () => {
574
+ expect(authFetch.getPaymentRetryDelay(5)).toBe(1250);
575
+ expect(authFetch.getPaymentRetryDelay(10)).toBe(1250);
576
+ expect(authFetch.getPaymentRetryDelay(100)).toBe(1250);
577
+ });
578
+ });
579
+ // ---------------------------------------------------------------------------
580
+ // 12. wait()
581
+ // ---------------------------------------------------------------------------
582
+ describe('AuthFetch.wait (private)', () => {
583
+ it('resolves immediately for ms <= 0', async () => {
584
+ const authFetch = new AuthFetch(buildWallet());
585
+ const start = Date.now();
586
+ await authFetch.wait(0);
587
+ expect(Date.now() - start).toBeLessThan(50);
588
+ });
589
+ it('resolves immediately for negative ms', async () => {
590
+ const authFetch = new AuthFetch(buildWallet());
591
+ const start = Date.now();
592
+ await authFetch.wait(-100);
593
+ expect(Date.now() - start).toBeLessThan(50);
594
+ });
595
+ it('uses a timer for positive ms', async () => {
596
+ jest.useFakeTimers();
597
+ try {
598
+ const authFetch = new AuthFetch(buildWallet());
599
+ let resolved = false;
600
+ const promise = authFetch.wait(500).then(() => { resolved = true; });
601
+ expect(resolved).toBe(false);
602
+ await jest.advanceTimersByTimeAsync(500);
603
+ await promise;
604
+ expect(resolved).toBe(true);
605
+ }
606
+ finally {
607
+ jest.useRealTimers();
608
+ }
609
+ });
610
+ });
611
+ // ---------------------------------------------------------------------------
612
+ // 13. isPaymentContextCompatible
613
+ // ---------------------------------------------------------------------------
614
+ describe('AuthFetch.isPaymentContextCompatible (private)', () => {
615
+ let authFetch;
616
+ beforeEach(() => {
617
+ authFetch = new AuthFetch(buildWallet());
618
+ });
619
+ function makeCtx(overrides = {}) {
620
+ return {
621
+ satoshisRequired: 10,
622
+ serverIdentityKey: 'srv',
623
+ derivationPrefix: 'pfx',
624
+ ...overrides
625
+ };
626
+ }
627
+ it('returns true when all fields match', () => {
628
+ const ctx = makeCtx();
629
+ expect(authFetch.isPaymentContextCompatible(ctx, 10, 'srv', 'pfx')).toBe(true);
630
+ });
631
+ it('returns false when satoshis differ', () => {
632
+ const ctx = makeCtx();
633
+ expect(authFetch.isPaymentContextCompatible(ctx, 20, 'srv', 'pfx')).toBe(false);
634
+ });
635
+ it('returns false when serverIdentityKey differs', () => {
636
+ const ctx = makeCtx();
637
+ expect(authFetch.isPaymentContextCompatible(ctx, 10, 'other', 'pfx')).toBe(false);
638
+ });
639
+ it('returns false when derivationPrefix differs', () => {
640
+ const ctx = makeCtx();
641
+ expect(authFetch.isPaymentContextCompatible(ctx, 10, 'srv', 'other')).toBe(false);
642
+ });
643
+ });
644
+ // ---------------------------------------------------------------------------
645
+ // 14. consumeReceivedCertificates
646
+ // ---------------------------------------------------------------------------
647
+ describe('AuthFetch.consumeReceivedCertificates', () => {
648
+ it('returns all received certs and empties the buffer', () => {
649
+ const authFetch = new AuthFetch(buildWallet());
650
+ authFetch.certificatesReceived.push({ serialNumber: 'cert1' }, { serialNumber: 'cert2' });
651
+ const certs = authFetch.consumeReceivedCertificates();
652
+ expect(certs).toHaveLength(2);
653
+ expect(certs[0]).toMatchObject({ serialNumber: 'cert1' });
654
+ // Buffer should now be empty
655
+ const second = authFetch.consumeReceivedCertificates();
656
+ expect(second).toHaveLength(0);
657
+ });
658
+ it('returns an empty array when no certs have been received', () => {
659
+ const authFetch = new AuthFetch(buildWallet());
660
+ expect(authFetch.consumeReceivedCertificates()).toEqual([]);
661
+ });
662
+ });
663
+ // ---------------------------------------------------------------------------
664
+ // 15. sendCertificateRequest – creates a new peer when none exists
665
+ // ---------------------------------------------------------------------------
666
+ describe('AuthFetch.sendCertificateRequest – new peer creation', () => {
667
+ it('creates a new Peer transport when no peer exists for the base URL', async () => {
668
+ const authFetch = new AuthFetch(buildWallet());
669
+ // Verify there is no existing peer
670
+ expect(authFetch.peers['https://new-server.com']).toBeUndefined();
671
+ const fakeCerts = [{ serialNumber: 'abc' }];
672
+ let capturedListener;
673
+ const peerProto = {
674
+ listenForCertificatesReceived: jest.fn((cb) => {
675
+ capturedListener = cb;
676
+ return 99;
677
+ }),
678
+ stopListeningForCertificatesReceived: jest.fn(),
679
+ requestCertificates: jest.fn(async () => {
680
+ capturedListener?.('srv-key', fakeCerts);
681
+ })
682
+ };
683
+ // Intercept Peer constructor by injecting peer directly after fetch call starts
684
+ const origFetch = authFetch.sendCertificateRequest.bind(authFetch);
685
+ jest.spyOn(authFetch, 'sendCertificateRequest').mockImplementationOnce(async (url, certs) => {
686
+ // Manually inject our stub peer so Peer constructor is bypassed
687
+ ;
688
+ authFetch.peers['https://new-server.com'] = {
689
+ peer: peerProto,
690
+ pendingCertificateRequests: []
691
+ };
692
+ return origFetch(url, certs);
693
+ });
694
+ const result = await authFetch.sendCertificateRequest('https://new-server.com/path', { certifiers: [], types: {} });
695
+ expect(result).toHaveLength(1);
696
+ });
697
+ });
698
+ // ---------------------------------------------------------------------------
699
+ // 16. logPaymentAttempt – all levels
700
+ // ---------------------------------------------------------------------------
701
+ describe('AuthFetch.logPaymentAttempt (private)', () => {
702
+ let authFetch;
703
+ beforeEach(() => {
704
+ authFetch = new AuthFetch(buildWallet());
705
+ });
706
+ it('calls console.error for level=error', () => {
707
+ const spy = jest.spyOn(console, 'error').mockImplementation(() => { });
708
+ authFetch.logPaymentAttempt('error', 'test error', { a: 1 });
709
+ expect(spy).toHaveBeenCalledWith('[AuthFetch][Payment] test error', { a: 1 });
710
+ });
711
+ it('calls console.warn for level=warn', () => {
712
+ const spy = jest.spyOn(console, 'warn').mockImplementation(() => { });
713
+ authFetch.logPaymentAttempt('warn', 'test warn', { b: 2 });
714
+ expect(spy).toHaveBeenCalledWith('[AuthFetch][Payment] test warn', { b: 2 });
715
+ });
716
+ it('calls console.info for level=info when available', () => {
717
+ const spy = jest.spyOn(console, 'info').mockImplementation(() => { });
718
+ authFetch.logPaymentAttempt('info', 'test info', { c: 3 });
719
+ expect(spy).toHaveBeenCalledWith('[AuthFetch][Payment] test info', { c: 3 });
720
+ });
721
+ it('falls back to console.log for level=info when console.info is not a function', () => {
722
+ const originalInfo = console.info;
723
+ console.info = undefined;
724
+ const spy = jest.spyOn(console, 'log').mockImplementation(() => { });
725
+ try {
726
+ ;
727
+ authFetch.logPaymentAttempt('info', 'fallback log', {});
728
+ expect(spy).toHaveBeenCalledWith('[AuthFetch][Payment] fallback log', {});
729
+ }
730
+ finally {
731
+ console.info = originalInfo;
732
+ }
733
+ });
734
+ });
735
+ // ---------------------------------------------------------------------------
736
+ // 17. createPaymentErrorEntry
737
+ // ---------------------------------------------------------------------------
738
+ describe('AuthFetch.createPaymentErrorEntry (private)', () => {
739
+ let authFetch;
740
+ beforeEach(() => {
741
+ authFetch = new AuthFetch(buildWallet());
742
+ });
743
+ it('extracts message and stack from an Error instance', () => {
744
+ const err = new Error('something went wrong');
745
+ const entry = authFetch.createPaymentErrorEntry(2, err);
746
+ expect(entry.attempt).toBe(2);
747
+ expect(entry.message).toBe('something went wrong');
748
+ expect(typeof entry.stack).toBe('string');
749
+ expect(typeof entry.timestamp).toBe('string');
750
+ });
751
+ it('converts non-Error to string message', () => {
752
+ const entry = authFetch.createPaymentErrorEntry(1, 'just a string error');
753
+ expect(entry.message).toBe('just a string error');
754
+ expect(entry.stack).toBeUndefined();
755
+ });
756
+ it('converts numeric error to string message', () => {
757
+ const entry = authFetch.createPaymentErrorEntry(1, 42);
758
+ expect(entry.message).toBe('42');
759
+ });
760
+ });
761
+ // ---------------------------------------------------------------------------
762
+ // 18. buildPaymentFailureError
763
+ // ---------------------------------------------------------------------------
764
+ describe('AuthFetch.buildPaymentFailureError (private)', () => {
765
+ let authFetch;
766
+ beforeEach(() => {
767
+ authFetch = new AuthFetch(buildWallet());
768
+ });
769
+ function makeContext() {
770
+ return {
771
+ satoshisRequired: 10,
772
+ transactionBase64: 'tx-base64',
773
+ derivationPrefix: 'pfx',
774
+ derivationSuffix: 'sfx',
775
+ serverIdentityKey: 'srv',
776
+ clientIdentityKey: 'cli',
777
+ attempts: 3,
778
+ maxAttempts: 3,
779
+ errors: [],
780
+ requestSummary: { url: 'https://ex.com', method: 'GET', headers: {}, bodyType: 'none', bodyByteLength: 0 }
781
+ };
782
+ }
783
+ it('creates an Error with a descriptive message', () => {
784
+ const err = authFetch.buildPaymentFailureError('https://example.com/pay', makeContext(), new Error('last error'));
785
+ expect(err).toBeInstanceOf(Error);
786
+ expect(err.message).toContain('https://example.com/pay');
787
+ expect(err.message).toContain('3/3');
788
+ expect(err.message).toContain('10 satoshis');
789
+ });
790
+ it('attaches details to the error', () => {
791
+ const err = authFetch.buildPaymentFailureError('https://example.com/pay', makeContext(), new Error('x'));
792
+ expect(err.details).toBeDefined();
793
+ expect(err.details.payment.satoshis).toBe(10);
794
+ expect(err.details.attempts.used).toBe(3);
795
+ });
796
+ it('sets cause when lastError is an Error', () => {
797
+ const cause = new Error('root cause');
798
+ const err = authFetch.buildPaymentFailureError('https://example.com/pay', makeContext(), cause);
799
+ expect(err.cause).toBe(cause);
800
+ });
801
+ it('does not set cause when lastError is a string', () => {
802
+ const err = authFetch.buildPaymentFailureError('https://example.com/pay', makeContext(), 'string error');
803
+ expect(err.cause).toBeUndefined();
804
+ });
805
+ });
806
+ // ---------------------------------------------------------------------------
807
+ // 19. buildPaymentRequestSummary
808
+ // ---------------------------------------------------------------------------
809
+ describe('AuthFetch.buildPaymentRequestSummary (private)', () => {
810
+ it('builds a summary with correct fields', () => {
811
+ const authFetch = new AuthFetch(buildWallet());
812
+ const summary = authFetch.buildPaymentRequestSummary('https://example.com/resource', { method: 'post', headers: { 'X-Custom': 'value' }, body: 'hello' });
813
+ expect(summary.url).toBe('https://example.com/resource');
814
+ expect(summary.method).toBe('POST');
815
+ expect(summary.headers).toMatchObject({ 'X-Custom': 'value' });
816
+ expect(summary.bodyType).toBe('string');
817
+ expect(summary.bodyByteLength).toBe(5);
818
+ });
819
+ it('defaults method to GET when not provided', () => {
820
+ const authFetch = new AuthFetch(buildWallet());
821
+ const summary = authFetch.buildPaymentRequestSummary('https://example.com', {});
822
+ expect(summary.method).toBe('GET');
823
+ });
824
+ });
825
+ //# sourceMappingURL=AuthFetch.additional.test.js.map