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 2b334462..171b465e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -132,6 +132,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", @@ -155,6 +156,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", @@ -1264,7 +1266,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -2132,7 +2133,6 @@ "resolved": "https://registry.npmjs.org/@elastic/elasticsearch/-/elasticsearch-9.4.2.tgz", "integrity": "sha512-H9myMlLUeotkZhZ4ppinoMGDFxmW3lY8/s+4TIk1vFHyCvWU1Ej4T7azX5buCzemyFApgN0ywnEuvOtpel2VZg==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@elastic/transport": "^9.3.5", "tslib": "^2.4.0" @@ -2371,7 +2371,6 @@ "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" @@ -2385,7 +2384,6 @@ "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.1.tgz", "integrity": "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==", "license": "Apache-2.0", - "peer": true, "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", @@ -3163,6 +3161,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", @@ -4223,7 +4228,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-4.0.1.tgz", "integrity": "sha512-68pFJgu+/AZbWkGu65Z3r55bTsCPlgyKaV4BSG8yUAD72q1PPuyVRgUwFv6BxdnibTUHlyxm06FmYWNC+bjN7A==", "license": "MIT", - "peer": true, "peerDependencies": { "@nestjs/common": "^10.0.0 || ^11.0.0", "axios": "^1.3.1", @@ -4464,7 +4468,6 @@ "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4525,7 +4528,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.27.tgz", "integrity": "sha512-kEGSzqM2lWr4whh4Ubflw+oPZSEzxvRMu9WL+LveZploJWTjec5bBlCiRVlVzTPg2kIwBiLwWSvCCW7Wnin1gg==", "license": "MIT", - "peer": true, "dependencies": { "file-type": "21.3.4", "iterare": "1.2.1", @@ -4624,7 +4626,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.27.tgz", "integrity": "sha512-K6DX7hcqmZdeXkv7tsPakKBRCgqL19a4mtbX4FluY0hWtFdtPKp6lbe+lb8gWPfvLdbOWr/CPScn7BSjBX+Ecg==", "license": "MIT", - "peer": true, "dependencies": { "fast-safe-stringify": "2.1.1", "iterare": "1.2.1", @@ -4688,7 +4689,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/graphql/-/graphql-13.4.2.tgz", "integrity": "sha512-MIaMIaV9o3Tj2LsoGGwhISTZVXEIfDK8rDXplE3tSYULj6cXSY1dofOSLMF/aY+BZLwlrN4BUUowgu8qNdDZFg==", "license": "MIT", - "peer": true, "dependencies": { "@graphql-tools/merge": "9.1.9", "@graphql-tools/schema": "10.0.33", @@ -4805,7 +4805,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.27.tgz", "integrity": "sha512-0ZFhz6H6EdGh4xQVbUNwjoAwBuz73P7FvUAl67h9CTdMqQlJDaQYJApBv8pKfVZ1fGjMCbl0m9DcC6pXaZPWSQ==", "license": "MIT", - "peer": true, "dependencies": { "cors": "2.8.6", "express": "5.2.1", @@ -4827,7 +4826,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-11.1.27.tgz", "integrity": "sha512-xgpLzaIDGOCC6xOAtHnRAz8sqieFgGxxu3MN5ID026Jt6oeL3efp29N5QHhPr7UlqBfy/Jd02uj0POkZq6Au3Q==", "license": "MIT", - "peer": true, "dependencies": { "socket.io": "4.8.3", "tslib": "2.8.1" @@ -4960,6 +4958,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", @@ -4997,6 +5013,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">= 14.18.0" }, @@ -5162,7 +5179,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-11.0.1.tgz", "integrity": "sha512-8rw/nKT0S+L+MkzgE9F2/mox7mAgsPlwfzmW9gsESN1lmQtIrVEfiiBwC2O8+guS1jBfQehJIdcdUj2OAp4VUQ==", "license": "MIT", - "peer": true, "peerDependencies": { "@nestjs/common": "^10.0.0 || ^11.0.0", "@nestjs/core": "^10.0.0 || ^11.0.0", @@ -5176,7 +5192,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-11.1.27.tgz", "integrity": "sha512-X3OgJt9KgYTvt9D7sNz9SOj3A1daAHy7DZrYhM1pky8Fh+erlKQH5IQ/tKm+GaJKA5M0srBUr1CMqjak/qNxOw==", "license": "MIT", - "peer": true, "dependencies": { "iterare": "1.2.1", "object-hash": "3.0.0", @@ -5390,7 +5405,6 @@ "integrity": "sha512-YV1HYDGsm2rnR0vrLKidtrG6jYX5yqiIjeur1j8++dKGqhhsJ6cjMs0RfQRSTUH7IjgDemA59/znQ8nRrE0D9g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "file-type": "21.3.4", "iterare": "1.2.1", @@ -5610,7 +5624,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -6568,7 +6581,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.1.tgz", "integrity": "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -11230,6 +11242,7 @@ "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" }, @@ -11267,6 +11280,7 @@ "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" } @@ -11276,6 +11290,7 @@ "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" }, @@ -11288,6 +11303,7 @@ "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" }, @@ -11300,6 +11316,7 @@ "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" }, @@ -11973,6 +11990,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", @@ -12015,8 +12042,7 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/jsonwebtoken": { "version": "9.0.10", @@ -12106,7 +12132,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -12450,7 +12475,6 @@ "integrity": "sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.60.0", "@typescript-eslint/types": "8.60.0", @@ -12934,7 +12958,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -12957,6 +12980,7 @@ "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10.13.0" }, @@ -13004,7 +13028,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -13262,7 +13285,6 @@ "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", "license": "MIT", - "peer": true, "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", @@ -13935,7 +13957,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -14028,7 +14049,6 @@ "resolved": "https://registry.npmjs.org/bull/-/bull-4.16.5.tgz", "integrity": "sha512-lDsx2BzkKe7gkCYiT5Acj02DpTwDznl/VNN7Psn7M3USPG7Vs/BaClZJJTAG+ufAR9++N1/NiUTdaFBWDIl5TQ==", "license": "MIT", - "peer": true, "dependencies": { "cron-parser": "^4.9.0", "get-port": "^5.1.1", @@ -14077,7 +14097,6 @@ "resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-7.2.8.tgz", "integrity": "sha512-0HDaDLBBY/maa/LmUVAr70XUOwsiQD+jyzCBjmUErYZUKdMS9dT59PqW59PpVqfGM7ve6H0J6307JTpkCYefHQ==", "license": "MIT", - "peer": true, "dependencies": { "@cacheable/utils": "^2.3.3", "keyv": "^5.5.5" @@ -14124,7 +14143,6 @@ "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", "license": "MIT", - "peer": true, "dependencies": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", @@ -14337,7 +14355,6 @@ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", @@ -14399,15 +14416,13 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/class-validator": { "version": "0.14.4", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.4.tgz", "integrity": "sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==", "license": "MIT", - "peer": true, "dependencies": { "@types/validator": "^13.15.3", "libphonenumber-js": "^1.11.1", @@ -15047,7 +15062,6 @@ "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -15976,7 +15990,8 @@ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/es-object-atoms": { "version": "1.1.2", @@ -16072,7 +16087,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -16129,7 +16143,6 @@ "integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -16579,7 +16592,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -16683,7 +16695,6 @@ "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz", "integrity": "sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==", "license": "MIT", - "peer": true, "dependencies": { "cookie": "~0.7.2", "cookie-signature": "~1.0.7", @@ -16962,6 +16973,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", @@ -17921,7 +17971,6 @@ "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.0.tgz", "integrity": "sha512-BBvQ/406p+4CZbTpCbVPSxfzrZrbnuWSP1ELYgyS6B+hNeKzgrdB4JczCa5VZUBQrDa9hUngm0KnexY6pJRN5Q==", "license": "MIT", - "peer": true, "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } @@ -18503,7 +18552,6 @@ "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.0.tgz", "integrity": "sha512-EZBErytyVovD8f6pDfG3Kb37N6Y3lmDA9NNj+4+IP13CzzHGeX+OyeRM2Um13khRzoBSzzL+5lVnCX8V2RLeMg==", "license": "MIT", - "peer": true, "dependencies": { "@ioredis/commands": "1.10.0", "cluster-key-slot": "1.1.1", @@ -18521,6 +18569,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", @@ -18906,7 +18975,6 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -20468,7 +20536,6 @@ "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", "license": "MIT", - "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -21623,6 +21690,7 @@ "integrity": "sha512-DoeAZz8Q1C1znwsUzej1fdoi4jCf7/+Em27ouLqfK/+3m8G+D7yDhUwrc3CNhjSzGUN1kn7Iv4sWmjflQHenpw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", @@ -21684,6 +21752,7 @@ "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", @@ -21699,6 +21768,7 @@ "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", @@ -22517,7 +22587,6 @@ "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", "license": "MIT", - "peer": true, "dependencies": { "passport-strategy": "1.x.x", "pause": "0.0.1", @@ -22729,7 +22798,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz", "integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.13.0", "pg-pool": "^3.14.0", @@ -23107,7 +23175,6 @@ "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -23503,8 +23570,7 @@ "resolved": "https://registry.npmjs.org/quickjs-wasi/-/quickjs-wasi-2.2.0.tgz", "integrity": "sha512-zQxXmQMrEoD3S+jQdYsloq4qAuaxKFHZj6hHqOYGwB2iQZH+q9e/lf5zQPXCKOk0WJuAjzRFbO4KwHIp2D05Iw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/random-bytes": { "version": "1.0.0", @@ -23673,6 +23739,16 @@ "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", @@ -24075,7 +24151,6 @@ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -25792,7 +25867,6 @@ "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-24.0.0.tgz", "integrity": "sha512-2OAOg/Ob5yx9Et7ZX4CvTCc0UFoZHwLEJ+dpDPSUi5TgwwlTlX47w+iFRrEwzUZwYACjq83cgjS/Da50Ga37uw==", "license": "MIT", - "peer": true, "dependencies": { "@ts-morph/common": "~0.25.0", "code-block-writer": "^13.0.3" @@ -25804,7 +25878,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -26052,7 +26125,6 @@ "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.30.tgz", "integrity": "sha512-8T35PzjefOdqc2ZR9mwLQj0pUGp6lQhMbK2EvVMwJVJWlaoHm0v/Q6dThNOZkFchD+0yMg8gwjKM28ePiLSXSQ==", "license": "MIT", - "peer": true, "dependencies": { "@sqltools/formatter": "^1.2.5", "ansis": "^4.2.0", @@ -26232,7 +26304,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -26573,6 +26644,7 @@ "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -26587,6 +26659,7 @@ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "engines": { "node": ">=4.0" } @@ -26597,6 +26670,7 @@ "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", @@ -26863,7 +26937,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, diff --git a/package.json b/package.json index 1e124797..af4337d6 100644 --- a/package.json +++ b/package.json @@ -194,6 +194,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", @@ -217,6 +218,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 bd1ffac6..d728ca54 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'; @@ -20,6 +22,11 @@ import { ComputationCacheService } from './computation-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({ @@ -29,25 +36,21 @@ import { ComputationCacheService } from './computation-cache.service'; MonitoringModule, SearchModule, 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 489f5505..56bce590 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 aa56c653..0ee99fc2 100644 --- a/src/media/validation/upload-progress.service.ts +++ b/src/media/validation/upload-progress.service.ts @@ -1,5 +1,6 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Inject, Injectable, Logger } 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,19 +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 { private readonly logger = new Logger(UploadProgressService.name); - private readonly redis: Redis; - constructor() { - // Initialize Redis client - this.redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379'); - this.redis.on('error', () => { - // Prevent unhandled error events during Redis outages - }); - } + constructor(@Inject(REDIS_CLIENT) private readonly redis: Redis) {} /** * Initialize upload progress tracking 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 47d73224..cb5061a4 100644 --- a/src/security/security.module.ts +++ b/src/security/security.module.ts @@ -1,6 +1,5 @@ import { Module } from '@nestjs/common'; import { ScheduleModule } from '@nestjs/schedule'; -import { ConfigService } from '@nestjs/config'; import { SecurityService } from './security.service'; import { EncryptionService } from './encryption/encryption.service'; import { ThreatDetectionService } from './threats/threat-detection.service'; @@ -9,7 +8,8 @@ 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 { getSharedRedisClient } from '../config/cache.config'; +import { RedisModule } from '../common/redis/redis.module'; +import { REDIS_CLIENT } from '../common/redis/redis.constants'; import { MonitoringModule } from '../monitoring/monitoring.module'; import { StructuredLoggerService } from '../observability/logging/structured-logger.service'; @@ -19,17 +19,17 @@ import { StructuredLoggerService } from '../observability/logging/structured-log * 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, - useFactory: (configService: ConfigService) => getSharedRedisClient(configService), - inject: [ConfigService], - }, + { provide: THREAT_REDIS_CLIENT, useExisting: REDIS_CLIENT }, ThreatDetectionService, ComplianceService, AuditLoggingService, 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.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(),