From f023168f932f76b615051fd49ab0b6bb1ca4c0fb Mon Sep 17 00:00:00 2001 From: Md Azizul Hakim Date: Fri, 25 Oct 2024 13:57:41 +0600 Subject: [PATCH 1/5] redis and cache integration wip --- .env.docker | 11 ++ .env.example | 10 ++ docker-compose.yml | 13 +- package-lock.json | 182 ++++++++++++++++++++++++ package.json | 4 +- src/app.module.ts | 2 + src/modules/cache/cache.module.ts | 36 +++++ src/modules/cache/cache.service.spec.ts | 18 +++ src/modules/cache/cache.service.ts | 4 + src/modules/cache/types.d.ts | 1 + 10 files changed, 279 insertions(+), 2 deletions(-) create mode 100644 src/modules/cache/cache.module.ts create mode 100644 src/modules/cache/cache.service.spec.ts create mode 100644 src/modules/cache/cache.service.ts create mode 100644 src/modules/cache/types.d.ts diff --git a/.env.docker b/.env.docker index b1e6055..9fcf34b 100644 --- a/.env.docker +++ b/.env.docker @@ -15,6 +15,17 @@ DB_NAME=ims-nest DB_USERNAME=postgres DB_PASSWORD=postgres +# Redis Config +REDIS_HOST=redis +REDIS_PORT=6379 +REDIS_PASSWORD= +REDIS_USERNAME= +REDIS_DB=0 +REDIS_MAX_RETRIES=3 +REDIS_RETRY_DELAY=50 +REDIS_RETRY_DELAY_MAX=2000 +REDIS_CONNECT_TIMEOUT=10000 + # SMTP Config SMTP_HOST=smtp.mailtrap.io diff --git a/.env.example b/.env.example index 4da90e0..9bf3dab 100644 --- a/.env.example +++ b/.env.example @@ -26,6 +26,16 @@ DB_PASSWORD= DB_POOL_MIN=2 DB_POOL_MAX=10 +# Redis Config +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD= +REDIS_USERNAME= +REDIS_DB=0 +REDIS_MAX_RETRIES=3 +REDIS_RETRY_DELAY=50 +REDIS_RETRY_DELAY_MAX=2000 +REDIS_CONNECT_TIMEOUT=10000 # SMTP Config SMTP_HOST=smtp.mailtrap.io diff --git a/docker-compose.yml b/docker-compose.yml index d72d1de..1834914 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,6 +25,17 @@ services: POSTGRES_PASSWORD: postgres ports: - "5432:5432" + redis: + image: redis:7-alpine + container_name: ims-nest-redis + ports: + - "6379:6379" + volumes: + - redis_data:/data + command: redis-server --appendonly yes # For data persistence + restart: unless-stopped + volumes: - postgres_data: \ No newline at end of file + postgres_data: + redis_data: \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 271d889..d7277c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@mikro-orm/nestjs": "^6.0.2", "@mikro-orm/postgresql": "^6.3.13", "@mikro-orm/seeder": "^6.3.13", + "@nestjs-modules/ioredis": "^2.0.2", "@nestjs/axios": "^3.0.3", "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.2.3", @@ -32,6 +33,7 @@ "class-validator": "^0.14.1", "dotenv": "^16.4.5", "ims-nest-api-starter": "file:", + "ioredis": "^5.4.1", "nestjs-command": "^3.1.4", "passport": "^0.7.0", "passport-jwt": "^4.0.1", @@ -996,6 +998,12 @@ "deprecated": "Use @eslint/object-schema instead", "dev": true }, + "node_modules/@ioredis/commands": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz", + "integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==", + "license": "MIT" + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -1797,6 +1805,99 @@ "@mikro-orm/core": "^6.0.0" } }, + "node_modules/@nestjs-modules/ioredis": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@nestjs-modules/ioredis/-/ioredis-2.0.2.tgz", + "integrity": "sha512-8pzSvT8R3XP6p8ZzQmEN8OnY0yWrJ/elFhwQK+PID2zf1SLBkAZ18bDcx3SKQ2atledt0gd9kBeP5xT4MlyS7Q==", + "license": "MIT", + "optionalDependencies": { + "@nestjs/terminus": "10.2.0" + }, + "peerDependencies": { + "@nestjs/common": ">=6.7.0", + "@nestjs/core": ">=6.7.0", + "ioredis": ">=5.0.0" + } + }, + "node_modules/@nestjs-modules/ioredis/node_modules/@nestjs/terminus": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@nestjs/terminus/-/terminus-10.2.0.tgz", + "integrity": "sha512-zPs98xvJ4ogEimRQOz8eU90mb7z+W/kd/mL4peOgrJ/VqER+ibN2Cboj65uJZW3XuNhpOqaeYOJte86InJd44A==", + "license": "MIT", + "optional": true, + "dependencies": { + "boxen": "5.1.2", + "check-disk-space": "3.4.0" + }, + "peerDependencies": { + "@grpc/grpc-js": "*", + "@grpc/proto-loader": "*", + "@mikro-orm/core": "*", + "@mikro-orm/nestjs": "*", + "@nestjs/axios": "^1.0.0 || ^2.0.0 || ^3.0.0", + "@nestjs/common": "^9.0.0 || ^10.0.0", + "@nestjs/core": "^9.0.0 || ^10.0.0", + "@nestjs/microservices": "^9.0.0 || ^10.0.0", + "@nestjs/mongoose": "^9.0.0 || ^10.0.0", + "@nestjs/sequelize": "^9.0.0 || ^10.0.0", + "@nestjs/typeorm": "^9.0.0 || ^10.0.0", + "@prisma/client": "*", + "mongoose": "*", + "reflect-metadata": "0.1.x", + "rxjs": "7.x", + "sequelize": "*", + "typeorm": "*" + }, + "peerDependenciesMeta": { + "@grpc/grpc-js": { + "optional": true + }, + "@grpc/proto-loader": { + "optional": true + }, + "@mikro-orm/core": { + "optional": true + }, + "@mikro-orm/nestjs": { + "optional": true + }, + "@nestjs/axios": { + "optional": true + }, + "@nestjs/microservices": { + "optional": true + }, + "@nestjs/mongoose": { + "optional": true + }, + "@nestjs/sequelize": { + "optional": true + }, + "@nestjs/typeorm": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "mongoose": { + "optional": true + }, + "sequelize": { + "optional": true + }, + "typeorm": { + "optional": true + } + } + }, + "node_modules/@nestjs-modules/ioredis/node_modules/reflect-metadata": { + "version": "0.1.14", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.14.tgz", + "integrity": "sha512-ZhYeb6nRaXCfhnndflDK8qI6ZQ/YcWZCISRAWICW9XYqMUwjZM9Z0DveWX/ABN01oxSHwVxKQmxeYZSsm0jh5A==", + "license": "Apache-2.0", + "optional": true, + "peer": true + }, "node_modules/@nestjs/axios": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-3.0.3.tgz", @@ -4105,6 +4206,15 @@ "node": ">=0.8" } }, + "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", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -4462,6 +4572,15 @@ "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==" }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -5985,6 +6104,30 @@ "node": ">= 0.10" } }, + "node_modules/ioredis": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.4.1.tgz", + "integrity": "sha512-2YZsvl7jopIa1gaePkeMtd9rAcSjOOjPtpcLlOeusyO+XH2SK5ZcT+UCrElPP+WVIInh2TzeI4XW9ENaSLVVHA==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "^1.1.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -7230,6 +7373,12 @@ "resolved": "https://registry.npmjs.org/lodash.compact/-/lodash.compact-3.0.1.tgz", "integrity": "sha512-2ozeiPi+5eBXW1CLtzjk8XQFhQOEMwwfxblqeq6EGyTxZJ1bPATqilY0e6g2SLQpP4KuMeuioBhEnWz5Pr7ICQ==" }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, "node_modules/lodash.flattendeep": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz", @@ -7240,6 +7389,12 @@ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, "node_modules/lodash.isboolean": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", @@ -8500,6 +8655,27 @@ "node": ">= 10.13.0" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", @@ -9018,6 +9194,12 @@ "node": ">=8" } }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", diff --git a/package.json b/package.json index dda3a03..9eeb941 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@mikro-orm/nestjs": "^6.0.2", "@mikro-orm/postgresql": "^6.3.13", "@mikro-orm/seeder": "^6.3.13", + "@nestjs-modules/ioredis": "^2.0.2", "@nestjs/axios": "^3.0.3", "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.2.3", @@ -53,6 +54,7 @@ "class-validator": "^0.14.1", "dotenv": "^16.4.5", "ims-nest-api-starter": "file:", + "ioredis": "^5.4.1", "nestjs-command": "^3.1.4", "passport": "^0.7.0", "passport-jwt": "^4.0.1", @@ -116,4 +118,4 @@ "./src/config/mikro-orm.config.ts" ] } -} \ No newline at end of file +} diff --git a/src/app.module.ts b/src/app.module.ts index 967e4a5..00d7359 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -15,6 +15,7 @@ import { MiscModule } from './modules/misc/misc.module'; import { PermissionModule } from './modules/permission/permission.module'; import { RoleModule } from './modules/role/role.module'; import { UserModule } from './modules/user/user.module'; +import { CacheModule } from './modules/cache/cache.module'; @Module({ imports: [ @@ -46,6 +47,7 @@ import { UserModule } from './modules/user/user.module'; RoleModule, UserModule, AuthModule, + CacheModule, ], controllers: [AppController], providers: [ diff --git a/src/modules/cache/cache.module.ts b/src/modules/cache/cache.module.ts new file mode 100644 index 0000000..692bf9e --- /dev/null +++ b/src/modules/cache/cache.module.ts @@ -0,0 +1,36 @@ +import { RedisModule } from '@nestjs-modules/ioredis'; +import { Module } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { CacheService } from './cache.service'; + +@Module({ + imports: [ + RedisModule.forRootAsync({ + useFactory: (configService: ConfigService) => ({ + type: 'single', + options: { + host: configService.get('REDIS_HOST'), + port: Number(configService.get('REDIS_PORT')), + username: configService.get('REDIS_USERNAME'), + password: configService.get('REDIS_PASSWORD'), + db: Number(configService.get('REDIS_DB')), + maxRetriesPerRequest: Number( + configService.get('REDIS_MAX_RETRIES'), + ), + connectTimeout: Number( + configService.get('REDIS_CONNECT_TIMEOUT'), + ), + retryStrategy: (times) => + Math.min( + times * Number(configService.get('REDIS_RETRY_DELAY')), + Number(configService.get('REDIS_RETRY_DELAY_MAX')), + ), + }, + }), + inject: [ConfigService], + }), + ], + providers: [CacheService], + exports: [CacheService], +}) +export class CacheModule {} diff --git a/src/modules/cache/cache.service.spec.ts b/src/modules/cache/cache.service.spec.ts new file mode 100644 index 0000000..244bfd9 --- /dev/null +++ b/src/modules/cache/cache.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { CacheService } from './cache.service'; + +describe('CacheService', () => { + let service: CacheService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [CacheService], + }).compile(); + + service = module.get(CacheService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/src/modules/cache/cache.service.ts b/src/modules/cache/cache.service.ts new file mode 100644 index 0000000..86c0837 --- /dev/null +++ b/src/modules/cache/cache.service.ts @@ -0,0 +1,4 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class CacheService {} diff --git a/src/modules/cache/types.d.ts b/src/modules/cache/types.d.ts new file mode 100644 index 0000000..bbd2ba2 --- /dev/null +++ b/src/modules/cache/types.d.ts @@ -0,0 +1 @@ +// Type definitions go here From 87359dc7c372ecca44258b3d381df05037f5cd15 Mon Sep 17 00:00:00 2001 From: Md Azizul Hakim Date: Sat, 26 Oct 2024 03:36:53 +0600 Subject: [PATCH 2/5] cache implemented with redis --- .husky/pre-push | 2 +- package-lock.json | 80 ++++++++++++ package.json | 2 + src/app.module.ts | 4 +- src/mocks/redis.mock.ts | 77 +++++++++++ src/modules/auth/auth.module.ts | 2 - src/modules/cache/cache.module.ts | 3 +- src/modules/cache/cache.service.spec.ts | 167 +++++++++++++++++++++++- src/modules/cache/cache.service.ts | 66 +++++++++- src/modules/misc/misc.module.ts | 3 +- src/modules/user/user.module.ts | 3 +- src/modules/user/user.service.ts | 47 ++++++- 12 files changed, 439 insertions(+), 17 deletions(-) create mode 100644 src/mocks/redis.mock.ts diff --git a/.husky/pre-push b/.husky/pre-push index e37998f..7e69111 100644 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1 +1 @@ -npm run test +npm run test:cov diff --git a/package-lock.json b/package-lock.json index d7277c4..d4aac02 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,6 +48,7 @@ "@nestjs/testing": "^10.0.0", "@types/bcrypt": "^5.0.2", "@types/express": "^4.17.17", + "@types/ioredis-mock": "^8.2.5", "@types/jest": "^29.5.2", "@types/node": "^20.3.1", "@types/passport-jwt": "^4.0.1", @@ -60,6 +61,7 @@ "eslint-config-prettier": "^9.0.0", "eslint-plugin-prettier": "^5.0.0", "husky": "^9.1.6", + "ioredis-mock": "^8.9.0", "jest": "^29.5.0", "prettier": "^3.0.0", "source-map-support": "^0.5.21", @@ -998,6 +1000,13 @@ "deprecated": "Use @eslint/object-schema instead", "dev": true }, + "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.2.0", "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz", @@ -2708,6 +2717,17 @@ "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", "dev": true }, + "node_modules/@types/ioredis-mock": { + "version": "8.2.5", + "resolved": "https://registry.npmjs.org/@types/ioredis-mock/-/ioredis-mock-8.2.5.tgz", + "integrity": "sha512-cZyuwC9LGtg7s5G9/w6rpy3IOZ6F/hFR0pQlWYZESMo1xQUYbDpa6haqB4grTePjsGzcB/YLBFCjqRunK5wieg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "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", @@ -5286,6 +5306,35 @@ "bser": "2.1.1" } }, + "node_modules/fengari": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/fengari/-/fengari-0.1.4.tgz", + "integrity": "sha512-6ujqUuiIYmcgkGz8MGAdERU57EIluGGPSUgGPTsco657EHa+srq0S3/YUl/r9kx1+D+d4rGfYObd+m8K22gB1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "readline-sync": "^1.4.9", + "sprintf-js": "^1.1.1", + "tmp": "^0.0.33" + } + }, + "node_modules/fengari-interop": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/fengari-interop/-/fengari-interop-0.1.3.tgz", + "integrity": "sha512-EtZ+oTu3kEwVJnoymFPBVLIbQcCoy9uWCVnMA6h3M/RqHkUBsLYp29+RRHf9rKr6GwjubWREU1O7RretFIXjHw==", + "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/figlet": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/figlet/-/figlet-1.8.0.tgz", @@ -6128,6 +6177,27 @@ "url": "https://opencollective.com/ioredis" } }, + "node_modules/ioredis-mock": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/ioredis-mock/-/ioredis-mock-8.9.0.tgz", + "integrity": "sha512-yIglcCkI1lvhwJVoMsR51fotZVsPsSk07ecTCgRTRlicG0Vq3lke6aAaHklyjmRNRsdYAgswqC2A0bPtQK4LSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ioredis/as-callback": "^3.0.0", + "@ioredis/commands": "^1.2.0", + "fengari": "^0.1.4", + "fengari-interop": "^0.1.3", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12.22" + }, + "peerDependencies": { + "@types/ioredis-mock": "^8", + "ioredis": "^5" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -8644,6 +8714,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/rechoir": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", diff --git a/package.json b/package.json index 9eeb941..a4ff56a 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "@nestjs/testing": "^10.0.0", "@types/bcrypt": "^5.0.2", "@types/express": "^4.17.17", + "@types/ioredis-mock": "^8.2.5", "@types/jest": "^29.5.2", "@types/node": "^20.3.1", "@types/passport-jwt": "^4.0.1", @@ -81,6 +82,7 @@ "eslint-config-prettier": "^9.0.0", "eslint-plugin-prettier": "^5.0.0", "husky": "^9.1.6", + "ioredis-mock": "^8.9.0", "jest": "^29.5.0", "prettier": "^3.0.0", "source-map-support": "^0.5.21", diff --git a/src/app.module.ts b/src/app.module.ts index 00d7359..0ddcca6 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -10,12 +10,12 @@ import { XSecureInstallCommand } from './commands/xsecurity.command'; import mikroOrmConfig from './config/mikro-orm.config'; import { XSecurityMiddleware } from './middlewares/xsecurity.middleware'; import { AuthModule } from './modules/auth/auth.module'; +import { CacheModule } from './modules/cache/cache.module'; import { HealthModule } from './modules/health/health.module'; import { MiscModule } from './modules/misc/misc.module'; import { PermissionModule } from './modules/permission/permission.module'; import { RoleModule } from './modules/role/role.module'; import { UserModule } from './modules/user/user.module'; -import { CacheModule } from './modules/cache/cache.module'; @Module({ imports: [ @@ -43,11 +43,11 @@ import { CacheModule } from './modules/cache/cache.module'; CommandModule, HealthModule, MiscModule, + CacheModule, PermissionModule, RoleModule, UserModule, AuthModule, - CacheModule, ], controllers: [AppController], providers: [ diff --git a/src/mocks/redis.mock.ts b/src/mocks/redis.mock.ts new file mode 100644 index 0000000..e7000aa --- /dev/null +++ b/src/mocks/redis.mock.ts @@ -0,0 +1,77 @@ +import Redis from 'ioredis'; + +export class RedisMock implements Partial { + private store = new Map(); + + get = jest.fn(async (key: string): Promise => { + const data = this.store.get(key); + if (!data) return null; + + if (data.expireAt !== null && Date.now() > data.expireAt) { + this.store.delete(key); + return null; + } + return data.value; + }); + + set = jest.fn(async (key: string, value: string): Promise<'OK'> => { + this.store.set(key, { value, expireAt: null }); + return 'OK' as const; + }); + + setex = jest.fn( + async (key: string, ttl: number, value: string): Promise<'OK'> => { + const expireAt = Date.now() + ttl * 1000; + this.store.set(key, { value, expireAt }); + return 'OK' as const; + }, + ); + + del = jest.fn(async (...keys: (string | Buffer)[]): Promise => { + let count = 0; + for (const key of keys) { + if (this.store.delete(String(key))) { + count++; + } + } + return count; + }) as unknown as Redis['del']; + + exists = jest.fn(async (...keys: string[]): Promise => { + let count = 0; + for (const key of keys) { + const data = this.store.get(key); + if (!data) continue; + + if (data.expireAt !== null && Date.now() > data.expireAt) { + this.store.delete(key); + continue; + } + + count++; + } + return count; + }) as unknown as Redis['exists']; + + keys = jest.fn(async (pattern: string): Promise => { + const regex = new RegExp(`^${pattern.replace('*', '.*')}$`); + return Array.from(this.store.keys()).filter((key) => regex.test(key)); + }); + + ttl = jest.fn(async (key: string): Promise => { + const data = this.store.get(key); + if (!data || data.expireAt === null) return -1; + + const ttl = Math.max(0, Math.floor((data.expireAt - Date.now()) / 1000)); + return ttl > 0 ? ttl : -2; + }); + + quit = jest.fn(async (): Promise<'OK'> => { + return 'OK' as const; + }); + + flushall = jest.fn(async (): Promise<'OK'> => { + this.store.clear(); + return 'OK' as const; + }); +} diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index e35d047..77e8d95 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -1,7 +1,6 @@ import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { JwtModule } from '@nestjs/jwt'; -import { MiscModule } from '../misc/misc.module'; import { UserModule } from '../user/user.module'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; @@ -19,7 +18,6 @@ import { JwtStrategy } from './strategies/jwt.strategy'; }), inject: [ConfigService], }), - MiscModule, UserModule, ], providers: [ConfigService, AuthService, JwtStrategy], diff --git a/src/modules/cache/cache.module.ts b/src/modules/cache/cache.module.ts index 692bf9e..a0fda72 100644 --- a/src/modules/cache/cache.module.ts +++ b/src/modules/cache/cache.module.ts @@ -1,8 +1,9 @@ import { RedisModule } from '@nestjs-modules/ioredis'; -import { Module } from '@nestjs/common'; +import { Global, Module } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { CacheService } from './cache.service'; +@Global() @Module({ imports: [ RedisModule.forRootAsync({ diff --git a/src/modules/cache/cache.service.spec.ts b/src/modules/cache/cache.service.spec.ts index 244bfd9..e56d87a 100644 --- a/src/modules/cache/cache.service.spec.ts +++ b/src/modules/cache/cache.service.spec.ts @@ -1,18 +1,179 @@ import { Test, TestingModule } from '@nestjs/testing'; +import Redis from 'ioredis'; +import RedisMock from 'ioredis-mock'; import { CacheService } from './cache.service'; +export const IORedisToken = 'default_IORedisModuleConnectionToken'; + describe('CacheService', () => { let service: CacheService; + let redisMock: Redis; + const DEFAULT_TTL = 3600; beforeEach(async () => { + redisMock = new RedisMock(); + redisMock.exists = jest.fn(); + const module: TestingModule = await Test.createTestingModule({ - providers: [CacheService], + providers: [ + { + provide: IORedisToken, + useValue: redisMock, + }, + CacheService, + ], }).compile(); service = module.get(CacheService); }); - it('should be defined', () => { - expect(service).toBeDefined(); + describe('generateKey', () => { + it('should generate key with prefix', () => { + const key = 'test-key'; + expect(service.generateKey(key)).toBe(`cache:${key}`); + }); + }); + + describe('get', () => { + it('should return null when key does not exist', async () => { + const key = 'non-existent-key'; + redisMock.get = jest.fn().mockResolvedValue(null); + const result = await service.get(key); + + expect(result).toBeNull(); + expect(redisMock.get).toHaveBeenCalledWith(service.generateKey(key)); + }); + + it('should parse and return JSON value', async () => { + const key = 'json-key'; + const testObj = { name: 'test', value: 123 }; + redisMock.get = jest.fn().mockResolvedValue(JSON.stringify(testObj)); + + const result = await service.get(key); + + expect(result).toEqual(testObj); + expect(redisMock.get).toHaveBeenCalledWith(service.generateKey(key)); + }); + + it('should return string value when not JSON', async () => { + const key = 'string-key'; + const testString = 'test-string'; + redisMock.get = jest.fn().mockResolvedValue(testString); + + const result = await service.get(key); + + expect(result).toBe(testString); + expect(redisMock.get).toHaveBeenCalledWith(service.generateKey(key)); + }); + }); + + describe('set', () => { + it('should set string value without TTL with default TTL', async () => { + const key = 'string-key'; + const value = 'test-value'; + redisMock.set = jest.fn(); + redisMock.setex = jest.fn(); + await service.set(key, value); + expect(redisMock.setex).toHaveBeenCalledWith( + service.generateKey(key), + DEFAULT_TTL, + value, + ); + }); + + it('should set JSON value without TTL with default TTL', async () => { + const key = 'json-key'; + const value = { name: 'test', value: 123 }; + redisMock.set = jest.fn(); + redisMock.setex = jest.fn(); + await service.set(key, value); + expect(redisMock.setex).toHaveBeenCalledWith( + service.generateKey(key), + DEFAULT_TTL, + JSON.stringify(value), + ); + }); + + it('should set value with TTL', async () => { + const key = 'ttl-key'; + const value = 'test-value'; + const ttl = 1000; + redisMock.setex = jest.fn(); + await service.set(key, value, ttl); + expect(redisMock.setex).toHaveBeenCalledWith( + service.generateKey(key), + ttl, + value, + ); + }); + }); + + describe('del', () => { + it('should delete key', async () => { + const key = 'test-key'; + redisMock.del = jest.fn(); + await service.del(key); + expect(redisMock.del).toHaveBeenCalledWith(service.generateKey(key)); + }); + + it('should delete all keys matching pattern', async () => { + const pattern = 'test*'; + redisMock.keys = jest.fn().mockResolvedValue(['test1', 'test2']); + redisMock.del = jest.fn(); + await service.delAll(pattern); + expect(redisMock.keys).toHaveBeenCalledWith(service.generateKey(pattern)); + expect(redisMock.del).toHaveBeenCalledWith('test1', 'test2'); + }); + }); + + describe('exists', () => { + it('should return true when key exists', async () => { + const key = 'existing-key'; + redisMock.exists = jest.fn().mockResolvedValue(1); + const result = await service.exists(key); + expect(result).toBe(true); + expect(redisMock.exists).toHaveBeenCalledWith(service.generateKey(key)); + }); + + it('should return false when key does not exist', async () => { + const key = 'non-existing-key'; + redisMock.exists = jest.fn().mockResolvedValue(0); + const result = await service.exists(key); + expect(result).toBe(false); + expect(redisMock.exists).toHaveBeenCalledWith(service.generateKey(key)); + }); + }); + + describe('keys', () => { + it('should return matching keys', async () => { + const pattern = 'test*'; + const expectedKeys = ['test1', 'test2']; + + redisMock.keys = jest.fn().mockResolvedValue(expectedKeys); + + const result = await service.keys(pattern); + + expect(result).toHaveLength(expectedKeys.length); + expect(redisMock.keys).toHaveBeenCalledWith(pattern); + }); + }); + + describe('ttl', () => { + it('should return TTL for key', async () => { + const key = 'test-key'; + const expectedTTL = 3600; + redisMock.ttl = jest.fn().mockResolvedValue(expectedTTL); + const result = await service.ttl(key); + expect(result).toBe(expectedTTL); + expect(redisMock.ttl).toHaveBeenCalledWith(service.generateKey(key)); + }); + }); + + describe('onModuleDestroy', () => { + it('should quit Redis connection', async () => { + redisMock.quit = jest.fn(); + await service.onModuleDestroy(); + expect(redisMock.quit).toHaveBeenCalled(); + }); }); }); diff --git a/src/modules/cache/cache.service.ts b/src/modules/cache/cache.service.ts index 86c0837..e9eefc2 100644 --- a/src/modules/cache/cache.service.ts +++ b/src/modules/cache/cache.service.ts @@ -1,4 +1,66 @@ -import { Injectable } from '@nestjs/common'; +import { InjectRedis } from '@nestjs-modules/ioredis'; +import { Injectable, OnModuleDestroy } from '@nestjs/common'; +import Redis from 'ioredis'; @Injectable() -export class CacheService {} +export class CacheService implements OnModuleDestroy { + constructor(@InjectRedis() private readonly redis: Redis) {} + + CACHE_PREFIX = 'cache:'; + + CACHE_DEFAULT_TTL = 3600; + + async onModuleDestroy() { + await this.redis.quit(); + } + + generateKey(key: string): string { + return `${this.CACHE_PREFIX}${key}`; + } + + async get(key: string): Promise { + const value = await this.redis.get(this.generateKey(key)); + if (!value) return null; + try { + return JSON.parse(value); + } catch { + return value as T; + } + } + + async set(key: string, value: any, ttlSeconds?: number): Promise { + key = this.generateKey(key); + const serializedValue = + typeof value === 'string' ? value : JSON.stringify(value); + + if (ttlSeconds) { + await this.redis.setex(key, ttlSeconds, serializedValue); + } else { + await this.redis.setex(key, this.CACHE_DEFAULT_TTL, serializedValue); + } + } + + async del(key: string): Promise { + await this.redis.del(this.generateKey(key)); + } + + async delAll(pattern: string): Promise { + const keys = await this.redis.keys(this.generateKey(pattern)); + if (keys.length > 0) { + await this.redis.del(...keys); + } + } + + async exists(key: string): Promise { + const result = await this.redis.exists(this.generateKey(key)); + return result === 1; + } + + async keys(pattern: string): Promise { + return this.redis.keys(pattern); + } + + async ttl(key: string): Promise { + return this.redis.ttl(this.generateKey(key)); + } +} diff --git a/src/modules/misc/misc.module.ts b/src/modules/misc/misc.module.ts index 061deea..99271b7 100644 --- a/src/modules/misc/misc.module.ts +++ b/src/modules/misc/misc.module.ts @@ -1,8 +1,9 @@ -import { Module } from '@nestjs/common'; +import { Global, Module } from '@nestjs/common'; import { FilterService } from './filter.service'; import { PaginationService } from './pagination.service'; import { PasswordService } from './password.service'; +@Global() @Module({ imports: [], controllers: [], diff --git a/src/modules/user/user.module.ts b/src/modules/user/user.module.ts index 7610481..4ccc0be 100644 --- a/src/modules/user/user.module.ts +++ b/src/modules/user/user.module.ts @@ -3,7 +3,6 @@ import { Module } from '@nestjs/common'; import { UserEmailUniqueValidator } from '../../common/decorators/user-email-unique.decorator'; import { IsValidPermissionsValidator } from '../../common/decorators/valid-permission.decorator'; import { IsValidRolesValidator } from '../../common/decorators/valid-roles.decorator'; -import { MiscModule } from '../misc/misc.module'; import { Permission } from '../permission/entities/permission.entity'; import { Role } from '../role/entities/role.entity'; import { User } from './entities/user.entity'; @@ -12,7 +11,7 @@ import { UserController } from './user.controller'; import { UserService } from './user.service'; @Module({ - imports: [MikroOrmModule.forFeature([User, Role, Permission]), MiscModule], + imports: [MikroOrmModule.forFeature([User, Role, Permission])], controllers: [UserController], providers: [ UserService, diff --git a/src/modules/user/user.service.ts b/src/modules/user/user.service.ts index 20e4622..8479d03 100644 --- a/src/modules/user/user.service.ts +++ b/src/modules/user/user.service.ts @@ -6,6 +6,8 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; +import { CacheService } from '../cache/cache.service'; +import { FilterService } from '../misc/filter.service'; import { PasswordService } from '../misc/password.service'; import { Permission } from '../permission/entities/permission.entity'; import { Role } from '../role/entities/role.entity'; @@ -16,7 +18,6 @@ import { ChangeSelfPasswordDto } from './dto/reset-self-password.dto'; import { UpdateUserDto } from './dto/update-user.dto'; import { User } from './entities/user.entity'; import { UserTransformer } from './transformer/user.transformer'; -import { FilterService } from '../misc/filter.service'; @Injectable() export class UserService { @@ -29,10 +30,16 @@ export class UserService { private readonly userRepository: EntityRepository, private readonly em: EntityManager, private readonly passwordService: PasswordService, + private readonly cacheService: CacheService, private readonly filterService: FilterService, private readonly userTransformer: UserTransformer, ) {} + private USER_CACHE_PREFIX = 'user:'; + private USER_CACHE_DEFAULT_TTL = 3600 * 24; + private USER_PAGINATED_CACHE_PREFIX = 'user-paginated'; + private USER_PAGINATED_CACHE_DEFAULT_TTL = 600; + // Create a new user async create(createUserDto: CreateUserDto): Promise> { createUserDto.password = await this.passwordService.hashPassword( @@ -44,6 +51,7 @@ export class UserService { createUserDto.roles = roleIds; const user = this.userRepository.create(createUserDto); await this.em.persistAndFlush(user); + await this.cacheService.delAll(`${this.USER_PAGINATED_CACHE_PREFIX}*`); return this.userTransformer.transform(user); } @@ -51,6 +59,10 @@ export class UserService { async findAll( params: FilterWithPaginationParams, ): Promise { + const cachedUsers = await this.cacheService.get( + `${this.USER_PAGINATED_CACHE_PREFIX}-${JSON.stringify(params)}`, + ); + if (cachedUsers) return cachedUsers; const { data, meta } = await this.filterService.filter( this.userRepository, params, @@ -61,21 +73,42 @@ export class UserService { loadRelations: true, }); - return { + const result = { data: mappedUsers, meta, }; + + await this.cacheService.set( + `${this.USER_PAGINATED_CACHE_PREFIX}-${JSON.stringify(params)}`, + result, + this.USER_PAGINATED_CACHE_DEFAULT_TTL, + ); + + return result; } // Find one user by ID async findOne(id: number): Promise | null> { + const cachedUser = + await this.cacheService.get | null>( + `${this.USER_CACHE_PREFIX}${id}`, + ); + if (cachedUser) return cachedUser; const user = await this.userRepository.findOne( { id }, { populate: ['roles'] }, ); - return this.userTransformer.transform(user, { + const userResponse = this.userTransformer.transform(user, { loadRelations: true, }); + + await this.cacheService.set( + `${this.USER_CACHE_PREFIX}${id}`, + userResponse, + this.USER_CACHE_DEFAULT_TTL, + ); + + return userResponse; } async findByEmail(email: string): Promise | null> { @@ -142,6 +175,7 @@ export class UserService { delete updateUserDto.roles; this.userRepository.assign(user, updateUserDto); await this.em.flush(); + this.cacheService.del(`${this.USER_CACHE_PREFIX}${id}`); return this.userTransformer.transform(user); } @@ -152,6 +186,8 @@ export class UserService { } this.userRepository.assign(user, { lastLoginAt: new Date() }); await this.em.flush(); + this.cacheService.del(`${this.USER_CACHE_PREFIX}${id}`); + await this.cacheService.delAll(`${this.USER_PAGINATED_CACHE_PREFIX}*`); return this.userTransformer.transform(user); } @@ -168,6 +204,7 @@ export class UserService { this.userRepository.assign(user, restOfDto); await this.em.flush(); + this.cacheService.del(`${this.USER_CACHE_PREFIX}${id}`); return this.userTransformer.transform(user); } @@ -214,6 +251,8 @@ export class UserService { throw new NotFoundException('User not found'); } try { + this.cacheService.del(`${this.USER_CACHE_PREFIX}${id}`); + await this.cacheService.delAll(`${this.USER_PAGINATED_CACHE_PREFIX}*`); await this.em.removeAndFlush(user); return true; } catch (error) { @@ -244,6 +283,7 @@ export class UserService { user.roles.set(roles); await this.em.flush(); + this.cacheService.del(`${this.USER_CACHE_PREFIX}${userId}`); return this.userTransformer.transform(user); } @@ -267,6 +307,7 @@ export class UserService { user.permissions.set(permissions); await this.em.flush(); + this.cacheService.del(`${this.USER_CACHE_PREFIX}${userId}`); return this.userTransformer.transform(user); } From 363e733b2913fbf9ddfe077f3ae3f5c061d22ee9 Mon Sep 17 00:00:00 2001 From: Md Azizul Hakim Date: Sat, 26 Oct 2024 12:07:58 +0600 Subject: [PATCH 3/5] fixed unit test --- src/modules/auth/auth.service.spec.ts | 14 ++++ src/modules/cache/cache.module.ts | 4 +- .../user/repositories/user.repository.ts | 20 +----- src/modules/user/user.service.spec.ts | 64 +++++++++++++++++++ 4 files changed, 82 insertions(+), 20 deletions(-) diff --git a/src/modules/auth/auth.service.spec.ts b/src/modules/auth/auth.service.spec.ts index 13b938c..04ff3bf 100644 --- a/src/modules/auth/auth.service.spec.ts +++ b/src/modules/auth/auth.service.spec.ts @@ -3,6 +3,8 @@ import { getRepositoryToken } from '@mikro-orm/nestjs'; import { ConfigService } from '@nestjs/config'; import { JwtModule } from '@nestjs/jwt'; import { Test, TestingModule } from '@nestjs/testing'; +import { CacheModule } from '../cache/cache.module'; +import { CacheService } from '../cache/cache.service'; import { MiscModule } from '../misc/misc.module'; import { User } from '../user/entities/user.entity'; import { UserTransformer } from '../user/transformer/user.transformer'; @@ -10,6 +12,13 @@ import { UserService } from '../user/user.service'; import { AuthService } from './auth.service'; import { JwtStrategy } from './strategies/jwt.strategy'; +const mockCacheService = { + get: jest.fn(), + set: jest.fn(), + del: jest.fn(), + delAll: jest.fn(), +}; + describe('AuthService', () => { let service: AuthService; let mockUserRepository: Partial>; @@ -61,6 +70,7 @@ describe('AuthService', () => { signOptions: { expiresIn: '1h' }, }), MiscModule, + CacheModule, ], providers: [ AuthService, @@ -75,6 +85,10 @@ describe('AuthService', () => { { provide: ConfigService, useValue: mockConfigService }, { provide: EntityManager, useValue: mockEntityManager }, { provide: getRepositoryToken(User), useValue: mockUserRepository }, + { + provide: CacheService, + useValue: mockCacheService, + }, ], }).compile(); diff --git a/src/modules/cache/cache.module.ts b/src/modules/cache/cache.module.ts index a0fda72..c18d8f0 100644 --- a/src/modules/cache/cache.module.ts +++ b/src/modules/cache/cache.module.ts @@ -1,12 +1,14 @@ import { RedisModule } from '@nestjs-modules/ioredis'; import { Global, Module } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; +import { ConfigModule, ConfigService } from '@nestjs/config'; import { CacheService } from './cache.service'; @Global() @Module({ imports: [ + ConfigModule, RedisModule.forRootAsync({ + imports: [ConfigModule], useFactory: (configService: ConfigService) => ({ type: 'single', options: { diff --git a/src/modules/user/repositories/user.repository.ts b/src/modules/user/repositories/user.repository.ts index ae6e266..5c9e568 100644 --- a/src/modules/user/repositories/user.repository.ts +++ b/src/modules/user/repositories/user.repository.ts @@ -1,22 +1,4 @@ import { EntityRepository } from '@mikro-orm/core'; // or any other driver package import { User } from '../entities/user.entity'; -export class UserRepository extends EntityRepository { - async findWithRoleAndPermission(id: number): Promise { - return await this.findOne( - { id }, - { - populate: ['roles', 'permissions'], - }, - ); - } - - async findByEmailWithRoleAndPermission(email: string): Promise { - return await this.findOne( - { email }, - { - populate: ['roles', 'permissions'], - }, - ); - } -} +export class UserRepository extends EntityRepository {} diff --git a/src/modules/user/user.service.spec.ts b/src/modules/user/user.service.spec.ts index 63665f0..47e8ef4 100644 --- a/src/modules/user/user.service.spec.ts +++ b/src/modules/user/user.service.spec.ts @@ -3,6 +3,7 @@ import { getRepositoryToken } from '@mikro-orm/nestjs'; import { BadRequestException, ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { createMockCollection } from '../../mocks/collection.mock'; +import { CacheService } from '../cache/cache.service'; import { MiscModule } from '../misc/misc.module'; import { Permission } from '../permission/entities/permission.entity'; import { Role } from '../role/entities/role.entity'; @@ -10,6 +11,13 @@ import { User } from './entities/user.entity'; import { UserTransformer } from './transformer/user.transformer'; import { UserService } from './user.service'; +const mockCacheService = { + get: jest.fn(), + set: jest.fn(), + del: jest.fn(), + delAll: jest.fn(), +}; + jest.mock('@mikro-orm/core', () => { const actual = jest.requireActual('@mikro-orm/core'); return { @@ -118,6 +126,11 @@ describe('UserService', () => { removeAndFlush: jest.fn(), }; + mockCacheService.get.mockReset(); + mockCacheService.set.mockReset(); + mockCacheService.del.mockReset(); + mockCacheService.delAll.mockReset(); + const module: TestingModule = await Test.createTestingModule({ imports: [MiscModule], providers: [ @@ -125,6 +138,7 @@ describe('UserService', () => { UserTransformer, { provide: getRepositoryToken(User), useValue: mockUserRepository }, // Provide the mock { provide: EntityManager, useValue: mockEntityManager }, + { provide: CacheService, useValue: mockCacheService }, // Add mock CacheService ], }).compile(); @@ -135,6 +149,56 @@ describe('UserService', () => { expect(service).toBeDefined(); }); + describe('Cache interactions', () => { + it('should try to get user from cache first when finding by id', async () => { + const userId = 1; + mockCacheService.get.mockResolvedValue(null); + + await service.findOne(userId); + + expect(mockCacheService.get).toHaveBeenCalledWith(`user:${userId}`); + expect(mockCacheService.set).toHaveBeenCalled(); + }); + + it('should return cached user if available', async () => { + const userId = 1; + const cachedUser = { + id: userId, + name: 'Cached User', + email: 'cached@example.com', + }; + mockCacheService.get.mockResolvedValue(cachedUser); + + const result = await service.findOne(userId); + + expect(result).toEqual(cachedUser); + expect(mockUserRepository.findOne).not.toHaveBeenCalled(); + }); + + it('should clear user cache when updating', async () => { + const userId = 10; + const updateUserDto = { isActive: false }; + + await service.update(userId, updateUserDto); + + expect(mockCacheService.del).toHaveBeenCalledWith(`user:${userId}`); + }); + + it('should clear paginated cache when creating new user', async () => { + const createUserDto = { + email: 'test@example.com', + name: 'Test User', + password: 'password123', + isActive: true, + roles: [2], + }; + + await service.create(createUserDto); + + expect(mockCacheService.delAll).toHaveBeenCalledWith('user-paginated*'); + }); + }); + it('should create a new user', async () => { const createUserDto = { email: '1q3U8@example.com', From aa8cc06a0d7919c723a38737b7d9e486d8fdd796 Mon Sep 17 00:00:00 2001 From: Md Azizul Hakim Date: Sat, 26 Oct 2024 13:45:49 +0600 Subject: [PATCH 4/5] add redis health indicator and improved unit test --- src/app.module.ts | 7 + src/config/redis.config.ts | 69 ++++++ src/modules/auth/auth.service.spec.ts | 71 ++----- src/modules/cache/cache.module.ts | 32 +-- src/modules/health/health.controller.ts | 3 + src/modules/health/health.module.ts | 7 +- .../redis-health-indicator.service.spec.ts | 197 ++++++++++++++++++ .../health/redis-health-indicator.service.ts | 20 ++ 8 files changed, 322 insertions(+), 84 deletions(-) create mode 100644 src/config/redis.config.ts create mode 100644 src/modules/health/redis-health-indicator.service.spec.ts create mode 100644 src/modules/health/redis-health-indicator.service.ts diff --git a/src/app.module.ts b/src/app.module.ts index 0ddcca6..2af2f10 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,4 +1,5 @@ import { MikroOrmModule } from '@mikro-orm/nestjs'; +import { RedisModule } from '@nestjs-modules/ioredis'; import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { APP_GUARD } from '@nestjs/core'; @@ -8,6 +9,7 @@ import { AppController } from './app.controller'; import { CreateModuleCommand } from './commands/create-module.command'; import { XSecureInstallCommand } from './commands/xsecurity.command'; import mikroOrmConfig from './config/mikro-orm.config'; +import redisConfig from './config/redis.config'; import { XSecurityMiddleware } from './middlewares/xsecurity.middleware'; import { AuthModule } from './modules/auth/auth.module'; import { CacheModule } from './modules/cache/cache.module'; @@ -40,6 +42,11 @@ import { UserModule } from './modules/user/user.module'; }, ], }), + RedisModule.forRootAsync({ + imports: [ConfigModule], + useFactory: (configService: ConfigService) => redisConfig(configService), + inject: [ConfigService], + }), CommandModule, HealthModule, MiscModule, diff --git a/src/config/redis.config.ts b/src/config/redis.config.ts new file mode 100644 index 0000000..149e67b --- /dev/null +++ b/src/config/redis.config.ts @@ -0,0 +1,69 @@ +import { RedisModuleOptions } from '@nestjs-modules/ioredis'; +import { ConfigService } from '@nestjs/config'; +import { config } from 'dotenv'; +import { getConfigValue } from '../utils/helper'; + +// Load environment variables for CLI usage +config(); + +export class RedisConfig { + constructor(private readonly configService?: ConfigService) {} + + configureOptions(): RedisModuleOptions { + return { + type: 'single', + options: { + host: getConfigValue( + 'REDIS_HOST', + 'localhost', + this.configService, + ), + port: Number( + getConfigValue('REDIS_PORT', '6379', this.configService), + ), + username: getConfigValue( + 'REDIS_USERNAME', + '', + this.configService, + ), + password: getConfigValue( + 'REDIS_PASSWORD', + '', + this.configService, + ), + db: Number(getConfigValue('REDIS_DB', '0', this.configService)), + maxRetriesPerRequest: Number( + getConfigValue('REDIS_MAX_RETRIES', '3', this.configService), + ), + connectTimeout: Number( + getConfigValue( + 'REDIS_CONNECT_TIMEOUT', + '10000', + this.configService, + ), + ), + retryStrategy: (times: number) => + Math.min( + times * + Number( + getConfigValue( + 'REDIS_RETRY_DELAY', + '50', + this.configService, + ), + ), + Number( + getConfigValue( + 'REDIS_RETRY_DELAY_MAX', + '2000', + this.configService, + ), + ), + ), + }, + }; + } +} + +export default (configService?: ConfigService) => + new RedisConfig(configService).configureOptions(); diff --git a/src/modules/auth/auth.service.spec.ts b/src/modules/auth/auth.service.spec.ts index 04ff3bf..1b0bb83 100644 --- a/src/modules/auth/auth.service.spec.ts +++ b/src/modules/auth/auth.service.spec.ts @@ -1,59 +1,31 @@ -import { EntityManager, EntityRepository } from '@mikro-orm/core'; -import { getRepositoryToken } from '@mikro-orm/nestjs'; import { ConfigService } from '@nestjs/config'; import { JwtModule } from '@nestjs/jwt'; import { Test, TestingModule } from '@nestjs/testing'; -import { CacheModule } from '../cache/cache.module'; -import { CacheService } from '../cache/cache.service'; import { MiscModule } from '../misc/misc.module'; -import { User } from '../user/entities/user.entity'; -import { UserTransformer } from '../user/transformer/user.transformer'; import { UserService } from '../user/user.service'; import { AuthService } from './auth.service'; import { JwtStrategy } from './strategies/jwt.strategy'; -const mockCacheService = { - get: jest.fn(), - set: jest.fn(), - del: jest.fn(), - delAll: jest.fn(), +const mockUserService = { + findByEmailWithRoleAndPermissions: jest.fn().mockResolvedValue({ + id: 1, + name: 'John Doe', + email: '1q3U8@example.com', + password: '$2b$10$q81aKunjGaLbPvt5biUjFeSXLKhXVsMtsNxF8.Nwjx8I5l7OcU7sy', + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + roles: [], + permissions: [], + }), + updateLoginDate: jest.fn(), }; describe('AuthService', () => { let service: AuthService; - let mockUserRepository: Partial>; - let mockEntityManager: Partial; let mockConfigService: Partial; beforeEach(async () => { - mockUserRepository = { - findOne: jest.fn().mockReturnValue({ - id: 1, - name: 'John Doe', - email: '1q3U8@example.com', - password: - '$2b$10$q81aKunjGaLbPvt5biUjFeSXLKhXVsMtsNxF8.Nwjx8I5l7OcU7sy', - isActive: true, - createdAt: new Date(), - updatedAt: new Date(), - }), - create: jest.fn().mockImplementation((dto) => ({ - ...dto, - id: 1, - createdAt: new Date(), - updatedAt: new Date(), - })), - assign: jest.fn().mockImplementation((user, dto) => { - Object.assign(user, dto); - }), - }; - - mockEntityManager = { - persistAndFlush: jest.fn(), - flush: jest.fn(), - removeAndFlush: jest.fn(), - }; - mockConfigService = { get: jest.fn((key: string) => { if (key === 'JWT_SECRET') { @@ -70,12 +42,13 @@ describe('AuthService', () => { signOptions: { expiresIn: '1h' }, }), MiscModule, - CacheModule, ], providers: [ AuthService, - UserService, - UserTransformer, + { + provide: UserService, + useValue: mockUserService, + }, { provide: JwtStrategy, useFactory: (configService: ConfigService) => @@ -83,12 +56,6 @@ describe('AuthService', () => { inject: [ConfigService], }, { provide: ConfigService, useValue: mockConfigService }, - { provide: EntityManager, useValue: mockEntityManager }, - { provide: getRepositoryToken(User), useValue: mockUserRepository }, - { - provide: CacheService, - useValue: mockCacheService, - }, ], }).compile(); @@ -108,8 +75,8 @@ describe('AuthService', () => { isActive: true, createdAt: expect.any(Date), AccessToken: expect.any(String), - roles: undefined, - permissions: undefined, + roles: [], + permissions: [], }); }); }); diff --git a/src/modules/cache/cache.module.ts b/src/modules/cache/cache.module.ts index c18d8f0..5cb29c5 100644 --- a/src/modules/cache/cache.module.ts +++ b/src/modules/cache/cache.module.ts @@ -1,38 +1,10 @@ -import { RedisModule } from '@nestjs-modules/ioredis'; import { Global, Module } from '@nestjs/common'; -import { ConfigModule, ConfigService } from '@nestjs/config'; +import { ConfigModule } from '@nestjs/config'; import { CacheService } from './cache.service'; @Global() @Module({ - imports: [ - ConfigModule, - RedisModule.forRootAsync({ - imports: [ConfigModule], - useFactory: (configService: ConfigService) => ({ - type: 'single', - options: { - host: configService.get('REDIS_HOST'), - port: Number(configService.get('REDIS_PORT')), - username: configService.get('REDIS_USERNAME'), - password: configService.get('REDIS_PASSWORD'), - db: Number(configService.get('REDIS_DB')), - maxRetriesPerRequest: Number( - configService.get('REDIS_MAX_RETRIES'), - ), - connectTimeout: Number( - configService.get('REDIS_CONNECT_TIMEOUT'), - ), - retryStrategy: (times) => - Math.min( - times * Number(configService.get('REDIS_RETRY_DELAY')), - Number(configService.get('REDIS_RETRY_DELAY_MAX')), - ), - }, - }), - inject: [ConfigService], - }), - ], + imports: [ConfigModule], providers: [CacheService], exports: [CacheService], }) diff --git a/src/modules/health/health.controller.ts b/src/modules/health/health.controller.ts index d99fd47..4abc9e5 100644 --- a/src/modules/health/health.controller.ts +++ b/src/modules/health/health.controller.ts @@ -8,6 +8,7 @@ import { MemoryHealthIndicator, MikroOrmHealthIndicator, } from '@nestjs/terminus'; +import { RedisHealthIndicator } from './redis-health-indicator.service'; @Controller('health') export class HealthController { @@ -18,6 +19,7 @@ export class HealthController { private db: MikroOrmHealthIndicator, private disk: DiskHealthIndicator, private memory: MemoryHealthIndicator, + private redis: RedisHealthIndicator, ) {} @Get() @@ -31,6 +33,7 @@ export class HealthController { () => this.db.pingCheck('database'), () => this.memory.checkHeap('memory_heap', 150 * 1024 * 1024), () => this.memory.checkRSS('memory_rss', 150 * 1024 * 1024), + () => this.redis.isHealthy('redis'), ]); } } diff --git a/src/modules/health/health.module.ts b/src/modules/health/health.module.ts index 2bb45cd..c4c6dba 100644 --- a/src/modules/health/health.module.ts +++ b/src/modules/health/health.module.ts @@ -1,10 +1,13 @@ +import { RedisModule } from '@nestjs-modules/ioredis'; +import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; import { TerminusModule } from '@nestjs/terminus'; import { HealthController } from './health.controller'; -import { HttpModule } from '@nestjs/axios'; +import { RedisHealthIndicator } from './redis-health-indicator.service'; @Module({ - imports: [TerminusModule, HttpModule], + imports: [TerminusModule, HttpModule, RedisModule], controllers: [HealthController], + providers: [RedisHealthIndicator], }) export class HealthModule {} diff --git a/src/modules/health/redis-health-indicator.service.spec.ts b/src/modules/health/redis-health-indicator.service.spec.ts new file mode 100644 index 0000000..68d6d2a --- /dev/null +++ b/src/modules/health/redis-health-indicator.service.spec.ts @@ -0,0 +1,197 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import Redis from 'ioredis'; +import RedisMock from 'ioredis-mock'; +import { RedisHealthIndicator } from './redis-health-indicator.service'; + +export const IORedisToken = 'default_IORedisModuleConnectionToken'; +describe('RedisHealthIndicator', () => { + let healthIndicator: RedisHealthIndicator; + let redisMock: Redis; + + beforeEach(async () => { + // Create Redis mock using ioredis-mock + redisMock = new RedisMock(); + + // Spy on the ping method + jest.spyOn(redisMock, 'ping'); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RedisHealthIndicator, + { + provide: IORedisToken, + useValue: redisMock, + }, + ], + }).compile(); + + healthIndicator = module.get(RedisHealthIndicator); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(healthIndicator).toBeDefined(); + }); + + describe('isHealthy', () => { + it('should return status up when Redis is connected', async () => { + // Act + const result = await healthIndicator.isHealthy('redis'); + + // Assert + expect(result).toEqual({ + redis: { + status: 'up', + }, + }); + expect(redisMock.ping).toHaveBeenCalled(); + }); + + it('should return status down when Redis connection fails', async () => { + // Arrange + const error = new Error('Redis connection failed'); + jest.spyOn(redisMock, 'ping').mockRejectedValueOnce(error); + + // Act + const result = await healthIndicator.isHealthy('redis'); + + // Assert + expect(result).toEqual({ + redis: { + status: 'down', + message: 'Redis connection failed', + }, + }); + expect(redisMock.ping).toHaveBeenCalled(); + }); + + it('should handle network errors', async () => { + // Arrange + const error = new Error('ECONNREFUSED'); + jest.spyOn(redisMock, 'ping').mockRejectedValueOnce(error); + + // Act + const result = await healthIndicator.isHealthy('redis'); + + // Assert + expect(result).toEqual({ + redis: { + status: 'down', + message: 'ECONNREFUSED', + }, + }); + }); + + it('should handle timeout errors', async () => { + // Arrange + const error = new Error('ETIMEDOUT'); + jest.spyOn(redisMock, 'ping').mockRejectedValueOnce(error); + + // Act + const result = await healthIndicator.isHealthy('redis'); + + // Assert + expect(result).toEqual({ + redis: { + status: 'down', + message: 'ETIMEDOUT', + }, + }); + }); + + it('should handle custom key names', async () => { + // Act + const result = await healthIndicator.isHealthy('custom-redis'); + + // Assert + expect(result).toEqual({ + 'custom-redis': { + status: 'up', + }, + }); + }); + + it('should handle multiple health checks sequentially', async () => { + // Act + const result1 = await healthIndicator.isHealthy('redis-1'); + const result2 = await healthIndicator.isHealthy('redis-2'); + + // Assert + expect(result1).toEqual({ + 'redis-1': { + status: 'up', + }, + }); + expect(result2).toEqual({ + 'redis-2': { + status: 'up', + }, + }); + expect(redisMock.ping).toHaveBeenCalledTimes(2); + }); + + it('should handle Redis server errors', async () => { + // Arrange + const error = new Error('ERR unknown command'); + jest.spyOn(redisMock, 'ping').mockRejectedValueOnce(error); + + // Act + const result = await healthIndicator.isHealthy('redis'); + + // Assert + expect(result).toEqual({ + redis: { + status: 'down', + message: 'ERR unknown command', + }, + }); + }); + }); + + describe('error scenarios', () => { + it('should not expose sensitive information in error messages', async () => { + // Arrange + const error = new Error('WRONGPASS invalid username-password pair'); + jest.spyOn(redisMock, 'ping').mockRejectedValueOnce(error); + + // Act + const result = await healthIndicator.isHealthy('redis'); + + // Assert + expect(result.redis.status).toBe('down'); + expect(result.redis.message).toBe( + 'WRONGPASS invalid username-password pair', + ); + }); + + it('should handle redis client being undefined', async () => { + // Arrange + const moduleRef = await Test.createTestingModule({ + providers: [ + RedisHealthIndicator, + { + provide: IORedisToken, + useValue: undefined, + }, + ], + }).compile(); + + const indicator = + moduleRef.get(RedisHealthIndicator); + + // Act + const result = await indicator.isHealthy('redis'); + + // Assert + expect(result).toEqual({ + redis: { + status: 'down', + message: "Cannot read properties of undefined (reading 'ping')", + }, + }); + }); + }); +}); diff --git a/src/modules/health/redis-health-indicator.service.ts b/src/modules/health/redis-health-indicator.service.ts new file mode 100644 index 0000000..ef51ab9 --- /dev/null +++ b/src/modules/health/redis-health-indicator.service.ts @@ -0,0 +1,20 @@ +import { InjectRedis } from '@nestjs-modules/ioredis'; +import { Injectable } from '@nestjs/common'; +import { HealthIndicator, HealthIndicatorResult } from '@nestjs/terminus'; +import Redis from 'ioredis'; + +@Injectable() +export class RedisHealthIndicator extends HealthIndicator { + constructor(@InjectRedis() private readonly redis: Redis) { + super(); + } + + async isHealthy(key: string): Promise { + try { + await this.redis.ping(); + return this.getStatus(key, true); + } catch (e) { + return this.getStatus(key, false, { message: e.message }); + } + } +} From 4be0c66707853ddac5002158e4cac6b8a3f3098c Mon Sep 17 00:00:00 2001 From: Azizul Hakim Date: Sat, 26 Oct 2024 14:24:55 +0600 Subject: [PATCH 5/5] Delete src/modules/cache/types.d.ts --- src/modules/cache/types.d.ts | 1 - 1 file changed, 1 deletion(-) delete mode 100644 src/modules/cache/types.d.ts diff --git a/src/modules/cache/types.d.ts b/src/modules/cache/types.d.ts deleted file mode 100644 index bbd2ba2..0000000 --- a/src/modules/cache/types.d.ts +++ /dev/null @@ -1 +0,0 @@ -// Type definitions go here