@bsv/sdk 2.0.13 → 2.0.14

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 (45) hide show
  1. package/README.md +2 -0
  2. package/dist/cjs/package.json +1 -1
  3. package/dist/cjs/src/identity/IdentityClient.js +3 -3
  4. package/dist/cjs/src/identity/IdentityClient.js.map +1 -1
  5. package/dist/cjs/src/identity/types/index.js +1 -1
  6. package/dist/cjs/src/identity/types/index.js.map +1 -1
  7. package/dist/cjs/src/storage/StorageUploader.js +315 -96
  8. package/dist/cjs/src/storage/StorageUploader.js.map +1 -1
  9. package/dist/cjs/src/storage/index.js +3 -1
  10. package/dist/cjs/src/storage/index.js.map +1 -1
  11. package/dist/cjs/src/wallet/WERR_REVIEW_ACTIONS.js +2 -2
  12. package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
  13. package/dist/esm/src/identity/IdentityClient.js +3 -3
  14. package/dist/esm/src/identity/IdentityClient.js.map +1 -1
  15. package/dist/esm/src/identity/types/index.js +1 -1
  16. package/dist/esm/src/identity/types/index.js.map +1 -1
  17. package/dist/esm/src/storage/StorageUploader.js +319 -95
  18. package/dist/esm/src/storage/StorageUploader.js.map +1 -1
  19. package/dist/esm/src/storage/index.js +1 -1
  20. package/dist/esm/src/storage/index.js.map +1 -1
  21. package/dist/esm/src/wallet/WERR_REVIEW_ACTIONS.js +2 -2
  22. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  23. package/dist/types/src/storage/StorageUploader.d.ts +94 -55
  24. package/dist/types/src/storage/StorageUploader.d.ts.map +1 -1
  25. package/dist/types/src/storage/index.d.ts +1 -1
  26. package/dist/types/src/storage/index.d.ts.map +1 -1
  27. package/dist/types/src/wallet/WERR_REVIEW_ACTIONS.d.ts +2 -2
  28. package/dist/types/src/wallet/Wallet.interfaces.d.ts +2 -2
  29. package/dist/types/tsconfig.types.tsbuildinfo +1 -1
  30. package/dist/umd/bundle.js +4 -4
  31. package/dist/umd/bundle.js.map +1 -1
  32. package/docs/index.md +3 -1
  33. package/docs/reference/identity.md +1 -1
  34. package/docs/reference/storage.md +214 -85
  35. package/docs/reference/transaction.md +2 -2
  36. package/docs/reference/wallet.md +4 -4
  37. package/package.json +1 -1
  38. package/src/identity/IdentityClient.ts +3 -3
  39. package/src/identity/__tests/IdentityClient.additional.test.ts +3 -3
  40. package/src/identity/types/index.ts +1 -1
  41. package/src/storage/StorageUploader.ts +427 -105
  42. package/src/storage/__tests/StorageUploader.test.ts +881 -64
  43. package/src/storage/index.ts +1 -1
  44. package/src/wallet/WERR_REVIEW_ACTIONS.ts +2 -2
  45. package/src/wallet/Wallet.interfaces.ts +2 -2
@@ -1,4 +1,4 @@
1
- import { StorageUploader } from '../StorageUploader.js'
1
+ import { StorageUploader, DEFAULT_UHRP_SERVERS, RenewResiliencyError } from '../StorageUploader.js'
2
2
  import * as StorageUtils from '../StorageUtils.js'
3
3
  import WalletClient from '../../wallet/WalletClient.js'
4
4
  import { createHash } from 'crypto'
@@ -6,15 +6,33 @@ import { createHash } from 'crypto'
6
6
  /**
7
7
  * A helper for converting a string to a number[] of UTF-8 bytes
8
8
  */
9
- function stringToUtf8Array(str: string): number[] {
9
+ function stringToUtf8Array (str: string): number[] {
10
10
  return Array.from(new TextEncoder().encode(str))
11
11
  }
12
12
 
13
- describe('StorageUploader Tests', () => {
13
+ /**
14
+ * Builds a JSON Response for mocked fetch calls.
15
+ */
16
+ function jsonResponse (body: unknown, status = 200): Response {
17
+ return new Response(JSON.stringify(body), {
18
+ status,
19
+ headers: { 'Content-Type': 'application/json' }
20
+ })
21
+ }
22
+
23
+ /**
24
+ * Normalizes whatever a caller passed to `fetch` into a URL string.
25
+ */
26
+ function extractFetchURL (input: RequestInfo | URL): string {
27
+ if (typeof input === 'string') return input
28
+ if (input instanceof URL) return input.toString()
29
+ return input.url
30
+ }
31
+
32
+ describe('StorageUploader — legacy single-host behavior', () => {
14
33
  let uploader: StorageUploader
15
34
  let walletClient: WalletClient
16
35
 
17
- // We'll have TWO spies:
18
36
  let authFetchSpy: jest.SpyInstance
19
37
  let globalFetchSpy: jest.SpiedFunction<typeof global.fetch>
20
38
 
@@ -25,12 +43,10 @@ describe('StorageUploader Tests', () => {
25
43
  wallet: walletClient
26
44
  })
27
45
 
28
- // 1) Spy on the "authFetch.fetch" calls for /find, /list, /renew
29
46
  authFetchSpy = jest
30
- .spyOn(uploader['authFetch'], 'fetch')
47
+ .spyOn((uploader as any).authFetch, 'fetch')
31
48
  .mockResolvedValue(new Response(null, { status: 200 }))
32
49
 
33
- // 2) Spy on the global "fetch" calls for file upload (uploadFile)
34
50
  globalFetchSpy = jest
35
51
  .spyOn(global, 'fetch')
36
52
  .mockResolvedValue(new Response(null, { status: 200 }))
@@ -43,22 +59,35 @@ describe('StorageUploader Tests', () => {
43
59
  it('should upload a file, produce a valid UHRP URL, and decode it to the known SHA-256', async () => {
44
60
  const data = stringToUtf8Array('Hello, world!')
45
61
 
46
- // Mock out getUploadInfo so we can control the returned upload/public URLs
47
- jest.spyOn(uploader as any, 'getUploadInfo').mockResolvedValue({
48
- uploadURL: 'https://example-upload.com/put'
49
- })
62
+ // Quote (global fetch) + PUT upload (global fetch)
63
+ globalFetchSpy
64
+ .mockResolvedValueOnce(jsonResponse({ quote: 42 }))
65
+ .mockResolvedValueOnce(new Response(null, { status: 200 }))
66
+
67
+ // /upload (authFetch)
68
+ authFetchSpy.mockResolvedValueOnce(jsonResponse({
69
+ status: 'success',
70
+ uploadURL: 'https://example-upload.com/put',
71
+ requiredHeaders: {},
72
+ amount: 42
73
+ }))
50
74
 
51
75
  const result = await uploader.publishFile({
52
76
  file: { data, type: 'text/plain' },
53
77
  retentionPeriod: 7
54
78
  })
55
79
 
56
- // This direct upload uses global.fetch, not authFetch
57
- expect(globalFetchSpy).toHaveBeenCalledTimes(1)
80
+ // One quote fetch + one PUT upload
81
+ expect(globalFetchSpy).toHaveBeenCalledTimes(2)
82
+ expect(globalFetchSpy.mock.calls[0][0]).toBe('https://example.test.system/quote')
83
+ expect(globalFetchSpy.mock.calls[1][0]).toBe('https://example-upload.com/put')
84
+
85
+ expect(authFetchSpy).toHaveBeenCalledTimes(1)
86
+ expect(authFetchSpy.mock.calls[0][0]).toBe('https://example.test.system/upload')
58
87
 
59
- // Check the result
60
88
  expect(StorageUtils.isValidURL(result.uhrpURL)).toBe(true)
61
89
  expect(result.published).toBe(true)
90
+ expect(result.hostedBy).toEqual(['https://example.test.system'])
62
91
 
63
92
  const url = StorageUtils.getHashFromURL(result.uhrpURL)
64
93
  const firstFour = url.slice(0, 4)
@@ -72,9 +101,16 @@ describe('StorageUploader Tests', () => {
72
101
  const data = new Uint8Array(size)
73
102
  for (let i = 0; i < size; i++) data[i] = i % 256
74
103
 
75
- jest.spyOn(uploader as any, 'getUploadInfo').mockResolvedValue({
76
- uploadURL: 'https://example-upload.com/put'
77
- })
104
+ globalFetchSpy
105
+ .mockResolvedValueOnce(jsonResponse({ quote: 100 }))
106
+ .mockResolvedValueOnce(new Response(null, { status: 200 }))
107
+
108
+ authFetchSpy.mockResolvedValueOnce(jsonResponse({
109
+ status: 'success',
110
+ uploadURL: 'https://example-upload.com/put',
111
+ requiredHeaders: {},
112
+ amount: 100
113
+ }))
78
114
 
79
115
  const result = await uploader.publishFile({
80
116
  file: { data, type: 'application/octet-stream' },
@@ -84,16 +120,20 @@ describe('StorageUploader Tests', () => {
84
120
  const expectedHash = createHash('sha256').update(data).digest()
85
121
  const urlHash = StorageUtils.getHashFromURL(result.uhrpURL)
86
122
  expect(Buffer.from(urlHash)).toEqual(expectedHash)
123
+ expect(result.hostedBy).toEqual(['https://example.test.system'])
87
124
  })
88
125
 
89
126
  it('should throw if the upload fails with HTTP 500', async () => {
90
- // Force the direct upload (global fetch) to fail
91
- globalFetchSpy.mockResolvedValueOnce(new Response(null, { status: 500 }))
127
+ globalFetchSpy
128
+ .mockResolvedValueOnce(jsonResponse({ quote: 42 }))
129
+ .mockResolvedValueOnce(new Response(null, { status: 500 }))
92
130
 
93
- // Also mock getUploadInfo
94
- jest.spyOn(uploader as any, 'getUploadInfo').mockResolvedValue({
95
- uploadURL: 'https://example-upload.com/put'
96
- })
131
+ authFetchSpy.mockResolvedValueOnce(jsonResponse({
132
+ status: 'success',
133
+ uploadURL: 'https://example-upload.com/put',
134
+ requiredHeaders: {},
135
+ amount: 42
136
+ }))
97
137
 
98
138
  const failingData = stringToUtf8Array('failing data')
99
139
 
@@ -102,24 +142,20 @@ describe('StorageUploader Tests', () => {
102
142
  file: { data: failingData, type: 'text/plain' },
103
143
  retentionPeriod: 30
104
144
  })
105
- ).rejects.toThrow('File upload failed: HTTP 500')
145
+ ).rejects.toThrow(/Resiliency threshold of 1 could not be met/)
106
146
  })
107
147
 
108
148
  it('should find a file and return metadata', async () => {
109
- // This route goes through authFetch, not global fetch
110
149
  authFetchSpy.mockResolvedValueOnce(
111
- new Response(
112
- JSON.stringify({
113
- status: 'success',
114
- data: {
115
- name: 'cdn/abc123',
116
- size: '1024',
117
- mimeType: 'text/plain',
118
- expiryTime: 123456
119
- }
120
- }),
121
- { status: 200 }
122
- )
150
+ jsonResponse({
151
+ status: 'success',
152
+ data: {
153
+ name: 'cdn/abc123',
154
+ size: '1024',
155
+ mimeType: 'text/plain',
156
+ expiryTime: 123456
157
+ }
158
+ })
123
159
  )
124
160
 
125
161
  const fileData = await uploader.findFile('uhrp://some-hash')
@@ -132,10 +168,7 @@ describe('StorageUploader Tests', () => {
132
168
 
133
169
  it('should throw an error if findFile returns an error status', async () => {
134
170
  authFetchSpy.mockResolvedValueOnce(
135
- new Response(
136
- JSON.stringify({ status: 'error', code: 'ERR_NOT_FOUND', description: 'File not found' }),
137
- { status: 200 }
138
- )
171
+ jsonResponse({ status: 'error', code: 'ERR_NOT_FOUND', description: 'File not found' })
139
172
  )
140
173
 
141
174
  await expect(uploader.findFile('uhrp://unknown-hash'))
@@ -144,16 +177,12 @@ describe('StorageUploader Tests', () => {
144
177
  })
145
178
 
146
179
  it('should list user uploads successfully', async () => {
147
- // /list uses authFetch
148
180
  const mockUploads = [
149
181
  { uhrpUrl: 'uhrp://hash1', expiryTime: 111111 },
150
182
  { uhrpUrl: 'uhrp://hash2', expiryTime: 222222 }
151
183
  ]
152
184
  authFetchSpy.mockResolvedValueOnce(
153
- new Response(
154
- JSON.stringify({ status: 'success', uploads: mockUploads }),
155
- { status: 200 }
156
- )
185
+ jsonResponse({ status: 'success', uploads: mockUploads })
157
186
  )
158
187
 
159
188
  const result = await uploader.listUploads()
@@ -163,10 +192,7 @@ describe('StorageUploader Tests', () => {
163
192
 
164
193
  it('should throw an error if listUploads returns an error', async () => {
165
194
  authFetchSpy.mockResolvedValueOnce(
166
- new Response(
167
- JSON.stringify({ status: 'error', code: 'ERR_INTERNAL', description: 'Something broke' }),
168
- { status: 200 }
169
- )
195
+ jsonResponse({ status: 'error', code: 'ERR_INTERNAL', description: 'Something broke' })
170
196
  )
171
197
 
172
198
  await expect(uploader.listUploads()).rejects.toThrow(
@@ -175,17 +201,13 @@ describe('StorageUploader Tests', () => {
175
201
  })
176
202
 
177
203
  it('should renew a file and return the new expiry info', async () => {
178
- // /renew uses authFetch
179
204
  authFetchSpy.mockResolvedValueOnce(
180
- new Response(
181
- JSON.stringify({
182
- status: 'success',
183
- prevExpiryTime: 123,
184
- newExpiryTime: 456,
185
- amount: 99
186
- }),
187
- { status: 200 }
188
- )
205
+ jsonResponse({
206
+ status: 'success',
207
+ prevExpiryTime: 123,
208
+ newExpiryTime: 456,
209
+ amount: 99
210
+ })
189
211
  )
190
212
 
191
213
  const renewal = await uploader.renewFile('uhrp://some-hash', 30)
@@ -198,10 +220,7 @@ describe('StorageUploader Tests', () => {
198
220
 
199
221
  it('should throw an error if renewFile returns error status JSON', async () => {
200
222
  authFetchSpy.mockResolvedValueOnce(
201
- new Response(
202
- JSON.stringify({ status: 'error', code: 'ERR_CANT_RENEW', description: 'Failed to renew' }),
203
- { status: 200 }
204
- )
223
+ jsonResponse({ status: 'error', code: 'ERR_CANT_RENEW', description: 'Failed to renew' })
205
224
  )
206
225
 
207
226
  await expect(uploader.renewFile('uhrp://some-other-hash', 15))
@@ -217,3 +236,801 @@ describe('StorageUploader Tests', () => {
217
236
  .toThrow('renewFile request failed: HTTP 404')
218
237
  })
219
238
  })
239
+
240
+ describe('StorageUploader — multi-provider behavior', () => {
241
+ let walletClient: WalletClient
242
+
243
+ beforeEach(() => {
244
+ walletClient = new WalletClient('json-api', 'non-admin.com')
245
+ })
246
+
247
+ afterEach(() => {
248
+ jest.restoreAllMocks()
249
+ })
250
+
251
+ /**
252
+ * Sets up URL-routed mocks for the quote, upload, and PUT steps across any
253
+ * number of providers. Returns the two spies for assertion.
254
+ */
255
+ function wireMocks (
256
+ uploader: StorageUploader,
257
+ quotes: Record<string, number | 'error'>,
258
+ uploadOutcomes: Record<string, 'ok' | 'fail'> = {}
259
+ ): {
260
+ authFetchSpy: jest.SpyInstance
261
+ globalFetchSpy: jest.SpiedFunction<typeof global.fetch>
262
+ putCalls: string[]
263
+ quoteCalls: string[]
264
+ uploadCalls: string[]
265
+ } {
266
+ const putCalls: string[] = []
267
+ const quoteCalls: string[] = []
268
+ const uploadCalls: string[] = []
269
+
270
+ const globalFetchSpy = jest
271
+ .spyOn(global, 'fetch')
272
+ .mockImplementation(async (input: RequestInfo | URL) => {
273
+ const url = extractFetchURL(input)
274
+ // Quote requests hit {host}/quote
275
+ if (url.endsWith('/quote')) {
276
+ quoteCalls.push(url)
277
+ const host = url.slice(0, -'/quote'.length)
278
+ const quote = quotes[host]
279
+ if (quote === undefined || quote === 'error') {
280
+ return new Response(null, { status: 500 })
281
+ }
282
+ return jsonResponse({ quote })
283
+ }
284
+ // Otherwise we treat it as the PUT upload.
285
+ putCalls.push(url)
286
+ const outcome = uploadOutcomes[url]
287
+ if (outcome === 'fail') {
288
+ return new Response(null, { status: 500 })
289
+ }
290
+ return new Response(null, { status: 200 })
291
+ })
292
+
293
+ const authFetchSpy = jest
294
+ .spyOn((uploader as any).authFetch, 'fetch')
295
+ .mockImplementation(async (input: RequestInfo | URL) => {
296
+ const url = extractFetchURL(input)
297
+ if (url.endsWith('/upload')) {
298
+ uploadCalls.push(url)
299
+ const host = url.slice(0, -'/upload'.length)
300
+ return jsonResponse({
301
+ status: 'success',
302
+ uploadURL: `${host}/put`,
303
+ requiredHeaders: {},
304
+ amount: quotes[host]
305
+ })
306
+ }
307
+ return new Response(null, { status: 404 })
308
+ })
309
+
310
+ return { authFetchSpy, globalFetchSpy, putCalls, quoteCalls, uploadCalls }
311
+ }
312
+
313
+ it('defaults to DEFAULT_UHRP_SERVERS when no hosts are configured', () => {
314
+ const uploader = new StorageUploader({ wallet: walletClient })
315
+ expect((uploader as any).hosts).toEqual(DEFAULT_UHRP_SERVERS)
316
+ expect((uploader as any).resilienceLevel).toBe(1)
317
+ })
318
+
319
+ it('clamps resilienceLevel to 1 for legacy storageURL callers', () => {
320
+ const uploader = new StorageUploader({
321
+ storageURL: 'https://legacy.example',
322
+ wallet: walletClient
323
+ })
324
+ expect((uploader as any).hosts).toEqual(['https://legacy.example'])
325
+ expect((uploader as any).resilienceLevel).toBe(1)
326
+ })
327
+
328
+ it('uses storageURLs over storageURL when both are provided', () => {
329
+ const uploader = new StorageUploader({
330
+ storageURL: 'https://legacy.example',
331
+ storageURLs: ['https://a.example', 'https://b.example'],
332
+ wallet: walletClient
333
+ })
334
+ expect((uploader as any).hosts).toEqual(['https://a.example', 'https://b.example'])
335
+ })
336
+
337
+ it('throws if resilienceLevel is not a positive integer', () => {
338
+ expect(() => new StorageUploader({
339
+ storageURLs: ['https://a.example'],
340
+ resilienceLevel: 0,
341
+ wallet: walletClient
342
+ })).toThrow(/positive integer/)
343
+
344
+ expect(() => new StorageUploader({
345
+ storageURLs: ['https://a.example'],
346
+ resilienceLevel: 1.5,
347
+ wallet: walletClient
348
+ })).toThrow(/positive integer/)
349
+ })
350
+
351
+ it('throws if storageURLs is an empty array', () => {
352
+ expect(() => new StorageUploader({
353
+ storageURLs: [],
354
+ wallet: walletClient
355
+ })).toThrow(/at least one/)
356
+ })
357
+
358
+ it('sorts quotes by price and uploads to the cheapest N', async () => {
359
+ const hosts = ['https://a.example', 'https://b.example', 'https://c.example']
360
+ const uploader = new StorageUploader({
361
+ storageURLs: hosts,
362
+ resilienceLevel: 2,
363
+ wallet: walletClient
364
+ })
365
+
366
+ const { quoteCalls, uploadCalls, putCalls } = wireMocks(uploader, {
367
+ 'https://a.example': 300,
368
+ 'https://b.example': 100,
369
+ 'https://c.example': 200
370
+ })
371
+
372
+ const data = stringToUtf8Array('multi-host payload')
373
+ const result = await uploader.publishFile({
374
+ file: { data, type: 'text/plain' },
375
+ retentionPeriod: 60
376
+ })
377
+
378
+ // All three quotes requested (up to 2 * resilienceLevel = 4 allowed, but we only have 3 hosts).
379
+ expect(quoteCalls.sort()).toEqual([
380
+ 'https://a.example/quote',
381
+ 'https://b.example/quote',
382
+ 'https://c.example/quote'
383
+ ])
384
+ // Uploads happen in ascending price order: b (100), c (200).
385
+ expect(uploadCalls).toEqual([
386
+ 'https://b.example/upload',
387
+ 'https://c.example/upload'
388
+ ])
389
+ expect(putCalls).toEqual([
390
+ 'https://b.example/put',
391
+ 'https://c.example/put'
392
+ ])
393
+ expect(result.hostedBy).toEqual(['https://b.example', 'https://c.example'])
394
+ expect(result.published).toBe(true)
395
+ expect(StorageUtils.isValidURL(result.uhrpURL)).toBe(true)
396
+ })
397
+
398
+ it('falls through to the next-cheapest host when a paid upload fails', async () => {
399
+ const hosts = ['https://a.example', 'https://b.example', 'https://c.example']
400
+ const uploader = new StorageUploader({
401
+ storageURLs: hosts,
402
+ resilienceLevel: 2,
403
+ wallet: walletClient
404
+ })
405
+
406
+ const { uploadCalls, putCalls } = wireMocks(
407
+ uploader,
408
+ {
409
+ 'https://a.example': 300,
410
+ 'https://b.example': 100,
411
+ 'https://c.example': 200
412
+ },
413
+ {
414
+ // Cheapest host's PUT fails; must fall through to c (200) then a (300).
415
+ 'https://b.example/put': 'fail'
416
+ }
417
+ )
418
+
419
+ const data = stringToUtf8Array('multi-host payload')
420
+ const result = await uploader.publishFile({
421
+ file: { data, type: 'text/plain' },
422
+ retentionPeriod: 60
423
+ })
424
+
425
+ expect(uploadCalls).toEqual([
426
+ 'https://b.example/upload',
427
+ 'https://c.example/upload',
428
+ 'https://a.example/upload'
429
+ ])
430
+ expect(putCalls).toEqual([
431
+ 'https://b.example/put',
432
+ 'https://c.example/put',
433
+ 'https://a.example/put'
434
+ ])
435
+ expect(result.hostedBy).toEqual(['https://c.example', 'https://a.example'])
436
+ })
437
+
438
+ it('throws when fewer providers respond with quotes than resilienceLevel', async () => {
439
+ const hosts = ['https://a.example', 'https://b.example', 'https://c.example']
440
+ const uploader = new StorageUploader({
441
+ storageURLs: hosts,
442
+ resilienceLevel: 3,
443
+ wallet: walletClient
444
+ })
445
+
446
+ wireMocks(uploader, {
447
+ 'https://a.example': 100,
448
+ 'https://b.example': 'error',
449
+ 'https://c.example': 'error'
450
+ })
451
+
452
+ const data = stringToUtf8Array('fails to meet threshold')
453
+ await expect(
454
+ uploader.publishFile({
455
+ file: { data, type: 'text/plain' },
456
+ retentionPeriod: 60
457
+ })
458
+ ).rejects.toThrow(/Resiliency threshold of 3 could not be met: only 1 of 3 provider\(s\) responded/)
459
+ })
460
+
461
+ it('throws when remaining quotes cannot cover the threshold after upload failures', async () => {
462
+ const hosts = ['https://a.example', 'https://b.example']
463
+ const uploader = new StorageUploader({
464
+ storageURLs: hosts,
465
+ resilienceLevel: 2,
466
+ wallet: walletClient
467
+ })
468
+
469
+ wireMocks(
470
+ uploader,
471
+ {
472
+ 'https://a.example': 100,
473
+ 'https://b.example': 200
474
+ },
475
+ {
476
+ 'https://a.example/put': 'fail',
477
+ 'https://b.example/put': 'fail'
478
+ }
479
+ )
480
+
481
+ const data = stringToUtf8Array('all uploads fail')
482
+ await expect(
483
+ uploader.publishFile({
484
+ file: { data, type: 'text/plain' },
485
+ retentionPeriod: 60
486
+ })
487
+ ).rejects.toThrow(/only 0 upload\(s\) succeeded/)
488
+ })
489
+
490
+ it('returns the same UHRP URL regardless of which providers hosted the file', async () => {
491
+ const hosts = ['https://a.example', 'https://b.example']
492
+ const uploader = new StorageUploader({
493
+ storageURLs: hosts,
494
+ resilienceLevel: 2,
495
+ wallet: walletClient
496
+ })
497
+
498
+ wireMocks(uploader, {
499
+ 'https://a.example': 50,
500
+ 'https://b.example': 75
501
+ })
502
+
503
+ const data = stringToUtf8Array('content addressed')
504
+ const result = await uploader.publishFile({
505
+ file: { data, type: 'text/plain' },
506
+ retentionPeriod: 60
507
+ })
508
+
509
+ // The UHRP URL is derived from the file hash, not from any host.
510
+ const expected = StorageUtils.getURLForFile(Uint8Array.from(data))
511
+ expect(result.uhrpURL).toBe(expected)
512
+ })
513
+
514
+ it('estimateCost returns sorted quotes and the total for the cheapest resilienceLevel hosts', async () => {
515
+ const hosts = ['https://a.example', 'https://b.example', 'https://c.example']
516
+ const uploader = new StorageUploader({
517
+ storageURLs: hosts,
518
+ resilienceLevel: 2,
519
+ wallet: walletClient
520
+ })
521
+
522
+ wireMocks(uploader, {
523
+ 'https://a.example': 300,
524
+ 'https://b.example': 100,
525
+ 'https://c.example': 200
526
+ })
527
+
528
+ const estimate = await uploader.estimateCost({ fileSize: 1024, retentionPeriod: 60 })
529
+
530
+ expect(estimate.resilienceLevel).toBe(2)
531
+ expect(estimate.meetsResilienceThreshold).toBe(true)
532
+ expect(estimate.quotes).toEqual([
533
+ { host: 'https://b.example', amount: 100 },
534
+ { host: 'https://c.example', amount: 200 },
535
+ { host: 'https://a.example', amount: 300 }
536
+ ])
537
+ // Cheapest 2 = 100 + 200
538
+ expect(estimate.totalForResilience).toBe(300)
539
+ })
540
+
541
+ it('estimateCost reports when the resilience threshold cannot be met', async () => {
542
+ const hosts = ['https://a.example', 'https://b.example', 'https://c.example']
543
+ const uploader = new StorageUploader({
544
+ storageURLs: hosts,
545
+ resilienceLevel: 3,
546
+ wallet: walletClient
547
+ })
548
+
549
+ // Only one host returns a quote.
550
+ wireMocks(uploader, {
551
+ 'https://a.example': 100,
552
+ 'https://b.example': 'error',
553
+ 'https://c.example': 'error'
554
+ })
555
+
556
+ const estimate = await uploader.estimateCost({ fileSize: 1024, retentionPeriod: 60 })
557
+
558
+ expect(estimate.meetsResilienceThreshold).toBe(false)
559
+ expect(estimate.quotes).toHaveLength(1)
560
+ // Partial total so callers can still see the cost of what was collected.
561
+ expect(estimate.totalForResilience).toBe(100)
562
+ })
563
+
564
+ it('estimateCost does not trigger any authenticated /upload requests', async () => {
565
+ const hosts = ['https://a.example', 'https://b.example']
566
+ const uploader = new StorageUploader({
567
+ storageURLs: hosts,
568
+ resilienceLevel: 2,
569
+ wallet: walletClient
570
+ })
571
+
572
+ const { authFetchSpy, globalFetchSpy } = wireMocks(uploader, {
573
+ 'https://a.example': 50,
574
+ 'https://b.example': 75
575
+ })
576
+
577
+ await uploader.estimateCost({ fileSize: 1024, retentionPeriod: 60 })
578
+
579
+ // Only the free /quote endpoint is used (via global fetch).
580
+ expect(globalFetchSpy).toHaveBeenCalledTimes(2)
581
+ expect(authFetchSpy).not.toHaveBeenCalled()
582
+ })
583
+
584
+ it('stops quoting further hosts once 2 * resilienceLevel quotes are collected', async () => {
585
+ const hosts = [
586
+ 'https://h1.example',
587
+ 'https://h2.example',
588
+ 'https://h3.example',
589
+ 'https://h4.example',
590
+ 'https://h5.example',
591
+ 'https://h6.example',
592
+ 'https://h7.example',
593
+ 'https://h8.example'
594
+ ]
595
+ const uploader = new StorageUploader({
596
+ storageURLs: hosts,
597
+ resilienceLevel: 2, // target = 4 quotes
598
+ wallet: walletClient
599
+ })
600
+
601
+ // Every host would return a valid quote if asked; we want to prove that
602
+ // hosts after the first batch of 4 are never contacted.
603
+ const quotes: Record<string, number> = {}
604
+ hosts.forEach((h, i) => { quotes[h] = 100 + i })
605
+
606
+ const { quoteCalls } = wireMocks(uploader, quotes)
607
+
608
+ await uploader.publishFile({
609
+ file: { data: stringToUtf8Array('bounded quoting'), type: 'text/plain' },
610
+ retentionPeriod: 60
611
+ })
612
+
613
+ // Only the first batch of 4 quote requests should have been issued.
614
+ expect(quoteCalls.sort()).toEqual([
615
+ 'https://h1.example/quote',
616
+ 'https://h2.example/quote',
617
+ 'https://h3.example/quote',
618
+ 'https://h4.example/quote'
619
+ ])
620
+ })
621
+
622
+ it('advances to a later batch when the first batch cannot fill the quote quota', async () => {
623
+ const hosts = [
624
+ 'https://h1.example',
625
+ 'https://h2.example',
626
+ 'https://h3.example',
627
+ 'https://h4.example',
628
+ 'https://h5.example',
629
+ 'https://h6.example'
630
+ ]
631
+ const uploader = new StorageUploader({
632
+ storageURLs: hosts,
633
+ resilienceLevel: 2, // target = 4 quotes
634
+ wallet: walletClient
635
+ })
636
+
637
+ // First batch of 4: only 2 valid quotes. Second batch of 2 fills the target.
638
+ const { quoteCalls } = wireMocks(uploader, {
639
+ 'https://h1.example': 100,
640
+ 'https://h2.example': 'error',
641
+ 'https://h3.example': 200,
642
+ 'https://h4.example': 'error',
643
+ 'https://h5.example': 300,
644
+ 'https://h6.example': 400
645
+ })
646
+
647
+ await uploader.publishFile({
648
+ file: { data: stringToUtf8Array('needs a second batch'), type: 'text/plain' },
649
+ retentionPeriod: 60
650
+ })
651
+
652
+ expect(quoteCalls.sort()).toEqual([
653
+ 'https://h1.example/quote',
654
+ 'https://h2.example/quote',
655
+ 'https://h3.example/quote',
656
+ 'https://h4.example/quote',
657
+ 'https://h5.example/quote',
658
+ 'https://h6.example/quote'
659
+ ])
660
+ })
661
+
662
+ it('asks only for the remaining number of quotes in subsequent batches', async () => {
663
+ const hosts = [
664
+ 'https://h1.example',
665
+ 'https://h2.example',
666
+ 'https://h3.example',
667
+ 'https://h4.example',
668
+ 'https://h5.example',
669
+ 'https://h6.example',
670
+ 'https://h7.example',
671
+ 'https://h8.example'
672
+ ]
673
+ const uploader = new StorageUploader({
674
+ storageURLs: hosts,
675
+ resilienceLevel: 2, // target = 4 quotes
676
+ wallet: walletClient
677
+ })
678
+
679
+ // First batch of 4 yields only 3 valid quotes. The second iteration must
680
+ // request just 1 more provider (h5), not fire to hosts 5-8.
681
+ const { quoteCalls } = wireMocks(uploader, {
682
+ 'https://h1.example': 100,
683
+ 'https://h2.example': 200,
684
+ 'https://h3.example': 'error',
685
+ 'https://h4.example': 300,
686
+ 'https://h5.example': 400,
687
+ 'https://h6.example': 500,
688
+ 'https://h7.example': 600,
689
+ 'https://h8.example': 700
690
+ })
691
+
692
+ await uploader.publishFile({
693
+ file: { data: stringToUtf8Array('exact-remaining batching'), type: 'text/plain' },
694
+ retentionPeriod: 60
695
+ })
696
+
697
+ // First batch queried h1-h4 (4 hosts), second batch queried only h5
698
+ // (1 host, the exact remainder). Hosts h6-h8 are never contacted.
699
+ expect(quoteCalls.sort()).toEqual([
700
+ 'https://h1.example/quote',
701
+ 'https://h2.example/quote',
702
+ 'https://h3.example/quote',
703
+ 'https://h4.example/quote',
704
+ 'https://h5.example/quote'
705
+ ])
706
+ })
707
+ })
708
+
709
+ describe('StorageUploader — multi-host findFile / listUploads / renewFile', () => {
710
+ let walletClient: WalletClient
711
+
712
+ beforeEach(() => {
713
+ walletClient = new WalletClient('json-api', 'non-admin.com')
714
+ })
715
+
716
+ afterEach(() => {
717
+ jest.restoreAllMocks()
718
+ })
719
+
720
+ /**
721
+ * Spies on the authFetch instance with a URL-routed handler. Each host
722
+ * gets a handler keyed by `${host}/find`, `${host}/list`, `${host}/renew`.
723
+ */
724
+ function wireAuthFetch (
725
+ uploader: StorageUploader,
726
+ handler: (url: string) => Promise<Response>
727
+ ): jest.SpyInstance {
728
+ return jest
729
+ .spyOn((uploader as any).authFetch, 'fetch')
730
+ .mockImplementation(async (input: RequestInfo | URL) => await handler(extractFetchURL(input)))
731
+ }
732
+
733
+ it('findFile fans out to every configured host and picks the longest-expiry result', async () => {
734
+ const uploader = new StorageUploader({
735
+ storageURLs: ['https://a.example', 'https://b.example', 'https://c.example'],
736
+ wallet: walletClient
737
+ })
738
+
739
+ const calls: string[] = []
740
+ wireAuthFetch(uploader, async url => {
741
+ calls.push(url)
742
+ if (url.startsWith('https://a.example/find')) {
743
+ return jsonResponse({
744
+ status: 'success',
745
+ data: { name: 'a-name', size: '10', mimeType: 'text/plain', expiryTime: 100 }
746
+ })
747
+ }
748
+ if (url.startsWith('https://b.example/find')) {
749
+ // Host b has a longer expiry — it should win.
750
+ return jsonResponse({
751
+ status: 'success',
752
+ data: { name: 'b-name', size: '10', mimeType: 'text/plain', expiryTime: 500 }
753
+ })
754
+ }
755
+ // Host c does not have the file.
756
+ return jsonResponse({
757
+ status: 'error',
758
+ code: 'ERR_NOT_FOUND',
759
+ description: 'nope',
760
+ data: { name: '', size: '', mimeType: '', expiryTime: 0 }
761
+ })
762
+ })
763
+
764
+ const result = await uploader.findFile('uhrp://x')
765
+
766
+ expect(calls.sort()).toEqual([
767
+ 'https://a.example/find?uhrpUrl=uhrp%3A%2F%2Fx',
768
+ 'https://b.example/find?uhrpUrl=uhrp%3A%2F%2Fx',
769
+ 'https://c.example/find?uhrpUrl=uhrp%3A%2F%2Fx'
770
+ ])
771
+ expect(result.name).toBe('b-name')
772
+ expect(result.expiryTime).toBe(500)
773
+ expect(result.hostedBy).toEqual(['https://b.example', 'https://a.example'])
774
+ })
775
+
776
+ it('findFile scopes to options.hostedBy when provided', async () => {
777
+ const uploader = new StorageUploader({
778
+ storageURLs: ['https://a.example', 'https://b.example', 'https://c.example'],
779
+ wallet: walletClient
780
+ })
781
+
782
+ const calls: string[] = []
783
+ wireAuthFetch(uploader, async url => {
784
+ calls.push(url)
785
+ return jsonResponse({
786
+ status: 'success',
787
+ data: { name: 'x', size: '10', mimeType: 'text/plain', expiryTime: 200 }
788
+ })
789
+ })
790
+
791
+ await uploader.findFile('uhrp://x', { hostedBy: ['https://b.example'] })
792
+ expect(calls).toEqual(['https://b.example/find?uhrpUrl=uhrp%3A%2F%2Fx'])
793
+ })
794
+
795
+ it('findFile throws with an aggregated error when every host fails (multi-host)', async () => {
796
+ const uploader = new StorageUploader({
797
+ storageURLs: ['https://a.example', 'https://b.example'],
798
+ wallet: walletClient
799
+ })
800
+
801
+ wireAuthFetch(uploader, async () => jsonResponse({
802
+ status: 'error', code: 'ERR_NOT_FOUND', description: 'nope'
803
+ }))
804
+
805
+ await expect(uploader.findFile('uhrp://ghost'))
806
+ .rejects.toThrow(/no configured host reported this UHRP URL/)
807
+ })
808
+
809
+ it('findFile rejects hostedBy sets with no configured intersection', async () => {
810
+ const uploader = new StorageUploader({
811
+ storageURLs: ['https://a.example'],
812
+ wallet: walletClient
813
+ })
814
+ await expect(uploader.findFile('uhrp://x', { hostedBy: ['https://unknown.example'] }))
815
+ .rejects.toThrow(/did not intersect any configured provider/)
816
+ })
817
+
818
+ it('listUploads unions entries from every host and merges by UHRP URL', async () => {
819
+ const uploader = new StorageUploader({
820
+ storageURLs: ['https://a.example', 'https://b.example'],
821
+ wallet: walletClient
822
+ })
823
+
824
+ wireAuthFetch(uploader, async url => {
825
+ if (url.startsWith('https://a.example/list')) {
826
+ return jsonResponse({
827
+ status: 'success',
828
+ uploads: [
829
+ { uhrpUrl: 'uhrp://one', expiryTime: 100 },
830
+ { uhrpUrl: 'uhrp://shared', expiryTime: 150 }
831
+ ]
832
+ })
833
+ }
834
+ return jsonResponse({
835
+ status: 'success',
836
+ uploads: [
837
+ { uhrpUrl: 'uhrp://two', expiryTime: 200 },
838
+ { uhrpUrl: 'uhrp://shared', expiryTime: 300 } // longer expiry on b
839
+ ]
840
+ })
841
+ })
842
+
843
+ const listing = await uploader.listUploads()
844
+ const byUrl = Object.fromEntries(listing.map((e: any) => [e.uhrpUrl, e]))
845
+
846
+ expect(Object.keys(byUrl).sort()).toEqual(['uhrp://one', 'uhrp://shared', 'uhrp://two'])
847
+ expect(byUrl['uhrp://shared'].expiryTime).toBe(300) // longest wins
848
+ expect(byUrl['uhrp://shared'].hostedBy.sort()).toEqual(['https://a.example', 'https://b.example'])
849
+ expect(byUrl['uhrp://one'].hostedBy).toEqual(['https://a.example'])
850
+ expect(byUrl['uhrp://two'].hostedBy).toEqual(['https://b.example'])
851
+ })
852
+
853
+ it('listUploads succeeds when at least one host responds (multi-host)', async () => {
854
+ const uploader = new StorageUploader({
855
+ storageURLs: ['https://a.example', 'https://b.example'],
856
+ wallet: walletClient
857
+ })
858
+
859
+ wireAuthFetch(uploader, async url => {
860
+ if (url.startsWith('https://a.example/list')) {
861
+ return new Response(null, { status: 500 })
862
+ }
863
+ return jsonResponse({
864
+ status: 'success',
865
+ uploads: [{ uhrpUrl: 'uhrp://only', expiryTime: 100 }]
866
+ })
867
+ })
868
+
869
+ const listing = await uploader.listUploads()
870
+ expect(listing).toHaveLength(1)
871
+ expect(listing[0].uhrpUrl).toBe('uhrp://only')
872
+ })
873
+
874
+ it('renewFile fans out, sums amounts, and reports per-host outcomes when threshold is met', async () => {
875
+ const uploader = new StorageUploader({
876
+ storageURLs: ['https://a.example', 'https://b.example', 'https://c.example'],
877
+ resilienceLevel: 2,
878
+ wallet: walletClient
879
+ })
880
+
881
+ wireAuthFetch(uploader, async url => {
882
+ if (url === 'https://a.example/renew') {
883
+ return jsonResponse({
884
+ status: 'success',
885
+ prevExpiryTime: 100,
886
+ newExpiryTime: 1000,
887
+ amount: 50
888
+ })
889
+ }
890
+ if (url === 'https://b.example/renew') {
891
+ return jsonResponse({
892
+ status: 'success',
893
+ prevExpiryTime: 200,
894
+ newExpiryTime: 2000,
895
+ amount: 75
896
+ })
897
+ }
898
+ // Host c does not hold the file; renew errors out. Two successes still
899
+ // meet the resilience threshold of 2, so the overall call succeeds.
900
+ return jsonResponse({
901
+ status: 'error',
902
+ code: 'ERR_OLD_ADVERTISEMENT_NOT_FOUND',
903
+ description: 'not on this host'
904
+ })
905
+ })
906
+
907
+ const result = await uploader.renewFile('uhrp://x', 30)
908
+
909
+ expect(result.status).toBe('success')
910
+ // b has the longest newExpiryTime, so it drives the top-level fields.
911
+ expect(result.newExpiryTime).toBe(2000)
912
+ expect(result.prevExpiryTime).toBe(200)
913
+ // Aggregate total is the sum of paid hosts only.
914
+ expect(result.amount).toBe(125)
915
+ expect(result.results).toHaveLength(3)
916
+ const byHost = Object.fromEntries((result.results ?? []).map(r => [r.host, r]))
917
+ expect(byHost['https://a.example'].status).toBe('success')
918
+ expect(byHost['https://b.example'].status).toBe('success')
919
+ expect(byHost['https://c.example'].status).toBe('error')
920
+ expect(byHost['https://c.example'].error).toMatch(/ERR_OLD_ADVERTISEMENT_NOT_FOUND/)
921
+ })
922
+
923
+ it('renewFile throws RenewResiliencyError when the resilience threshold is not met', async () => {
924
+ const uploader = new StorageUploader({
925
+ storageURLs: ['https://a.example', 'https://b.example', 'https://c.example'],
926
+ resilienceLevel: 3,
927
+ wallet: walletClient
928
+ })
929
+
930
+ wireAuthFetch(uploader, async url => {
931
+ if (url === 'https://a.example/renew') {
932
+ return jsonResponse({
933
+ status: 'success',
934
+ prevExpiryTime: 100,
935
+ newExpiryTime: 1000,
936
+ amount: 50
937
+ })
938
+ }
939
+ // b and c both fail — only 1 of 3 required hosts renewed.
940
+ return jsonResponse({
941
+ status: 'error',
942
+ code: 'ERR_OLD_ADVERTISEMENT_NOT_FOUND',
943
+ description: 'not on this host'
944
+ })
945
+ })
946
+
947
+ const promise = uploader.renewFile('uhrp://x', 30)
948
+ await expect(promise).rejects.toBeInstanceOf(RenewResiliencyError)
949
+ await expect(promise).rejects.toThrow(/only 1 of 3 required hosts renewed/)
950
+
951
+ // The error must surface per-host outcomes so the caller can see which
952
+ // host was billed even though the overall call failed.
953
+ try {
954
+ await uploader.renewFile('uhrp://x', 30)
955
+ fail('expected RenewResiliencyError')
956
+ } catch (e) {
957
+ const err = e as RenewResiliencyError
958
+ expect(err.requiredSuccesses).toBe(3)
959
+ expect(err.successCount).toBe(1)
960
+ const byHost = Object.fromEntries(err.results.map(r => [r.host, r]))
961
+ expect(byHost['https://a.example'].status).toBe('success')
962
+ expect(byHost['https://a.example'].amount).toBe(50)
963
+ expect(byHost['https://b.example'].status).toBe('error')
964
+ expect(byHost['https://c.example'].status).toBe('error')
965
+ }
966
+ })
967
+
968
+ it('renewFile threshold is clamped to targets.length when hostedBy scopes smaller than resilienceLevel', async () => {
969
+ const uploader = new StorageUploader({
970
+ storageURLs: ['https://a.example', 'https://b.example', 'https://c.example'],
971
+ resilienceLevel: 3,
972
+ wallet: walletClient
973
+ })
974
+
975
+ // Scope to 2 hosts; threshold clamps to 2. Both succeed → overall success.
976
+ wireAuthFetch(uploader, async () => jsonResponse({
977
+ status: 'success',
978
+ prevExpiryTime: 0,
979
+ newExpiryTime: 500,
980
+ amount: 10
981
+ }))
982
+
983
+ const result = await uploader.renewFile('uhrp://x', 30, {
984
+ hostedBy: ['https://a.example', 'https://b.example']
985
+ })
986
+ expect(result.status).toBe('success')
987
+ expect(result.amount).toBe(20)
988
+ })
989
+
990
+ it('renewFile throws when every host fails (multi-host)', async () => {
991
+ const uploader = new StorageUploader({
992
+ storageURLs: ['https://a.example', 'https://b.example'],
993
+ resilienceLevel: 2,
994
+ wallet: walletClient
995
+ })
996
+
997
+ wireAuthFetch(uploader, async () => jsonResponse({
998
+ status: 'error',
999
+ code: 'ERR_OLD_ADVERTISEMENT_NOT_FOUND',
1000
+ description: 'gone'
1001
+ }))
1002
+
1003
+ const promise = uploader.renewFile('uhrp://ghost', 30)
1004
+ await expect(promise).rejects.toBeInstanceOf(RenewResiliencyError)
1005
+ await expect(promise).rejects.toThrow(/only 0 of 2 required hosts renewed/)
1006
+ })
1007
+
1008
+ it('renewFile honors options.hostedBy and only renews the specified replicas', async () => {
1009
+ const uploader = new StorageUploader({
1010
+ storageURLs: ['https://a.example', 'https://b.example', 'https://c.example'],
1011
+ wallet: walletClient
1012
+ })
1013
+
1014
+ const calls: string[] = []
1015
+ wireAuthFetch(uploader, async url => {
1016
+ calls.push(url)
1017
+ return jsonResponse({
1018
+ status: 'success',
1019
+ prevExpiryTime: 0,
1020
+ newExpiryTime: 100,
1021
+ amount: 10
1022
+ })
1023
+ })
1024
+
1025
+ const result = await uploader.renewFile('uhrp://x', 30, {
1026
+ hostedBy: ['https://a.example', 'https://c.example']
1027
+ })
1028
+
1029
+ expect(calls.sort()).toEqual([
1030
+ 'https://a.example/renew',
1031
+ 'https://c.example/renew'
1032
+ ])
1033
+ expect(result.amount).toBe(20)
1034
+ expect(result.results).toHaveLength(2)
1035
+ })
1036
+ })