diff --git a/lib/governanceRpc.test.js b/lib/governanceRpc.test.js index 23311bf..4660bec 100644 --- a/lib/governanceRpc.test.js +++ b/lib/governanceRpc.test.js @@ -87,29 +87,87 @@ test('malformed current-vote data is not certified as an empty tally', async () code: 'governance_rpc_unavailable', }); }); -test('actual HTTP timeout after receiving vote is unknown and never automatically retried', async () => { +test('deadline after the real server receives a vote is unknown and never automatically retried', async () => { let count = 0; + let received; const server = http.createServer((req) => { count++; - req.resume(); + const chunks = []; + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', () => received(JSON.parse(Buffer.concat(chunks).toString()))); }); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const actualSetTimeout = global.setTimeout; + const transport = axios.create(); + // Keep the real HTTP connection, while controlling which deadline fires. + // Axios's own timeout is disabled here so the server receipt is observed + // before the RPC's 30 ms deadline callback is invoked deterministically. + const instance = { + post: jest.fn((url, body, options) => { + expect(options.timeout).toBe(30); + return transport.post(url, body, { ...options, timeout: 0 }); + }), + }; const rpc = createGovernanceRpc( - { - url: `http://127.0.0.1:${server.address().port}`, - instance: axios.create(), - }, + { url: `http://127.0.0.1:${server.address().port}`, instance }, { timeoutMs: 30 } ); try { - await expect(rpc.voteRaw(...args)).rejects.toMatchObject({ + async function afterReceipt(call, expectedMethod, expectedParams) { + const requestReceived = new Promise((resolve) => { received = resolve; }); + let expire; + const spy = jest.spyOn(global, 'setTimeout').mockImplementationOnce((fn, ms) => { + expect(ms).toBe(30); + expire = fn; + // Return a real, cleared timer handle for the RPC's normal cleanup. + return actualSetTimeout(() => {}, ms); + }); + let result; + try { + result = call(); + } finally { + spy.mockRestore(); + } + const body = await requestReceived; + expect(body).toMatchObject({ method: expectedMethod, params: expectedParams }); + expire(); + return result; + } + await expect(afterReceipt(() => rpc.voteRaw(...args), 'voteraw', args)).rejects.toMatchObject({ + code: 'submission_unknown', uncertain: true, }); expect(count).toBe(1); - await expect(rpc.getCurrentVotes(args[2])).rejects.toMatchObject({ + expect(instance.post).toHaveBeenCalledTimes(1); + await expect(afterReceipt(() => rpc.getCurrentVotes(args[2]), 'gobject_getcurrentvotes', [args[2]])).rejects.toMatchObject({ code: 'governance_rpc_unavailable', }); expect(count).toBe(2); + expect(instance.post).toHaveBeenCalledTimes(2); + } finally { + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + } +}); +test('real HTTP timeout is uncertain even when delivery cannot be established', async () => { + let count = 0; + const server = http.createServer((req) => { + count++; + req.resume(); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const instance = axios.create(); + const post = jest.spyOn(instance, 'post'); + const rpc = createGovernanceRpc( + { url: `http://127.0.0.1:${server.address().port}`, instance }, + { timeoutMs: 30 } + ); + try { + await expect(rpc.voteRaw(...args)).rejects.toMatchObject({ + code: 'submission_unknown', uncertain: true, + }); + expect(post).toHaveBeenCalledTimes(1); + expect(count).toBeLessThanOrEqual(1); } finally { server.closeAllConnections(); await new Promise((resolve) => server.close(resolve));