import { describe, expect, test } from 'bun:test' import { OGStorage } from './og' /** * Regression guard for the upload-side hang: the SDK's Uploader.waitForLogEntry * is an unbounded `while (true)` poll that never returns on a reachable-but-stuck * storage node. putBlob now wraps the whole upload in a wall-clock deadline (the * write-side twin of downloadBlobByRoot's guard). These tests pin that behavior: * a hung SDK surfaces a bounded error, the success path is untouched, and real * SDK errors still propagate (they are not masked as timeouts). * * Construction is offline-safe: new Indexer/JsonRpcProvider/Wallet make no * network call. We inject a tiny uploadTimeoutMs and monkeypatch the SDK seam so * nothing here touches the network. */ const FAKE_KEY = `0x${'11'.repeat(32)}` as const const BYTES = new TextEncoder().encode('anima-test-blob') function makeStorage(uploadTimeoutMs: number): OGStorage { return new OGStorage({ network: '0g-testnet', privkeyHex: FAKE_KEY, uploadTimeoutMs }) } const hang = () => new Promise(() => {}) describe('OGStorage.putBlob upload timeout', () => { test('times out when the SDK indexer path hangs (the waitForLogEntry bug)', async () => { const s = makeStorage(50) // Simulate the unbounded waitForLogEntry: indexer.upload never settles. ;(s as unknown as { indexer: unknown }).indexer = { upload: hang } const started = performance.now() await expect(s.putBlob(BYTES)).rejects.toThrow(/timed out/i) // The reject resolving at all already proves it didn't hang. This loose // real-time bound additionally catches the override being ignored (which // would fall back to the 120s default), with enough slack never to flake. expect(performance.now() - started).toBeLessThan(5_000) }) test('times out when the discovered-nodes fallback hangs (covers both paths)', async () => { const s = makeStorage(50) // Path 1 returns no rootHash -> falls through to the discovered-nodes path, // which we make hang the way a wedged single node would. ;(s as unknown as { indexer: unknown }).indexer = { upload: async () => [{}, null] } ;(s as unknown as { uploadViaDiscoveredNodes: unknown }).uploadViaDiscoveredNodes = hang await expect(s.putBlob(BYTES)).rejects.toThrow(/timed out/i) }) test('returns the root hash on success (success path unchanged)', async () => { const s = makeStorage(50) ;(s as unknown as { indexer: unknown }).indexer = { upload: async () => [{ rootHash: '0xabc123' }, null], } expect(await s.putBlob(BYTES)).toBe('0xabc123') }) test('propagates a real SDK error instead of masking it as a timeout', async () => { const s = makeStorage(5_000) ;(s as unknown as { indexer: unknown }).indexer = { upload: async () => { throw new Error('insufficient funds for gas') }, } // The real cause surfaces, NOT a generic timeout, and well before 5s. await expect(s.putBlob(BYTES)).rejects.toThrow(/insufficient funds/i) }) test('a rejection that lands after the timeout does not become an unhandledRejection', async () => { const leaked: unknown[] = [] // Filter to THIS test's detached rejection so an unrelated rejection bleeding // in from elsewhere in the shared test process can't fail us misleadingly. const onUnhandled = (reason: unknown) => { if (reason instanceof Error && /late node failure/.test(reason.message)) { leaked.push(reason) } } process.on('unhandledRejection', onUnhandled) try { const s = makeStorage(30) // Upload loses the race (30ms), then rejects much later (detached). ;(s as unknown as { indexer: unknown }).indexer = { upload: async () => { await new Promise(r => setTimeout(r, 120)) throw new Error('late node failure') }, } await expect(s.putBlob(BYTES)).rejects.toThrow(/timed out/i) // Wait past the detached rejection so it would have fired by now. await new Promise(r => setTimeout(r, 200)) expect(leaked).toHaveLength(0) } finally { process.off('unhandledRejection', onUnhandled) } }) })