This commit is contained in:
sbriat 2023-07-20 17:22:31 +02:00
parent ee9474828e
commit d5c2bb396d
83 changed files with 1332 additions and 1798 deletions

View File

@ -7,9 +7,9 @@ HEALTH_SERVICE_PORT=6001
# PRISMA
DATABASE_URL="postgresql://mobicoop:mobicoop@v3-db:5432/mobicoop?schema=user"
# RABBIT MQ
RMQ_URI=amqp://v3-broker:5672
RMQ_EXCHANGE=mobicoop
# MESSAGE BROKER
MESSAGE_BROKER_URI=amqp://v3-broker:5672
MESSAGE_BROKER_EXCHANGE=mobicoop
# REDIS
REDIS_HOST=v3-redis

222
package-lock.json generated
View File

@ -9,14 +9,13 @@
"version": "0.0.1",
"license": "AGPL",
"dependencies": {
"@automapper/classes": "^8.7.7",
"@automapper/core": "^8.7.7",
"@automapper/nestjs": "^8.7.7",
"@grpc/grpc-js": "^1.8.0",
"@grpc/proto-loader": "^0.7.4",
"@liaoliaots/nestjs-redis": "^9.0.5",
"@mobicoop/configuration-module": "^1.1.0",
"@mobicoop/message-broker-module": "^1.0.5",
"@mobicoop/configuration-module": "^1.2.0",
"@mobicoop/ddd-library": "^0.3.0",
"@mobicoop/health-module": "^2.0.0",
"@mobicoop/message-broker-module": "^1.2.0",
"@nestjs/cache-manager": "^1.0.0",
"@nestjs/common": "^9.0.0",
"@nestjs/config": "^2.2.0",
@ -32,6 +31,7 @@
"cache-manager": "^5.2.1",
"cache-manager-ioredis-yet": "^1.1.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"dotenv-cli": "^6.0.0",
"ioredis": "^5.3.0"
},
@ -41,6 +41,7 @@
"@nestjs/testing": "^9.0.0",
"@types/jest": "^29.2.5",
"@types/node": "^18.11.18",
"@types/uuid": "^9.0.2",
"eslint": "^8.0.1",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^4.0.0",
@ -174,39 +175,6 @@
"node": ">=12.0.0"
}
},
"node_modules/@automapper/classes": {
"version": "8.7.7",
"resolved": "https://registry.npmjs.org/@automapper/classes/-/classes-8.7.7.tgz",
"integrity": "sha512-FSbvt6QE8XnhKKQZA3kpKLuLrr9x1iW+lNYTrawVLjxQ05zsCGccLxe7moMNrg1wFAVAouQKupFgCGQ7XRjmJw==",
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"@automapper/core": "8.7.7",
"reflect-metadata": "~0.1.13"
}
},
"node_modules/@automapper/core": {
"version": "8.7.7",
"resolved": "https://registry.npmjs.org/@automapper/core/-/core-8.7.7.tgz",
"integrity": "sha512-YfpDJ/xqwUuC0S+BLNk81ZJfeL7CmjirUX/Gk9eQyx146DKvneBZgeZ9v5rDB51Ti14jTxVHis+5JuT7W/q0TA==",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@automapper/nestjs": {
"version": "8.7.7",
"resolved": "https://registry.npmjs.org/@automapper/nestjs/-/nestjs-8.7.7.tgz",
"integrity": "sha512-9/uYY2cmN7SJjr2QxnfyXsteHrn/RHD+Dg0VMBflzK/e8Bh/KWyOve7+kaFixlUoyHe44aXs2LVaCslqt8wnhQ==",
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"@automapper/core": "8.7.7",
"@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0",
"@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0"
}
},
"node_modules/@babel/code-frame": {
"version": "7.21.4",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.21.4.tgz",
@ -259,9 +227,9 @@
}
},
"node_modules/@babel/core/node_modules/semver": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
"integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"bin": {
"semver": "bin/semver.js"
@ -311,9 +279,9 @@
}
},
"node_modules/@babel/helper-compilation-targets/node_modules/semver": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
"integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"bin": {
"semver": "bin/semver.js"
@ -1938,12 +1906,12 @@
}
},
"node_modules/@mobicoop/configuration-module": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@mobicoop/configuration-module/-/configuration-module-1.1.0.tgz",
"integrity": "sha512-4yzCrY8m40XOO3CZnWJC4kHk66sTQCwe5UjKCV/UpNkN9IGUKW+R84J/53aulmGTL95vec7g6tFIwlHJd9BCoA==",
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@mobicoop/configuration-module/-/configuration-module-1.2.0.tgz",
"integrity": "sha512-l0iDae7SgVVmjnCa2MBqAr3Er0yn4E7yiG8e7cs4XtNGUKrC1N0Ju56TEAraEYK9aZAZ36TCs06m1fep+rgwFA==",
"dependencies": {
"@golevelup/nestjs-rabbitmq": "^3.6.0",
"@liaoliaots/nestjs-redis": "^9.0.5",
"@mobicoop/message-broker-module": "^1.0.4",
"@nestjs/cqrs": "^9.0.4",
"@types/amqplib": "^0.10.1",
"amqplib": "^0.10.3",
@ -1954,10 +1922,43 @@
"@nestjs/common": "^9.4.2"
}
},
"node_modules/@mobicoop/ddd-library": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@mobicoop/ddd-library/-/ddd-library-0.3.0.tgz",
"integrity": "sha512-MoUDqlrDmJkumCFSyW9FY2DLbguT4rytFrmBt9tVNCr2Es6nlz4Ml3HVBwJTZrlJFU79XmiUQ5WAO0MHJt+nAg==",
"dependencies": {
"@nestjs/event-emitter": "^1.4.2",
"@nestjs/microservices": "^9.4.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"uuid": "^9.0.0"
},
"peerDependencies": {
"@nestjs/common": "^9.4.2"
}
},
"node_modules/@mobicoop/health-module": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@mobicoop/health-module/-/health-module-2.0.0.tgz",
"integrity": "sha512-r/7zrHJKVRTIiZ50ILy3lEUC/9vi6k0TRcYPMS8zcnUssQg+MPcT5DQS9B9tTB2gkKwcCyxOQlZZIppIybFX3A==",
"dependencies": {
"@grpc/grpc-js": "^1.8.14",
"@grpc/proto-loader": "^0.7.7",
"@mobicoop/ddd-library": "^0.3.0",
"@mobicoop/message-broker-module": "^1.0.5",
"@nestjs/axios": "^3.0.0",
"@nestjs/microservices": "^9.4.2",
"@nestjs/terminus": "^9.2.2",
"axios": "^1.4.0"
},
"peerDependencies": {
"@nestjs/common": "^9.4.2"
}
},
"node_modules/@mobicoop/message-broker-module": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@mobicoop/message-broker-module/-/message-broker-module-1.0.5.tgz",
"integrity": "sha512-9l2qCUXide2R1GzTmH1Z8CDHV0+zPJVp1OAk0q+PW9M73id6vX3j0uXURhGLQOwe01IhEMKkFs+D6YNPUPqqmw==",
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@mobicoop/message-broker-module/-/message-broker-module-1.2.0.tgz",
"integrity": "sha512-RoSHHK1GyQ/QVDmm3JS/wBfh171oChvyEp6YWmJd12krFLrPVn9MoEvZdyT3I5J31oBiUabMPle5Kdpw+Nrmww==",
"dependencies": {
"@golevelup/nestjs-rabbitmq": "^3.6.0",
"@types/amqplib": "^0.10.1",
@ -1967,6 +1968,17 @@
"@nestjs/common": "^9.4.2"
}
},
"node_modules/@nestjs/axios": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-3.0.0.tgz",
"integrity": "sha512-ULdH03jDWkS5dy9X69XbUVbhC+0pVnrRcj7bIK/ytTZ76w7CgvTZDJqsIyisg3kNOiljRW/4NIjSf3j6YGvl+g==",
"peerDependencies": {
"@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0",
"axios": "^1.3.1",
"reflect-metadata": "^0.1.12",
"rxjs": "^6.0.0 || ^7.0.0"
}
},
"node_modules/@nestjs/cache-manager": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@nestjs/cache-manager/-/cache-manager-1.0.0.tgz",
@ -2193,6 +2205,19 @@
"rxjs": "^7.2.0"
}
},
"node_modules/@nestjs/event-emitter": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/@nestjs/event-emitter/-/event-emitter-1.4.2.tgz",
"integrity": "sha512-5mskPMS4KVH6LghC+NynfdmGiMCOOv9CdgVpuWGipLrJECv5KWc7vaW5o/9BYrcqPkN7Ted6CJ+O4AfsTiRlgw==",
"dependencies": {
"eventemitter2": "6.4.9"
},
"peerDependencies": {
"@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0",
"@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0",
"reflect-metadata": "^0.1.12"
}
},
"node_modules/@nestjs/microservices": {
"version": "9.4.2",
"resolved": "https://registry.npmjs.org/@nestjs/microservices/-/microservices-9.4.2.tgz",
@ -2754,6 +2779,12 @@
"@types/superagent": "*"
}
},
"node_modules/@types/uuid": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.2.tgz",
"integrity": "sha512-kNnC1GFBLuhImSnV7w4njQkUiJi0ZXUycu1rUaouPqiKlXkh77JKgdRnTAp1x5eBwcIwbtI+3otwzuIDEuDoxQ==",
"dev": true
},
"node_modules/@types/validator": {
"version": "13.7.17",
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.7.17.tgz",
@ -3340,8 +3371,17 @@
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"node_modules/axios": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.4.0.tgz",
"integrity": "sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==",
"dependencies": {
"follow-redirects": "^1.15.0",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/babel-jest": {
"version": "28.1.3",
@ -4043,7 +4083,6 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"dependencies": {
"delayed-stream": "~1.0.0"
},
@ -4284,7 +4323,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"dev": true,
"engines": {
"node": ">=0.4.0"
}
@ -4764,6 +4802,11 @@
"node": ">= 0.6"
}
},
"node_modules/eventemitter2": {
"version": "6.4.9",
"resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.9.tgz",
"integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg=="
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
@ -5130,6 +5173,25 @@
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz",
"integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ=="
},
"node_modules/follow-redirects": {
"version": "1.15.2",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz",
"integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/fork-ts-checker-webpack-plugin": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-8.0.0.tgz",
@ -5162,7 +5224,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"dev": true,
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
@ -5228,20 +5289,6 @@
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
@ -5776,9 +5823,9 @@
}
},
"node_modules/istanbul-lib-instrument/node_modules/semver": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
"integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"bin": {
"semver": "bin/semver.js"
@ -7362,9 +7409,9 @@
}
},
"node_modules/make-dir/node_modules/semver": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
"integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"bin": {
"semver": "bin/semver.js"
@ -8133,9 +8180,9 @@
}
},
"node_modules/protobufjs": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.2.3.tgz",
"integrity": "sha512-TtpvOqwB5Gdz/PQmOjgsrGH1nHjAQVCN7JG4A6r1sXRWESL5rNMAiRcBQlCAdKxZcAbstExQePYG8xof/JVRgg==",
"version": "7.2.4",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.2.4.tgz",
"integrity": "sha512-AT+RJgD2sH8phPmCf7OUZR8xGdcJRga4+1cOaXJ64hvcSkVhNcRHOwIxUatPH15+nj59WAGTDv3LSGZPEQbJaQ==",
"hasInstallScript": true,
"dependencies": {
"@protobufjs/aspromise": "^1.1.2",
@ -8172,6 +8219,11 @@
"node": ">= 0.10"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
},
"node_modules/pump": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz",
@ -8583,9 +8635,9 @@
"dev": true
},
"node_modules/semver": {
"version": "7.5.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.1.tgz",
"integrity": "sha512-Wvss5ivl8TMRZXXESstBA4uR5iXgEN/VC5/sOcuXdVLzcdkz4HWetIoRfG5gb5X+ij/G9rw9YoGn3QoQ8OCSpw==",
"version": "7.5.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
"integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==",
"dependencies": {
"lru-cache": "^6.0.0"
},
@ -9782,9 +9834,9 @@
}
},
"node_modules/word-wrap": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
"integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==",
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.4.tgz",
"integrity": "sha512-2V81OA4ugVo5pRo46hAoD2ivUJx8jXmWXfUkY4KFNw0hEptvN0QfH3K4nHiwzGeKl5rFKedV48QVoqYavy4YpA==",
"engines": {
"node": ">=0.10.0"
}

View File

@ -17,28 +17,26 @@
"lint:check": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix-dry-run --ignore-path .gitignore",
"pretty:check": "./node_modules/.bin/prettier --check .",
"pretty": "./node_modules/.bin/prettier --write .",
"test": "npm run migrate:test && dotenv -e .env.test jest",
"test": "npm run test:unit && npm run test:integration",
"test:unit": "jest --testPathPattern 'tests/unit/' --verbose",
"test:unit:ci": "jest --testPathPattern 'tests/unit/' --coverage",
"test:integration": "npm run migrate:test && dotenv -e .env.test -- jest --testPathPattern 'tests/integration/' --verbose",
"test:integration:ci": "npm run migrate:test:ci && dotenv -e ci/.env.ci -- jest --testPathPattern 'tests/integration/'",
"test:integration": "npm run migrate:test && dotenv -e .env.test -- jest --testPathPattern 'tests/integration/' --verbose --runInBand",
"test:integration:ci": "npm run migrate:test:ci && dotenv -e ci/.env.ci -- jest --testPathPattern 'tests/integration/' --runInBand",
"test:cov": "jest --testPathPattern 'tests/unit/' --coverage",
"test:e2e": "jest --config ./test/jest-e2e.json",
"generate": "docker exec v3-user-api sh -c 'npx prisma generate'",
"migrate": "docker exec v3-user-api sh -c 'npx prisma migrate dev'",
"migrate": "docker exec v3-auth-api sh -c 'npx prisma migrate dev'",
"migrate:test": "dotenv -e .env.test -- npx prisma migrate deploy",
"migrate:test:ci": "dotenv -e ci/.env.ci -- npx prisma migrate deploy",
"migrate:deploy": "npx prisma migrate deploy"
},
"dependencies": {
"@automapper/classes": "^8.7.7",
"@automapper/core": "^8.7.7",
"@automapper/nestjs": "^8.7.7",
"@grpc/grpc-js": "^1.8.0",
"@grpc/proto-loader": "^0.7.4",
"@liaoliaots/nestjs-redis": "^9.0.5",
"@mobicoop/configuration-module": "^1.1.0",
"@mobicoop/message-broker-module": "^1.0.5",
"@mobicoop/configuration-module": "^1.2.0",
"@mobicoop/ddd-library": "^0.3.0",
"@mobicoop/health-module": "^2.0.0",
"@mobicoop/message-broker-module": "^1.2.0",
"@nestjs/cache-manager": "^1.0.0",
"@nestjs/common": "^9.0.0",
"@nestjs/config": "^2.2.0",
@ -54,6 +52,7 @@
"cache-manager": "^5.2.1",
"cache-manager-ioredis-yet": "^1.1.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"dotenv-cli": "^6.0.0",
"ioredis": "^5.3.0"
},
@ -63,6 +62,7 @@
"@nestjs/testing": "^9.0.0",
"@types/jest": "^29.2.5",
"@types/node": "^18.11.18",
"@types/uuid": "^9.0.2",
"eslint": "^8.0.1",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^4.0.0",
@ -84,12 +84,12 @@
"ts"
],
"modulePathIgnorePatterns": [
".controller.ts",
".module.ts",
".request.ts",
".presenter.ts",
".profile.ts",
".exception.ts",
".dto.ts",
".di-tokens.ts",
".response.ts",
".port.ts",
"prisma.service.ts",
"main.ts"
],
"rootDir": "src",
@ -101,15 +101,19 @@
"**/*.(t|j)s"
],
"coveragePathIgnorePatterns": [
".controller.ts",
".module.ts",
".request.ts",
".presenter.ts",
".profile.ts",
".exception.ts",
".dto.ts",
".di-tokens.ts",
".response.ts",
".port.ts",
"prisma.service.ts",
"main.ts"
],
"coverageDirectory": "../coverage",
"moduleNameMapper": {
"^@modules(.*)": "<rootDir>/modules/$1",
"^@src(.*)": "<rootDir>$1"
},
"testEnvironment": "node"
}
}

View File

@ -1,2 +0,0 @@
export const MESSAGE_BROKER_PUBLISHER = Symbol();
export const MESSAGE_PUBLISHER = Symbol();

View File

@ -1,70 +1,65 @@
import { classes } from '@automapper/classes';
import { AutomapperModule } from '@automapper/nestjs';
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { HealthModule } from './modules/health/health.module';
import { UserModule } from './modules/user/user.module';
import {
ConfigurationModule,
ConfigurationModuleOptions,
} from '@mobicoop/configuration-module';
import {
MessageBrokerModule,
MessageBrokerModuleOptions,
} from '@mobicoop/message-broker-module';
HealthModule,
HealthModuleOptions,
HealthRepositoryPort,
} from '@mobicoop/health-module';
import { MessagerModule } from './modules/messager/messager.module';
import { USER_REPOSITORY } from './modules/user/user.di-tokens';
import { MESSAGE_PUBLISHER } from './modules/messager/messager.di-tokens';
import { MessagePublisherPort } from '@mobicoop/ddd-library';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
AutomapperModule.forRoot({ strategyInitializer: classes() }),
UserModule,
MessageBrokerModule.forRootAsync(
{
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (
configService: ConfigService,
): Promise<MessageBrokerModuleOptions> => ({
ConfigurationModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (
configService: ConfigService,
): Promise<ConfigurationModuleOptions> => ({
domain: configService.get<string>('SERVICE_CONFIGURATION_DOMAIN'),
messageBroker: {
uri: configService.get<string>('MESSAGE_BROKER_URI'),
exchange: configService.get<string>('MESSAGE_BROKER_EXCHANGE'),
handlers: {},
}),
},
false,
),
ConfigurationModule.forRootAsync(
{
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (
configService: ConfigService,
): Promise<ConfigurationModuleOptions> => ({
domain: configService.get<string>('SERVICE_CONFIGURATION_DOMAIN'),
messageBroker: {
uri: configService.get<string>('MESSAGE_BROKER_URI'),
exchange: configService.get<string>('MESSAGE_BROKER_EXCHANGE'),
},
redis: {
host: configService.get<string>('REDIS_HOST'),
password: configService.get<string>('REDIS_PASSWORD'),
port: configService.get<number>('REDIS_PORT'),
},
setConfigurationBrokerQueue: 'user-configuration-create-update',
deleteConfigurationQueue: 'user-configuration-delete',
propagateConfigurationQueue: 'user-configuration-propagate',
}),
}),
HealthModule.forRootAsync({
imports: [UserModule, MessagerModule],
inject: [USER_REPOSITORY, MESSAGE_PUBLISHER],
useFactory: async (
userRepository: HealthRepositoryPort,
messagePublisher: MessagePublisherPort,
): Promise<HealthModuleOptions> => ({
serviceName: 'user',
criticalLoggingKey: 'logging.user.health.crit',
checkRepositories: [
{
name: 'UserRepository',
repository: userRepository,
},
redis: {
host: configService.get<string>('REDIS_HOST'),
password: configService.get<string>('REDIS_PASSWORD'),
port: configService.get<number>('REDIS_PORT'),
},
setConfigurationBrokerRoutingKeys: [
'configuration.create',
'configuration.update',
],
deleteConfigurationRoutingKey: 'configuration.delete',
propagateConfigurationRoutingKey: 'configuration.propagate',
setConfigurationBrokerQueue: 'user-configuration-create-update',
deleteConfigurationQueue: 'user-configuration-delete',
propagateConfigurationQueue: 'user-configuration-propagate',
}),
},
true,
),
HealthModule,
],
messagePublisher,
}),
}),
UserModule,
MessagerModule,
],
controllers: [],
providers: [],
exports: [UserModule, MessagerModule],
})
export class AppModule {}

View File

@ -2,7 +2,6 @@ syntax = "proto3";
package health;
service Health {
rpc Check(HealthCheckRequest) returns (HealthCheckResponse);
}
@ -18,4 +17,5 @@ message HealthCheckResponse {
NOT_SERVING = 2;
}
ServingStatus status = 1;
string message = 2;
}

View File

@ -1,3 +0,0 @@
export interface IPublishMessage {
publish(routingKey: string, message: string): void;
}

View File

@ -13,8 +13,8 @@ async function bootstrap() {
options: {
package: ['user', 'health'],
protoPath: [
join(__dirname, 'modules/user/adapters/primaries/user.proto'),
join(__dirname, 'modules/health/adapters/primaries/health.proto'),
join(__dirname, 'modules/user/interface/grpc-controllers/user.proto'),
join(__dirname, 'health.proto'),
],
url: process.env.SERVICE_URL + ':' + process.env.SERVICE_PORT,
loader: { keepCase: true },

View File

@ -1,259 +0,0 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { DatabaseException } from '../../exceptions/database.exception';
import { ICollection } from '../../interfaces/collection.interface';
import { IRepository } from '../../interfaces/repository.interface';
import { PrismaService } from './prisma-service';
/**
* Child classes MUST redefined _model property with appropriate model name
*/
@Injectable()
export abstract class PrismaRepository<T> implements IRepository<T> {
protected model: string;
constructor(protected readonly prisma: PrismaService) {}
findAll = async (
page = 1,
perPage = 10,
where?: any,
include?: any,
): Promise<ICollection<T>> => {
const [data, total] = await this.prisma.$transaction([
this.prisma[this.model].findMany({
where,
include,
skip: (page - 1) * perPage,
take: perPage,
}),
this.prisma[this.model].count({
where,
}),
]);
return Promise.resolve({
data,
total,
});
};
findOneByUuid = async (uuid: string): Promise<T> => {
try {
const entity = await this.prisma[this.model].findUnique({
where: { uuid },
});
return entity;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseException(
Prisma.PrismaClientKnownRequestError.name,
e.code,
e.message,
);
} else {
throw new DatabaseException();
}
}
};
findOne = async (where: any, include?: any): Promise<T> => {
try {
const entity = await this.prisma[this.model].findFirst({
where: where,
include: include,
});
return entity;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseException(
Prisma.PrismaClientKnownRequestError.name,
e.code,
);
} else {
throw new DatabaseException();
}
}
};
// TODO : using any is not good, but needed for nested entities
// TODO : Refactor for good clean architecture ?
create = async (entity: Partial<T> | any, include?: any): Promise<T> => {
try {
const res = await this.prisma[this.model].create({
data: entity,
include: include,
});
return res;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseException(
Prisma.PrismaClientKnownRequestError.name,
e.code,
e.message,
);
} else {
throw new DatabaseException();
}
}
};
update = async (uuid: string, entity: Partial<T>): Promise<T> => {
try {
const updatedEntity = await this.prisma[this.model].update({
where: { uuid },
data: entity,
});
return updatedEntity;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseException(
Prisma.PrismaClientKnownRequestError.name,
e.code,
e.message,
);
} else {
throw new DatabaseException();
}
}
};
updateWhere = async (
where: any,
entity: Partial<T> | any,
include?: any,
): Promise<T> => {
try {
const updatedEntity = await this.prisma[this.model].update({
where: where,
data: entity,
include: include,
});
return updatedEntity;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseException(
Prisma.PrismaClientKnownRequestError.name,
e.code,
e.message,
);
} else {
throw new DatabaseException();
}
}
};
delete = async (uuid: string): Promise<T> => {
try {
const entity = await this.prisma[this.model].delete({
where: { uuid },
});
return entity;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseException(
Prisma.PrismaClientKnownRequestError.name,
e.code,
e.message,
);
} else {
throw new DatabaseException();
}
}
};
deleteMany = async (where: any): Promise<void> => {
try {
const entity = await this.prisma[this.model].deleteMany({
where: where,
});
return entity;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseException(
Prisma.PrismaClientKnownRequestError.name,
e.code,
e.message,
);
} else {
throw new DatabaseException();
}
}
};
findAllByQuery = async (
include: string[],
where: string[],
): Promise<ICollection<T>> => {
const query = `SELECT ${include.join(',')} FROM ${
this.model
} WHERE ${where.join(' AND ')}`;
const data: T[] = await this.prisma.$queryRawUnsafe(query);
return Promise.resolve({
data,
total: data.length,
});
};
createWithFields = async (fields: object): Promise<number> => {
try {
const command = `INSERT INTO ${this.model} ("${Object.keys(fields).join(
'","',
)}") VALUES (${Object.values(fields).join(',')})`;
return await this.prisma.$executeRawUnsafe(command);
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseException(
Prisma.PrismaClientKnownRequestError.name,
e.code,
e.message,
);
} else {
throw new DatabaseException();
}
}
};
updateWithFields = async (uuid: string, entity: object): Promise<number> => {
entity['"updatedAt"'] = `to_timestamp(${Date.now()} / 1000.0)`;
const values = Object.keys(entity).map((key) => `${key} = ${entity[key]}`);
try {
const command = `UPDATE ${this.model} SET ${values.join(
', ',
)} WHERE uuid = '${uuid}'`;
return await this.prisma.$executeRawUnsafe(command);
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseException(
Prisma.PrismaClientKnownRequestError.name,
e.code,
e.message,
);
} else {
throw new DatabaseException();
}
}
};
healthCheck = async (): Promise<boolean> => {
try {
await this.prisma.$queryRaw`SELECT 1`;
return true;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseException(
Prisma.PrismaClientKnownRequestError.name,
e.code,
e.message,
);
} else {
throw new DatabaseException();
}
}
};
}

View File

@ -1,9 +0,0 @@
import { Module } from '@nestjs/common';
import { PrismaService } from './adapters/secondaries/prisma-service';
import { UserRepository } from './domain/user-repository';
@Module({
providers: [PrismaService, UserRepository],
exports: [PrismaService, UserRepository],
})
export class DatabaseModule {}

View File

@ -1,3 +0,0 @@
import { PrismaRepository } from '../adapters/secondaries/prisma-repository.abstract';
export class UserRepository<T> extends PrismaRepository<T> {}

View File

@ -1,24 +0,0 @@
export class DatabaseException implements Error {
name: string;
message: string;
constructor(
private _type: string = 'unknown',
private _code: string = '',
message?: string,
) {
this.name = 'DatabaseException';
this.message = message ?? 'An error occured with the database.';
if (this.message.includes('Unique constraint failed')) {
this.message = 'Already exists.';
}
}
get type(): string {
return this._type;
}
get code(): string {
return this._code;
}
}

View File

@ -1,4 +0,0 @@
export interface ICollection<T> {
data: T[];
total: number;
}

View File

@ -1,18 +0,0 @@
import { ICollection } from './collection.interface';
export interface IRepository<T> {
findAll(
page: number,
perPage: number,
params?: any,
include?: any,
): Promise<ICollection<T>>;
findOne(where: any, include?: any): Promise<T>;
findOneByUuid(uuid: string, include?: any): Promise<T>;
create(entity: Partial<T> | any, include?: any): Promise<T>;
update(uuid: string, entity: Partial<T>, include?: any): Promise<T>;
updateWhere(where: any, entity: Partial<T> | any, include?: any): Promise<T>;
delete(uuid: string): Promise<T>;
deleteMany(where: any): Promise<void>;
healthCheck(): Promise<boolean>;
}

View File

@ -1,571 +0,0 @@
import { Injectable } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { PrismaService } from '../../adapters/secondaries/prisma-service';
import { PrismaRepository } from '../../adapters/secondaries/prisma-repository.abstract';
import { DatabaseException } from '../../exceptions/database.exception';
import { Prisma } from '@prisma/client';
class FakeEntity {
uuid?: string;
name: string;
}
let entityId = 2;
const entityUuid = 'uuid-';
const entityName = 'name-';
const createRandomEntity = (): FakeEntity => {
const entity: FakeEntity = {
uuid: `${entityUuid}${entityId}`,
name: `${entityName}${entityId}`,
};
entityId++;
return entity;
};
const fakeEntityToCreate: FakeEntity = {
name: 'test',
};
const fakeEntityCreated: FakeEntity = {
...fakeEntityToCreate,
uuid: 'some-uuid',
};
const fakeEntities: FakeEntity[] = [];
Array.from({ length: 10 }).forEach(() => {
fakeEntities.push(createRandomEntity());
});
@Injectable()
class FakePrismaRepository extends PrismaRepository<FakeEntity> {
protected model = 'fake';
}
class FakePrismaService extends PrismaService {
fake: any;
}
const mockPrismaService = {
$transaction: jest.fn().mockImplementation(async (data: any) => {
const entities = await data[0];
if (entities.length == 1) {
return Promise.resolve([[fakeEntityCreated], 1]);
}
return Promise.resolve([fakeEntities, fakeEntities.length]);
}),
// eslint-disable-next-line @typescript-eslint/no-unused-vars
$queryRawUnsafe: jest.fn().mockImplementation((query?: string) => {
return Promise.resolve(fakeEntities);
}),
$executeRawUnsafe: jest
.fn()
.mockResolvedValueOnce(fakeEntityCreated)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.mockImplementationOnce((fields: object) => {
throw new Prisma.PrismaClientKnownRequestError('unknown request', {
code: 'code',
clientVersion: 'version',
});
})
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.mockImplementationOnce((fields: object) => {
throw new Error('an unknown error');
})
.mockResolvedValueOnce(fakeEntityCreated)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.mockImplementationOnce((fields: object) => {
throw new Prisma.PrismaClientKnownRequestError('unknown request', {
code: 'code',
clientVersion: 'version',
});
})
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.mockImplementationOnce((fields: object) => {
throw new Error('an unknown error');
}),
$queryRaw: jest
.fn()
.mockImplementationOnce(() => {
throw new Prisma.PrismaClientKnownRequestError('unknown request', {
code: 'code',
clientVersion: 'version',
});
})
.mockImplementationOnce(() => {
return true;
})
.mockImplementation(() => {
throw new Prisma.PrismaClientKnownRequestError('Database unavailable', {
code: 'code',
clientVersion: 'version',
});
}),
fake: {
create: jest
.fn()
.mockResolvedValueOnce(fakeEntityCreated)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.mockImplementationOnce((params?: any) => {
throw new Prisma.PrismaClientKnownRequestError('unknown request', {
code: 'code',
clientVersion: 'version',
});
})
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.mockImplementationOnce((params?: any) => {
throw new Error('an unknown error');
}),
findMany: jest.fn().mockImplementation((params?: any) => {
if (params?.where?.limit == 1) {
return Promise.resolve([fakeEntityCreated]);
}
return Promise.resolve(fakeEntities);
}),
count: jest.fn().mockResolvedValue(fakeEntities.length),
findUnique: jest.fn().mockImplementation(async (params?: any) => {
let entity;
if (params?.where?.uuid) {
entity = fakeEntities.find(
(entity) => entity.uuid === params?.where?.uuid,
);
}
if (!entity && params?.where?.uuid == 'unknown') {
throw new Prisma.PrismaClientKnownRequestError('unknown request', {
code: 'code',
clientVersion: 'version',
});
} else if (!entity) {
throw new Error('no entity');
}
return entity;
}),
findFirst: jest
.fn()
.mockImplementationOnce((params?: any) => {
if (params?.where?.name) {
return Promise.resolve(
fakeEntities.find((entity) => entity.name === params?.where?.name),
);
}
})
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.mockImplementationOnce((params?: any) => {
throw new Prisma.PrismaClientKnownRequestError('unknown request', {
code: 'code',
clientVersion: 'version',
});
})
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.mockImplementationOnce((params?: any) => {
throw new Error('an unknown error');
}),
update: jest
.fn()
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.mockImplementationOnce((params?: any) => {
throw new Prisma.PrismaClientKnownRequestError('unknown request', {
code: 'code',
clientVersion: 'version',
});
})
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.mockImplementationOnce((params?: any) => {
throw new Prisma.PrismaClientKnownRequestError('unknown request', {
code: 'code',
clientVersion: 'version',
});
})
.mockImplementationOnce((params: any) => {
const entity = fakeEntities.find(
(entity) => entity.name === params.where.name,
);
Object.entries(params.data).map(([key, value]) => {
entity[key] = value;
});
return Promise.resolve(entity);
})
.mockImplementation((params: any) => {
const entity = fakeEntities.find(
(entity) => entity.uuid === params.where.uuid,
);
Object.entries(params.data).map(([key, value]) => {
entity[key] = value;
});
return Promise.resolve(entity);
}),
delete: jest
.fn()
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.mockImplementationOnce((params?: any) => {
throw new Prisma.PrismaClientKnownRequestError('unknown request', {
code: 'code',
clientVersion: 'version',
});
})
.mockImplementation((params: any) => {
let found = false;
fakeEntities.forEach((entity, index) => {
if (entity.uuid === params?.where?.uuid) {
found = true;
fakeEntities.splice(index, 1);
}
});
if (!found) {
throw new Error();
}
}),
deleteMany: jest
.fn()
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.mockImplementationOnce((params?: any) => {
throw new Prisma.PrismaClientKnownRequestError('unknown request', {
code: 'code',
clientVersion: 'version',
});
})
.mockImplementation((params: any) => {
let found = false;
fakeEntities.forEach((entity, index) => {
if (entity.uuid === params?.where?.uuid) {
found = true;
fakeEntities.splice(index, 1);
}
});
if (!found) {
throw new Error();
}
}),
},
};
describe('PrismaRepository', () => {
let fakeRepository: FakePrismaRepository;
let prisma: FakePrismaService;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
FakePrismaRepository,
{
provide: PrismaService,
useValue: mockPrismaService,
},
],
}).compile();
fakeRepository = module.get<FakePrismaRepository>(FakePrismaRepository);
prisma = module.get<PrismaService>(PrismaService) as FakePrismaService;
});
it('should be defined', () => {
expect(fakeRepository).toBeDefined();
expect(prisma).toBeDefined();
});
describe('findAll', () => {
it('should return an array of entities', async () => {
jest.spyOn(prisma.fake, 'findMany');
jest.spyOn(prisma.fake, 'count');
jest.spyOn(prisma, '$transaction');
const entities = await fakeRepository.findAll();
expect(entities).toStrictEqual({
data: fakeEntities,
total: fakeEntities.length,
});
});
it('should return an array containing only one entity', async () => {
const entities = await fakeRepository.findAll(1, 10, { limit: 1 });
expect(prisma.fake.findMany).toHaveBeenCalledWith({
skip: 0,
take: 10,
where: { limit: 1 },
});
expect(entities).toEqual({
data: [fakeEntityCreated],
total: 1,
});
});
});
describe('create', () => {
it('should create an entity', async () => {
jest.spyOn(prisma.fake, 'create');
const newEntity = await fakeRepository.create(fakeEntityToCreate);
expect(newEntity).toBe(fakeEntityCreated);
expect(prisma.fake.create).toHaveBeenCalledTimes(1);
});
it('should throw a DatabaseException for client error', async () => {
await expect(
fakeRepository.create(fakeEntityToCreate),
).rejects.toBeInstanceOf(DatabaseException);
});
it('should throw a DatabaseException if uuid is not found', async () => {
await expect(
fakeRepository.create(fakeEntityToCreate),
).rejects.toBeInstanceOf(DatabaseException);
});
});
describe('findOneByUuid', () => {
it('should find an entity by uuid', async () => {
const entity = await fakeRepository.findOneByUuid(fakeEntities[0].uuid);
expect(entity).toBe(fakeEntities[0]);
});
it('should throw a DatabaseException for client error', async () => {
await expect(
fakeRepository.findOneByUuid('unknown'),
).rejects.toBeInstanceOf(DatabaseException);
});
it('should throw a DatabaseException if uuid is not found', async () => {
await expect(
fakeRepository.findOneByUuid('wrong-uuid'),
).rejects.toBeInstanceOf(DatabaseException);
});
});
describe('findOne', () => {
it('should find one entity', async () => {
const entity = await fakeRepository.findOne({
name: fakeEntities[0].name,
});
expect(entity.name).toBe(fakeEntities[0].name);
});
it('should throw a DatabaseException for client error', async () => {
await expect(
fakeRepository.findOne({
name: fakeEntities[0].name,
}),
).rejects.toBeInstanceOf(DatabaseException);
});
it('should throw a DatabaseException for unknown error', async () => {
await expect(
fakeRepository.findOne({
name: fakeEntities[0].name,
}),
).rejects.toBeInstanceOf(DatabaseException);
});
});
describe('update', () => {
it('should throw a DatabaseException for client error', async () => {
await expect(
fakeRepository.update('fake-uuid', { name: 'error' }),
).rejects.toBeInstanceOf(DatabaseException);
await expect(
fakeRepository.updateWhere({ name: 'error' }, { name: 'new error' }),
).rejects.toBeInstanceOf(DatabaseException);
});
it('should update an entity with name', async () => {
const newName = 'new-random-name';
await fakeRepository.updateWhere(
{ name: fakeEntities[0].name },
{
name: newName,
},
);
expect(fakeEntities[0].name).toBe(newName);
});
it('should update an entity with uuid', async () => {
const newName = 'random-name';
await fakeRepository.update(fakeEntities[0].uuid, {
name: newName,
});
expect(fakeEntities[0].name).toBe(newName);
});
it("should throw an exception if an entity doesn't exist", async () => {
await expect(
fakeRepository.update('fake-uuid', { name: 'error' }),
).rejects.toBeInstanceOf(DatabaseException);
await expect(
fakeRepository.updateWhere({ name: 'error' }, { name: 'new error' }),
).rejects.toBeInstanceOf(DatabaseException);
});
});
describe('delete', () => {
it('should throw a DatabaseException for client error', async () => {
await expect(fakeRepository.delete('fake-uuid')).rejects.toBeInstanceOf(
DatabaseException,
);
});
it('should delete an entity', async () => {
const savedUuid = fakeEntities[0].uuid;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const res = await fakeRepository.delete(savedUuid);
const deletedEntity = fakeEntities.find(
(entity) => entity.uuid === savedUuid,
);
expect(deletedEntity).toBeUndefined();
});
it("should throw an exception if an entity doesn't exist", async () => {
await expect(fakeRepository.delete('fake-uuid')).rejects.toBeInstanceOf(
DatabaseException,
);
});
});
describe('deleteMany', () => {
it('should throw a DatabaseException for client error', async () => {
await expect(
fakeRepository.deleteMany({ uuid: 'fake-uuid' }),
).rejects.toBeInstanceOf(DatabaseException);
});
it('should delete entities based on their uuid', async () => {
const savedUuid = fakeEntities[0].uuid;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const res = await fakeRepository.deleteMany({ uuid: savedUuid });
const deletedEntity = fakeEntities.find(
(entity) => entity.uuid === savedUuid,
);
expect(deletedEntity).toBeUndefined();
});
it("should throw an exception if an entity doesn't exist", async () => {
await expect(
fakeRepository.deleteMany({ uuid: 'fake-uuid' }),
).rejects.toBeInstanceOf(DatabaseException);
});
});
describe('findAllByquery', () => {
it('should return an array of entities', async () => {
const entities = await fakeRepository.findAllByQuery(
['uuid', 'name'],
['name is not null'],
);
expect(entities).toStrictEqual({
data: fakeEntities,
total: fakeEntities.length,
});
});
});
describe('createWithFields', () => {
it('should create an entity', async () => {
jest.spyOn(prisma, '$queryRawUnsafe');
const newEntity = await fakeRepository.createWithFields({
uuid: '804319b3-a09b-4491-9f82-7976bfce0aff',
name: 'my-name',
});
expect(newEntity).toBe(fakeEntityCreated);
expect(prisma.$queryRawUnsafe).toHaveBeenCalledTimes(1);
});
it('should throw a DatabaseException for client error', async () => {
await expect(
fakeRepository.createWithFields({
uuid: '804319b3-a09b-4491-9f82-7976bfce0aff',
name: 'my-name',
}),
).rejects.toBeInstanceOf(DatabaseException);
});
it('should throw a DatabaseException if uuid is not found', async () => {
await expect(
fakeRepository.createWithFields({
name: 'my-name',
}),
).rejects.toBeInstanceOf(DatabaseException);
});
});
describe('updateWithFields', () => {
it('should update an entity', async () => {
jest.spyOn(prisma, '$queryRawUnsafe');
const updatedEntity = await fakeRepository.updateWithFields(
'804319b3-a09b-4491-9f82-7976bfce0aff',
{
name: 'my-name',
},
);
expect(updatedEntity).toBe(fakeEntityCreated);
expect(prisma.$queryRawUnsafe).toHaveBeenCalledTimes(1);
});
it('should throw a DatabaseException for client error', async () => {
await expect(
fakeRepository.updateWithFields(
'804319b3-a09b-4491-9f82-7976bfce0aff',
{
name: 'my-name',
},
),
).rejects.toBeInstanceOf(DatabaseException);
});
it('should throw a DatabaseException if uuid is not found', async () => {
await expect(
fakeRepository.updateWithFields(
'804319b3-a09b-4491-9f82-7976bfce0aff',
{
name: 'my-name',
},
),
).rejects.toBeInstanceOf(DatabaseException);
});
});
describe('healthCheck', () => {
it('should throw a DatabaseException for client error', async () => {
await expect(fakeRepository.healthCheck()).rejects.toBeInstanceOf(
DatabaseException,
);
});
it('should return a healthy result', async () => {
const res = await fakeRepository.healthCheck();
expect(res).toBeTruthy();
});
it('should throw an exception if database is not available', async () => {
await expect(fakeRepository.healthCheck()).rejects.toBeInstanceOf(
DatabaseException,
);
});
});
});

View File

@ -1,42 +0,0 @@
import { Controller } from '@nestjs/common';
import { GrpcMethod } from '@nestjs/microservices';
import { PrismaHealthIndicatorUseCase } from '../../domain/usecases/prisma.health-indicator.usecase';
enum ServingStatus {
UNKNOWN = 0,
SERVING = 1,
NOT_SERVING = 2,
}
interface HealthCheckRequest {
service: string;
}
interface HealthCheckResponse {
status: ServingStatus;
}
@Controller()
export class HealthServerController {
constructor(
private readonly prismaHealthIndicatorUseCase: PrismaHealthIndicatorUseCase,
) {}
@GrpcMethod('Health', 'Check')
async check(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
data: HealthCheckRequest,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
metadata: any,
): Promise<HealthCheckResponse> {
const healthCheck = await this.prismaHealthIndicatorUseCase.isHealthy(
'prisma',
);
return {
status:
healthCheck['prisma'].status == 'up'
? ServingStatus.SERVING
: ServingStatus.NOT_SERVING,
};
}
}

View File

@ -1,36 +0,0 @@
import { Controller, Get, Inject } from '@nestjs/common';
import {
HealthCheckService,
HealthCheck,
HealthCheckResult,
} from '@nestjs/terminus';
import { PrismaHealthIndicatorUseCase } from '../../domain/usecases/prisma.health-indicator.usecase';
import { MESSAGE_PUBLISHER } from 'src/app.constants';
import { IPublishMessage } from 'src/interfaces/message-publisher';
@Controller('health')
export class HealthController {
constructor(
private readonly prismaHealthIndicatorUseCase: PrismaHealthIndicatorUseCase,
private readonly healthCheckService: HealthCheckService,
@Inject(MESSAGE_PUBLISHER)
private readonly messagePublisher: IPublishMessage,
) {}
@Get()
@HealthCheck()
async check() {
try {
return await this.healthCheckService.check([
async () => this.prismaHealthIndicatorUseCase.isHealthy('prisma'),
]);
} catch (error) {
const healthCheckResult: HealthCheckResult = error.response;
this.messagePublisher.publish(
'logging.user.health.crit',
JSON.stringify(healthCheckResult.error),
);
throw error;
}
}
}

View File

@ -1,16 +0,0 @@
import { Inject, Injectable } from '@nestjs/common';
import { MESSAGE_BROKER_PUBLISHER } from '../../../../app.constants';
import { MessageBrokerPublisher } from '@mobicoop/message-broker-module';
import { IPublishMessage } from 'src/interfaces/message-publisher';
@Injectable()
export class MessagePublisher implements IPublishMessage {
constructor(
@Inject(MESSAGE_BROKER_PUBLISHER)
private readonly messageBrokerPublisher: MessageBrokerPublisher,
) {}
publish = (routingKey: string, message: string): void => {
this.messageBrokerPublisher.publish(routingKey, message);
};
}

View File

@ -1,25 +0,0 @@
import { Injectable } from '@nestjs/common';
import {
HealthCheckError,
HealthIndicator,
HealthIndicatorResult,
} from '@nestjs/terminus';
import { UsersRepository } from '../../../user/adapters/secondaries/users.repository';
@Injectable()
export class PrismaHealthIndicatorUseCase extends HealthIndicator {
constructor(private readonly repository: UsersRepository) {
super();
}
isHealthy = async (key: string): Promise<HealthIndicatorResult> => {
try {
await this.repository.healthCheck();
return this.getStatus(key, true);
} catch (e) {
throw new HealthCheckError('Prisma', {
prisma: e.message,
});
}
};
}

View File

@ -1,28 +0,0 @@
import { Module } from '@nestjs/common';
import { HealthServerController } from './adapters/primaries/health-server.controller';
import { PrismaHealthIndicatorUseCase } from './domain/usecases/prisma.health-indicator.usecase';
import { UsersRepository } from '../user/adapters/secondaries/users.repository';
import { DatabaseModule } from '../database/database.module';
import { HealthController } from './adapters/primaries/health.controller';
import { TerminusModule } from '@nestjs/terminus';
import { MESSAGE_BROKER_PUBLISHER, MESSAGE_PUBLISHER } from 'src/app.constants';
import { MessageBrokerPublisher } from '@mobicoop/message-broker-module';
import { MessagePublisher } from './adapters/secondaries/message-publisher';
@Module({
imports: [TerminusModule, DatabaseModule],
controllers: [HealthServerController, HealthController],
providers: [
PrismaHealthIndicatorUseCase,
UsersRepository,
{
provide: MESSAGE_BROKER_PUBLISHER,
useClass: MessageBrokerPublisher,
},
{
provide: MESSAGE_PUBLISHER,
useClass: MessagePublisher,
},
],
})
export class HealthModule {}

View File

@ -1,36 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { MessagePublisher } from '../../adapters/secondaries/message-publisher';
import { MESSAGE_BROKER_PUBLISHER } from '../../../../app.constants';
const mockMessageBrokerPublisher = {
publish: jest.fn().mockImplementation(),
};
describe('Messager', () => {
let messagePublisher: MessagePublisher;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [],
providers: [
MessagePublisher,
{
provide: MESSAGE_BROKER_PUBLISHER,
useValue: mockMessageBrokerPublisher,
},
],
}).compile();
messagePublisher = module.get<MessagePublisher>(MessagePublisher);
});
it('should be defined', () => {
expect(messagePublisher).toBeDefined();
});
it('should publish a message', async () => {
jest.spyOn(mockMessageBrokerPublisher, 'publish');
messagePublisher.publish('health.info', 'my-test');
expect(mockMessageBrokerPublisher.publish).toHaveBeenCalledTimes(1);
});
});

View File

@ -1,58 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { PrismaHealthIndicatorUseCase } from '../../domain/usecases/prisma.health-indicator.usecase';
import { UsersRepository } from '../../../user/adapters/secondaries/users.repository';
import { HealthCheckError, HealthIndicatorResult } from '@nestjs/terminus';
import { Prisma } from '@prisma/client';
const mockUsersRepository = {
healthCheck: jest
.fn()
.mockImplementationOnce(() => {
return Promise.resolve(true);
})
.mockImplementation(() => {
throw new Prisma.PrismaClientKnownRequestError('Service unavailable', {
code: 'code',
clientVersion: 'version',
});
}),
};
describe('PrismaHealthIndicatorUseCase', () => {
let prismaHealthIndicatorUseCase: PrismaHealthIndicatorUseCase;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: UsersRepository,
useValue: mockUsersRepository,
},
PrismaHealthIndicatorUseCase,
],
}).compile();
prismaHealthIndicatorUseCase = module.get<PrismaHealthIndicatorUseCase>(
PrismaHealthIndicatorUseCase,
);
});
it('should be defined', () => {
expect(prismaHealthIndicatorUseCase).toBeDefined();
});
describe('execute', () => {
it('should check health successfully', async () => {
const healthIndicatorResult: HealthIndicatorResult =
await prismaHealthIndicatorUseCase.isHealthy('prisma');
expect(healthIndicatorResult['prisma'].status).toBe('up');
});
it('should throw an error if database is unavailable', async () => {
await expect(
prismaHealthIndicatorUseCase.isHealthy('prisma'),
).rejects.toBeInstanceOf(HealthCheckError);
});
});
});

View File

@ -0,0 +1 @@
export const MESSAGE_PUBLISHER = Symbol('MESSAGE_PUBLISHER');

View File

@ -0,0 +1,36 @@
import { Module, Provider } from '@nestjs/common';
import { MESSAGE_PUBLISHER } from './messager.di-tokens';
import {
MessageBrokerModule,
MessageBrokerModuleOptions,
MessageBrokerPublisher,
} from '@mobicoop/message-broker-module';
import { ConfigModule, ConfigService } from '@nestjs/config';
const imports = [
MessageBrokerModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (
configService: ConfigService,
): Promise<MessageBrokerModuleOptions> => ({
uri: configService.get<string>('MESSAGE_BROKER_URI'),
exchange: configService.get<string>('MESSAGE_BROKER_EXCHANGE'),
name: 'user',
}),
}),
];
const providers: Provider[] = [
{
provide: MESSAGE_PUBLISHER,
useClass: MessageBrokerPublisher,
},
];
@Module({
imports,
providers,
exports: [MESSAGE_PUBLISHER],
})
export class MessagerModule {}

View File

@ -0,0 +1,16 @@
import { Command, CommandProps } from '@mobicoop/ddd-library';
export class CreateUserCommand extends Command {
readonly firstName?: string;
readonly lastName?: string;
readonly email?: string;
readonly phone?: string;
constructor(props: CommandProps<CreateUserCommand>) {
super(props);
this.firstName = props.firstName;
this.lastName = props.lastName;
this.email = props.email;
this.phone = props.phone;
}
}

View File

@ -0,0 +1,35 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common';
import { AggregateID, ConflictException } from '@mobicoop/ddd-library';
import { CreateUserCommand } from './create-user.command';
import { USER_REPOSITORY } from '@modules/user/user.di-tokens';
import { UserRepositoryPort } from '../../ports/user.repository.port';
import { UserEntity } from '../../../domain/user.entity';
import { UserAlreadyExistsException } from '../../../domain/user.errors';
@CommandHandler(CreateUserCommand)
export class CreateUserService implements ICommandHandler {
constructor(
@Inject(USER_REPOSITORY)
private readonly repository: UserRepositoryPort,
) {}
async execute(command: CreateUserCommand): Promise<AggregateID> {
const user = UserEntity.create({
firstName: command.firstName,
lastName: command.lastName,
email: command.email,
phone: command.phone,
});
try {
await this.repository.insert(user);
return user.id;
} catch (error: any) {
if (error instanceof ConflictException) {
throw new UserAlreadyExistsException(error);
}
throw error;
}
}
}

View File

@ -0,0 +1,16 @@
import { Command, CommandProps } from '@mobicoop/ddd-library';
export class UpdateUserCommand extends Command {
readonly firstName?: string;
readonly lastName?: string;
readonly email?: string;
readonly phone?: string;
constructor(props: CommandProps<UpdateUserCommand>) {
super(props);
this.firstName = props.firstName;
this.lastName = props.lastName;
this.email = props.email;
this.phone = props.phone;
}
}

View File

@ -0,0 +1,57 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common';
import {
AggregateID,
ConflictException,
UniqueConstraintException,
} from '@mobicoop/ddd-library';
import { USER_REPOSITORY } from '@modules/user/user.di-tokens';
import { UserRepositoryPort } from '../../ports/user.repository.port';
import { UserEntity } from '../../../domain/user.entity';
import { UpdateUserCommand } from './update-user.command';
import {
EmailAlreadyExistsException,
PhoneAlreadyExistsException,
UserAlreadyExistsException,
} from '@modules/user/core/domain/user.errors';
@CommandHandler(UpdateUserCommand)
export class UpdateUserService implements ICommandHandler {
constructor(
@Inject(USER_REPOSITORY)
private readonly userRepository: UserRepositoryPort,
) {}
async execute(command: UpdateUserCommand): Promise<AggregateID> {
try {
const user: UserEntity = await this.userRepository.findOneById(
command.id,
);
user.update({
firstName: command.firstName,
lastName: command.lastName,
email: command.email,
phone: command.phone,
});
await this.userRepository.update(user.id, user);
return user.id;
} catch (error: any) {
if (error instanceof ConflictException) {
throw new UserAlreadyExistsException(error);
}
if (
error instanceof UniqueConstraintException &&
error.message.includes('email')
) {
throw new EmailAlreadyExistsException(error);
}
if (
error instanceof UniqueConstraintException &&
error.message.includes('phone')
) {
throw new PhoneAlreadyExistsException(error);
}
throw error;
}
}
}

View File

@ -0,0 +1,4 @@
import { RepositoryPort } from '@mobicoop/ddd-library';
import { UserEntity } from '../../domain/user.entity';
export type UserRepositoryPort = RepositoryPort<UserEntity>;

View File

@ -0,0 +1,16 @@
import { DomainEvent, DomainEventProps } from '@mobicoop/ddd-library';
export class UserCreatedDomainEvent extends DomainEvent {
readonly firstName: string;
readonly lastName: string;
readonly email: string;
readonly phone: string;
constructor(props: DomainEventProps<UserCreatedDomainEvent>) {
super(props);
this.firstName = props.firstName;
this.lastName = props.lastName;
this.email = props.email;
this.phone = props.phone;
}
}

View File

@ -0,0 +1,16 @@
import { DomainEvent, DomainEventProps } from '@mobicoop/ddd-library';
export class UserUpdatedDomainEvent extends DomainEvent {
readonly firstName: string;
readonly lastName: string;
readonly email: string;
readonly phone: string;
constructor(props: DomainEventProps<UserUpdatedDomainEvent>) {
super(props);
this.firstName = props.firstName;
this.lastName = props.lastName;
this.email = props.email;
this.phone = props.phone;
}
}

View File

@ -0,0 +1,46 @@
import { AggregateRoot, AggregateID } from '@mobicoop/ddd-library';
import { v4 } from 'uuid';
import { CreateUserProps, UpdateUserProps, UserProps } from './user.types';
import { UserCreatedDomainEvent } from './events/user-created.domain-events';
import { UserUpdatedDomainEvent } from './events/user-updated.domain-events';
export class UserEntity extends AggregateRoot<UserProps> {
protected readonly _id: AggregateID;
static create = (create: CreateUserProps): UserEntity => {
const id = v4();
const props: UserProps = { ...create };
const user = new UserEntity({ id, props });
user.addEvent(
new UserCreatedDomainEvent({
aggregateId: id,
firstName: props.firstName,
lastName: props.lastName,
email: props.email,
phone: props.phone,
}),
);
return user;
};
update(props: UpdateUserProps): void {
this.props.firstName = props.firstName;
this.props.lastName = props.lastName;
this.props.email = props.email;
this.props.phone = props.phone;
this.addEvent(
new UserUpdatedDomainEvent({
aggregateId: this._id,
firstName: props.firstName,
lastName: props.lastName,
email: props.email,
phone: props.phone,
}),
);
}
validate(): void {
// entity business rules validation to protect it's invariant before saving entity to a database
}
}

View File

@ -0,0 +1,31 @@
import { ExceptionBase } from '@mobicoop/ddd-library';
export class UserAlreadyExistsException extends ExceptionBase {
static readonly message = 'User already exists';
public readonly code = 'USER.ALREADY_EXISTS';
constructor(cause?: Error, metadata?: unknown) {
super(UserAlreadyExistsException.message, cause, metadata);
}
}
export class EmailAlreadyExistsException extends ExceptionBase {
static readonly message = 'Email already exists';
public readonly code = 'EMAIL.ALREADY_EXISTS';
constructor(cause?: Error, metadata?: unknown) {
super(EmailAlreadyExistsException.message, cause, metadata);
}
}
export class PhoneAlreadyExistsException extends ExceptionBase {
static readonly message = 'Phone already exists';
public readonly code = 'PHONE.ALREADY_EXISTS';
constructor(cause?: Error, metadata?: unknown) {
super(PhoneAlreadyExistsException.message, cause, metadata);
}
}

View File

@ -0,0 +1,22 @@
// All properties that a User has
export interface UserProps {
firstName: string;
lastName: string;
email: string;
phone: string;
}
// Properties that are needed for a User creation
export interface CreateUserProps {
firstName: string;
lastName: string;
email: string;
phone: string;
}
export interface UpdateUserProps {
firstName: string;
lastName: string;
email: string;
phone: string;
}

View File

@ -0,0 +1,51 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import {
LoggerBase,
MessagePublisherPort,
PrismaRepositoryBase,
} from '@mobicoop/ddd-library';
import { PrismaService } from './prisma.service';
import { UserEntity } from '../core/domain/user.entity';
import { UserRepositoryPort } from '../core/application/ports/user.repository.port';
import { UserMapper } from '../user.mapper';
import { USER_MESSAGE_PUBLISHER } from '../user.di-tokens';
export type UserModel = {
uuid: string;
firstName: string;
lastName: string;
email: string;
phone: string;
createdAt: Date;
updatedAt: Date;
};
/**
* Repository is used for retrieving/saving domain entities
* */
@Injectable()
export class UserRepository
extends PrismaRepositoryBase<UserEntity, UserModel, UserModel>
implements UserRepositoryPort
{
constructor(
prisma: PrismaService,
mapper: UserMapper,
eventEmitter: EventEmitter2,
@Inject(USER_MESSAGE_PUBLISHER)
protected readonly messagePublisher: MessagePublisherPort,
) {
super(
prisma.user,
prisma,
mapper,
eventEmitter,
new LoggerBase({
logger: new Logger(UserRepository.name),
domain: 'user',
messagePublisher,
}),
);
}
}

View File

@ -0,0 +1,41 @@
import { Controller, UsePipes } from '@nestjs/common';
import { CommandBus } from '@nestjs/cqrs';
import { GrpcMethod, RpcException } from '@nestjs/microservices';
import { AggregateID } from '@mobicoop/ddd-library';
import { IdResponse } from '@mobicoop/ddd-library';
import { RpcExceptionCode } from '@mobicoop/ddd-library';
import { RpcValidationPipe } from '@mobicoop/ddd-library';
import { CreateUserRequestDto } from './dtos/create-user.request.dto';
import { CreateUserCommand } from '@modules/user/core/application/commands/create-user/create-user.command';
import { UserAlreadyExistsException } from '@modules/user/core/domain/user.errors';
@UsePipes(
new RpcValidationPipe({
whitelist: false,
forbidUnknownValues: false,
}),
)
@Controller()
export class CreateUserGrpcController {
constructor(private readonly commandBus: CommandBus) {}
@GrpcMethod('UserService', 'Create')
async create(data: CreateUserRequestDto): Promise<IdResponse> {
try {
const aggregateID: AggregateID = await this.commandBus.execute(
new CreateUserCommand(data),
);
return new IdResponse(aggregateID);
} catch (error: any) {
if (error instanceof UserAlreadyExistsException)
throw new RpcException({
code: RpcExceptionCode.ALREADY_EXISTS,
message: error.message,
});
throw new RpcException({
code: RpcExceptionCode.UNKNOWN,
message: error.message,
});
}
}
}

View File

@ -0,0 +1,19 @@
import { IsEmail, IsOptional, IsPhoneNumber, IsString } from 'class-validator';
export class CreateUserRequestDto {
@IsString()
@IsOptional()
firstName: string;
@IsString()
@IsOptional()
lastName: string;
@IsEmail()
@IsOptional()
email?: string;
@IsPhoneNumber()
@IsOptional()
phone?: string;
}

View File

@ -0,0 +1,22 @@
import { IsEmail, IsOptional, IsPhoneNumber, IsString } from 'class-validator';
export class UpdateUserRequestDto {
@IsString()
id: string;
@IsString()
@IsOptional()
firstName?: string;
@IsString()
@IsOptional()
lastName?: string;
@IsEmail()
@IsOptional()
email?: string;
@IsPhoneNumber()
@IsOptional()
phone?: string;
}

View File

@ -0,0 +1,56 @@
import {
AggregateID,
IdResponse,
RpcExceptionCode,
RpcValidationPipe,
} from '@mobicoop/ddd-library';
import { Controller, UsePipes } from '@nestjs/common';
import { CommandBus } from '@nestjs/cqrs';
import { GrpcMethod, RpcException } from '@nestjs/microservices';
import { UpdateUserRequestDto } from './dtos/update-user.request.dto';
import { UpdateUserCommand } from '@modules/user/core/application/commands/update-user/update-user.command';
import {
EmailAlreadyExistsException,
PhoneAlreadyExistsException,
UserAlreadyExistsException,
} from '@modules/user/core/domain/user.errors';
@UsePipes(
new RpcValidationPipe({
whitelist: true,
forbidUnknownValues: false,
}),
)
@Controller()
export class UpdateUserGrpcController {
constructor(private readonly commandBus: CommandBus) {}
@GrpcMethod('UserService', 'UpdateUser')
async updateUser(data: UpdateUserRequestDto): Promise<IdResponse> {
try {
const aggregateID: AggregateID = await this.commandBus.execute(
new UpdateUserCommand({
firstName: data.firstName,
lastName: data.lastName,
email: data.email,
phone: data.phone,
}),
);
return new IdResponse(aggregateID);
} catch (error: any) {
if (
error instanceof UserAlreadyExistsException ||
error instanceof EmailAlreadyExistsException ||
error instanceof PhoneAlreadyExistsException
)
throw new RpcException({
code: RpcExceptionCode.ALREADY_EXISTS,
message: error.message,
});
throw new RpcException({
code: RpcExceptionCode.UNKNOWN,
message: error.message,
});
}
}
}

View File

@ -0,0 +1,35 @@
syntax = "proto3";
package user;
service UserService {
rpc FindOneByUuid(UserByUuid) returns (User);
rpc FindAll(UserFilter) returns (Users);
rpc Create(User) returns (User);
rpc Update(User) returns (User);
rpc Delete(UserByUuid) returns (Empty);
}
message UserByUuid {
string uuid = 1;
}
message User {
string uuid = 1;
string firstName = 2;
string lastName = 3;
string email = 4;
string phone = 5;
}
message UserFilter {
optional int32 page = 1;
optional int32 perPage = 2;
}
message Users {
repeated User data = 1;
int32 total = 2;
}
message Empty {}

View File

@ -0,0 +1,6 @@
import { PaginatedResponseDto } from '@mobicoop/ddd-library';
import { UserResponseDto } from './user.response.dto';
export class UserPaginatedResponseDto extends PaginatedResponseDto<UserResponseDto> {
readonly data: readonly UserResponseDto[];
}

View File

@ -0,0 +1,8 @@
import { ResponseBase } from '@mobicoop/ddd-library';
export class UserResponseDto extends ResponseBase {
firstName: string;
lastName: string;
email: string;
phone: string;
}

View File

@ -0,0 +1,119 @@
import { UserEntity } from '@modules/user/core/domain/user.entity';
import { CreateUserProps } from '@modules/user/core/domain/user.types';
import { PrismaService } from '@modules/user/infrastructure/prisma.service';
import { UserRepository } from '@modules/user/infrastructure/user.repository';
import {
USER_MESSAGE_PUBLISHER,
USER_REPOSITORY,
} from '@modules/user/user.di-tokens';
import { UserMapper } from '@modules/user/user.mapper';
import { ConfigModule } from '@nestjs/config';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { Test } from '@nestjs/testing';
describe('User Repository', () => {
let prismaService: PrismaService;
let userRepository: UserRepository;
const executeInsertCommand = async (table: string, object: any) => {
const command = `INSERT INTO ${table} ("${Object.keys(object).join(
'","',
)}") VALUES (${Object.values(object).join(',')})`;
await prismaService.$executeRawUnsafe(command);
};
const getSeed = (index: number, uuid: string): string => {
return `'${uuid.slice(0, -2)}${index.toString(16).padStart(2, '0')}'`;
};
const baseUuid = {
uuid: 'be459a29-7a41-4c0b-b371-abe90bfb6f00',
};
const createUsers = async (nbToCreate = 10) => {
for (let i = 0; i < nbToCreate; i++) {
const userToCreate = {
uuid: getSeed(i, baseUuid.uuid),
firstName: `John${i}`,
lastName: `Doe${i}`,
email: `john.doe${i}@email.com`,
phone: `+33611223344${i}`,
};
userToCreate.uuid = getSeed(i, baseUuid.uuid);
await executeInsertCommand('user', userToCreate);
}
};
const mockMessagePublisher = {
publish: jest.fn().mockImplementation(),
};
const mockLogger = {
log: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
};
beforeAll(async () => {
const module = await Test.createTestingModule({
imports: [
EventEmitterModule.forRoot(),
ConfigModule.forRoot({ isGlobal: true }),
],
providers: [
PrismaService,
UserMapper,
{
provide: USER_REPOSITORY,
useClass: UserRepository,
},
{
provide: USER_MESSAGE_PUBLISHER,
useValue: mockMessagePublisher,
},
],
})
// disable logging
.setLogger(mockLogger)
.compile();
prismaService = module.get<PrismaService>(PrismaService);
userRepository = module.get<UserRepository>(USER_REPOSITORY);
});
afterAll(async () => {
await prismaService.$disconnect();
});
beforeEach(async () => {
await prismaService.user.deleteMany();
});
describe('findOneById', () => {
it('should return a user', async () => {
await createUsers(1);
const result = await userRepository.findOneById(baseUuid.uuid);
expect(result.id).toBe(baseUuid.uuid);
});
});
describe('create', () => {
it('should create a user', async () => {
const beforeCount = await prismaService.user.count();
const createUserProps: CreateUserProps = {
firstName: 'Jane',
lastName: 'Doe',
email: 'jane.doe@email.com',
phone: '+33622334455',
};
const userToCreate: UserEntity = UserEntity.create(createUserProps);
await userRepository.insert(userToCreate);
const afterCount = await prismaService.user.count();
expect(afterCount - beforeCount).toBe(1);
});
});
});

View File

@ -1,174 +0,0 @@
import { TestingModule, Test } from '@nestjs/testing';
import { DatabaseModule } from '../../../database/database.module';
import { PrismaService } from '../../../database/adapters/secondaries/prisma-service';
import { DatabaseException } from '../../../database/exceptions/database.exception';
import { UsersRepository } from '../../adapters/secondaries/users.repository';
import { User } from '../../domain/entities/user';
describe('UsersRepository', () => {
let prismaService: PrismaService;
let usersRepository: UsersRepository;
const createUsers = async (nbToCreate = 10) => {
for (let i = 0; i < nbToCreate; i++) {
await prismaService.user.create({
data: {
firstName: `firstName-${i}`,
},
});
}
};
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [DatabaseModule],
providers: [UsersRepository, PrismaService],
}).compile();
prismaService = module.get<PrismaService>(PrismaService);
usersRepository = module.get<UsersRepository>(UsersRepository);
});
afterAll(async () => {
await prismaService.$disconnect();
});
beforeEach(async () => {
await prismaService.user.deleteMany();
});
describe('findAll', () => {
it('should return an empty data array', async () => {
const res = await usersRepository.findAll();
expect(res).toEqual({
data: [],
total: 0,
});
});
it('should return a data array with 8 users', async () => {
await createUsers(8);
const users = await usersRepository.findAll();
expect(users.data.length).toBe(8);
expect(users.total).toBe(8);
});
it('should return a data array limited to 10 users', async () => {
await createUsers(20);
const users = await usersRepository.findAll();
expect(users.data.length).toBe(10);
expect(users.total).toBe(20);
});
});
describe('findOneByUuid', () => {
it('should return a user', async () => {
const userToFind = await prismaService.user.create({
data: {
firstName: 'test',
},
});
const user = await usersRepository.findOneByUuid(userToFind.uuid);
expect(user.uuid).toBe(userToFind.uuid);
});
it('should return null', async () => {
const user = await usersRepository.findOneByUuid(
'544572be-11fb-4244-8235-587221fc9104',
);
expect(user).toBeNull();
});
});
describe('findOne', () => {
it('should return a user according to its email', async () => {
const userToFind = await prismaService.user.create({
data: {
email: 'test@test.com',
},
});
const user = await usersRepository.findOne({
email: 'test@test.com',
});
expect(user.uuid).toBe(userToFind.uuid);
});
it('should return null with unknown email', async () => {
const user = await usersRepository.findOne({
email: 'wrong@email.com',
});
expect(user).toBeNull();
});
});
describe('create', () => {
it('should create a user', async () => {
const beforeCount = await prismaService.user.count();
const userToCreate: User = new User();
userToCreate.firstName = 'test';
const user = await usersRepository.create(userToCreate);
const afterCount = await prismaService.user.count();
expect(afterCount - beforeCount).toBe(1);
expect(user.uuid).toBeDefined();
});
});
describe('update', () => {
it('should update user firstName', async () => {
const userToUpdate = await prismaService.user.create({
data: {
firstName: 'test',
},
});
const toUpdate: User = new User();
toUpdate.firstName = 'updated';
const updateduser = await usersRepository.update(
userToUpdate.uuid,
toUpdate,
);
expect(updateduser.uuid).toBe(userToUpdate.uuid);
expect(updateduser.firstName).toBe('updated');
});
it('should throw DatabaseException', async () => {
const toUpdate: User = new User();
toUpdate.firstName = 'updated';
await expect(
usersRepository.update(
'544572be-11fb-4244-8235-587221fc9104',
toUpdate,
),
).rejects.toBeInstanceOf(DatabaseException);
});
});
describe('delete', () => {
it('should delete a user', async () => {
const userToRemove = await prismaService.user.create({
data: {
firstName: 'test',
},
});
await usersRepository.delete(userToRemove.uuid);
const count = await prismaService.user.count();
expect(count).toBe(0);
});
it('should throw DatabaseException', async () => {
await expect(
usersRepository.delete('544572be-11fb-4244-8235-587221fc9104'),
).rejects.toBeInstanceOf(DatabaseException);
});
});
});

View File

@ -0,0 +1,79 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AggregateID } from '@mobicoop/ddd-library';
import { ConflictException } from '@mobicoop/ddd-library';
import { CreateUserRequestDto } from '@modules/user/interface/dtos/grpc-controllers/dtos/create-user.request.dto';
import { CreateUserService } from '@modules/user/core/application/commands/create-user/create-user.service';
import { USER_REPOSITORY } from '@modules/user/user.di-tokens';
import { UserEntity } from '@modules/user/core/domain/user.entity';
import { CreateUserCommand } from '@modules/user/core/application/commands/create-user/create-user.command';
import { UserAlreadyExistsException } from '@modules/user/core/domain/user.errors';
const createUserRequest: CreateUserRequestDto = {
firstName: 'John',
lastName: 'Doe',
email: 'john.doe@email.com',
phone: '+33611223344',
};
const mockUserRepository = {
insert: jest
.fn()
.mockImplementationOnce(() => ({}))
.mockImplementationOnce(() => {
throw new Error();
})
.mockImplementationOnce(() => {
throw new ConflictException('already exists');
}),
};
describe('create-user.service', () => {
let createUserService: CreateUserService;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: USER_REPOSITORY,
useValue: mockUserRepository,
},
CreateUserService,
],
}).compile();
createUserService = module.get<CreateUserService>(CreateUserService);
});
it('should be defined', () => {
expect(createUserService).toBeDefined();
});
describe('execution', () => {
const createUserCommand = new CreateUserCommand(createUserRequest);
it('should create a new user', async () => {
UserEntity.create = jest.fn().mockReturnValue({
id: '047a6ecf-23d4-4d3e-877c-3225d560a8da',
});
const result: AggregateID = await createUserService.execute(
createUserCommand,
);
expect(result).toBe('047a6ecf-23d4-4d3e-877c-3225d560a8da');
});
it('should throw an error if something bad happens', async () => {
UserEntity.create = jest.fn().mockReturnValue({
id: '047a6ecf-23d4-4d3e-877c-3225d560a8da',
});
await expect(
createUserService.execute(createUserCommand),
).rejects.toBeInstanceOf(Error);
});
it('should throw an exception if User already exists', async () => {
UserEntity.create = jest.fn().mockReturnValue({
id: '047a6ecf-23d4-4d3e-877c-3225d560a8da',
});
await expect(
createUserService.execute(createUserCommand),
).rejects.toBeInstanceOf(UserAlreadyExistsException);
});
});
});

View File

@ -0,0 +1,17 @@
import { UserEntity } from '@modules/user/core/domain/user.entity';
import { CreateUserProps } from '@modules/user/core/domain/user.types';
const createUserProps: CreateUserProps = {
firstName: 'John',
lastName: 'Doe',
email: 'john.doe@email.com',
phone: '+33611223344',
};
describe('User entity create', () => {
it('should create a new user entity', async () => {
const userEntity: UserEntity = UserEntity.create(createUserProps);
expect(userEntity.id.length).toBe(36);
expect(userEntity.getProps().email).toBe('john.doe@email.com');
});
});

View File

@ -1,79 +0,0 @@
import { classes } from '@automapper/classes';
import { AutomapperModule } from '@automapper/nestjs';
import { Test, TestingModule } from '@nestjs/testing';
import { UsersRepository } from '../../adapters/secondaries/users.repository';
import { CreateUserCommand } from '../../commands/create-user.command';
import { CreateUserRequest } from '../../domain/dtos/create-user.request';
import { User } from '../../domain/entities/user';
import { CreateUserUseCase } from '../../domain/usecases/create-user.usecase';
import { UserProfile } from '../../mappers/user.profile';
import { MESSAGE_PUBLISHER } from '../../../../app.constants';
const newUserRequest: CreateUserRequest = {
firstName: 'John',
lastName: 'Doe',
email: 'john.doe@email.com',
phone: '0601020304',
};
const newUserCommand: CreateUserCommand = new CreateUserCommand(newUserRequest);
const mockUsersRepository = {
create: jest
.fn()
.mockImplementationOnce(() => {
return Promise.resolve({
...newUserRequest,
uuid: 'bb281075-1b98-4456-89d6-c643d3044a91',
});
})
.mockImplementation(() => {
throw new Error('Already exists');
}),
};
const mockMessagePublisher = {
publish: jest.fn().mockImplementation(),
};
describe('CreateUserUseCase', () => {
let createUserUseCase: CreateUserUseCase;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [AutomapperModule.forRoot({ strategyInitializer: classes() })],
providers: [
{
provide: UsersRepository,
useValue: mockUsersRepository,
},
CreateUserUseCase,
UserProfile,
{
provide: MESSAGE_PUBLISHER,
useValue: mockMessagePublisher,
},
],
}).compile();
createUserUseCase = module.get<CreateUserUseCase>(CreateUserUseCase);
});
it('should be defined', () => {
expect(createUserUseCase).toBeDefined();
});
describe('execute', () => {
it('should create and return a new user', async () => {
const newUser: User = await createUserUseCase.execute(newUserCommand);
expect(newUser.lastName).toBe(newUserRequest.lastName);
expect(newUser.uuid).toBeDefined();
});
it('should throw an error if user already exists', async () => {
await expect(
createUserUseCase.execute(newUserCommand),
).rejects.toBeInstanceOf(Error);
});
});
});

View File

@ -1,93 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { UsersRepository } from '../../adapters/secondaries/users.repository';
import { DeleteUserCommand } from '../../commands/delete-user.command';
import { DeleteUserUseCase } from '../../domain/usecases/delete-user.usecase';
import { MESSAGE_PUBLISHER } from '../../../../app.constants';
const mockUsers = [
{
uuid: 'bb281075-1b98-4456-89d6-c643d3044a91',
firstName: 'John',
lastName: 'Doe',
email: 'john.doe@email.com',
phone: '0601020304',
},
{
uuid: 'bb281075-1b98-4456-89d6-c643d3044a92',
firstName: 'Jane',
lastName: 'Doe',
email: 'jane.doe@email.com',
phone: '0602030405',
},
{
uuid: 'bb281075-1b98-4456-89d6-c643d3044a93',
firstName: 'Jimmy',
lastName: 'Doe',
email: 'jimmy.doe@email.com',
phone: '0603040506',
},
];
const mockUsersRepository = {
delete: jest
.fn()
.mockImplementationOnce((uuid: string) => {
let savedUser = {};
mockUsers.forEach((user, index) => {
if (user.uuid === uuid) {
savedUser = { ...user };
mockUsers.splice(index, 1);
}
});
return savedUser;
})
.mockImplementation(() => {
throw new Error('Error');
}),
};
const mockMessagePublisher = {
publish: jest.fn().mockImplementation(),
};
describe('DeleteUserUseCase', () => {
let deleteUserUseCase: DeleteUserUseCase;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: UsersRepository,
useValue: mockUsersRepository,
},
DeleteUserUseCase,
{
provide: MESSAGE_PUBLISHER,
useValue: mockMessagePublisher,
},
],
}).compile();
deleteUserUseCase = module.get<DeleteUserUseCase>(DeleteUserUseCase);
});
it('should be defined', () => {
expect(deleteUserUseCase).toBeDefined();
});
describe('execute', () => {
it('should delete a user', async () => {
const savedUuid = mockUsers[0].uuid;
const deleteUserCommand = new DeleteUserCommand(savedUuid);
await deleteUserUseCase.execute(deleteUserCommand);
const deletedUser = mockUsers.find((user) => user.uuid === savedUuid);
expect(deletedUser).toBeUndefined();
});
it('should throw an error if user does not exist', async () => {
await expect(
deleteUserUseCase.execute(new DeleteUserCommand('wrong uuid')),
).rejects.toBeInstanceOf(Error);
});
});
});

View File

@ -0,0 +1,36 @@
import { EventEmitter2, EventEmitterModule } from '@nestjs/event-emitter';
import { Test, TestingModule } from '@nestjs/testing';
import { PrismaService } from '@modules/user/infrastructure/prisma.service';
import { UserRepository } from '@modules/user/infrastructure/user.repository';
import { UserMapper } from '@modules/user/user.mapper';
const mockMessagePublisher = {
publish: jest.fn().mockImplementation(),
};
describe('User repository', () => {
let prismaService: PrismaService;
let userMapper: UserMapper;
let eventEmitter: EventEmitter2;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [EventEmitterModule.forRoot()],
providers: [PrismaService, UserMapper],
}).compile();
prismaService = module.get<PrismaService>(PrismaService);
userMapper = module.get<UserMapper>(UserMapper);
eventEmitter = module.get<EventEmitter2>(EventEmitter2);
});
it('should be defined', () => {
expect(
new UserRepository(
prismaService,
userMapper,
eventEmitter,
mockMessagePublisher,
),
).toBeDefined();
});
});

View File

@ -0,0 +1,89 @@
import { IdResponse } from '@mobicoop/ddd-library';
import { RpcExceptionCode } from '@mobicoop/ddd-library';
import { CommandBus } from '@nestjs/cqrs';
import { RpcException } from '@nestjs/microservices';
import { Test, TestingModule } from '@nestjs/testing';
import { UserAlreadyExistsException } from '@modules/user/core/domain/user.errors';
import { CreateUserGrpcController } from '@modules/user/interface/dtos/grpc-controllers/create-user.grpc.controller';
import { CreateUserRequestDto } from '@modules/user/interface/dtos/grpc-controllers/dtos/create-user.request.dto';
const createUserRequest: CreateUserRequestDto = {
firstName: 'John',
lastName: 'Doe',
email: 'john.doe@email.com',
phone: '+33611223344',
};
const mockCommandBus = {
execute: jest
.fn()
.mockImplementationOnce(() => '200d61a8-d878-4378-a609-c19ea71633d2')
.mockImplementationOnce(() => {
throw new UserAlreadyExistsException();
})
.mockImplementationOnce(() => {
throw new Error();
}),
};
describe('Create User Grpc Controller', () => {
let createUserGrpcController: CreateUserGrpcController;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: CommandBus,
useValue: mockCommandBus,
},
CreateUserGrpcController,
],
}).compile();
createUserGrpcController = module.get<CreateUserGrpcController>(
CreateUserGrpcController,
);
});
afterEach(async () => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(createUserGrpcController).toBeDefined();
});
it('should create a new user', async () => {
jest.spyOn(mockCommandBus, 'execute');
const result: IdResponse = await createUserGrpcController.create(
createUserRequest,
);
expect(result).toBeInstanceOf(IdResponse);
expect(result.id).toBe('200d61a8-d878-4378-a609-c19ea71633d2');
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
it('should throw a dedicated RpcException if user already exists', async () => {
jest.spyOn(mockCommandBus, 'execute');
expect.assertions(3);
try {
await createUserGrpcController.create(createUserRequest);
} catch (e: any) {
expect(e).toBeInstanceOf(RpcException);
expect(e.error.code).toBe(RpcExceptionCode.ALREADY_EXISTS);
}
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
it('should throw a generic RpcException', async () => {
jest.spyOn(mockCommandBus, 'execute');
expect.assertions(3);
try {
await createUserGrpcController.create(createUserRequest);
} catch (e: any) {
expect(e).toBeInstanceOf(RpcException);
expect(e.error.code).toBe(RpcExceptionCode.UNKNOWN);
}
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,116 @@
import { IdResponse } from '@mobicoop/ddd-library';
import { RpcExceptionCode } from '@mobicoop/ddd-library';
import {
EmailAlreadyExistsException,
PhoneAlreadyExistsException,
} from '@modules/user/core/domain/user.errors';
import { UpdateUserRequestDto } from '@modules/user/interface/dtos/grpc-controllers/dtos/update-user.request.dto';
import { UpdateUserGrpcController } from '@modules/user/interface/dtos/grpc-controllers/update-user.grpc.controller';
import { CommandBus } from '@nestjs/cqrs';
import { RpcException } from '@nestjs/microservices';
import { Test, TestingModule } from '@nestjs/testing';
const updateFirstNameUserRequest: UpdateUserRequestDto = {
id: 'c97b1783-76cf-4840-b298-b90b13c58894',
firstName: 'Johnny',
};
const updateEmailUserRequest: UpdateUserRequestDto = {
id: 'c97b1783-76cf-4840-b298-b90b13c58894',
email: 'john.doe@already.exists.email.com',
};
const updatePhoneUserRequest: UpdateUserRequestDto = {
id: 'c97b1783-76cf-4840-b298-b90b13c58894',
phone: '+33611223344',
};
const mockCommandBus = {
execute: jest
.fn()
.mockImplementationOnce(() => 'c97b1783-76cf-4840-b298-b90b13c58894')
.mockImplementationOnce(() => {
throw new EmailAlreadyExistsException();
})
.mockImplementationOnce(() => {
throw new PhoneAlreadyExistsException();
})
.mockImplementationOnce(() => {
throw new Error();
}),
};
describe('Update User Grpc Controller', () => {
let updateUserGrpcController: UpdateUserGrpcController;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: CommandBus,
useValue: mockCommandBus,
},
UpdateUserGrpcController,
],
}).compile();
updateUserGrpcController = module.get<UpdateUserGrpcController>(
UpdateUserGrpcController,
);
});
afterEach(async () => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(updateUserGrpcController).toBeDefined();
});
it('should update a user', async () => {
jest.spyOn(mockCommandBus, 'execute');
const result: IdResponse = await updateUserGrpcController.updateUser(
updateFirstNameUserRequest,
);
expect(result).toBeInstanceOf(IdResponse);
expect(result.id).toBe('c97b1783-76cf-4840-b298-b90b13c58894');
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
it('should throw a dedicated RpcException if email already exists', async () => {
jest.spyOn(mockCommandBus, 'execute');
expect.assertions(3);
try {
await updateUserGrpcController.updateUser(updateEmailUserRequest);
} catch (e: any) {
expect(e).toBeInstanceOf(RpcException);
expect(e.error.code).toBe(RpcExceptionCode.ALREADY_EXISTS);
}
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
it('should throw a dedicated RpcException if phone already exists', async () => {
jest.spyOn(mockCommandBus, 'execute');
expect.assertions(3);
try {
await updateUserGrpcController.updateUser(updatePhoneUserRequest);
} catch (e: any) {
expect(e).toBeInstanceOf(RpcException);
expect(e.error.code).toBe(RpcExceptionCode.ALREADY_EXISTS);
}
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
it('should throw a generic RpcException', async () => {
jest.spyOn(mockCommandBus, 'execute');
expect.assertions(3);
try {
await updateUserGrpcController.updateUser(updateFirstNameUserRequest);
} catch (e: any) {
expect(e).toBeInstanceOf(RpcException);
expect(e.error.code).toBe(RpcExceptionCode.UNKNOWN);
}
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
});

View File

@ -1,36 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { MessagePublisher } from '../../adapters/secondaries/message-publisher';
import { MESSAGE_BROKER_PUBLISHER } from '../../../../app.constants';
const mockMessageBrokerPublisher = {
publish: jest.fn().mockImplementation(),
};
describe('Messager', () => {
let messagePublisher: MessagePublisher;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [],
providers: [
MessagePublisher,
{
provide: MESSAGE_BROKER_PUBLISHER,
useValue: mockMessageBrokerPublisher,
},
],
}).compile();
messagePublisher = module.get<MessagePublisher>(MessagePublisher);
});
it('should be defined', () => {
expect(messagePublisher).toBeDefined();
});
it('should publish a message', async () => {
jest.spyOn(mockMessageBrokerPublisher, 'publish');
messagePublisher.publish('user.create.info', 'my-test');
expect(mockMessageBrokerPublisher.publish).toHaveBeenCalledTimes(1);
});
});

View File

@ -1,84 +0,0 @@
import { classes } from '@automapper/classes';
import { AutomapperModule } from '@automapper/nestjs';
import { Test, TestingModule } from '@nestjs/testing';
import { UsersRepository } from '../../adapters/secondaries/users.repository';
import { UpdateUserCommand } from '../../commands/update-user.command';
import { UpdateUserRequest } from '../../domain/dtos/update-user.request';
import { User } from '../../domain/entities/user';
import { UpdateUserUseCase } from '../../domain/usecases/update-user.usecase';
import { UserProfile } from '../../mappers/user.profile';
import { MESSAGE_PUBLISHER } from '../../../../app.constants';
const originalUser: User = new User();
originalUser.uuid = 'bb281075-1b98-4456-89d6-c643d3044a91';
originalUser.lastName = 'Doe';
const updateUserRequest: UpdateUserRequest = {
uuid: 'bb281075-1b98-4456-89d6-c643d3044a91',
lastName: 'Dane',
};
const updateUserCommand: UpdateUserCommand = new UpdateUserCommand(
updateUserRequest,
);
const mockUsersRepository = {
update: jest
.fn()
.mockImplementationOnce((uuid: string, params: any) => {
originalUser.lastName = params.lastName;
return Promise.resolve(originalUser);
})
.mockImplementation(() => {
throw new Error('Error');
}),
};
const mockMessager = {
publish: jest.fn().mockImplementation(),
};
describe('UpdateUserUseCase', () => {
let updateUserUseCase: UpdateUserUseCase;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [AutomapperModule.forRoot({ strategyInitializer: classes() })],
providers: [
{
provide: UsersRepository,
useValue: mockUsersRepository,
},
UpdateUserUseCase,
UserProfile,
{
provide: MESSAGE_PUBLISHER,
useValue: mockMessager,
},
],
}).compile();
updateUserUseCase = module.get<UpdateUserUseCase>(UpdateUserUseCase);
});
it('should be defined', () => {
expect(updateUserUseCase).toBeDefined();
});
describe('execute', () => {
it('should update a user', async () => {
const updatedUser: User = await updateUserUseCase.execute(
updateUserCommand,
);
expect(updatedUser.lastName).toBe(updateUserRequest.lastName);
});
it('should throw an error if user does not exist', async () => {
await expect(
updateUserUseCase.execute(updateUserCommand),
).rejects.toBeInstanceOf(Error);
});
});
});

View File

@ -0,0 +1,57 @@
import { UserEntity } from '@modules/user/core/domain/user.entity';
import { UserModel } from '@modules/user/infrastructure/user.repository';
import { UserResponseDto } from '@modules/user/interface/dtos/user.response.dto';
import { UserMapper } from '@modules/user/user.mapper';
import { Test } from '@nestjs/testing';
const now = new Date('2023-06-21 06:00:00');
const userEntity: UserEntity = new UserEntity({
id: 'c160cf8c-f057-4962-841f-3ad68346df44',
props: {
firstName: 'John',
lastName: 'Doe',
email: 'john.doe@email.com',
phone: '+33611223344',
},
createdAt: now,
updatedAt: now,
});
const userModel: UserModel = {
uuid: 'c160cf8c-f057-4962-841f-3ad68346df44',
firstName: 'John',
lastName: 'Doe',
email: 'john.doe@email.com',
phone: '+33611223344',
createdAt: now,
updatedAt: now,
};
describe('User Mapper', () => {
let userMapper: UserMapper;
beforeAll(async () => {
const module = await Test.createTestingModule({
providers: [UserMapper],
}).compile();
userMapper = module.get<UserMapper>(UserMapper);
});
it('should be defined', () => {
expect(userMapper).toBeDefined();
});
it('should map domain entity to persistence data', async () => {
const mapped: UserModel = userMapper.toPersistence(userEntity);
expect(mapped.lastName).toBe('Doe');
});
it('should map persisted data to domain entity', async () => {
const mapped: UserEntity = userMapper.toDomain(userModel);
expect(mapped.getProps().firstName).toBe('John');
});
it('should map domain entity to response', async () => {
const mapped: UserResponseDto = userMapper.toResponse(userEntity);
expect(mapped.id).toBe('c160cf8c-f057-4962-841f-3ad68346df44');
});
});

View File

@ -0,0 +1,2 @@
export const USER_MESSAGE_PUBLISHER = Symbol('USER_MESSAGE_PUBLISHER');
export const USER_REPOSITORY = Symbol('USER_REPOSITORY');

View File

@ -0,0 +1,64 @@
import { Mapper } from '@mobicoop/ddd-library';
import { Injectable } from '@nestjs/common';
import { UserEntity } from './core/domain/user.entity';
import { UserModel } from './infrastructure/user.repository';
import { UserResponseDto } from './interface/dtos/user.response.dto';
/**
* Mapper constructs objects that are used in different layers:
* Record is an object that is stored in a database,
* Entity is an object that is used in application domain layer,
* and a ResponseDTO is an object returned to a user (usually as json).
*/
@Injectable()
export class UserMapper
implements Mapper<UserEntity, UserModel, UserModel, UserResponseDto>
{
toPersistence = (entity: UserEntity): UserModel => {
const copy = entity.getProps();
const record: UserModel = {
uuid: copy.id,
firstName: copy.firstName,
lastName: copy.lastName,
email: copy.email,
phone: copy.phone,
createdAt: copy.createdAt,
updatedAt: copy.updatedAt,
};
return record;
};
toDomain = (record: UserModel): UserEntity => {
const entity = new UserEntity({
id: record.uuid,
createdAt: new Date(record.createdAt),
updatedAt: new Date(record.updatedAt),
props: {
firstName: record.firstName,
lastName: record.lastName,
email: record.email,
phone: record.phone,
},
});
return entity;
};
toResponse = (entity: UserEntity): UserResponseDto => {
const props = entity.getProps();
const response = new UserResponseDto(entity);
response.firstName = props.firstName;
response.lastName = props.lastName;
response.email = props.email;
response.phone = props.phone;
return response;
};
/* ^ Data returned to the user is whitelisted to avoid leaks.
If a new property is added, like password or a
credit card number, it won't be returned
unless you specifically allow this.
(avoid blacklisting, which will return everything
but blacklisted items, which can lead to a data leak).
*/
}

View File

@ -1,14 +0,0 @@
import { Injectable, ValidationPipe } from '@nestjs/common';
import { RpcException } from '@nestjs/microservices';
@Injectable()
export class RpcValidationPipe extends ValidationPipe {
createExceptionFactory() {
return (validationErrors = []) => {
return new RpcException({
code: 3,
message: this.flattenValidationErrors(validationErrors),
});
};
}
}

View File

@ -1,20 +0,0 @@
import { ArgumentMetadata } from '@nestjs/common';
import { UpdateUserRequest } from '../../../modules/user/domain/dtos/update-user.request';
import { RpcValidationPipe } from '../../pipes/rpc.validation-pipe';
describe('RpcValidationPipe', () => {
it('should not validate request', async () => {
const target: RpcValidationPipe = new RpcValidationPipe({
whitelist: true,
forbidUnknownValues: false,
});
const metadata: ArgumentMetadata = {
type: 'body',
metatype: UpdateUserRequest,
data: '',
};
await target.transform(<UpdateUserRequest>{}, metadata).catch((err) => {
expect(err.message).toEqual('Rpc Exception');
});
});
});

View File

@ -16,6 +16,10 @@
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false
"noFallthroughCasesInSwitch": false,
"paths": {
"@modules/*": ["src/modules/*"],
"@src/*": ["src/*"]
}
}
}