diff --git a/.env.example b/.env.example index 8f71d812..581eaace 100644 --- a/.env.example +++ b/.env.example @@ -155,9 +155,36 @@ REDIS_PORT=6379 # Format: redis://[user:password@]host:port[/db] REDIS_URL= +# Password for standalone/Sentinel/Cluster connections (optional) +REDIS_PASSWORD= + # Separate Redis URL for Bull job queue (uses different DB) QUEUE_REDIS_URL= +# ── High Availability: Redis Sentinel (optional) ────────────────────────── +# When set, the shared Redis connection (session, caching, threat detection, +# distributed locks, upload progress) connects via Sentinel instead of +# REDIS_HOST/REDIS_PORT, so failover to a promoted replica does not require +# an application restart. Takes effect only when REDIS_CLUSTER_NODES is unset. + +# Comma-separated list of sentinel host:port pairs +# Example: sentinel1:26379,sentinel2:26379,sentinel3:26379 +REDIS_SENTINEL_HOSTS= + +# Sentinel master group name +REDIS_SENTINEL_NAME=mymaster + +# Password for the sentinel nodes themselves (optional, separate from REDIS_PASSWORD) +REDIS_SENTINEL_PASSWORD= + +# ── High Availability: Redis Cluster (optional) ──────────────────────────── +# When set, the shared Redis connection connects to a Redis Cluster for +# sharding. Takes precedence over REDIS_SENTINEL_HOSTS if both are set. + +# Comma-separated list of cluster seed node host:port pairs +# Example: node1:7000,node2:7001,node3:7002 +REDIS_CLUSTER_NODES= + # ───────────────────────────────────────────────────────────────────────────── # 5. EMAIL & SMTP # ───────────────────────────────────────────────────────────────────────────── diff --git a/package-lock.json b/package-lock.json index 765bf649..55139488 100644 --- a/package-lock.json +++ b/package-lock.json @@ -137,6 +137,7 @@ "@types/connect": "^3.4.38", "@types/express": "^5.0.6", "@types/fluent-ffmpeg": "^2.1.28", + "@types/ioredis-mock": "^8.2.7", "@types/istanbul-lib-coverage": "^2.0.6", "@types/istanbul-lib-report": "^3.0.3", "@types/jest": "^29.5.2", @@ -161,6 +162,7 @@ "eslint-config-prettier": "^9.0.0", "eslint-plugin-prettier": "^5.0.0", "husky": "^9.0.0", + "ioredis-mock": "^8.13.1", "jest": "^29.5.0", "lint-staged": "^15.0.0", "prettier": "^3.0.0", @@ -2101,7 +2103,7 @@ "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "0.3.9" @@ -2114,7 +2116,7 @@ "version": "0.3.9", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", @@ -3165,6 +3167,13 @@ "node": ">=18" } }, + "node_modules/@ioredis/as-callback": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@ioredis/as-callback/-/as-callback-3.0.0.tgz", + "integrity": "sha512-Kqv1rZ3WbgOrS+hgzJ5xG5WQuhvzzSTRYvNeyPMLOAM78MHSnuKI20JeJGbpuAt//LCuP0vsexZcorqW7kWhJg==", + "dev": true, + "license": "MIT" + }, "node_modules/@ioredis/commands": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz", @@ -3995,7 +4004,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -4016,7 +4025,7 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -5053,6 +5062,24 @@ } } }, + "node_modules/@nestjs/schematics/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nestjs/schematics/node_modules/jsonc-parser": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", @@ -5083,6 +5110,22 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/@nestjs/schematics/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nestjs/schematics/node_modules/rxjs": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", @@ -11271,6 +11314,93 @@ "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", "license": "BSD-3-Clause" }, + "node_modules/@redis/bloom": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.12.1.tgz", + "integrity": "sha512-PUUfv+ms7jgPSBVoo/DN4AkPHj4D5TZSd6SbJX7egzBplkYUcKmHRE8RKia7UtZ8bSQbLguLvxVO+asKtQfZWA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, + "node_modules/@redis/client": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.12.1.tgz", + "integrity": "sha512-7aPGWeqA3uFm43o19umzdl16CEjK/JQGtSXVPevplTaOU3VJA/rseBC1QvYUz9lLDIMBimc4SW/zrW4S89BaCA==", + "license": "MIT", + "peer": true, + "dependencies": { + "cluster-key-slot": "1.1.2" + }, + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@node-rs/xxhash": "^1.1.0", + "@opentelemetry/api": ">=1 <2" + }, + "peerDependenciesMeta": { + "@node-rs/xxhash": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@redis/client/node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@redis/json": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-5.12.1.tgz", + "integrity": "sha512-eOze75esLve4vfqDel7aMX08CNaiLLQS2fV8mpRN9NxPe1rVR4vQyYiW/OgtGUysF6QOr9ANhfxABKNOJfXdKg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, + "node_modules/@redis/search": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-5.12.1.tgz", + "integrity": "sha512-ItlxbxC9cKI6IU1TLWoczwJCRb6TdmkEpWv05UrPawqaAnWGRu3rcIqsc5vN483T2fSociuyV1UkWIL5I4//2w==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, + "node_modules/@redis/time-series": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.12.1.tgz", + "integrity": "sha512-c6JL6E3EcZJuNqKFz+KM+l9l5mpcQiKvTwgA3blt5glWJ8hjDk0yeHN3beE/MpqYIQ8UEX44ItQzgkE/gCBELQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, "node_modules/@scarf/scarf": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", @@ -11698,28 +11828,28 @@ "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@tsconfig/node12": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@tsconfig/node14": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@tsconfig/node16": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/aws-lambda": { @@ -11980,6 +12110,16 @@ "@types/node": "*" } }, + "node_modules/@types/ioredis-mock": { + "version": "8.2.7", + "resolved": "https://registry.npmjs.org/@types/ioredis-mock/-/ioredis-mock-8.2.7.tgz", + "integrity": "sha512-YsGiaOIYBKeVvu/7GYziAD8qX3LJem5LK00d5PKykzsQJMLysAqXA61AkNuYWCekYl64tbMTqVOMF4SYoCPbQg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ioredis": ">=5" + } + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -12022,7 +12162,6 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, "license": "MIT" }, "node_modules/@types/jsonwebtoken": { @@ -12969,7 +13108,7 @@ "version": "8.3.5", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "acorn": "^8.11.0" @@ -13169,7 +13308,7 @@ "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/argparse": { @@ -15152,7 +15291,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/cron": { @@ -15554,7 +15693,7 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" @@ -15900,9 +16039,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.24.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.1.tgz", - "integrity": "sha512-7DdUaTjmNwMcH2gLr1qycesKII3BK4RLy/mdAb7x10Lq7bR4aNKHt1BR1ZALSv0rPM/hF5wYF0PhGop/rJm8vw==", + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", "dev": true, "license": "MIT", "dependencies": { @@ -16976,6 +17115,45 @@ "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", "license": "MIT" }, + "node_modules/fengari": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/fengari/-/fengari-0.1.5.tgz", + "integrity": "sha512-0DS4Nn4rV8qyFlQCpKK8brT61EUtswynrpfFTcgLErcilBIBskSMQ86fO2WVuybr14ywyKdRjv91FiRZwnEuvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "readline-sync": "^1.4.10", + "sprintf-js": "^1.1.3", + "tmp": "^0.2.5" + } + }, + "node_modules/fengari-interop": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/fengari-interop/-/fengari-interop-0.1.4.tgz", + "integrity": "sha512-4/CW/3PJUo3ebD4ACgE1g/3NGEYSq7OQAyETyypsAl/WeySDBbxExikkayNkZzbpgyC9GyJp8v1DU2VOXxNq7Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "fengari": "^0.1.0" + } + }, + "node_modules/fengari/node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/fengari/node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, "node_modules/fetch-blob": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", @@ -18534,6 +18712,27 @@ "url": "https://opencollective.com/ioredis" } }, + "node_modules/ioredis-mock": { + "version": "8.13.1", + "resolved": "https://registry.npmjs.org/ioredis-mock/-/ioredis-mock-8.13.1.tgz", + "integrity": "sha512-Wsi50AU+cMiI32nAgfwpUaJVBtb4iQdVsOHl9M6R3tePCO/8vGsToCVIG82XWAxN4Se55TZoOzVseu+QngFLyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ioredis/as-callback": "^3.0.0", + "@ioredis/commands": "^1.4.0", + "fengari": "^0.1.4", + "fengari-interop": "^0.1.3", + "semver": "^7.7.2" + }, + "engines": { + "node": ">=12.22" + }, + "peerDependencies": { + "@types/ioredis-mock": "^8", + "ioredis": "^5" + } + }, "node_modules/ip-address": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", @@ -21401,7 +21600,7 @@ "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/makeerror": { @@ -21637,6 +21836,105 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minimizer-webpack-plugin": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/minimizer-webpack-plugin/-/minimizer-webpack-plugin-5.6.1.tgz", + "integrity": "sha512-DoeAZz8Q1C1znwsUzej1fdoi4jCf7/+Em27ouLqfK/+3m8G+D7yDhUwrc3CNhjSzGUN1kn7Iv4sWmjflQHenpw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@minify-html/node": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "html-minifier-terser": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/minimizer-webpack-plugin/node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/minimizer-webpack-plugin/node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", @@ -22761,7 +23059,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.1.tgz", "integrity": "sha512-xUXwsxNjwTQ8K3GnT4pCJm+xq3RUPQbmkYJTP5aFIfNIvbcc/4MUxgBaaRSZJ6yGJZiGSyYlM6MzwTsRk8SYCg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -23608,6 +23906,33 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/readline-sync": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/readline-sync/-/readline-sync-1.4.10.tgz", + "integrity": "sha512-gNva8/6UAe8QYepIQH/jQ2qn91Qj0B9sYjMBBs3QOB8F2CXcKgLxQaJRP76sWVRQt+QU+8fAkCbCvjjMFu7Ycw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/redis": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/redis/-/redis-5.12.1.tgz", + "integrity": "sha512-LDsoVvb/CpoV9EN3FXvgvSHNJWuCIzl9MiO3ppOevuGLpSGJhwfQjpEwfFJcQvNSddHADDdZaWx0HnmMxRXG7g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@redis/bloom": "5.12.1", + "@redis/client": "5.12.1", + "@redis/json": "5.12.1", + "@redis/search": "5.12.1", + "@redis/time-series": "5.12.1" + }, + "engines": { + "node": ">= 18.19.0" + } + }, "node_modules/redis-errors": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", @@ -25822,7 +26147,7 @@ "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "^0.8.0", @@ -26248,7 +26573,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -26433,7 +26758,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/v8-to-istanbul": { @@ -26517,6 +26842,51 @@ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "license": "BSD-2-Clause" }, + "node_modules/webpack": { + "version": "5.109.2", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.109.2.tgz", + "integrity": "sha512-U9/cvLzxObKNEZ9+TtdqrHM5/9z3lgl2c+c4BzbqGxFQvQvBAq87yql5A8pQ+rrMbS496MZJeF5enVBndIy2hw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.16.0", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.24.4", + "es-module-lexer": "^2.1.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "graceful-fs": "^4.2.11", + "mime-db": "^1.54.0", + "minimizer-webpack-plugin": "^5.6.1", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "watchpack": "^2.5.2", + "webpack-sources": "^3.5.1" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, "node_modules/webpack-node-externals": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/webpack-node-externals/-/webpack-node-externals-3.0.0.tgz", @@ -26528,15 +26898,62 @@ } }, "node_modules/webpack-sources": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.5.0.tgz", - "integrity": "sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ==", + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.5.1.tgz", + "integrity": "sha512-jyuiGJdtvY434z5bUZrjz67v76/ePNvFZTp9Mdz29IlH4+GPsgyGjiv0fKI+M7BdkU6ADjulUcKAd3tUK3WlEw==", "dev": true, "license": "MIT", "engines": { "node": ">=10.13.0" } }, + "node_modules/webpack/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/webpack/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/webpack/node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/whatwg-mimetype": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", @@ -26919,7 +27336,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" diff --git a/package.json b/package.json index 7d3acaa2..ce37d483 100644 --- a/package.json +++ b/package.json @@ -204,6 +204,7 @@ "@types/connect": "^3.4.38", "@types/express": "^5.0.6", "@types/fluent-ffmpeg": "^2.1.28", + "@types/ioredis-mock": "^8.2.7", "@types/istanbul-lib-coverage": "^2.0.6", "@types/istanbul-lib-report": "^3.0.3", "@types/jest": "^29.5.2", @@ -228,6 +229,7 @@ "eslint-config-prettier": "^9.0.0", "eslint-plugin-prettier": "^5.0.0", "husky": "^9.0.0", + "ioredis-mock": "^8.13.1", "jest": "^29.5.0", "lint-staged": "^15.0.0", "prettier": "^3.0.0", diff --git a/src/caching/caching.module.ts b/src/caching/caching.module.ts index 6ea4d085..f232eb84 100644 --- a/src/caching/caching.module.ts +++ b/src/caching/caching.module.ts @@ -3,14 +3,16 @@ import { CacheModule } from '@nestjs/cache-manager'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { EventEmitterModule } from '@nestjs/event-emitter'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { redisStore } from 'cache-manager-ioredis-yet'; +import { redisInsStore } from 'cache-manager-ioredis-yet'; +import type Redis from 'ioredis'; import { Course } from '../courses/entities/course.entity'; import { Enrollment } from '../courses/entities/enrollment.entity'; import { User } from '../users/entities/user.entity'; import { ProfileCompletenessService } from '../profile-completeness/profile-completeness.service'; import { SearchModule } from '../search/search.module'; import { MonitoringModule } from '../monitoring/monitoring.module'; -import { getRedisOptions } from '../config/cache.config'; +import { RedisModule } from '../common/redis/redis.module'; +import { REDIS_CLIENT } from '../common/redis/redis.constants'; import { CachingService } from './caching.service'; import { CacheInvalidationService } from './cache-invalidation.service'; import { CacheInvalidationListener } from './cache-invalidation.listener'; @@ -21,6 +23,11 @@ import { QueryCacheService } from './query-cache.service'; /** * Registers the application-level Redis cache layer, warming engine, and invalidation listeners. + * + * Issue #837 — the cache-manager store now wraps the shared `REDIS_CLIENT` + * connection (standalone/Sentinel/Cluster, see `RedisModule`) via + * `redisInsStore` instead of opening its own host/port connection, so + * caching gains HA failover/sharding for free. */ @Global() @Module({ @@ -31,25 +38,21 @@ import { QueryCacheService } from './query-cache.service'; SearchModule, TenancyModule, TypeOrmModule.forFeature([Course, Enrollment, User]), + RedisModule.forRoot(), CacheModule.registerAsync({ - imports: [ConfigModule], - inject: [ConfigService], + imports: [ConfigModule, RedisModule.forRoot()], + inject: [ConfigService, REDIS_CLIENT], isGlobal: true, - useFactory: async (configService: ConfigService) => { + useFactory: async (configService: ConfigService, redis: Redis) => { const ttlSeconds = parseInt(configService.get('REDIS_TTL') || '60', 10); const ttlMs = ttlSeconds * 1000; - const redisOptions = getRedisOptions(configService); if (process.env.NODE_ENV === 'test') { return { ttl: ttlMs }; } return { - store: await redisStore({ - host: redisOptions.host as string, - port: redisOptions.port as number, - ttl: ttlMs, - }), + store: redisInsStore(redis, { ttl: ttlMs }), ttl: ttlMs, }; }, diff --git a/src/common/redis/redis.constants.ts b/src/common/redis/redis.constants.ts new file mode 100644 index 00000000..20cce2a5 --- /dev/null +++ b/src/common/redis/redis.constants.ts @@ -0,0 +1,6 @@ +/** + * DI token for the shared ioredis connection provided by {@link RedisModule}. + * String token (rather than the `Redis` class) so tests can inject a mock + * client — e.g. an `ioredis-mock` instance — without opening a real socket. + */ +export const REDIS_CLIENT = 'REDIS_CLIENT'; diff --git a/src/common/redis/redis.module.spec.ts b/src/common/redis/redis.module.spec.ts new file mode 100644 index 00000000..1c042232 --- /dev/null +++ b/src/common/redis/redis.module.spec.ts @@ -0,0 +1,50 @@ +/** + * Issue #837 — verifies `RedisModule.forRoot()` wires a working `REDIS_CLIENT` + * provider through NestJS DI, backed by `ioredis-mock` so no real Redis + * connection is required. + */ +jest.mock('ioredis', () => jest.requireActual('ioredis-mock')); + +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigModule } from '@nestjs/config'; +import { RedisModule } from './redis.module'; +import { REDIS_CLIENT } from './redis.constants'; +import { resetSharedRedisClientForTests } from '../../config/cache.config'; + +describe('RedisModule', () => { + afterEach(() => { + resetSharedRedisClientForTests(); + }); + + it('provides a working REDIS_CLIENT from forRoot()', async () => { + const moduleRef: TestingModule = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ isGlobal: true, ignoreEnvFile: true }), + RedisModule.forRoot(), + ], + }).compile(); + + const client = moduleRef.get(REDIS_CLIENT); + expect(client).toBeDefined(); + + await client.set('redis-module-key', 'redis-module-value'); + await expect(client.get('redis-module-key')).resolves.toBe('redis-module-value'); + + await moduleRef.close(); + }); + + it('is global, so REDIS_CLIENT is injectable by feature modules without re-importing it', async () => { + const moduleRef: TestingModule = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ isGlobal: true, ignoreEnvFile: true }), + RedisModule.forRoot(), + RedisModule.forRoot(), // multiple imports must not open a second connection + ], + }).compile(); + + const client = moduleRef.get(REDIS_CLIENT); + expect(client).toBeDefined(); + + await moduleRef.close(); + }); +}); diff --git a/src/common/redis/redis.module.ts b/src/common/redis/redis.module.ts new file mode 100644 index 00000000..edc524da --- /dev/null +++ b/src/common/redis/redis.module.ts @@ -0,0 +1,48 @@ +import { DynamicModule, Global, Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { getSharedRedisClient } from '../../config/cache.config'; +import { REDIS_CLIENT } from './redis.constants'; + +/** + * Issue #837 — shared, high-availability-aware Redis connection. + * + * Provides a single ioredis client behind the `REDIS_CLIENT` token so every + * Redis-backed feature module (session, caching, threat detection, ...) + * reuses the same connection instead of each opening its own socket. + * + * The underlying connection topology is selected from environment + * configuration (see `getRedisDeploymentMode` in `config/cache.config.ts`): + * + * - `REDIS_CLUSTER_NODES` set → Redis Cluster (sharding) + * - `REDIS_SENTINEL_HOSTS` set → Redis Sentinel (HA failover) + * - neither set → standalone `REDIS_HOST`/`REDIS_PORT` + * + * ioredis handles Sentinel/Cluster failover transparently (it re-resolves + * the current master via Sentinel, or refreshes cluster slot ownership, and + * transparently redirects in-flight commands) — no application restart is + * required when a replica is promoted. + * + * `RedisModule.forRoot()` is safe to import from multiple feature modules: + * NestJS de-duplicates dynamic module registrations with identical shape, + * and `getSharedRedisClient` itself is backed by a process-wide singleton, + * so only one physical connection is ever opened regardless of how many + * modules import it. + */ +@Global() +@Module({}) +export class RedisModule { + static forRoot(): DynamicModule { + return { + module: RedisModule, + imports: [ConfigModule], + providers: [ + { + provide: REDIS_CLIENT, + inject: [ConfigService], + useFactory: (configService: ConfigService) => getSharedRedisClient(configService), + }, + ], + exports: [REDIS_CLIENT], + }; + } +} diff --git a/src/config/cache.config.spec.ts b/src/config/cache.config.spec.ts new file mode 100644 index 00000000..c921b225 --- /dev/null +++ b/src/config/cache.config.spec.ts @@ -0,0 +1,162 @@ +/** + * Issue #837 — Redis Sentinel/Cluster connection factory. + * + * Exercises `getRedisDeploymentMode`/`getSharedRedisClient` against + * `ioredis-mock` so the topology-selection logic (standalone vs Sentinel vs + * Cluster) and basic command execution can be verified without a real + * Redis/Sentinel/Cluster deployment. + */ +jest.mock('ioredis', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const ActualMock = jest.requireActual('ioredis-mock'); + + const ctorSpy = jest.fn((...args: unknown[]) => new ActualMock(...args)); + const clusterCtorSpy = jest.fn((...args: unknown[]) => new ActualMock.Cluster(...args)); + (ctorSpy as unknown as { Cluster: unknown }).Cluster = clusterCtorSpy; + + return { __esModule: true, default: ctorSpy }; +}); + +import Redis from 'ioredis'; +import { ConfigService } from '@nestjs/config'; +import { + getRedisDeploymentMode, + getSharedRedisClient, + resetSharedRedisClientForTests, +} from './cache.config'; + +function buildConfigService(values: Record): ConfigService { + return { + get: (key: string) => values[key], + } as unknown as ConfigService; +} + +const redisCtorSpy = Redis as unknown as jest.Mock; +const clusterCtorSpy = (Redis as unknown as { Cluster: jest.Mock }).Cluster; + +describe('getRedisDeploymentMode', () => { + it('defaults to standalone when neither cluster nor sentinel vars are set', () => { + expect(getRedisDeploymentMode(buildConfigService({}))).toBe('standalone'); + }); + + it('resolves sentinel mode when REDIS_SENTINEL_HOSTS is set', () => { + const config = buildConfigService({ + REDIS_SENTINEL_HOSTS: 'sentinel1:26379,sentinel2:26379', + }); + expect(getRedisDeploymentMode(config)).toBe('sentinel'); + }); + + it('resolves cluster mode when REDIS_CLUSTER_NODES is set', () => { + const config = buildConfigService({ REDIS_CLUSTER_NODES: 'node1:7000,node2:7001' }); + expect(getRedisDeploymentMode(config)).toBe('cluster'); + }); + + it('prefers cluster over sentinel when both are configured', () => { + const config = buildConfigService({ + REDIS_CLUSTER_NODES: 'node1:7000', + REDIS_SENTINEL_HOSTS: 'sentinel1:26379', + }); + expect(getRedisDeploymentMode(config)).toBe('cluster'); + }); +}); + +describe('getSharedRedisClient', () => { + afterEach(() => { + resetSharedRedisClientForTests(); + redisCtorSpy.mockClear(); + clusterCtorSpy.mockClear(); + }); + + it('builds a standalone client from REDIS_HOST/REDIS_PORT and executes commands', async () => { + const config = buildConfigService({ REDIS_HOST: 'localhost', REDIS_PORT: '6379' }); + + const client = getSharedRedisClient(config); + + expect(redisCtorSpy).toHaveBeenCalledWith( + expect.objectContaining({ host: 'localhost', port: 6379 }), + ); + expect(clusterCtorSpy).not.toHaveBeenCalled(); + + await client.set('standalone-key', 'standalone-value'); + await expect(client.get('standalone-key')).resolves.toBe('standalone-value'); + }); + + it('builds a Sentinel-backed client from REDIS_SENTINEL_HOSTS / REDIS_SENTINEL_NAME', async () => { + const config = buildConfigService({ + REDIS_SENTINEL_HOSTS: 'sentinel1:26379,sentinel2:26379,sentinel3:26379', + REDIS_SENTINEL_NAME: 'mymaster', + REDIS_SENTINEL_PASSWORD: 'sentinel-secret', + }); + + const client = getSharedRedisClient(config); + + expect(clusterCtorSpy).not.toHaveBeenCalled(); + expect(redisCtorSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'mymaster', + sentinelPassword: 'sentinel-secret', + sentinels: [ + { host: 'sentinel1', port: 26379 }, + { host: 'sentinel2', port: 26379 }, + { host: 'sentinel3', port: 26379 }, + ], + }), + ); + + // Failover to a promoted replica is handled internally by ioredis via + // the sentinel connector — from the application's perspective the + // client keeps serving the same command interface. + await client.set('sentinel-key', 'sentinel-value'); + await expect(client.get('sentinel-key')).resolves.toBe('sentinel-value'); + }); + + it('defaults REDIS_SENTINEL_NAME to "mymaster" when unset', () => { + const config = buildConfigService({ REDIS_SENTINEL_HOSTS: 'sentinel1:26379' }); + + getSharedRedisClient(config); + + expect(redisCtorSpy).toHaveBeenCalledWith(expect.objectContaining({ name: 'mymaster' })); + }); + + it('builds a Cluster client from REDIS_CLUSTER_NODES and executes commands', async () => { + const config = buildConfigService({ + REDIS_CLUSTER_NODES: 'node1:7000,node2:7001,node3:7002', + }); + + const client = getSharedRedisClient(config); + + expect(clusterCtorSpy).toHaveBeenCalledWith( + [ + { host: 'node1', port: 7000 }, + { host: 'node2', port: 7001 }, + { host: 'node3', port: 7002 }, + ], + expect.objectContaining({ enableReadyCheck: true }), + ); + expect(redisCtorSpy).not.toHaveBeenCalled(); + + await client.set('cluster-key', 'cluster-value'); + await expect(client.get('cluster-key')).resolves.toBe('cluster-value'); + }); + + it('reuses the same singleton client across calls until reset', () => { + const config = buildConfigService({}); + + const first = getSharedRedisClient(config); + const second = getSharedRedisClient(config); + + expect(first).toBe(second); + expect(redisCtorSpy).toHaveBeenCalledTimes(1); + }); + + it('builds a fresh client after resetSharedRedisClientForTests', () => { + const config = buildConfigService({}); + + const first = getSharedRedisClient(config); + resetSharedRedisClientForTests(); + const second = getSharedRedisClient(config); + + expect(first).not.toBe(second); + expect(redisCtorSpy).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/config/cache.config.ts b/src/config/cache.config.ts index 6256586d..d71f2d63 100644 --- a/src/config/cache.config.ts +++ b/src/config/cache.config.ts @@ -1,5 +1,5 @@ import { ConfigService } from '@nestjs/config'; -import Redis, { RedisOptions } from 'ioredis'; +import Redis, { Cluster, ClusterNode, RedisOptions } from 'ioredis'; import { redisStore } from 'cache-manager-redis-store'; /** @@ -45,6 +45,49 @@ class EnvReader { } } +/** + * ===================================================== + * 🌐 DEPLOYMENT TOPOLOGY + * ===================================================== + */ + +export type RedisDeploymentMode = 'standalone' | 'sentinel' | 'cluster'; + +/** + * Parses a comma-separated `host:port,host:port` list (as used by + * `REDIS_SENTINEL_HOSTS` / `REDIS_CLUSTER_NODES`) into node descriptors. + */ +const parseHostPortList = (raw: string): { host: string; port: number }[] => + raw + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) + .map((entry) => { + const [host, portStr] = entry.split(':'); + const port = parseInt(portStr, 10); + return { host, port: Number.isNaN(port) ? 6379 : port }; + }); + +/** + * Determines which Redis topology to connect to based on env configuration. + * Cluster takes precedence over Sentinel, which takes precedence over a + * plain standalone connection, since both `*_NODES`/`*_HOSTS` vars are only + * ever set intentionally by the operator. + */ +export const getRedisDeploymentMode = (configService?: ConfigService): RedisDeploymentMode => { + const env = new EnvReader(configService); + + if (env.getString({ key: 'REDIS_CLUSTER_NODES', fallback: '' })) { + return 'cluster'; + } + + if (env.getString({ key: 'REDIS_SENTINEL_HOSTS', fallback: '' })) { + return 'sentinel'; + } + + return 'standalone'; +}; + /** * ===================================================== * 🧱 REDIS CLIENT SINGLETON MANAGER @@ -52,9 +95,14 @@ class EnvReader { */ class RedisClientManager { - private static instance: Redis | null = null; - - static get(config: ConfigService | undefined): Redis { + // `Cluster` only surfaces when REDIS_CLUSTER_NODES is configured. It is + // exposed to callers as `Redis` (see `getSharedRedisClient`) because + // `Cluster` implements the same command surface (get/set/multi/scan/eval/ + // zadd/...) that every current consumer relies on — see redis.module.ts + // for the rationale. + private static instance: Redis | Cluster | null = null; + + static get(config: ConfigService | undefined): Redis | Cluster { if (this.instance && this.instance.status !== 'end') { return this.instance; } @@ -76,38 +124,86 @@ class RedisClientManager { fallback: 10, }); - const options: RedisOptions = { - host: env.getString({ key: 'REDIS_HOST', fallback: 'localhost' }), - port: env.getNumber({ key: 'REDIS_PORT', fallback: 6379 }), - - maxRetriesPerRequest: env.getNumber({ - key: 'REDIS_MAX_RETRIES_PER_REQUEST', - fallback: 3, - }), - - enableReadyCheck: true, - lazyConnect: false, - enableAutoPipelining: true, - - connectTimeout: env.getNumber({ - key: 'REDIS_CONNECT_TIMEOUT_MS', - fallback: 10000, - }), + const retryStrategy = (attempt: number): number | null => { + if (attempt > retryAttempts) return null; + return Math.min(attempt * retryBase, retryMax); + }; - keepAlive: env.getNumber({ - key: 'REDIS_KEEPALIVE_MS', - fallback: 30000, - }), + const maxRetriesPerRequest = env.getNumber({ + key: 'REDIS_MAX_RETRIES_PER_REQUEST', + fallback: 3, + }); - retryStrategy: (attempt: number) => { - if (attempt > retryAttempts) return null; + const connectTimeout = env.getNumber({ + key: 'REDIS_CONNECT_TIMEOUT_MS', + fallback: 10000, + }); - const delay = Math.min(attempt * retryBase, retryMax); - return delay; - }, - }; + const keepAlive = env.getNumber({ + key: 'REDIS_KEEPALIVE_MS', + fallback: 30000, + }); - this.instance = new Redis(options); + const password = env.getString({ key: 'REDIS_PASSWORD', fallback: '' }) || undefined; + + const mode = getRedisDeploymentMode(config); + + if (mode === 'cluster') { + const nodes: ClusterNode[] = parseHostPortList( + env.getString({ key: 'REDIS_CLUSTER_NODES', fallback: '' }), + ); + + this.instance = new Redis.Cluster(nodes, { + enableReadyCheck: true, + clusterRetryStrategy: retryStrategy, + redisOptions: { + password, + maxRetriesPerRequest, + connectTimeout, + keepAlive, + }, + }); + } else if (mode === 'sentinel') { + const sentinels = parseHostPortList( + env.getString({ key: 'REDIS_SENTINEL_HOSTS', fallback: '' }), + ); + const sentinelName = env.getString({ key: 'REDIS_SENTINEL_NAME', fallback: 'mymaster' }); + const sentinelPassword = + env.getString({ key: 'REDIS_SENTINEL_PASSWORD', fallback: '' }) || undefined; + + const options: RedisOptions = { + sentinels, + name: sentinelName, + sentinelPassword, + password, + + maxRetriesPerRequest, + enableReadyCheck: true, + lazyConnect: false, + enableAutoPipelining: true, + connectTimeout, + keepAlive, + retryStrategy, + }; + + this.instance = new Redis(options); + } else { + const options: RedisOptions = { + host: env.getString({ key: 'REDIS_HOST', fallback: 'localhost' }), + port: env.getNumber({ key: 'REDIS_PORT', fallback: 6379 }), + password, + + maxRetriesPerRequest, + enableReadyCheck: true, + lazyConnect: false, + enableAutoPipelining: true, + connectTimeout, + keepAlive, + retryStrategy, + }; + + this.instance = new Redis(options); + } this.instance.on('error', (_err) => { // centralized safe error handling @@ -116,6 +212,11 @@ class RedisClientManager { return this.instance; } + + /** Test-only escape hatch: forces the next `get()` to build a fresh client. */ + static reset(): void { + this.instance = null; + } } /** @@ -124,8 +225,24 @@ class RedisClientManager { * ===================================================== */ +/** + * Returns the shared ioredis connection, lazily created on first use. + * + * Backed by a standalone connection, a Sentinel-monitored master, or a + * Cluster, depending on `REDIS_CLUSTER_NODES` / `REDIS_SENTINEL_HOSTS` (see + * `getRedisDeploymentMode`). The declared return type stays `Redis` for + * source compatibility with existing call sites — `Cluster` implements the + * same command surface they use (get/set/multi/scan/eval/zadd/duplicate/...). + */ export const getSharedRedisClient = (configService?: ConfigService): Redis => { - return RedisClientManager.get(configService); + return RedisClientManager.get(configService) as Redis; +}; + +/** Test-only: clears the shared client singleton so the next call to + * `getSharedRedisClient` re-reads env/ConfigService and builds a fresh + * connection (standalone/sentinel/cluster). */ +export const resetSharedRedisClientForTests = (): void => { + RedisClientManager.reset(); }; export const getRedisOptions = (configService?: ConfigService): RedisOptions => { diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts index 304747e7..95feaf3e 100644 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -24,6 +24,15 @@ export const envValidationSchema = Joi.object({ // Redis Configuration REDIS_HOST: Joi.string().required(), REDIS_PORT: Joi.number().required(), + REDIS_PASSWORD: Joi.string().optional(), + + // Redis Sentinel (optional — HA failover; see RedisModule) + REDIS_SENTINEL_HOSTS: Joi.string().optional(), + REDIS_SENTINEL_NAME: Joi.string().default('mymaster'), + REDIS_SENTINEL_PASSWORD: Joi.string().optional(), + + // Redis Cluster (optional — sharding; takes precedence over Sentinel) + REDIS_CLUSTER_NODES: Joi.string().optional(), // JWT Configuration // Either JWT_SECRET (HS256) or JWT_PRIVATE_KEY + JWT_PUBLIC_KEY (RS256) must be configured diff --git a/src/media/validation/upload-progress.service.spec.ts b/src/media/validation/upload-progress.service.spec.ts new file mode 100644 index 00000000..7c743274 --- /dev/null +++ b/src/media/validation/upload-progress.service.spec.ts @@ -0,0 +1,112 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { UploadProgressService } from './upload-progress.service'; +import { REDIS_CLIENT } from '../../common/redis/redis.constants'; +import { createMockRedisClient } from '../../../test/utils/mock-factories'; + +describe('UploadProgressService', () => { + let service: UploadProgressService; + let redis: ReturnType; + + beforeEach(async () => { + redis = createMockRedisClient(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [UploadProgressService, { provide: REDIS_CLIENT, useValue: redis }], + }).compile(); + + service = module.get(UploadProgressService); + }); + + it('initializes upload progress and persists it via the shared Redis client', async () => { + (redis.setex as jest.Mock).mockResolvedValue('OK'); + + const progress = await service.initializeUpload('upload-1', 'file.mp4', 1024); + + expect(progress).toMatchObject({ + uploadId: 'upload-1', + status: 'pending', + progress: 0, + fileName: 'file.mp4', + fileSize: 1024, + }); + expect(redis.setex).toHaveBeenCalledWith( + expect.stringContaining('upload-1'), + expect.any(Number), + expect.any(String), + ); + }); + + it('returns null when progress is not found', async () => { + (redis.get as jest.Mock).mockResolvedValue(null); + + const progress = await service.getProgress('missing'); + + expect(progress).toBeNull(); + }); + + it('marks an upload completed once progress reaches 100', async () => { + const stored = { + uploadId: 'upload-2', + status: 'uploading', + progress: 50, + fileName: 'file.mp4', + fileSize: 2048, + bytesProcessed: 1024, + stage: 'uploading', + message: 'uploading', + startedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + (redis.get as jest.Mock).mockResolvedValue(JSON.stringify(stored)); + (redis.setex as jest.Mock).mockResolvedValue('OK'); + + const updated = await service.updateProgress('upload-2', { progress: 100 }); + + expect(updated.status).toBe('completed'); + expect(updated.completedAt).toBeDefined(); + }); + + it('deletes upload progress via the shared Redis client', async () => { + (redis.del as jest.Mock).mockResolvedValue(1); + + await service.deleteProgress('upload-3'); + + expect(redis.del).toHaveBeenCalledWith(expect.stringContaining('upload-3')); + }); + + it('lists only active (non-terminal) uploads', async () => { + const active = { + uploadId: 'upload-4', + status: 'processing', + progress: 40, + fileName: 'a.mp4', + fileSize: 10, + bytesProcessed: 4, + stage: 'processing', + message: '', + startedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + const completed = { ...active, uploadId: 'upload-5', status: 'completed' }; + + (redis.keys as jest.Mock).mockResolvedValue(['upload:upload-4', 'upload:upload-5']); + (redis.mget as jest.Mock).mockResolvedValue([ + JSON.stringify(active), + JSON.stringify(completed), + ]); + + const uploads = await service.listActiveUploads(); + + expect(uploads).toHaveLength(1); + expect(uploads[0].uploadId).toBe('upload-4'); + }); + + it('returns zeroed statistics when no uploads exist', async () => { + (redis.keys as jest.Mock).mockResolvedValue([]); + + const stats = await service.getStatistics(); + + expect(stats.total).toBe(0); + expect(stats.completed).toBe(0); + }); +}); diff --git a/src/media/validation/upload-progress.service.ts b/src/media/validation/upload-progress.service.ts index 81caccb0..4e7ae710 100644 --- a/src/media/validation/upload-progress.service.ts +++ b/src/media/validation/upload-progress.service.ts @@ -1,5 +1,6 @@ -import { Injectable, Logger, OnModuleDestroy, Inject } from '@nestjs/common'; +import { Inject, Injectable, Logger, OnModuleDestroy } from '@nestjs/common'; import Redis from 'ioredis'; +import { REDIS_CLIENT } from '../../common/redis/redis.constants'; import { UPLOAD_PROGRESS_CONFIG } from './file-validation.constants'; export interface IUploadProgress { @@ -41,22 +42,16 @@ export interface IProgressUpdate { /** * Provides upload Progress operations. + * + * Issue #837 — uses the shared `REDIS_CLIENT` connection + * (standalone/Sentinel/Cluster, see `RedisModule`) instead of opening its + * own connection to `REDIS_URL`. */ @Injectable() export class UploadProgressService implements OnModuleDestroy { private readonly logger = new Logger(UploadProgressService.name); - private readonly redis: Redis; - constructor(@Inject('REDIS_CLIENT') redisClient?: Redis) { - const redisUrl = process.env.REDIS_URL; - if (!redisClient && !redisUrl) { - this.logger.warn('REDIS_URL is not set, initializing default Redis client'); - } - this.redis = redisClient || new Redis(redisUrl || 'redis://localhost:6379'); - this.redis.on('error', (err) => { - this.logger.error('Redis connection error in UploadProgressService:', err); - }); - } + constructor(@Inject(REDIS_CLIENT) private readonly redis: Redis) {} async onModuleDestroy() { if (this.redis) { diff --git a/src/orchestration/locks/distributed-lock.service.spec.ts b/src/orchestration/locks/distributed-lock.service.spec.ts index 13ae6b15..2ccee547 100644 --- a/src/orchestration/locks/distributed-lock.service.spec.ts +++ b/src/orchestration/locks/distributed-lock.service.spec.ts @@ -1,16 +1,14 @@ -jest.mock('ioredis', () => { - return jest.fn(); -}); - -import Redis from 'ioredis'; +import { Test, TestingModule } from '@nestjs/testing'; import { DistributedLockService } from './distributed-lock.service'; +import { REDIS_CLIENT } from '../../common/redis/redis.constants'; describe('DistributedLockService', () => { + let service: DistributedLockService; let store: Map; let setSpy: jest.Mock; let evalSpy: jest.Mock; - beforeEach(() => { + beforeEach(async () => { store = new Map(); setSpy = jest.fn( @@ -31,21 +29,23 @@ describe('DistributedLockService', () => { return 0; }); - (Redis as unknown as jest.Mock).mockImplementation(() => ({ - on: jest.fn(), + const mockRedis = { set: setSpy, del: jest.fn(async (key: string) => { store.delete(key); return 1; }), eval: evalSpy, - })); + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [DistributedLockService, { provide: REDIS_CLIENT, useValue: mockRedis }], + }).compile(); + + service = module.get(DistributedLockService); }); it('acquires the lock using a single SET key value NX PX command', async () => { - const redisMock = new Redis() as unknown as Redis; - const service = new DistributedLockService(redisMock); - const token = await service.acquireLock('lock:test', 5000); expect(token).not.toBeNull(); @@ -54,9 +54,6 @@ describe('DistributedLockService', () => { }); it('returns null when the lock is already held', async () => { - const redisMock = new Redis() as unknown as Redis; - const service = new DistributedLockService(redisMock); - const first = await service.acquireLock('lock:test', 5000); const second = await service.acquireLock('lock:test', 5000); @@ -65,9 +62,6 @@ describe('DistributedLockService', () => { }); it('allows exactly one caller to acquire the lock under 100 concurrent attempts', async () => { - const redisMock = new Redis() as unknown as Redis; - const service = new DistributedLockService(redisMock); - const results = await Promise.all( Array.from({ length: 100 }, () => service.acquireLock('lock:contended', 5000)), ); @@ -77,8 +71,6 @@ describe('DistributedLockService', () => { }); it('releaseLock only removes the lock if the token matches', async () => { - const redisMock = new Redis() as unknown as Redis; - const service = new DistributedLockService(redisMock); const token = await service.acquireLock('lock:test', 5000); await service.releaseLock('lock:test', 'wrong-token'); @@ -89,9 +81,6 @@ describe('DistributedLockService', () => { }); it('withLock releases the lock even if fn throws', async () => { - const redisMock = new Redis() as unknown as Redis; - const service = new DistributedLockService(redisMock); - await expect( service.withLock('lock:test', 5000, async () => { throw new Error('boom'); @@ -104,9 +93,6 @@ describe('DistributedLockService', () => { }); it('withLock returns the value produced by fn and releases the lock', async () => { - const redisMock = new Redis() as unknown as Redis; - const service = new DistributedLockService(redisMock); - const result = await service.withLock('lock:test', 5000, async () => 'done'); expect(result).toBe('done'); @@ -114,8 +100,6 @@ describe('DistributedLockService', () => { }); it('withLock throws if the lock cannot be acquired', async () => { - const redisMock = new Redis() as unknown as Redis; - const service = new DistributedLockService(redisMock); await service.acquireLock('lock:test', 5000); await expect(service.withLock('lock:test', 5000, async () => 'unreachable')).rejects.toThrow( diff --git a/src/orchestration/locks/distributed-lock.service.ts b/src/orchestration/locks/distributed-lock.service.ts index 5112be31..17151197 100644 --- a/src/orchestration/locks/distributed-lock.service.ts +++ b/src/orchestration/locks/distributed-lock.service.ts @@ -1,6 +1,7 @@ -import { Injectable, Inject, OnModuleDestroy, Logger } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import Redis from 'ioredis'; import { randomUUID } from 'crypto'; +import { REDIS_CLIENT } from '../../common/redis/redis.constants'; const RELEASE_SCRIPT = ` if redis.call('GET', KEYS[1]) == ARGV[1] then @@ -9,21 +10,16 @@ const RELEASE_SCRIPT = ` return 0 `; -export const DISTRIBUTED_LOCK_REDIS = 'DISTRIBUTED_LOCK_REDIS'; - /** * Provides distributed Lock operations. + * + * Issue #837 — uses the shared `REDIS_CLIENT` connection + * (standalone/Sentinel/Cluster, see `RedisModule`) instead of opening its + * own connection to `REDIS_URL`. */ @Injectable() -export class DistributedLockService implements OnModuleDestroy { - private readonly logger = new Logger(DistributedLockService.name); - - constructor(@Inject(DISTRIBUTED_LOCK_REDIS) private readonly redis: Redis) {} - - onModuleDestroy() { - this.redis.quit(); - this.logger.log('Redis connection closed'); - } +export class DistributedLockService { + constructor(@Inject(REDIS_CLIENT) private readonly redis: Redis) {} /** * Atomically acquires a lock via a single SET key value NX PX command, @@ -58,7 +54,6 @@ export class DistributedLockService implements OnModuleDestroy { if (!token) { throw new Error(`Could not acquire lock: ${key}`); } - try { return await fn(); } finally { diff --git a/src/orchestration/orchestration.module.ts b/src/orchestration/orchestration.module.ts index 2f0623a7..05f16a2d 100644 --- a/src/orchestration/orchestration.module.ts +++ b/src/orchestration/orchestration.module.ts @@ -1,19 +1,11 @@ import { Global, Module } from '@nestjs/common'; -import { ConfigModule, ConfigService } from '@nestjs/config'; -import { DistributedLockService, DISTRIBUTED_LOCK_REDIS } from './locks/distributed-lock.service'; -import { getSharedRedisClient } from '../config/cache.config'; +import { DistributedLockService } from './locks/distributed-lock.service'; +import { RedisModule } from '../common/redis/redis.module'; @Global() @Module({ - imports: [ConfigModule], - providers: [ - { - provide: DISTRIBUTED_LOCK_REDIS, - inject: [ConfigService], - useFactory: (configService: ConfigService) => getSharedRedisClient(configService), - }, - DistributedLockService, - ], + imports: [RedisModule.forRoot()], + providers: [DistributedLockService], exports: [DistributedLockService], }) export class OrchestrationModule {} diff --git a/src/security/security.module.ts b/src/security/security.module.ts index d5801b7f..7d99d560 100644 --- a/src/security/security.module.ts +++ b/src/security/security.module.ts @@ -6,10 +6,15 @@ import { ThreatDetectionService } from './threats/threat-detection.service'; import { ComplianceService } from './compliance/compliance.service'; import { AuditLoggingService } from './audit/audit-logging.service'; import { SecretsModule } from './secrets/secrets.module'; +import { SecurityEventLogger } from './audit/security-event-logger'; +import { RedisModule } from '../common/redis/redis.module'; +import { REDIS_CLIENT } from '../common/redis/redis.constants'; +import { THREAT_REDIS_CLIENT } from './threats/threat-detection.constants'; import { RequestSigningService } from './request-signing.service'; import { ServiceAuthService } from './service-auth.service'; import { ZeroTrustGuard } from './zero-trust.guard'; import { MonitoringModule } from '../monitoring/monitoring.module'; +import { StructuredLoggerService } from '../observability/logging/structured-logger.service'; /** * SecurityModule wires the zero-trust architecture: @@ -19,15 +24,26 @@ import { MonitoringModule } from '../monitoring/monitoring.module'; * - EncryptionService: AES-256-GCM encryption in transit and at rest * - AuditLoggingService: structured audit trail for all security events * - ThreatDetectionService: IP-level abuse detection + * + * Issue #798: wires the shared Redis client behind `THREAT_REDIS_CLIENT` + * so ThreatDetectionService uses a distributed store instead of an + * in-process Map. The token keeps the dependency mockable in unit tests. + * + * Issue #837: `THREAT_REDIS_CLIENT` is now aliased to the shared + * `REDIS_CLIENT` connection (standalone/Sentinel/Cluster, see + * `RedisModule`) instead of opening its own connection. */ @Module({ - imports: [ScheduleModule.forRoot(), SecretsModule, MonitoringModule], + imports: [ScheduleModule.forRoot(), SecretsModule, MonitoringModule, RedisModule.forRoot()], providers: [ SecurityService, EncryptionService, + { provide: THREAT_REDIS_CLIENT, useExisting: REDIS_CLIENT }, ThreatDetectionService, ComplianceService, AuditLoggingService, + StructuredLoggerService, + SecurityEventLogger, RequestSigningService, ServiceAuthService, ZeroTrustGuard, @@ -36,6 +52,8 @@ import { MonitoringModule } from '../monitoring/monitoring.module'; SecurityService, EncryptionService, SecretsModule, + THREAT_REDIS_CLIENT, + SecurityEventLogger, RequestSigningService, ServiceAuthService, ZeroTrustGuard, diff --git a/src/session/session.module.spec.ts b/src/session/session.module.spec.ts new file mode 100644 index 00000000..2b940b7f --- /dev/null +++ b/src/session/session.module.spec.ts @@ -0,0 +1,59 @@ +/** + * Issue #837 — end-to-end integration test proving SessionModule connects + * through the shared, HA-aware Redis connection (standalone by default, + * Sentinel when REDIS_SENTINEL_HOSTS is configured) instead of opening its + * own connection. Backed by `ioredis-mock` so no real Redis/Sentinel + * deployment is required. + */ +jest.mock('ioredis', () => jest.requireActual('ioredis-mock')); + +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigModule } from '@nestjs/config'; +import { SessionModule } from './session.module'; +import { SessionService } from './session.service'; +import { SESSION_REDIS_CLIENT } from './session.constants'; +import { REDIS_CLIENT } from '../common/redis/redis.constants'; +import { resetSharedRedisClientForTests } from '../config/cache.config'; + +describe('SessionModule (Redis HA integration)', () => { + afterEach(() => { + resetSharedRedisClientForTests(); + }); + + it('aliases SESSION_REDIS_CLIENT to the shared REDIS_CLIENT connection', async () => { + const moduleRef: TestingModule = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ isGlobal: true, ignoreEnvFile: true }), SessionModule], + }).compile(); + + const sharedClient = moduleRef.get(REDIS_CLIENT); + const sessionClient = moduleRef.get(SESSION_REDIS_CLIENT); + expect(sessionClient).toBe(sharedClient); + + const sessionService = moduleRef.get(SessionService); + const sid = await sessionService.createSession('user-1', { role: 'student' }); + const session = await sessionService.getSession(sid); + expect(session?.userId).toBe('user-1'); + + await moduleRef.close(); + }); + + it('serves sessions through a Sentinel-configured shared client when REDIS_SENTINEL_HOSTS is set', async () => { + process.env.REDIS_SENTINEL_HOSTS = 'sentinel1:26379,sentinel2:26379'; + process.env.REDIS_SENTINEL_NAME = 'mymaster'; + + try { + const moduleRef: TestingModule = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ isGlobal: true, ignoreEnvFile: true }), SessionModule], + }).compile(); + + const sessionService = moduleRef.get(SessionService); + const sid = await sessionService.createSession('user-2'); + expect(await sessionService.getSession(sid)).not.toBeNull(); + + await moduleRef.close(); + } finally { + delete process.env.REDIS_SENTINEL_HOSTS; + delete process.env.REDIS_SENTINEL_NAME; + } + }); +}); diff --git a/src/session/session.module.ts b/src/session/session.module.ts index be9ad268..530345c3 100644 --- a/src/session/session.module.ts +++ b/src/session/session.module.ts @@ -1,23 +1,22 @@ import { Global, Module } from '@nestjs/common'; -import { ConfigModule, ConfigService } from '@nestjs/config'; -import { getSharedRedisClient } from '../config/cache.config'; +import { RedisModule } from '../common/redis/redis.module'; +import { REDIS_CLIENT } from '../common/redis/redis.constants'; import { SESSION_REDIS_CLIENT } from './session.constants'; import { SessionService } from './session.service'; import { SessionCleanupTask } from './tasks/session-cleanup.task'; /** * Registers the session module. + * + * Issue #837 — `SESSION_REDIS_CLIENT` is aliased to the shared + * `REDIS_CLIENT` connection (standalone/Sentinel/Cluster, see + * `RedisModule`) instead of opening its own connection. */ @Global() @Module({ - imports: [ConfigModule], + imports: [RedisModule.forRoot()], providers: [ - { - provide: SESSION_REDIS_CLIENT, - inject: [ConfigService], - useFactory: (configService: ConfigService): ReturnType => - getSharedRedisClient(configService), - }, + { provide: SESSION_REDIS_CLIENT, useExisting: REDIS_CLIENT }, SessionService, SessionCleanupTask, ], diff --git a/test/utils/mock-factories.js b/test/utils/mock-factories.js index 32692b9b..f8207ce0 100644 --- a/test/utils/mock-factories.js +++ b/test/utils/mock-factories.js @@ -96,6 +96,7 @@ function createMockRedisClient() { return { get: jest.fn(), set: jest.fn(), + setex: jest.fn(), getex: jest.fn(), getdel: jest.fn(), append: jest.fn(), diff --git a/test/utils/mock-factories.ts b/test/utils/mock-factories.ts index f3642d7d..d3c1f798 100644 --- a/test/utils/mock-factories.ts +++ b/test/utils/mock-factories.ts @@ -175,6 +175,7 @@ export function createMockRedisClient(): jest.Mocked { // ─── String Commands ──────────────────────────────────────────────────── get: jest.fn(), set: jest.fn(), + setex: jest.fn(), getex: jest.fn(), getdel: jest.fn(), append: jest.fn(),