diff --git a/README.md b/README.md index b90891a..8a768bb 100644 --- a/README.md +++ b/README.md @@ -394,7 +394,7 @@ Returns the overall health of the service and its dependencies. ``` GET /api/v1/airdrops/:id/status -GET /api/v1/airdrops/:id/recipients +GET /api/v1/airdrops/:id/onchain-recipients GET /api/v1/recipients/:address/claims GET /api/v1/indexer/status ``` @@ -540,7 +540,7 @@ Express tip: capture the raw body via `express.json({ verify: (req, _res, buf) = - Up to `WEBHOOK_MAX_ATTEMPTS` (default 3) total attempts per event. - Retries are scheduled in Redis and processed by a background worker, so retries survive process restarts. -- Backoff is exponential: `base * factor^(attempts-1)` (default 30s → 60s → 120s). +- Backoff is exponential with "equal jitter": `deterministic = base * factor^(attempts-1)`, then the actual delay is randomized within `[deterministic/2, deterministic)` (default deterministic values 30s → 60s → 120s, so e.g. attempt 1's actual delay lands somewhere in 15s–30s). This prevents deliveries that fail at the same attempt count around the same moment (e.g. every in-flight delivery to a subscriber whose endpoint just went down) from computing identical `nextRetryAt` values and arriving back at that endpoint in a synchronized burst. - **Retryable**: network errors, HTTP 5xx, 408, 429. - **Not retried**: HTTP 4xx (except 408/429). These are marked `failed` immediately so a misconfigured consumer cannot be retried into the ground. - Each delivery is logged in `webhook_deliveries` (Redis-backed today, drop-in PG migration documented in `src/repositories/deliveryRepository.js`). diff --git a/package-lock.json b/package-lock.json index f5724fc..c8ef62c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -68,7 +68,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -120,14 +119,14 @@ "license": "MIT" }, "node_modules/@babel/generator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", - "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -250,13 +249,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.7" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -520,18 +519,18 @@ } }, "node_modules/@babel/traverse": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", - "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", + "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.7", + "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7", + "@babel/types": "^7.29.8", "debug": "^4.3.1" }, "engines": { @@ -564,9 +563,9 @@ "license": "MIT" }, "node_modules/@babel/types": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { @@ -1003,9 +1002,9 @@ } }, "node_modules/@redocly/cli": { - "version": "2.35.1", - "resolved": "https://registry.npmjs.org/@redocly/cli/-/cli-2.35.1.tgz", - "integrity": "sha512-8XcUIR6bCI4KmVg6RJyzL3peZhld/tu7oO8WGVaHp43byhcds6ProHlfqEFa+dZA+qA+dUMebgRELVOe5AW4Lg==", + "version": "2.46.1", + "resolved": "https://registry.npmjs.org/@redocly/cli/-/cli-2.46.1.tgz", + "integrity": "sha512-FSUSq2FU8VN7DmTobTmq7zb3zPnDkRKSMd3n38D62JFJUUZKnLMQtn7BUhsD2dCFH2WgF05I3fbN7QHDQyCBeA==", "dev": true, "license": "MIT", "bin": { @@ -1025,9 +1024,9 @@ "license": "Apache-2.0" }, "node_modules/@sinclair/typebox": { - "version": "0.27.10", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", - "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "version": "0.27.12", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", + "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", "dev": true, "license": "MIT" }, @@ -1168,9 +1167,9 @@ } }, "node_modules/@types/node": { - "version": "26.0.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.1.tgz", - "integrity": "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==", + "version": "26.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", + "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", "dev": true, "license": "MIT", "dependencies": { @@ -1367,13 +1366,13 @@ } }, "node_modules/axios": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", - "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz", + "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==", "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", + "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } @@ -1501,9 +1500,9 @@ "license": "MIT" }, "node_modules/bare-addon-resolve": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/bare-addon-resolve/-/bare-addon-resolve-1.10.0.tgz", - "integrity": "sha512-sSd0jieRJlDaODOzj0oe0RjFVC1QI0ZIjGIdPkbrTXsdVVtENg14c+lHHAhHwmWCZ2nQlMhy8jA3Y5LYPc/isA==", + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/bare-addon-resolve/-/bare-addon-resolve-1.10.1.tgz", + "integrity": "sha512-F/SD2du8keuYSb4xipnGz5j2E6yhNdHA8ZVxtHae6h2uOrpBIjjbhXvjzKZbr5XUOzqBzh/i8GVFycj2DlFQIA==", "license": "Apache-2.0", "optional": true, "dependencies": { @@ -1520,9 +1519,9 @@ } }, "node_modules/bare-module-resolve": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/bare-module-resolve/-/bare-module-resolve-1.12.2.tgz", - "integrity": "sha512-j+hiD5k99qec4KjJvYsI67q5AOBifmy9JG3oeMVxTmvrhn2sIdp8StrUvZu4YNgwTpO+NhniQG16N1ETDe1k5w==", + "version": "1.12.4", + "resolved": "https://registry.npmjs.org/bare-module-resolve/-/bare-module-resolve-1.12.4.tgz", + "integrity": "sha512-xcfgg2u7HqgJiBmah71O9vvdFAgHCvkqC/WSC2O7Bbgosoc1eC/BWe/6IDJ4OsfKlkxuvC/TDWXC+oH5yeW8mA==", "license": "Apache-2.0", "optional": true, "dependencies": { @@ -1574,9 +1573,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.40", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", - "integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==", + "version": "2.11.14", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.14.tgz", + "integrity": "sha512-JyJ954WzuIR8/FFzX0o5krdSTrBAkcCSRfWSleRsIHSWV+cZe2FI1PKggVkFke1hBldRs+LRxUczzE9iPmgZww==", "dev": true, "license": "Apache-2.0", "bin": { @@ -1596,9 +1595,9 @@ } }, "node_modules/body-parser": { - "version": "1.20.5", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", - "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -1620,9 +1619,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -1643,9 +1642,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", - "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", "dev": true, "funding": [ { @@ -1662,13 +1661,12 @@ } ], "license": "MIT", - "peer": true, "dependencies": { - "baseline-browser-mapping": "^2.10.38", - "caniuse-lite": "^1.0.30001799", - "electron-to-chromium": "^1.5.376", - "node-releases": "^2.0.48", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" @@ -1805,9 +1803,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001799", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", - "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "version": "1.0.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", "dev": true, "funding": [ { @@ -1963,9 +1961,9 @@ } }, "node_modules/color-string/node_modules/color-name": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", - "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.1.tgz", + "integrity": "sha512-p2FdgwVx1a9yWBHP2wI0VgShkDpgN4kZISkxdNipGBJWpa5G6b04OINlVWCyJj0JmfvcPrgqt95E9k8yvaOJFg==", "license": "MIT", "engines": { "node": ">=12.20" @@ -1984,9 +1982,9 @@ } }, "node_modules/color/node_modules/color-name": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", - "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.1.tgz", + "integrity": "sha512-p2FdgwVx1a9yWBHP2wI0VgShkDpgN4kZISkxdNipGBJWpa5G6b04OINlVWCyJj0JmfvcPrgqt95E9k8yvaOJFg==", "license": "MIT", "engines": { "node": ">=12.20" @@ -2318,9 +2316,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.380", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.380.tgz", - "integrity": "sha512-W6d5AbuEoRayO447cqrg6lKJIlscgRnnxOZl/08kfV71BQDoEBC7Wwis68z87LjyK6f4kWyTaubuDbhHKrZkbA==", + "version": "1.5.407", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.407.tgz", + "integrity": "sha512-4R8XgQOdfxexCd/u63lRm6wCHjECwI45MV9wxAs2ggtfWe2hwlo1ql97jKsju2IcJ+jFSTwBssyYoiWhh7mauQ==", "dev": true, "license": "ISC" }, @@ -2982,9 +2980,9 @@ } }, "node_modules/helmet": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.2.0.tgz", - "integrity": "sha512-DRgTIUgnWcJ62KyarxxziuqYxKGnR6Rgg19BlbucN/dpmJbl1XOit6qvoOX0ZT+HhWe5OUVhU/a1zpGyc1xA0Q==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz", + "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -4025,9 +4023,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", - "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", + "version": "3.15.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz", + "integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==", "dev": true, "license": "MIT", "dependencies": { @@ -4460,9 +4458,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.50", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", - "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", "dev": true, "license": "MIT", "engines": { @@ -4693,14 +4691,14 @@ "license": "MIT" }, "node_modules/pg": { - "version": "8.22.0", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", - "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", + "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==", "license": "MIT", "dependencies": { "pg-connection-string": "^2.14.0", "pg-pool": "^3.14.0", - "pg-protocol": "^1.15.0", + "pg-protocol": "^1.16.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, @@ -4751,9 +4749,9 @@ } }, "node_modules/pg-protocol": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz", - "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz", + "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==", "license": "MIT" }, "node_modules/pg-types": { @@ -5690,17 +5688,10 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/tarn": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tarn/-/tarn-3.1.0.tgz", - "integrity": "sha512-QDihlHbXxQ4SnuQcRd1TPNBHUYo/hafDRDP82COYSVfRcx/3qnfGDkn1WFjozVl+AYr8ZyDKSQ/kIgHH1ri1yg==", - "license": "MIT", - "engines": { - "node": ">=8.0.0" "node_modules/swagger-ui-dist": { - "version": "5.32.8", - "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.8.tgz", - "integrity": "sha512-dgMdWXIgnI4zX4OPhKEdWnlDODbgm8W3AX0Ivn/BBqcUh6xZsBxhZMnvk6DJyRz1BTrj8dPxtarmEGgkz30oyA==", + "version": "5.32.13", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.13.tgz", + "integrity": "sha512-qQobzb3DeC2LeK0j3E8812Ef4aIq1y9flJxvZkimkqUC/w4u7wS+yCc+VakqGJLweUUBrI24effhwo8OsAvNAw==", "license": "Apache-2.0", "dependencies": { "@scarf/scarf": "=1.4.0" @@ -5721,6 +5712,15 @@ "express": ">=4.0.0 || >=5.0.0-beta" } }, + "node_modules/tarn": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/tarn/-/tarn-3.1.2.tgz", + "integrity": "sha512-3RTvqKZcK/17jnJ8rMKFXbyNogywTs1z0gVPPwFsJGX46rkmUHOdIaSQ/aVO1rS7nH+soiXiWk7rvUXxndm8Dg==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -5894,9 +5894,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", "dev": true, "funding": [ { @@ -6031,7 +6031,6 @@ "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", "license": "MIT", - "peer": true, "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.8", @@ -6119,17 +6118,10 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "license": "MIT", - "engines": { - "node": ">=0.4" "node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -6147,6 +6139,15 @@ } } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 062089d..f8c832d 100644 --- a/package.json +++ b/package.json @@ -30,9 +30,6 @@ "swagger-ui-express": "^5.0.1", "winston": "^3.14.0", "winston-daily-rotate-file": "^5.0.0", - "zod": "^4.4.3", - "winston-daily-rotate-file": "^5.0.0" - "winston-daily-rotate-file": "^5.0.0", "ws": "^8.21.0", "yamljs": "^0.3.0", "zod": "^4.4.3" diff --git a/src/config.js b/src/config.js index 05500a3..a871bbe 100644 --- a/src/config.js +++ b/src/config.js @@ -114,12 +114,9 @@ module.exports = { redis: { url: env.REDIS_URL, }, - databaseUrl: process.env.DATABASE_URL || 'postgres://postgres:postgres@localhost:5432/smartdrop', stellar: { - horizonUrl: process.env.STELLAR_HORIZON_URL || 'https://horizon.stellar.org', - sorobanRpcUrl: process.env.SOROBAN_RPC_URL || 'https://soroban-rpc.mainnet.stellar.gateway.fm', - usdcIssuer: process.env.USDC_ISSUER || 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA', horizonUrl: env.STELLAR_HORIZON_URL, + sorobanRpcUrl: process.env.SOROBAN_RPC_URL || 'https://soroban-rpc.mainnet.stellar.gateway.fm', usdcIssuer, }, indexer: { @@ -207,6 +204,14 @@ module.exports = { retryBaseMs: parseInt(process.env.WEBHOOK_RETRY_BASE_MS, 10) || 30000, retryFactor: parseFloat(process.env.WEBHOOK_RETRY_FACTOR) || 2, timeoutMs: parseInt(process.env.WEBHOOK_TIMEOUT_MS, 10) || 5000, + // retryPollMs/retryBatchSize: #128 considered retuning these once + // backoffMs() gained jitter (a wider spread of nextRetryAt values could + // argue for a shorter poll interval and/or smaller batch, since due + // items are less likely to arrive in one dense cluster). Left + // unchanged here — jitter already substantially reduces the size of + // any one burst on its own, and retuning the poll/batch knobs is a + // separate operational tradeoff (worker load vs. retry latency) worth + // its own measurement rather than a guess made alongside this fix. retryPollMs: parseInt(process.env.WEBHOOK_RETRY_POLL_MS, 10) || 5000, retryBatchSize: parseInt(process.env.WEBHOOK_RETRY_BATCH, 10) || 25, rateLimit: { diff --git a/src/routes/airdrops.js b/src/routes/airdrops.js index f5c1472..3d770e2 100644 --- a/src/routes/airdrops.js +++ b/src/routes/airdrops.js @@ -16,9 +16,15 @@ const { recipientsSchema, routeIdParamsSchema, } = require('../validation/schemas'); +const buildRateLimit = require('../middleware/rateLimit'); +const { StrKey } = require('stellar-sdk'); const router = express.Router(); -const upload = multer(); +const CSV_PARSE_CHUNK_BYTES = 64 * 1024; +const upload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: config.airdrops.csvMaxBytes }, +}); const validateRouteIdParams = validate(routeIdParamsSchema, 'params'); const validatePaginationQuery = validate(paginationQuerySchema, 'query'); const validateRecipientBody = validate(airdropRecipientsBodySchema); @@ -31,15 +37,9 @@ function validateWithCurrentLedger(schemaFactory) { } catch (err) { logger.error('Airdrop validation error', { error: err.message }); return next(err); -const buildRateLimit = require('../middleware/rateLimit'); -const { StrKey } = require('stellar-sdk'); - -const router = express.Router(); -const CSV_PARSE_CHUNK_BYTES = 64 * 1024; -const upload = multer({ - storage: multer.memoryStorage(), - limits: { fileSize: config.airdrops.csvMaxBytes }, -}); + } + }; +} const createAirdropLimit = buildRateLimit({ windowSeconds: config.airdrops.rateLimit.windowSeconds, @@ -75,41 +75,6 @@ function isValidStellarAddress(address) { } } -function validateAirdropCreate(body, currentLedger) { - const { name, asset, asset_issuer, total_amount, expiry_ledger, recipients = [] } = body; - - if (!name || typeof name !== 'string') { - return 'name is required and must be a string'; - } - if (!asset || typeof asset !== 'string' || !/^[A-Z0-9]{1,12}$/i.test(asset)) { - return 'asset is required and must be 1-12 alphanumeric characters'; - } - if (!asset_issuer || !isValidStellarAddress(asset_issuer)) { - return 'asset_issuer is required and must be a valid Stellar address'; - } - if (typeof total_amount !== 'number' || total_amount <= 0) { - return 'total_amount is required and must be a positive number'; - } - if (typeof expiry_ledger !== 'number' || expiry_ledger <= currentLedger) { - return `expiry_ledger is required and must be greater than current ledger (${currentLedger})`; - } - if (recipients.length > config.airdrops.maxRecipients) { - return 'recipients cannot exceed 10,000'; - } - - const recipientSet = new Set(); - let sum = 0; - for (let i = 0; i < recipients.length; i++) { - const r = recipients[i]; - if (!r.address || !isValidStellarAddress(r.address)) { - return `recipient ${i}: invalid Stellar address`; - } - if (recipientSet.has(r.address)) { - return `recipient ${i}: duplicate address ${r.address}`; - } - }; -} - function parseRecipients(recipients, next) { const result = recipientsSchema.safeParse(recipients); if (!result.success) { @@ -147,8 +112,7 @@ async function parseCSV(buffer) { return results; } -router.post('/airdrops', validateWithCurrentLedger(airdropCreateBodySchema), async (req, res, next) => { -router.post('/airdrops', createAirdropLimit, async (req, res, next) => { +router.post('/airdrops', createAirdropLimit, validateWithCurrentLedger(airdropCreateBodySchema), async (req, res, next) => { try { const airdrop = await airdropsService.create(req.validated.body); return res.status(201).json(airdrop); @@ -221,8 +185,7 @@ router.post('/airdrops/:id/cancel', validateRouteIdParams, async (req, res, next } }); -router.post('/airdrops/:id/recipients', validateRouteIdParams, upload.single('file'), validateRecipientBody, async (req, res, next) => { -router.post('/airdrops/:id/recipients', addRecipientsLimit, uploadRecipientsFile, async (req, res, next) => { +router.post('/airdrops/:id/recipients', validateRouteIdParams, addRecipientsLimit, uploadRecipientsFile, validateRecipientBody, async (req, res, next) => { try { const airdrop = await airdropsService.get(req.params.id); if (!airdrop) { diff --git a/src/routes/indexer.js b/src/routes/indexer.js index 2c16300..75d452f 100644 --- a/src/routes/indexer.js +++ b/src/routes/indexer.js @@ -31,7 +31,13 @@ router.get('/airdrops/:id/status', async (req, res) => { } }); -router.get('/airdrops/:id/recipients', async (req, res) => { +// Named distinctly from airdrops.js's own `/airdrops/:id/recipients` (the +// stored/intended recipient list): this returns recipients derived from +// indexed on-chain claim events, a different source of truth. The two +// routers previously registered the exact same path, and since this +// router is mounted first in src/index.js, it silently shadowed the real +// listRecipients handler in airdrops.js on every request. +router.get('/airdrops/:id/onchain-recipients', async (req, res) => { try { if (!isValidId(req.params.id)) { return res.status(400).json({ error: 'Invalid airdrop id' }); diff --git a/src/services/priceOracle.js b/src/services/priceOracle.js index 4bbea43..6147e9f 100644 --- a/src/services/priceOracle.js +++ b/src/services/priceOracle.js @@ -19,15 +19,14 @@ const SOURCES = [ name: 'coingecko', fetch: coingecko.fetchPrice, breaker: new CircuitBreaker('coingecko', breakerOptions), + getCircuitState: coingecko.getCircuitState, }, { name: 'coinmarketcap', fetch: coinmarketcap.fetchPrice, breaker: new CircuitBreaker('coinmarketcap', breakerOptions), + getCircuitState: coinmarketcap.getCircuitState, }, - { name: 'stellar_dex', fetch: stellarDex.fetchPrice }, - { name: 'coingecko', fetch: coingecko.fetchPrice, getCircuitState: coingecko.getCircuitState }, - { name: 'coinmarketcap', fetch: coinmarketcap.fetchPrice, getCircuitState: coinmarketcap.getCircuitState }, ]; /** diff --git a/src/services/webhookDispatcher.js b/src/services/webhookDispatcher.js index 45ce9ca..bb83837 100644 --- a/src/services/webhookDispatcher.js +++ b/src/services/webhookDispatcher.js @@ -10,10 +10,34 @@ const deliveryRepo = require('../repositories/deliveryRepository'); const USER_AGENT = 'SmartDrop-Webhooks/1.0'; -function backoffMs(attemptsCompleted) { +/** + * Computes the retry delay for a webhook delivery that has completed + * `attemptsCompleted` attempts, using exponential backoff with "equal + * jitter": half of the deterministic delay is fixed, the other half is + * randomized within [0, half). This spreads out deliveries that fail at + * the same attempt count around the same wall-clock moment — preventing + * the synchronized-retry thundering-herd burst described in #128 — while + * keeping the result always within [deterministic/2, deterministic): + * never zero or negative, and never reaching or exceeding the original + * deterministic delay, so worst-case retry latency stays predictable for + * operators. "Full jitter" (uniformly random in [0, deterministic)) was + * considered and rejected: it can produce near-immediate retries, and — + * with the default 2x factor — its range for one attempt overlaps the + * next attempt's range, which would make delays non-monotonic across + * attempts. + * + * The random source is injectable via `options.random` (mirroring + * CircuitBreaker's `options.now`/`options.logger` pattern in + * `utils/circuitBreaker.js`) so tests can assert exact min/max bounds + * rather than only "looks random". + */ +function backoffMs(attemptsCompleted, options = {}) { + const random = options.random || Math.random; const base = config.webhooks.retryBaseMs; const factor = config.webhooks.retryFactor; - return base * factor ** (attemptsCompleted - 1); + const deterministicDelay = base * factor ** (attemptsCompleted - 1); + const half = deterministicDelay / 2; + return half + random() * half; } function shouldRetry(responseStatus, networkError) { diff --git a/test/airdrops.test.js b/test/airdrops.test.js index 932de22..9839baf 100644 --- a/test/airdrops.test.js +++ b/test/airdrops.test.js @@ -2,7 +2,6 @@ const mockStore = new Map(); const mockSets = new Map(); -const mockSortedSets = new Map(); const mockZSets = new Map(); const mockLists = new Map(); const mockCounters = new Map(); @@ -17,20 +16,6 @@ const mockRedis = { mockSets.get(key)?.delete(val); }), zadd: jest.fn(async (key, score, member) => { - if (!mockSortedSets.has(key)) mockSortedSets.set(key, new Map()); - mockSortedSets.get(key).set(member, score); - }), - zrem: jest.fn(async (key, member) => { - mockSortedSets.get(key)?.delete(member); - }), - zcard: jest.fn(async (key) => mockSortedSets.get(key)?.size || 0), - zrevrange: jest.fn(async (key, start, stop) => { - const sortedSet = mockSortedSets.get(key); - if (!sortedSet) return []; - const entries = Array.from(sortedSet.entries()).sort((a, b) => b[1] - a[1]); - const startIdx = start === -1 ? entries.length + start : start; - const stopIdx = stop === -1 ? entries.length + stop : stop; - return entries.slice(startIdx, stopIdx + 1).map(([member]) => member); if (!mockZSets.has(key)) mockZSets.set(key, new Map()); mockZSets.get(key).set(member, Number(score)); }), @@ -118,6 +103,9 @@ jest.mock('stellar-sdk', () => ({ StrKey: { isValidEd25519PublicKey: jest.fn((address) => address.startsWith('G') && address.length === 56), }, + SorobanRpc: { + Server: jest.fn(() => ({})), + }, })); const request = require('supertest'); @@ -127,14 +115,11 @@ let app; beforeAll(() => { app = require('../src/index').app; - const { app: importedApp } = require('../src/index'); - app = importedApp; }); beforeEach(() => { mockStore.clear(); mockSets.clear(); - mockSortedSets.clear(); mockZSets.clear(); mockLists.clear(); mockCounters.clear(); diff --git a/test/alerts-routes.test.js b/test/alerts-routes.test.js index fd83264..0ff9401 100644 --- a/test/alerts-routes.test.js +++ b/test/alerts-routes.test.js @@ -5,7 +5,6 @@ process.env.ADMIN_API_KEY = adminApiKey; const mockStore = new Map(); const mockSortedSets = new Map(); -const mockZSets = new Map(); const mockRedis = { smembers: jest.fn(async () => []), @@ -25,22 +24,6 @@ const mockRedis = { const stopIdx = stop === -1 ? entries.length + stop : stop; return entries.slice(startIdx, stopIdx + 1).map(([member]) => member); }), - if (!mockZSets.has(key)) mockZSets.set(key, new Map()); - mockZSets.get(key).set(member, Number(score)); - }), - zrem: jest.fn(async (key, ...members) => { - const z = mockZSets.get(key); - if (!z) return; - for (const m of members) z.delete(m); - }), - zrevrange: jest.fn(async (key, start, stop) => { - const z = mockZSets.get(key); - if (!z) return []; - const sorted = [...z.entries()].sort((a, b) => b[1] - a[1]).map(([m]) => m); - const end = stop === -1 ? sorted.length : stop + 1; - return sorted.slice(start, end); - }), - zcard: jest.fn(async (key) => (mockZSets.get(key)?.size || 0)), }; jest.mock('../src/services/cache', () => ({ diff --git a/test/auth.test.js b/test/auth.test.js index 80346d0..c432e3e 100644 --- a/test/auth.test.js +++ b/test/auth.test.js @@ -7,7 +7,6 @@ const crypto = require('crypto'); const mockStore = new Map(); const mockSets = new Map(); const mockSortedSets = new Map(); -const mockZSets = new Map(); const mockRedis = { smembers: jest.fn(async (key) => [...(mockSets.get(key) || [])]), @@ -32,20 +31,6 @@ const mockRedis = { const startIdx = start === -1 ? entries.length + start : start; const stopIdx = stop === -1 ? entries.length + stop : stop; return entries.slice(startIdx, stopIdx + 1).map(([member]) => member); - if (!mockZSets.has(key)) mockZSets.set(key, new Map()); - mockZSets.get(key).set(member, Number(score)); - }), - zrem: jest.fn(async (key, ...members) => { - const z = mockZSets.get(key); - if (!z) return; - for (const m of members) z.delete(m); - }), - zrevrange: jest.fn(async (key, start, stop) => { - const z = mockZSets.get(key); - if (!z) return []; - const sorted = [...z.entries()].sort((a, b) => b[1] - a[1]).map(([m]) => m); - const end = stop === -1 ? sorted.length : stop + 1; - return sorted.slice(start, end); }), }; diff --git a/test/circuitBreaker.test.js b/test/circuitBreaker.test.js index 817e495..5f6b425 100644 --- a/test/circuitBreaker.test.js +++ b/test/circuitBreaker.test.js @@ -1,5 +1,14 @@ 'use strict'; +const mockLogger = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), +}; + +jest.mock('../src/logger', () => mockLogger); + const { CircuitBreaker, STATES } = require('../src/utils/circuitBreaker'); function buildBreaker(options = {}) { @@ -85,14 +94,8 @@ describe('CircuitBreaker', () => { })).rejects.toThrow('rate limited'); expect(breaker.getState()).toBe(STATES.CLOSED); -const mockLogger = { - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - debug: jest.fn(), -}; - -jest.mock('../src/logger', () => mockLogger); + }); +}); function loadCircuitBreaker() { jest.resetModules(); diff --git a/test/health.test.js b/test/health.test.js index 6a0dd3d..9925e26 100644 --- a/test/health.test.js +++ b/test/health.test.js @@ -13,27 +13,30 @@ jest.mock('../src/services/priceOracle', () => ({ coinmarketcap: 'open', stellar_dex: 'half-open', })), + getSourceCircuitStates: jest.fn(() => [ + { source: 'coingecko', open: false, openUntil: null }, + { source: 'coinmarketcap', open: false, openUntil: null }, + ]), refreshAllCachedPrices: jest.fn(), })); jest.mock('../src/jobs/priceRefresh', () => ({ start: jest.fn(), stop: jest.fn(), + getHealth: () => ({ healthy: true, lastSuccessAt: Date.now(), lastError: null, stalled: false }), })); jest.mock('../src/jobs/webhookRetryWorker', () => ({ start: jest.fn(), stop: jest.fn(), + tick: jest.fn(), + getHealth: () => ({ healthy: true, lastSuccessAt: Date.now(), lastError: null, stalled: false }), })); jest.mock('../src/ws/priceWebSocket', () => ({ attach: jest.fn(), })); -describe('health endpoint', () => { - test('exposes price source circuit states', async () => { - jest.resetModules(); - const { app } = require('../src/index'); // --------------------------------------------------------------------------- // Helpers – reset modules between tests so mocks are applied cleanly // --------------------------------------------------------------------------- diff --git a/test/helpers/cacheMock.js b/test/helpers/cacheMock.js index 9f19e6d..8b14f90 100644 --- a/test/helpers/cacheMock.js +++ b/test/helpers/cacheMock.js @@ -9,6 +9,10 @@ function createCacheMock() { const sets = new Map(); const zsets = new Map(); const counters = new Map(); + // Separate raw string store (with per-key TTL) backing redis.set/get/del — + // distinct from `store` above, which backs the higher-level cacheMock. + // get/set JSON API with different key/value semantics. + const rawStore = new Map(); function getSet(key) { if (!sets.has(key)) sets.set(key, new Set()); @@ -18,6 +22,14 @@ function createCacheMock() { if (!zsets.has(key)) zsets.set(key, new Map()); return zsets.get(key); } + function isExpired(entry) { + return entry.expiresAt !== null && Date.now() >= entry.expiresAt; + } + function getLive(key) { + const entry = rawStore.get(key); + if (!entry || isExpired(entry)) return null; + return entry; + } const redis = { smembers: jest.fn(async (key) => [...(sets.get(key) || [])]), @@ -68,11 +80,39 @@ function createCacheMock() { return n; }), expire: jest.fn(async () => 1), - // Mimics ioredis#defineCommand for the one custom command this codebase - // registers (see deliveryRepository.js). Real Redis runs the Lua body - // single-threaded to completion, so this mock implementation reads and - // deletes without an intervening `await`, preserving that atomicity - // guarantee for tests. + // ioredis-style raw SET, supporting the NX/PX/EX option pairs used by + // leaderElection.js's lease acquisition (`SET key val NX PX ttlMs`). + // Returns 'OK' on success, null if NX and the key already holds a + // live (non-expired) value — matching real Redis's SET NX semantics. + set: jest.fn(async (key, value, ...args) => { + let nx = false; + let ttlMs = null; + for (let i = 0; i < args.length; i += 1) { + const arg = String(args[i]).toUpperCase(); + if (arg === 'NX') nx = true; + else if (arg === 'PX') { ttlMs = Number(args[i + 1]); i += 1; } + else if (arg === 'EX') { ttlMs = Number(args[i + 1]) * 1000; i += 1; } + } + if (nx && getLive(key)) return null; + rawStore.set(key, { value: String(value), expiresAt: ttlMs !== null ? Date.now() + ttlMs : null }); + return 'OK'; + }), + get: jest.fn(async (key) => { + const entry = getLive(key); + return entry ? entry.value : null; + }), + del: jest.fn(async (key) => (rawStore.delete(key) ? 1 : 0)), + pexpire: jest.fn(async (key, ms) => { + const entry = getLive(key); + if (!entry) return 0; + entry.expiresAt = Date.now() + Number(ms); + return 1; + }), + // Mimics ioredis#defineCommand for the custom commands this codebase + // registers (see deliveryRepository.js and leaderElection.js). Real + // Redis runs the Lua body single-threaded to completion, so this mock + // implementation reads and mutates without an intervening `await`, + // preserving that atomicity guarantee for tests. defineCommand: jest.fn((name, { lua } = {}) => { if (name === 'popDueRetriesAtomic') { redis.popDueRetriesAtomic = jest.fn(async (queueKey, maxScore, limit) => { @@ -88,6 +128,30 @@ function createCacheMock() { }); return; } + if (name === 'renewLease') { + // Mirrors RENEW_LUA: renew only if we still hold the lease. + redis.renewLease = jest.fn(async (key, expectedValue, ttlMs) => { + const entry = getLive(key); + if (entry && entry.value === expectedValue) { + entry.expiresAt = Date.now() + Number(ttlMs); + return 1; + } + return 0; + }); + return; + } + if (name === 'releaseLease') { + // Mirrors RELEASE_LUA: release only if we still hold the lease. + redis.releaseLease = jest.fn(async (key, expectedValue) => { + const entry = getLive(key); + if (entry && entry.value === expectedValue) { + rawStore.delete(key); + return 1; + } + return 0; + }); + return; + } throw new Error(`cacheMock.defineCommand: unsupported command "${name}" (lua: ${typeof lua})`); }), }; @@ -109,6 +173,7 @@ function createCacheMock() { sets.clear(); zsets.clear(); counters.clear(); + rawStore.clear(); Object.values(redis).forEach((fn) => fn.mockClear?.()); cacheMock.get.mockClear(); cacheMock.set.mockClear(); diff --git a/test/indexerRoutes.test.js b/test/indexerRoutes.test.js index 673daaa..e933f8f 100644 --- a/test/indexerRoutes.test.js +++ b/test/indexerRoutes.test.js @@ -72,7 +72,7 @@ describe('indexer routes', () => { test('returns indexed recipients', async () => { mockGetAirdropRecipients.mockResolvedValue([{ recipient: 'GRECIPIENT', status: 'claimed' }]); - const res = await request(buildApp()).get('/api/v1/airdrops/drop-1/recipients'); + const res = await request(buildApp()).get('/api/v1/airdrops/drop-1/onchain-recipients'); expect(res.status).toBe(200); expect(res.body.recipients).toHaveLength(1); diff --git a/test/leaderElection.test.js b/test/leaderElection.test.js index 4f764a0..b0e79a4 100644 --- a/test/leaderElection.test.js +++ b/test/leaderElection.test.js @@ -10,12 +10,15 @@ * run without a real Redis instance. */ -const { createCacheMock } = require('./helpers/cacheMock'); +const { createCacheMock: mockCreateCacheMock } = require('./helpers/cacheMock'); const { createLeaderElection } = require('../src/services/leaderElection'); -// We need to override the cache module before requiring leaderElection +// We need to override the cache module before requiring leaderElection. +// The factory below may only reference identifiers Jest's mock-hoisting +// considers safe (globals, or names prefixed with "mock") — hence the +// renamed import above instead of the plain `createCacheMock`. jest.mock('../src/services/cache', () => { - const mock = createCacheMock(); + const mock = mockCreateCacheMock(); // Store reference for test access global.__cacheMock__ = mock; return mock.cacheMock; @@ -144,9 +147,10 @@ describe('Leader Election', () => { await leaderElection.tryAcquire(); expect(leaderElection.isLeader()).toBe(true); - // Simulate someone else taking the lock (direct Redis manipulation) - const redis2 = cacheMock.getClient(); - await redis2.set('leader:test_job', 'test-instance-002', 'PX', 500); + // Simulate someone else taking the lock (direct Redis manipulation). + // Same shared in-memory client as `redis` above — there's only one + // Redis (real or mocked) for every instance to contend over. + await redis.set('leader:test_job', 'test-instance-002', 'PX', 500); const result = await leaderElection.renew(); expect(result).toBe(false); diff --git a/test/prices.test.js b/test/prices.test.js index e9604c7..5f9c92a 100644 --- a/test/prices.test.js +++ b/test/prices.test.js @@ -8,10 +8,7 @@ jest.mock('../src/services/cache', () => ({ del: jest.fn(), disconnect: jest.fn(), isConnected: jest.fn(() => false), -process.env.ADMIN_API_KEY = 'b'.repeat(64); - -const express = require('express'); -const request = require('supertest'); +})); const mockGetPrice = jest.fn(); const mockFetchFreshPrice = jest.fn(); @@ -19,6 +16,7 @@ const mockFetchFreshPrice = jest.fn(); jest.mock('../src/services/priceOracle', () => ({ getPrice: mockGetPrice, fetchFreshPrice: mockFetchFreshPrice, + refreshAllCachedPrices: jest.fn(), })); jest.mock('../src/logger', () => ({ @@ -28,83 +26,20 @@ jest.mock('../src/logger', () => ({ debug: jest.fn(), })); -jest.mock('../src/services/priceOracle', () => ({ - getPrice: jest.fn(), - fetchFreshPrice: jest.fn(), - refreshAllCachedPrices: jest.fn(), -})); - jest.mock('../src/services/apiKeys', () => ({ validateApiKey: jest.fn(), })); // --- Imports --- -const request = require('supertest'); -const { app } = require('../src'); -const priceOracle = require('../src/services/priceOracle'); -const apiKeys = require('../src/services/apiKeys'); - -// --- Fixtures --- - -const PRICE_HAPPY = { - asset_code: 'XLM', - issuer: null, - price_usd: 0.12, - source: 'stellar_dex', - fetched_at: '2024-01-01T00:00:00.000Z', - is_stale: false, - stale_warning: null, - sources_attempted: ['stellar_dex'], - redis_unavailable: false, -}; - -const PRICE_STALE = { - ...PRICE_HAPPY, - is_stale: true, - stale_warning: 'Price is 35.0 minutes old (threshold: 30 min)', -}; - -const PRICE_NULL = { - asset_code: 'UNKNOWN', - issuer: null, - price_usd: null, - source: 'unavailable', - fetched_at: '2024-01-01T00:00:00.000Z', - is_stale: true, - stale_warning: 'No price data available from any source', - sources_attempted: [], - redis_unavailable: false, -}; - -// Valid 56-char Stellar address (G + 55 uppercase alphanumeric chars) -const VALID_ISSUER = 'G' + 'A'.repeat(55); - -// --- GET /api/v1/prices/:asset_code --- - -describe('GET /api/v1/prices/:asset_code', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); +process.env.ADMIN_API_KEY = 'b'.repeat(64); - test('happy path — 200 with full response shape', async () => { - priceOracle.getPrice.mockResolvedValue(PRICE_HAPPY); - - const res = await request(app).get('/api/v1/prices/XLM'); - - expect(res.status).toBe(200); - expect(res.body).toMatchObject({ - asset_code: 'XLM', - issuer: null, - price_usd: expect.any(Number), - source: expect.any(String), - fetched_at: expect.any(String), - is_stale: false, - stale_warning: null, - sources_attempted: expect.any(Array), +const express = require('express'); +const request = require('supertest'); const pricesRouter = require('../src/routes/prices'); const logger = require('../src/logger'); const { errorHandler } = require('../src/middleware/errorHandler'); +const apiKeys = require('../src/services/apiKeys'); function buildApp() { const app = express(); @@ -136,275 +71,214 @@ describe('price routes', () => { app = buildApp(); mockGetPrice.mockReset(); mockFetchFreshPrice.mockReset(); + apiKeys.validateApiKey.mockReset(); logger.error.mockClear(); }); - test('GET /prices/:asset_code returns the full price response shape', async () => { - mockGetPrice.mockResolvedValueOnce(priceResponse()); - - const res = await request(app) - .get('/api/v1/prices/usdc') - .query({ issuer: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' }); - - expect(res.status).toBe(200); - expect(mockGetPrice).toHaveBeenCalledWith( - 'USDC', - 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - ); - expect(res.body).toEqual({ - asset_code: 'USDC', - issuer: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', - price_usd: 1.01, - source: 'coingecko', - fetched_at: '2026-06-25T00:00:00.000Z', - is_stale: false, - stale_warning: null, - sources_attempted: ['coingecko'], - redis_unavailable: false, - }); - }); + describe('GET /api/v1/prices/:asset_code', () => { + test('returns the full price response shape', async () => { + mockGetPrice.mockResolvedValueOnce(priceResponse()); - test('stale price — 200 with is_stale: true and non-empty stale_warning', async () => { - priceOracle.getPrice.mockResolvedValue(PRICE_STALE); + const res = await request(app) + .get('/api/v1/prices/usdc') + .query({ issuer: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' }); - const res = await request(app).get('/api/v1/prices/XLM'); - - expect(res.status).toBe(200); - expect(res.body.is_stale).toBe(true); - expect(typeof res.body.stale_warning).toBe('string'); - expect(res.body.stale_warning.length).toBeGreaterThan(0); - }); - - test('oracle throws — 500 with generic message, no internal details leaked', async () => { - priceOracle.getPrice.mockRejectedValue(new Error('DB connection failed')); - - const res = await request(app).get('/api/v1/prices/XLM'); - - expect(res.status).toBe(500); - expect(res.body).toMatchObject({ - error: 'Internal server error', - message: 'Failed to fetch price data', + expect(res.status).toBe(200); + expect(mockGetPrice).toHaveBeenCalledWith( + 'USDC', + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ); + expect(res.body).toEqual({ + asset_code: 'USDC', + issuer: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + price_usd: 1.01, + source: 'coingecko', + fetched_at: '2026-06-25T00:00:00.000Z', + is_stale: false, + stale_warning: null, + sources_attempted: ['coingecko'], + redis_unavailable: false, + }); }); - expect(res.body).not.toHaveProperty('stack'); - expect(JSON.stringify(res.body)).not.toContain('DB connection failed'); - }); - // NOTE: issue #9 expects 200 when price_usd is null; the route actually returns 404. - test('unknown asset (price_usd: null) — 404 with error body', async () => { - priceOracle.getPrice.mockResolvedValue(PRICE_NULL); - test('GET /prices/:asset_code preserves stale warnings from the oracle', async () => { - mockGetPrice.mockResolvedValueOnce( - priceResponse({ - is_stale: true, - stale_warning: 'Price is 45.0 minutes old (threshold: 30 min)', - }) - ); - - const res = await request(app).get('/api/v1/prices/USDC'); - - expect(res.status).toBe(200); - expect(res.body.is_stale).toBe(true); - expect(res.body.stale_warning).toBe('Price is 45.0 minutes old (threshold: 30 min)'); - }); + test('preserves stale warnings from the oracle', async () => { + mockGetPrice.mockResolvedValueOnce( + priceResponse({ + is_stale: true, + stale_warning: 'Price is 45.0 minutes old (threshold: 30 min)', + }) + ); - test('GET /prices/:asset_code handles native XLM without an issuer', async () => { - mockGetPrice.mockResolvedValueOnce( - priceResponse({ - asset_code: 'XLM', - issuer: null, - price_usd: 0.12, - source: 'stellar_dex', - }) - ); - - const res = await request(app).get('/api/v1/prices/xlm'); - - expect(res.status).toBe(200); - expect(mockGetPrice).toHaveBeenCalledWith('XLM', null); - expect(res.body.asset_code).toBe('XLM'); - expect(res.body.issuer).toBeNull(); - }); + const res = await request(app).get('/api/v1/prices/USDC'); - test('GET /prices/:asset_code returns 404 with stale warning when no source has data', async () => { - mockGetPrice.mockResolvedValueOnce( - priceResponse({ - price_usd: null, - source: 'unavailable', - is_stale: true, - stale_warning: 'No price data available from any source', - }) - ); - - const res = await request(app).get('/api/v1/prices/UNKNOWN'); - - expect(res.status).toBe(404); - expect(res.body).toMatchObject({ - error: 'Price not available', - message: expect.stringContaining('UNKNOWN'), + expect(res.status).toBe(200); + expect(res.body.is_stale).toBe(true); + expect(res.body.stale_warning).toBe('Price is 45.0 minutes old (threshold: 30 min)'); }); - }); - - test('XLM native (no issuer) — oracle called with null issuer', async () => { - priceOracle.getPrice.mockResolvedValue(PRICE_HAPPY); - - await request(app).get('/api/v1/prices/XLM'); - - expect(priceOracle.getPrice).toHaveBeenCalledWith('XLM', null); - }); - - test('?issuer query param — passed through to oracle', async () => { - priceOracle.getPrice.mockResolvedValue({ ...PRICE_HAPPY, issuer: VALID_ISSUER }); - - await request(app).get(`/api/v1/prices/USDC?issuer=${VALID_ISSUER}`); - - expect(priceOracle.getPrice).toHaveBeenCalledWith('USDC', VALID_ISSUER); - }); - - test('invalid asset code (>12 chars) — 400', async () => { - const res = await request(app).get('/api/v1/prices/TOOLONGCODE123'); - - expect(res.status).toBe(400); - expect(res.body).toMatchObject({ error: 'Invalid asset code' }); - }); - - test('malformed issuer — 400', async () => { - const res = await request(app).get('/api/v1/prices/XLM?issuer=BADISSUER'); - expect(res.status).toBe(400); - expect(res.body).toMatchObject({ error: 'Invalid issuer' }); - }); + test('handles native XLM without an issuer', async () => { + mockGetPrice.mockResolvedValueOnce( + priceResponse({ + asset_code: 'XLM', + issuer: null, + price_usd: 0.12, + source: 'stellar_dex', + }) + ); + + const res = await request(app).get('/api/v1/prices/xlm'); + + expect(res.status).toBe(200); + expect(mockGetPrice).toHaveBeenCalledWith('XLM', null); + expect(res.body.asset_code).toBe('XLM'); + expect(res.body.issuer).toBeNull(); + }); - test('Redis unavailable — 200 with redis_unavailable: true (graceful degradation)', async () => { - priceOracle.getPrice.mockResolvedValue({ ...PRICE_HAPPY, redis_unavailable: true }); + test('returns 404 with a structured error when no source has data', async () => { + mockGetPrice.mockResolvedValueOnce( + priceResponse({ + price_usd: null, + source: 'unavailable', + is_stale: true, + stale_warning: 'No price data available from any source', + }) + ); + + const res = await request(app).get('/api/v1/prices/UNKNOWN'); + + expect(res.status).toBe(404); + expect(res.body.error).toMatchObject({ + code: 'NOT_FOUND', + message: expect.stringContaining('UNKNOWN'), + }); + }); - const res = await request(app).get('/api/v1/prices/XLM'); + test('rejects invalid asset codes before oracle lookup', async () => { + const res = await request(app).get('/api/v1/prices/TOO-LONG-ASSET'); + + expect(res.status).toBe(400); + expect(res.body.error).toMatchObject({ + code: 'VALIDATION_ERROR', + message: 'Validation failed', + }); + expect(res.body.error.details.fields.asset_code).toEqual( + expect.arrayContaining(['Asset code must be alphanumeric']) + ); + expect(mockGetPrice).not.toHaveBeenCalled(); + }); - expect(res.status).toBe(200); - expect(res.body.redis_unavailable).toBe(true); - expect(res.body.price_usd).not.toBeNull(); - }); -}); + test('rejects malformed issuers before oracle lookup', async () => { + const res = await request(app) + .get('/api/v1/prices/USDC') + .query({ issuer: 'not-a-stellar-address' }); + + expect(res.status).toBe(400); + expect(res.body.error).toMatchObject({ + code: 'VALIDATION_ERROR', + message: 'Validation failed', + }); + expect(res.body.error.details.fields.issuer).toEqual( + expect.arrayContaining(['Must be a valid Stellar public key']) + ); + expect(mockGetPrice).not.toHaveBeenCalled(); + }); -// --- GET /api/v1/prices/:asset_code/refresh --- + test('hides stack traces on unhandled oracle errors', async () => { + mockGetPrice.mockRejectedValueOnce(new Error('redis exploded with stack details')); -describe('GET /api/v1/prices/:asset_code/refresh', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); + const res = await request(app).get('/api/v1/prices/XLM'); - test('no Authorization header — 401', async () => { - const res = await request(app).get('/api/v1/prices/XLM/refresh'); - - expect(res.status).toBe(401); - expect(res.body).toMatchObject({ error: 'Missing or invalid API key' }); - }); + expect(res.status).toBe(500); + expect(res.body.error).toMatchObject({ code: 'INTERNAL_ERROR' }); + expect(JSON.stringify(res.body)).not.toContain('redis exploded'); + }); - test('invalid API key — 401', async () => { - apiKeys.validateApiKey.mockResolvedValue(null); + test('Redis unavailable — 200 with redis_unavailable: true (graceful degradation)', async () => { + mockGetPrice.mockResolvedValueOnce(priceResponse({ redis_unavailable: true })); - const res = await request(app) - .get('/api/v1/prices/XLM/refresh') - .set('Authorization', 'Bearer bad-key'); + const res = await request(app).get('/api/v1/prices/USDC'); - expect(res.status).toBe(401); - expect(res.body).toMatchObject({ error: 'Missing or invalid API key' }); + expect(res.status).toBe(200); + expect(res.body.redis_unavailable).toBe(true); + expect(res.body.price_usd).not.toBeNull(); + }); }); - test('valid API key — 200 with full response shape', async () => { - apiKeys.validateApiKey.mockResolvedValue({ scopes: [] }); - priceOracle.fetchFreshPrice.mockResolvedValue(PRICE_HAPPY); - - const res = await request(app) - .get('/api/v1/prices/XLM/refresh') - .set('Authorization', 'Bearer valid-key'); + describe('GET /api/v1/prices/:asset_code/refresh', () => { + test('no Authorization header — 401', async () => { + const res = await request(app).get('/api/v1/prices/XLM/refresh'); - expect(res.status).toBe(200); - expect(res.body).toMatchObject({ - asset_code: 'XLM', - price_usd: expect.any(Number), + expect(res.status).toBe(401); + expect(res.body.error).toMatchObject({ + code: 'UNAUTHORIZED', + message: 'Missing or invalid API key', + }); }); - }); - test('valid API key + oracle throws — 500, no internal details leaked', async () => { - apiKeys.validateApiKey.mockResolvedValue({ scopes: [] }); - priceOracle.fetchFreshPrice.mockRejectedValue(new Error('External source failed')); + test('invalid API key — 401', async () => { + apiKeys.validateApiKey.mockResolvedValue(null); - const res = await request(app) - .get('/api/v1/prices/XLM/refresh') - .set('Authorization', 'Bearer valid-key'); + const res = await request(app) + .get('/api/v1/prices/XLM/refresh') + .set('Authorization', 'Bearer bad-key'); - expect(res.status).toBe(500); - expect(res.body).toMatchObject({ - error: 'Internal server error', - message: 'Failed to refresh price data', - }); - expect(res.body).not.toHaveProperty('stack'); - expect(JSON.stringify(res.body)).not.toContain('External source failed'); - expect(res.body.error).toMatchObject({ - code: 'NOT_FOUND', - message: 'No price data found for UNKNOWN', + expect(res.status).toBe(401); + expect(res.body.error).toMatchObject({ + code: 'UNAUTHORIZED', + message: 'Missing or invalid API key', + }); }); - }); - test('GET /prices/:asset_code rejects invalid asset codes before oracle lookup', async () => { - const res = await request(app).get('/api/v1/prices/TOO-LONG-ASSET'); + test('valid API key — 200 with full response shape', async () => { + apiKeys.validateApiKey.mockResolvedValue({ scopes: [] }); + mockFetchFreshPrice.mockResolvedValueOnce(priceResponse({ asset_code: 'XLM' })); - expect(res.status).toBe(400); - expect(res.body.error).toMatchObject({ - code: 'VALIDATION_ERROR', - message: 'Validation failed', - }); - expect(res.body.error.details.fields.asset_code).toEqual( - expect.arrayContaining(['Asset code must be alphanumeric']) - ); - expect(mockGetPrice).not.toHaveBeenCalled(); - }); - - test('GET /prices/:asset_code rejects malformed issuers before oracle lookup', async () => { - const res = await request(app) - .get('/api/v1/prices/USDC') - .query({ issuer: 'not-a-stellar-address' }); + const res = await request(app) + .get('/api/v1/prices/XLM/refresh') + .set('Authorization', 'Bearer valid-key'); - expect(res.status).toBe(400); - expect(res.body.error).toMatchObject({ - code: 'VALIDATION_ERROR', - message: 'Validation failed', + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + asset_code: 'XLM', + price_usd: expect.any(Number), + }); }); - expect(res.body.error.details.fields.issuer).toEqual( - expect.arrayContaining(['Must be a valid Stellar public key']) - ); - expect(mockGetPrice).not.toHaveBeenCalled(); - }); - test('GET /prices/:asset_code hides stack traces on unhandled oracle errors', async () => { - mockGetPrice.mockRejectedValueOnce(new Error('redis exploded with stack details')); + test('valid API key + oracle throws — 500, no internal details leaked', async () => { + apiKeys.validateApiKey.mockResolvedValue({ scopes: [] }); + mockFetchFreshPrice.mockRejectedValueOnce(new Error('External source failed')); - const res = await request(app).get('/api/v1/prices/XLM'); + const res = await request(app) + .get('/api/v1/prices/XLM/refresh') + .set('Authorization', 'Bearer valid-key'); - expect(res.status).toBe(500); - expect(res.body.error).toMatchObject({ code: 'INTERNAL_ERROR' }); - expect(JSON.stringify(res.body)).not.toContain('redis exploded'); - }); + expect(res.status).toBe(500); + expect(res.body.error).toMatchObject({ code: 'INTERNAL_ERROR' }); + expect(res.body).not.toHaveProperty('stack'); + expect(JSON.stringify(res.body)).not.toContain('External source failed'); + }); - test('GET /prices/:asset_code/refresh validates params and calls fresh oracle lookup', async () => { - mockFetchFreshPrice.mockResolvedValueOnce( - priceResponse({ - asset_code: 'USDC', - source: 'stellar_dex', - sources_attempted: ['stellar_dex'], - }) - ); - - const res = await request(app) - .get('/api/v1/prices/usdc/refresh') - .set('Authorization', `Bearer ${process.env.ADMIN_API_KEY}`) - .query({ issuer: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' }); - - expect(res.status).toBe(200); - expect(mockFetchFreshPrice).toHaveBeenCalledWith( - 'USDC', - 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - ); - expect(res.body.source).toBe('stellar_dex'); + test('validates params and calls fresh oracle lookup', async () => { + apiKeys.validateApiKey.mockResolvedValue({ scopes: [] }); + mockFetchFreshPrice.mockResolvedValueOnce( + priceResponse({ + asset_code: 'USDC', + source: 'stellar_dex', + sources_attempted: ['stellar_dex'], + }) + ); + + const res = await request(app) + .get('/api/v1/prices/usdc/refresh') + .set('Authorization', `Bearer ${process.env.ADMIN_API_KEY}`) + .query({ issuer: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' }); + + expect(res.status).toBe(200); + expect(mockFetchFreshPrice).toHaveBeenCalledWith( + 'USDC', + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ); + expect(res.body.source).toBe('stellar_dex'); + }); }); }); diff --git a/test/webhookDispatcher.test.js b/test/webhookDispatcher.test.js index 34e144a..645ffb2 100644 --- a/test/webhookDispatcher.test.js +++ b/test/webhookDispatcher.test.js @@ -190,6 +190,98 @@ describe('exponential backoff', () => { }); }); +describe('backoff jitter (#128)', () => { + test('repeated calls with the same attemptsCompleted produce a distribution, not an identical value', () => { + // Simulates 100 deliveries all failing on attempt 1 "at once" — before + // jitter, every one of these computed exactly the same delay. + const delays = new Set(); + for (let i = 0; i < 100; i++) { + delays.add(dispatcher.backoffMs(1)); + } + expect(delays.size).toBeGreaterThan(1); + }); + + test('an injected random source of 0 produces exactly the lower bound (deterministic / 2)', () => { + // config defaults: base=30000, factor=2 -> attempt 1 deterministic=30000 + const delay = dispatcher.backoffMs(1, { random: () => 0 }); + expect(delay).toBe(15000); + }); + + test('an injected random source just under 1 stays just under the deterministic upper bound', () => { + const delay = dispatcher.backoffMs(1, { random: () => 0.999999 }); + expect(delay).toBeLessThan(30000); + expect(delay).toBeGreaterThan(29999); + }); + + test('delay is never zero or negative, even at the minimum jitter, across several attempt counts', () => { + for (let attempt = 1; attempt <= 5; attempt++) { + const delay = dispatcher.backoffMs(attempt, { random: () => 0 }); + expect(delay).toBeGreaterThan(0); + } + }); + + test('delay never reaches or exceeds the undjittered deterministic value, across several attempt counts', () => { + const base = 30000; + const factor = 2; + for (let attempt = 1; attempt <= 5; attempt++) { + const deterministic = base * factor ** (attempt - 1); + const delay = dispatcher.backoffMs(attempt, { random: () => 0.999999 }); + expect(delay).toBeLessThan(deterministic); + } + }); + + test('delay still strictly grows across attempts even in the worst-case jitter ordering', () => { + // Worst case for monotonicity: attempt N rolls the minimum possible + // jitter (random=0) while attempt N-1 rolls the maximum (random~1). + // Even then, attempt N's delay must exceed attempt N-1's, because the + // default 2x factor means each attempt's [half, full) range never + // overlaps the previous attempt's range. + const attempt1Max = dispatcher.backoffMs(1, { random: () => 0.999999 }); + const attempt2Min = dispatcher.backoffMs(2, { random: () => 0 }); + expect(attempt2Min).toBeGreaterThan(attempt1Max); + + const attempt2Max = dispatcher.backoffMs(2, { random: () => 0.999999 }); + const attempt3Min = dispatcher.backoffMs(3, { random: () => 0 }); + expect(attempt3Min).toBeGreaterThan(attempt2Max); + }); + + test('defaults to the real Math.random when no random source is injected', () => { + const spy = jest.spyOn(Math, 'random').mockReturnValue(0.5); + try { + const delay = dispatcher.backoffMs(1); + expect(spy).toHaveBeenCalled(); + expect(delay).toBe(15000 + 0.5 * 15000); + } finally { + spy.mockRestore(); + } + }); +}); + +describe('thundering-herd prevention across a real dispatch tick (#128)', () => { + test('many deliveries failing at the same attempt within one tick get spread next_retry_at values', async () => { + // 20 different subscribers, all failing on attempt 1 at the same + // wall-clock moment (one dispatch() call, one Promise.all batch) — + // exactly the scenario the issue describes: a correlated outage + // affecting many in-flight deliveries at once. + const webhookCount = 20; + for (let i = 0; i < webhookCount; i++) { + await createWebhook({ url: `https://sub-${i}.example.com` }); + } + mockAxiosPost.mockResolvedValue({ status: 503 }); + + const deliveries = await dispatcher.dispatch({ + event_type: 'pool.assets_locked', + event_id: 'evt_herd', + }); + + expect(deliveries).toHaveLength(webhookCount); + deliveries.forEach((d) => expect(d.status).toBe('pending')); + + const nextRetryAtValues = new Set(deliveries.map((d) => d.next_retry_at)); + expect(nextRetryAtValues.size).toBeGreaterThan(1); + }); +}); + describe('shouldRetry decision table', () => { test('retries on network error', () => expect(dispatcher.shouldRetry(null, true)).toBe(true)); test('retries on 500', () => expect(dispatcher.shouldRetry(500, false)).toBe(true));