UNPKG

@bsv/sdk

Version:

BSV Blockchain Software Development Kit

237 lines (197 loc) 9.17 kB
import { StorageDownloader } from '../StorageDownloader.js' import { StorageUtils } from '../index.js' import { LookupResolver } from '../../overlay-tools/index.js' import Transaction from '../../transaction/Transaction.js' import PushDrop from '../../script/templates/PushDrop.js' import { PublicKey } from '../../primitives/index.js' import { Utils } from '../../primitives/index.js' import { ReadableStream } from 'stream/web' beforeEach(() => { jest.restoreAllMocks() }) describe('StorageDownloader', () => { let downloader: StorageDownloader beforeEach(() => { // Create a fresh instance downloader = new StorageDownloader() }) describe('resolve()', () => { it('throws if the lookup response is not "output-list"', async () => { // Mock the LookupResolver to return something invalid jest.spyOn(LookupResolver.prototype, 'query').mockResolvedValue({ type: 'something-else', outputs: [] } as any) await expect(downloader.resolve('fakeUhrpUrl')) .rejects .toThrow('Lookup answer must be an output list') }) it('decodes each output with Transaction.fromBEEF and PushDrop.decode', async () => { // 1) Mock lookup response jest.spyOn(LookupResolver.prototype, 'query').mockResolvedValue({ type: 'output-list', outputs: [ { beef: 'fake-beef-a', outputIndex: 0 }, { beef: 'fake-beef-b', outputIndex: 1 } ] } as any) // 2) Mock Transaction.fromBEEF -> returns a dummy transaction jest.spyOn(Transaction, 'fromBEEF').mockImplementation(() => { // Each transaction might have multiple outputs; we only care about `outputIndex` return { outputs: [ { lockingScript: {} }, // index 0 { lockingScript: {} } // index 1 ] } as any }) // 3) Mock PushDrop.decode -> returns { fields: number[][] } jest.spyOn(PushDrop, 'decode').mockImplementation(() => { // The decode function returns an object with `fields`, return { lockingPublicKey: {} as PublicKey, fields: [ [11], [22], [104, 116, 116, 112, 58, 47, 47, 97, 46, 99, 111, 109] ] } }) // 4) Mock Utils.toUTF8 to convert that number[] to a string jest.spyOn(Utils, 'toUTF8').mockReturnValue('http://a.com') const resolved = await downloader.resolve('fakeUhrpUrl') expect(resolved).toEqual(['http://a.com', 'http://a.com']) }) }) describe('download()', () => { it('throws if UHRP URL is invalid', async () => { jest.spyOn(StorageUtils, 'isValidURL').mockReturnValue(false) await expect(downloader.download('invalidUrl')) .rejects .toThrow('Invalid parameter UHRP url') }) it('throws if no hosts are found', async () => { // Valid UHRP URL jest.spyOn(StorageUtils, 'isValidURL').mockReturnValue(true) // Return some random 32-byte hash so we can pass the check jest.spyOn(StorageUtils, 'getHashFromURL').mockReturnValue(new Array(32).fill(0)) // Force resolve() to return an empty array jest.spyOn(downloader, 'resolve').mockResolvedValue([]) await expect(downloader.download('validButUnhostedUrl')) .rejects .toThrow('No one currently hosts this file!') }) it('downloads successfully from the first working host', async () => { jest.spyOn(StorageUtils, 'isValidURL').mockReturnValue(true) const knownHash = [ 102, 104, 122, 173, 248, 98, 189, 119, 108, 143, 193, 139, 142, 159, 142, 32, 8, 151, 20, 133, 110, 226, 51, 179, 144, 42, 89, 29, 13, 95, 41, 37 ] jest.spyOn(StorageUtils, 'getHashFromURL').mockReturnValue(knownHash) // Suppose two possible download URLs jest.spyOn(downloader, 'resolve').mockResolvedValue([ 'http://host1/404', 'http://host2/ok' ]) // The first fetch -> 404, second fetch -> success const fetchSpy = jest.spyOn(global, 'fetch') .mockResolvedValueOnce(new Response(null, { status: 404 })) .mockResolvedValueOnce(new Response(new Uint8Array(32).fill(0), { status: 200, headers: { 'Content-Type': 'application/test' } })) const result = await downloader.download('validUrl') expect(fetchSpy).toHaveBeenCalledTimes(2) expect(result).toEqual({ data: new Uint8Array(32).fill(0), mimeType: 'application/test' }) }) it('throws if content hash mismatches the UHRP hash', async () => { jest.spyOn(StorageUtils, 'isValidURL').mockReturnValue(true) // The expected hash is all zeros jest.spyOn(StorageUtils, 'getHashFromURL').mockReturnValue(new Array(32).fill(0)) // One potential host jest.spyOn(downloader, 'resolve').mockResolvedValue([ 'http://bad-content.test' ]) // The fetch returns 32 bytes of all 1's => hash mismatch jest.spyOn(global, 'fetch').mockResolvedValue( new Response(new Uint8Array(32).fill(1), { status: 200 }) ) await expect(downloader.download('validButBadHashUrl')) .rejects .toThrow() }) it('throws if all hosts fail or mismatch', async () => { jest.spyOn(StorageUtils, 'isValidURL').mockReturnValue(true) jest.spyOn(StorageUtils, 'getHashFromURL').mockReturnValue(new Array(32).fill(0)) jest.spyOn(downloader, 'resolve').mockResolvedValue([ 'http://host1.test', 'http://host2.test' ]) // Both fetches fail with 500 or something >=400 jest.spyOn(global, 'fetch').mockResolvedValue( new Response(null, { status: 500 }) ) await expect(downloader.download('validButNoGoodHostUrl')) .rejects .toThrow('Unable to download content from validButNoGoodHostUrl') }) it('throws if all entries are expired', async () => { const currentTime = Math.floor(Date.now()) jest.spyOn(LookupResolver.prototype, 'query').mockResolvedValue({ type: 'output-list', outputs: [ { beef: 'fake-beef-a', outputIndex: 0 }, { beef: 'fake-beef-b', outputIndex: 1 } ] } as any) jest.spyOn(Transaction, 'fromBEEF').mockImplementation(() => { return { outputs: [ { lockingScript: {} }, { lockingScript: {} } ] } as any }) jest.spyOn(PushDrop, 'decode').mockImplementation(() => { return { lockingPublicKey: {} as PublicKey, fields: [[], [], [], [currentTime - 100]] } }) await expect(downloader.resolve('expiredUhrpUrl')) .resolves .toEqual(["", ""]) }) it.skip('downloads and verifies large streamed content', async () => { const size = 5 * 1024 * 1024 const data = new Uint8Array(size) for (let i = 0; i < size; i++) data[i] = i % 256 const uhrpUrl = StorageUtils.getURLForFile(data) jest.spyOn(downloader, 'resolve').mockResolvedValue(['http://large-file']) const chunkSize = 64 * 1024 const stream = new ReadableStream<Uint8Array>({ start (controller) { for (let offset = 0; offset < data.length; offset += chunkSize) { controller.enqueue(data.subarray(offset, offset + chunkSize)) } controller.close() } }) jest.spyOn(global, 'fetch').mockResolvedValue( new Response(stream as any, { status: 200, headers: { 'Content-Type': 'application/octet-stream' } }) ) const result = await downloader.download(uhrpUrl) expect(result.mimeType).toBe('application/octet-stream') expect(result.data.length).toBe(size) expect(result.data).toEqual(data) }) }) })