UNPKG

qerrors

Version:

Intelligent error handling middleware with AI-powered analysis, environment validation, caching, and production-ready logging. Provides OpenAI-based error suggestions, queue management, retry mechanisms, and comprehensive configuration options for Node.js

260 lines (242 loc) 12.9 kB
const test = require('node:test'); //node builtin test runner const assert = require('node:assert/strict'); //strict assertions for reliability const qtests = require('qtests'); //qtests stubbing utilities const crypto = require('crypto'); //node crypto for hashing count const qerrorsModule = require('../lib/qerrors'); //import module under test const { analyzeError } = qerrorsModule; //extract analyzeError for direct calls const { axiosInstance } = qerrorsModule; //instance used inside analyzeError const { postWithRetry } = qerrorsModule; //helper used for retrying requests const config = require('../lib/config'); //load env defaults for assertions //(new import) function withOpenAIToken(token) { //(temporarily set OPENAI_TOKEN) const orig = process.env.OPENAI_TOKEN; //(capture existing value) if (token === undefined) { //(check if token unset) delete process.env.OPENAI_TOKEN; //(remove from env) } else { process.env.OPENAI_TOKEN = token; //(assign token) } return () => { //(return restore) if (orig === undefined) { //(restore by delete) delete process.env.OPENAI_TOKEN; //(delete if absent before) } else { process.env.OPENAI_TOKEN = orig; //(otherwise restore value) } }; } function withRetryEnv(retry, base, max) { //(temporarily set retry env vars) const origRetry = process.env.QERRORS_RETRY_ATTEMPTS; //(store original attempts) const origBase = process.env.QERRORS_RETRY_BASE_MS; //(store original delay) const origMax = process.env.QERRORS_RETRY_MAX_MS; //(store original cap) if (retry === undefined) { delete process.env.QERRORS_RETRY_ATTEMPTS; } else { process.env.QERRORS_RETRY_ATTEMPTS = String(retry); } //(apply retry) if (base === undefined) { delete process.env.QERRORS_RETRY_BASE_MS; } else { process.env.QERRORS_RETRY_BASE_MS = String(base); } //(apply delay) if (max === undefined) { delete process.env.QERRORS_RETRY_MAX_MS; } else { process.env.QERRORS_RETRY_MAX_MS = String(max); } //(apply cap) return () => { //(restore both variables) if (origRetry === undefined) { delete process.env.QERRORS_RETRY_ATTEMPTS; } else { process.env.QERRORS_RETRY_ATTEMPTS = origRetry; } if (origBase === undefined) { delete process.env.QERRORS_RETRY_BASE_MS; } else { process.env.QERRORS_RETRY_BASE_MS = origBase; } if (origMax === undefined) { delete process.env.QERRORS_RETRY_MAX_MS; } else { process.env.QERRORS_RETRY_MAX_MS = origMax; } }; } function stubAxiosPost(content, capture) { //(capture axiosInstance.post args and stub response) return qtests.stubMethod(axiosInstance, 'post', async (url, body) => { //(store url and body for assertions) capture.url = url; //(save called url) capture.body = body; //(save called body) return { data: { choices: [{ message: { content } }] } }; //(return predictable api response as object) }); } // Scenario: skip analyzing Axios errors to prevent infinite loops test('analyzeError handles AxiosError gracefully', async () => { const err = new Error('axios fail'); err.name = 'AxiosError'; err.uniqueErrorName = 'AXERR'; const result = await analyzeError(err, 'ctx'); assert.equal(result, null); //(expect null when axios error is skipped) }); // Scenario: return null when API token is missing test('analyzeError returns null without token', async () => { const restoreToken = withOpenAIToken(undefined); //(unset OPENAI_TOKEN) try { const err = new Error('no token'); err.uniqueErrorName = 'NOTOKEN'; const result = await analyzeError(err, 'ctx'); assert.equal(result, null); //should return null when token missing } finally { restoreToken(); //(restore original token) } }); // Scenario: handle successful API response with JSON content test('analyzeError processes JSON response from API', async () => { const restoreToken = withOpenAIToken('test-token'); //(set valid token) const capture = {}; //(object to collect axios call args) const restoreAxios = stubAxiosPost({ advice: 'test advice' }, capture); //(stub axios and capture arguments with object) try { const err = new Error('test error'); err.uniqueErrorName = 'TESTERR'; const result = await analyzeError(err, 'test context'); assert.ok(result); //result should be defined on success assert.equal(result.advice, 'test advice'); //parsed advice should match assert.equal(capture.url, config.getEnv('QERRORS_OPENAI_URL')); //(assert api endpoint used) assert.equal(capture.body.model, 'gpt-4o'); //(validate model in request body) assert.ok(Array.isArray(capture.body.messages)); //(ensure messages array sent) assert.equal(capture.body.messages[0].role, 'user'); //(first message role should be user) assert.deepEqual(capture.body.response_format, { type: 'json_object' }); //(verify response_format object) } finally { restoreToken(); //(restore original token) restoreAxios(); //(restore axios) } }); // Scenario: handle API response parsing errors gracefully test('analyzeError handles JSON parse errors', async () => { const restoreToken = withOpenAIToken('test-token'); //(set valid token) const cap = {}; //(obj to capture axios args if needed) const restoreAxios = stubAxiosPost('invalid json', cap); //(stub axios with invalid JSON) try { const err = new Error('test error'); err.uniqueErrorName = 'PARSEERR'; const result = await analyzeError(err, 'test context'); assert.equal(result, null); //(expect null when JSON parsing fails) } finally { restoreToken(); //(restore original token) restoreAxios(); //(restore axios) } }); // Scenario: reuse cached advice when same error repeats test('analyzeError returns cached advice on repeat call', async () => { const restoreToken = withOpenAIToken('cache-token'); //(set token for analysis) const capture = {}; //(capture axios parameters) const restoreAxios = stubAxiosPost({ advice: 'cached' }, capture); //(first api response as object) try { const err = new Error('cache me'); err.stack = 'stack'; err.uniqueErrorName = 'CACHE1'; const first = await analyzeError(err, 'ctx'); assert.equal(first.advice, 'cached'); //initial call stores advice restoreAxios(); //(remove first stub) let secondCalled = false; //(track second axios call) const restoreAxios2 = qtests.stubMethod(axiosInstance, 'post', async () => { secondCalled = true; return {}; }); const err2 = new Error('cache me'); err2.stack = 'stack'; err2.uniqueErrorName = 'CACHE2'; const second = await analyzeError(err2, 'ctx'); restoreAxios2(); //(restore second stub) assert.equal(second.advice, 'cached'); //cache should supply same advice assert.equal(secondCalled, false); //(axios should not run second time) } finally { restoreToken(); //(restore environment) } }); // Scenario: reuse provided qerrorsKey without rehashing test('analyzeError reuses error.qerrorsKey when present', async () => { const restoreToken = withOpenAIToken('reuse-token'); //(set token for test) const capture = {}; //(capture axios parameters) const restoreAxios = stubAxiosPost({ info: 'first' }, capture); //(stub axios with object) let hashCount = 0; //(track calls to crypto.createHash) const origHash = crypto.createHash; //(store original function) const restoreHash = qtests.stubMethod(crypto, 'createHash', (...args) => { hashCount++; return origHash(...args); }); try { const err = new Error('reuse error'); err.stack = 'stack'; err.uniqueErrorName = 'REUSEKEY'; err.qerrorsKey = 'preset'; const first = await analyzeError(err, 'ctx'); assert.equal(first.info, 'first'); //advice from API stored assert.equal(hashCount, 0); //(ensure hashing not called) const again = await analyzeError(err, 'ctx'); assert.equal(again.info, 'first'); //second call reuses advice without hash } finally { restoreHash(); //(restore crypto.createHash) restoreToken(); //(restore token) restoreAxios(); //(restore axios) } }); test('analyzeError retries failed axios calls', async () => { const restoreToken = withOpenAIToken('retry-token'); //(set token for test) const restoreEnv = withRetryEnv(2, 1); //(set small retry delay for speed) let callCount = 0; //(track number of axios posts) const restoreAxios = qtests.stubMethod(axiosInstance, 'post', async () => { //(stub post to fail then succeed) callCount++; //(increment counter) if (callCount < 3) { throw new Error('fail'); } //(fail first two) return { data: { choices: [{ message: { content: { ok: true } } }] } }; //(success after retries) }); try { const err = new Error('retry'); err.uniqueErrorName = 'RETRYERR'; const res = await analyzeError(err, 'ctx'); assert.equal(res.ok, true); //(ensure success after retry) assert.equal(callCount, 3); //(called initial + 2 retries) } finally { restoreAxios(); //(restore axios) restoreEnv(); //(restore env vars) restoreToken(); //(restore token) } }); test('analyzeError uses postWithRetry helper', async () => { const restoreToken = withOpenAIToken('helper-token'); //(set token for test) let helperCalled = false; //(flag when helper invoked) const restoreHelper = qtests.stubMethod(qerrorsModule, 'postWithRetry', async () => { //(stub helper) helperCalled = true; //(mark invocation) return { data: { choices: [{ message: { content: { ok: true } } }] } }; //(fake success response) }); try { const err = new Error('helper'); err.uniqueErrorName = 'HELPER'; const result = await analyzeError(err, 'ctx'); assert.equal(result.ok, true); //(expect parsed advice) assert.equal(helperCalled, true); //(ensure helper ran) } finally { restoreHelper(); //(restore stub) restoreToken(); //(restore token) } }); function reloadQerrors() { //helper to reload module with current env for cache tests delete require.cache[require.resolve('../lib/qerrors')]; //remove cached module so env changes apply return require('../lib/qerrors'); //load qerrors again with new env } // Scenario: disable caching when limit is zero test('analyzeError bypasses cache when limit is zero', async () => { const restoreToken = withOpenAIToken('zero-token'); //(set token for api) const origLimit = process.env.QERRORS_CACHE_LIMIT; //(store existing cache limit) process.env.QERRORS_CACHE_LIMIT = '0'; //(env value to disable cache) const fresh = reloadQerrors(); //(reload module with zero cache limit) const restoreAxios1 = qtests.stubMethod(fresh.axiosInstance, 'post', async () => ({ data: { choices: [{ message: { content: { msg: 1 } } }] } })); //(stub for first analysis) try { const err = new Error('nocache'); err.stack = 'stack'; err.uniqueErrorName = 'NOCACHE1'; await fresh.analyzeError(err, 'ctx'); restoreAxios1(); //(remove first stub) let secondCalled = false; //(track second axios call when cache disabled) const restoreAxios2 = qtests.stubMethod(fresh.axiosInstance, 'post', async () => { secondCalled = true; return { data: { choices: [{ message: { content: { msg: 2 } } }] } }; }); const err2 = new Error('nocache'); err2.stack = 'stack'; err2.uniqueErrorName = 'NOCACHE2'; await fresh.analyzeError(err2, 'ctx'); restoreAxios2(); //(restore second stub) assert.equal(secondCalled, true); //(axios should run again without caching) } finally { if (origLimit === undefined) { delete process.env.QERRORS_CACHE_LIMIT; } else { process.env.QERRORS_CACHE_LIMIT = origLimit; } reloadQerrors(); //(restore default module state) restoreToken(); //(restore token) } }); // Scenario: ensure hashing skipped when cache disabled test('analyzeError does not hash when cache limit is zero', async () => { const restoreToken = withOpenAIToken('nohash-token'); //(set token for api) const origLimit = process.env.QERRORS_CACHE_LIMIT; //(store current limit) process.env.QERRORS_CACHE_LIMIT = '0'; //(disable caching) const fresh = reloadQerrors(); //(reload module with new env) let hashCount = 0; //(track hashing) const origHash = crypto.createHash; //(reference original) const restoreHash = qtests.stubMethod(crypto, 'createHash', (...args) => { hashCount++; return origHash(...args); }); //(count calls) try { const err = new Error('nohash'); err.stack = 'stack'; err.uniqueErrorName = 'NOHASH'; await fresh.analyzeError(err, 'ctx'); assert.equal(hashCount, 0); //(expect hashing skipped) assert.equal(err.qerrorsKey, undefined); //(ensure key not set) } finally { restoreHash(); //(restore createHash) if (origLimit === undefined) { delete process.env.QERRORS_CACHE_LIMIT; } else { process.env.QERRORS_CACHE_LIMIT = origLimit; } reloadQerrors(); //(reset module) restoreToken(); //(restore token) } });