UNPKG

filecoin-pin

Version:

Bridge IPFS content to Filecoin Onchain Cloud using familiar tools

452 lines (372 loc) 17.5 kB
import type { ProviderInfo } from '@filoz/synapse-sdk' import { CID } from 'multiformats/cid' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { waitForIpniProviderResults } from '../../core/utils/validate-ipni-advertisement.js' describe('waitForIpniProviderResults', () => { const testCid = CID.parse('bafkreia5fn4rmshmb7cl7fufkpcw733b5anhuhydtqstnglpkzosqln5kq') const defaultIndexerUrl = 'https://filecoinpin.contact' const mockFetch = vi.fn() const createProviderInfo = (serviceURL: string): ProviderInfo => ({ id: 1234, serviceProvider: 'f01234', name: 'Test Provider', products: { PDP: { data: { serviceURL, }, }, }, }) as ProviderInfo const successResponse = (multiaddrs: string[] = ['/dns/example.com/tcp/443/https']) => ({ ok: true, json: vi.fn(async () => ({ MultihashResults: [ { ProviderResults: multiaddrs.map((addr, index) => ({ Provider: { ID: `12D3KooWProvider${index}`, Addrs: [addr], }, })), }, ], })), }) const emptyProviderResponse = () => ({ ok: true, json: vi.fn(async () => ({ MultihashResults: [], })), }) beforeEach(() => { vi.stubGlobal('fetch', mockFetch) vi.useFakeTimers() }) afterEach(() => { vi.unstubAllGlobals() vi.clearAllMocks() vi.useRealTimers() }) describe('successful announcement', () => { it('should resolve true and emit a final complete event on first attempt', async () => { mockFetch.mockResolvedValueOnce(successResponse()) const onProgress = vi.fn() const promise = waitForIpniProviderResults(testCid, { onProgress }) await vi.runAllTimersAsync() const result = await promise expect(result).toBe(true) expect(mockFetch).toHaveBeenCalledTimes(1) expect(mockFetch).toHaveBeenCalledWith(`${defaultIndexerUrl}/cid/${testCid}`, { headers: { Accept: 'application/json' }, }) // Should emit retryUpdate for attempt 0 and a final complete(true) expect(onProgress).toHaveBeenCalledWith({ type: 'ipniProviderResults.retryUpdate', data: { retryCount: 0 } }) expect(onProgress).toHaveBeenCalledWith({ type: 'ipniProviderResults.complete', data: { result: true, retryCount: 0 }, }) }) it('should retry multiple times before succeeding and emit a final complete(true)', async () => { mockFetch .mockResolvedValueOnce({ ok: false }) .mockResolvedValueOnce({ ok: false }) .mockResolvedValueOnce({ ok: false }) .mockResolvedValueOnce(successResponse()) const onProgress = vi.fn() const promise = waitForIpniProviderResults(testCid, { maxAttempts: 5, onProgress }) await vi.runAllTimersAsync() const result = await promise expect(result).toBe(true) expect(mockFetch).toHaveBeenCalledTimes(4) // Expect retryUpdate with counts 0,1,2,3 and final complete with retryCount 3 expect(onProgress).toHaveBeenCalledWith({ type: 'ipniProviderResults.retryUpdate', data: { retryCount: 0 } }) expect(onProgress).toHaveBeenCalledWith({ type: 'ipniProviderResults.retryUpdate', data: { retryCount: 1 } }) expect(onProgress).toHaveBeenCalledWith({ type: 'ipniProviderResults.retryUpdate', data: { retryCount: 2 } }) expect(onProgress).toHaveBeenCalledWith({ type: 'ipniProviderResults.retryUpdate', data: { retryCount: 3 } }) expect(onProgress).toHaveBeenCalledWith({ type: 'ipniProviderResults.complete', data: { result: true, retryCount: 3 }, }) }) it('should succeed when the expected provider advertises the derived multiaddr', async () => { const provider = createProviderInfo('https://example.com') const expectedMultiaddr = '/dns/example.com/tcp/443/https' mockFetch.mockResolvedValueOnce(successResponse([expectedMultiaddr])) const promise = waitForIpniProviderResults(testCid, { expectedProviders: [provider] }) await vi.runAllTimersAsync() const result = await promise expect(result).toBe(true) expect(mockFetch).toHaveBeenCalledWith(`${defaultIndexerUrl}/cid/${testCid}`, { headers: { Accept: 'application/json' }, }) }) it('should succeed when all expected providers are in the IPNI ProviderResults', async () => { const providerA = createProviderInfo('https://a.example.com') const providerB = createProviderInfo('https://b.example.com:8443') const expectedMultiaddrs = ['/dns/a.example.com/tcp/443/https', '/dns/b.example.com/tcp/8443/https'] mockFetch.mockResolvedValueOnce(successResponse(expectedMultiaddrs)) const promise = waitForIpniProviderResults(testCid, { expectedProviders: [providerA, providerB] }) await vi.runAllTimersAsync() const result = await promise expect(result).toBe(true) }) }) describe('failed announcement', () => { it('should reject after custom maxAttempts and emit a failed event', async () => { mockFetch.mockResolvedValue({ ok: false }) const onProgress = vi.fn() const promise = waitForIpniProviderResults(testCid, { maxAttempts: 3, onProgress }) // Attach rejection handler immediately const expectPromise = expect(promise).rejects.toThrow( `IPFS root CID "${testCid.toString()}" does not have expected IPNI ProviderResults after 3 attempts` ) await vi.runAllTimersAsync() await expectPromise expect(mockFetch).toHaveBeenCalledTimes(3) // Expect retryUpdate with counts 0,1,2 and final failed event (no complete event on failure) expect(onProgress).toHaveBeenCalledWith({ type: 'ipniProviderResults.retryUpdate', data: { retryCount: 0 } }) expect(onProgress).toHaveBeenCalledWith({ type: 'ipniProviderResults.retryUpdate', data: { retryCount: 1 } }) expect(onProgress).toHaveBeenCalledWith({ type: 'ipniProviderResults.retryUpdate', data: { retryCount: 2 } }) // Should emit failed event, not complete(false) expect(onProgress).toHaveBeenCalledWith({ type: 'ipniProviderResults.failed', data: { error: expect.any(Error) }, }) // Should NOT emit complete event expect(onProgress).not.toHaveBeenCalledWith({ type: 'ipniProviderResults.complete', data: { result: false, retryCount: expect.any(Number) }, }) }) it('should reject immediately when maxAttempts is 1', async () => { mockFetch.mockResolvedValue({ ok: false }) const promise = waitForIpniProviderResults(testCid, { maxAttempts: 1 }) // Attach rejection handler immediately const expectPromise = expect(promise).rejects.toThrow( `IPFS root CID "${testCid.toString()}" does not have expected IPNI ProviderResults after 1 attempt` ) await vi.runAllTimersAsync() await expectPromise expect(mockFetch).toHaveBeenCalledTimes(1) }) it('should reject when an expected provider is missing from the IPNI ProviderResults', async () => { const provider = createProviderInfo('https://expected.example.com') mockFetch.mockResolvedValueOnce(successResponse(['/dns/other.example.com/tcp/443/https'])) const promise = waitForIpniProviderResults(testCid, { maxAttempts: 1, expectedProviders: [provider], }) const expectPromise = expect(promise).rejects.toThrow( `IPFS root CID "${testCid.toString()}" does not have expected IPNI ProviderResults after 1 attempt. Last observation: Missing provider records with expected multiaddr(s): /dns/expected.example.com/tcp/443/https` ) await vi.runAllTimersAsync() await expectPromise }) it('should reject when not all expected providers are in the IPNI ProviderResults', async () => { const providerA = createProviderInfo('https://a.example.com') const providerB = createProviderInfo('https://b.example.com') mockFetch.mockResolvedValueOnce(successResponse(['/dns/a.example.com/tcp/443/https'])) const promise = waitForIpniProviderResults(testCid, { maxAttempts: 1, expectedProviders: [providerA, providerB], }) const expectPromise = expect(promise).rejects.toThrow( `IPFS root CID "${testCid.toString()}" does not have expected IPNI ProviderResults after 1 attempt. Last observation: Missing provider records with expected multiaddr(s): /dns/b.example.com/tcp/443/https` ) await vi.runAllTimersAsync() await expectPromise }) it('should retry until the expected provider appears in subsequent attempts', async () => { const provider = createProviderInfo('https://expected.example.com') const expectedMultiaddr = '/dns/expected.example.com/tcp/443/https' mockFetch .mockResolvedValueOnce(successResponse(['/dns/other.example.com/tcp/443/https'])) .mockResolvedValueOnce(successResponse([expectedMultiaddr])) const promise = waitForIpniProviderResults(testCid, { maxAttempts: 3, expectedProviders: [provider], delayMs: 1, }) await vi.runAllTimersAsync() const result = await promise expect(result).toBe(true) expect(mockFetch).toHaveBeenCalledTimes(2) }) it('should retry when the IPNI response is empty', async () => { const provider = createProviderInfo('https://expected.example.com') const expectedMultiaddr = '/dns/expected.example.com/tcp/443/https' mockFetch .mockResolvedValueOnce(emptyProviderResponse()) .mockResolvedValueOnce(successResponse([expectedMultiaddr])) const promise = waitForIpniProviderResults(testCid, { maxAttempts: 3, expectedProviders: [provider], delayMs: 1, }) await vi.runAllTimersAsync() const result = await promise expect(result).toBe(true) expect(mockFetch).toHaveBeenCalledTimes(2) }) }) describe('abort signal', () => { it('should abort when signal is triggered before first check', async () => { const abortController = new AbortController() abortController.abort() const promise = waitForIpniProviderResults(testCid, { signal: abortController.signal }) // Attach rejection handler immediately const expectPromise = expect(promise).rejects.toThrow('Check IPNI announce aborted') await vi.runAllTimersAsync() await expectPromise expect(mockFetch).not.toHaveBeenCalled() }) it('should abort when signal is triggered during retry', async () => { const abortController = new AbortController() mockFetch.mockResolvedValue({ ok: false }) const promise = waitForIpniProviderResults(testCid, { signal: abortController.signal, maxAttempts: 5 }) // Let first check complete await vi.advanceTimersByTimeAsync(0) expect(mockFetch).toHaveBeenCalledTimes(1) // Abort before retry abortController.abort() // Attach rejection handler before running remaining timers const expectPromise = expect(promise).rejects.toThrow('Check IPNI announce aborted') await vi.runAllTimersAsync() await expectPromise // Should not make additional calls after abort expect(mockFetch).toHaveBeenCalledTimes(1) }) it('should pass abort signal to fetch when provided', async () => { const abortController = new AbortController() mockFetch.mockResolvedValueOnce(successResponse()) const promise = waitForIpniProviderResults(testCid, { signal: abortController.signal }) await vi.runAllTimersAsync() await promise expect(mockFetch).toHaveBeenCalledWith(`${defaultIndexerUrl}/cid/${testCid}`, { headers: { Accept: 'application/json' }, signal: abortController.signal, }) }) }) describe('edge cases', () => { it('should retry when fetch throws before succeeding within maxAttempts', async () => { mockFetch.mockRejectedValueOnce(new Error('Network error')).mockResolvedValueOnce(successResponse()) const promise = waitForIpniProviderResults(testCid, { maxAttempts: 2, delayMs: 1 }) await vi.runAllTimersAsync() const result = await promise expect(result).toBe(true) expect(mockFetch).toHaveBeenCalledTimes(2) }) it('should handle different CID formats', async () => { const v0Cid = CID.parse('QmNT6isqrhH6LZWg8NeXQYTD9wPjJo2BHHzyezpf9BdHbD') mockFetch.mockResolvedValueOnce(successResponse()) const promise = waitForIpniProviderResults(v0Cid, {}) await vi.runAllTimersAsync() const result = await promise expect(result).toBe(true) expect(mockFetch).toHaveBeenCalledWith(`${defaultIndexerUrl}/cid/${v0Cid}`, { headers: { Accept: 'application/json' }, }) }) it('should handle empty or missing provider data gracefully', async () => { // Test that validation handles various malformed provider responses mockFetch.mockResolvedValueOnce({ ok: true, json: vi.fn(async () => ({ MultihashResults: [ { ProviderResults: [ { Provider: null }, // null provider { Provider: { ID: '12D3Koo1', Addrs: [] } }, // empty addrs { Provider: { ID: '12D3Koo2', Addrs: ['/dns/valid.com/tcp/443/https'] } }, // valid ], }, ], })), }) const promise = waitForIpniProviderResults(testCid, { maxAttempts: 1 }) await vi.runAllTimersAsync() const result = await promise // Should succeed because at least one valid provider exists expect(result).toBe(true) }) it('should handle provider without serviceURL by falling back to generic validation', async () => { const providerWithoutURL = { id: 1234, serviceProvider: 'f01234', name: 'Test Provider', products: { PDP: { data: {} } }, } as ProviderInfo mockFetch.mockResolvedValueOnce(successResponse()) const promise = waitForIpniProviderResults(testCid, { expectedProviders: [providerWithoutURL] }) await vi.runAllTimersAsync() const result = await promise expect(result).toBe(true) }) it('should preserve parse error message instead of overwriting with generic message', async () => { mockFetch.mockResolvedValueOnce({ ok: true, json: vi.fn(async () => { throw new Error('Invalid JSON') }), }) const promise = waitForIpniProviderResults(testCid, { maxAttempts: 1 }) // Should preserve the specific "Failed to parse" message, not overwrite with generic message const expectPromise = expect(promise).rejects.toThrow('Failed to parse IPNI response body') await vi.runAllTimersAsync() await expectPromise }) it('should clear stale multiaddrs when parse error occurs after successful response', async () => { // Attempt 1: successful response with multiaddrs but doesn't match expectations // Attempt 2: parse error - should clear the multiaddrs from attempt 1 const provider = createProviderInfo('https://expected.example.com') mockFetch.mockResolvedValueOnce(successResponse(['/dns/other.example.com/tcp/443/https'])).mockResolvedValueOnce({ ok: true, json: vi.fn(async () => { throw new Error('Invalid JSON') }), }) const promise = waitForIpniProviderResults(testCid, { maxAttempts: 2, expectedProviders: [provider], }) const expectPromise = expect(promise).rejects.toThrow( `IPFS root CID "${testCid.toString()}" does not have expected IPNI ProviderResults after 2 attempts. Last observation: Failed to parse IPNI response body: Invalid JSON. Expected multiaddrs: [/dns/expected.example.com/tcp/443/https]. Actual multiaddrs in response: []` ) await vi.runAllTimersAsync() await expectPromise }) it('should update failure reason on each attempt instead of preserving first error', async () => { // Attempt 1: parse error // Attempt 2: successful parse but empty results // Final error should report empty results as last observation, not parse error mockFetch .mockResolvedValueOnce({ ok: true, json: vi.fn(async () => { throw new Error('Invalid JSON') }), }) .mockResolvedValueOnce(emptyProviderResponse()) const promise = waitForIpniProviderResults(testCid, { maxAttempts: 2 }) const expectPromise = expect(promise).rejects.toThrow( 'Last observation: IPNI response did not include any provider results' ) await vi.runAllTimersAsync() await expectPromise }) it('should use custom IPNI indexer URL when provided', async () => { const customIndexerUrl = 'https://custom-indexer.example.com' mockFetch.mockResolvedValueOnce(successResponse()) const promise = waitForIpniProviderResults(testCid, { ipniIndexerUrl: customIndexerUrl }) await vi.runAllTimersAsync() const result = await promise expect(result).toBe(true) expect(mockFetch).toHaveBeenCalledWith(`${customIndexerUrl}/cid/${testCid}`, { headers: { Accept: 'application/json' }, }) }) }) })