diff --git a/.env.dist b/.env.dist index 8c1344f..8d4b7f0 100644 --- a/.env.dist +++ b/.env.dist @@ -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 diff --git a/package-lock.json b/package-lock.json index 68b8edf..604bacb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" } diff --git a/package.json b/package.json index 956471f..5099135 100644 --- a/package.json +++ b/package.json @@ -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(.*)": "/modules/$1", + "^@src(.*)": "$1" + }, "testEnvironment": "node" } } diff --git a/src/app.constants.ts b/src/app.constants.ts deleted file mode 100644 index ff96e07..0000000 --- a/src/app.constants.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const MESSAGE_BROKER_PUBLISHER = Symbol(); -export const MESSAGE_PUBLISHER = Symbol(); diff --git a/src/app.module.ts b/src/app.module.ts index 4d481cc..9e7c1bc 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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 => ({ + ConfigurationModule.forRootAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: async ( + configService: ConfigService, + ): Promise => ({ + domain: configService.get('SERVICE_CONFIGURATION_DOMAIN'), + messageBroker: { uri: configService.get('MESSAGE_BROKER_URI'), exchange: configService.get('MESSAGE_BROKER_EXCHANGE'), - handlers: {}, - }), - }, - false, - ), - ConfigurationModule.forRootAsync( - { - imports: [ConfigModule], - inject: [ConfigService], - useFactory: async ( - configService: ConfigService, - ): Promise => ({ - domain: configService.get('SERVICE_CONFIGURATION_DOMAIN'), - messageBroker: { - uri: configService.get('MESSAGE_BROKER_URI'), - exchange: configService.get('MESSAGE_BROKER_EXCHANGE'), + }, + redis: { + host: configService.get('REDIS_HOST'), + password: configService.get('REDIS_PASSWORD'), + port: configService.get('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 => ({ + serviceName: 'user', + criticalLoggingKey: 'logging.user.health.crit', + checkRepositories: [ + { + name: 'UserRepository', + repository: userRepository, }, - redis: { - host: configService.get('REDIS_HOST'), - password: configService.get('REDIS_PASSWORD'), - port: configService.get('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 {} diff --git a/src/modules/health/adapters/primaries/health.proto b/src/health.proto similarity index 93% rename from src/modules/health/adapters/primaries/health.proto rename to src/health.proto index 74e1a4c..556c72b 100644 --- a/src/modules/health/adapters/primaries/health.proto +++ b/src/health.proto @@ -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; } diff --git a/src/interfaces/message-publisher.ts b/src/interfaces/message-publisher.ts deleted file mode 100644 index 29ad456..0000000 --- a/src/interfaces/message-publisher.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface IPublishMessage { - publish(routingKey: string, message: string): void; -} diff --git a/src/main.ts b/src/main.ts index 5de5048..4019e8b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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 }, diff --git a/src/modules/database/adapters/secondaries/prisma-repository.abstract.ts b/src/modules/database/adapters/secondaries/prisma-repository.abstract.ts deleted file mode 100644 index c62eaf2..0000000 --- a/src/modules/database/adapters/secondaries/prisma-repository.abstract.ts +++ /dev/null @@ -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 implements IRepository { - protected model: string; - - constructor(protected readonly prisma: PrismaService) {} - - findAll = async ( - page = 1, - perPage = 10, - where?: any, - include?: any, - ): Promise> => { - 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 => { - 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 => { - 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 | any, include?: any): Promise => { - 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): Promise => { - 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 | any, - include?: any, - ): Promise => { - 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 => { - 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 => { - 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> => { - 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 => { - 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 => { - 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 => { - 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(); - } - } - }; -} diff --git a/src/modules/database/database.module.ts b/src/modules/database/database.module.ts deleted file mode 100644 index 82ca949..0000000 --- a/src/modules/database/database.module.ts +++ /dev/null @@ -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 {} diff --git a/src/modules/database/domain/user-repository.ts b/src/modules/database/domain/user-repository.ts deleted file mode 100644 index 3e88dbb..0000000 --- a/src/modules/database/domain/user-repository.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { PrismaRepository } from '../adapters/secondaries/prisma-repository.abstract'; - -export class UserRepository extends PrismaRepository {} diff --git a/src/modules/database/exceptions/database.exception.ts b/src/modules/database/exceptions/database.exception.ts deleted file mode 100644 index b0782a6..0000000 --- a/src/modules/database/exceptions/database.exception.ts +++ /dev/null @@ -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; - } -} diff --git a/src/modules/database/interfaces/collection.interface.ts b/src/modules/database/interfaces/collection.interface.ts deleted file mode 100644 index 6e9a96d..0000000 --- a/src/modules/database/interfaces/collection.interface.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface ICollection { - data: T[]; - total: number; -} diff --git a/src/modules/database/interfaces/repository.interface.ts b/src/modules/database/interfaces/repository.interface.ts deleted file mode 100644 index 1e23984..0000000 --- a/src/modules/database/interfaces/repository.interface.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { ICollection } from './collection.interface'; - -export interface IRepository { - findAll( - page: number, - perPage: number, - params?: any, - include?: any, - ): Promise>; - findOne(where: any, include?: any): Promise; - findOneByUuid(uuid: string, include?: any): Promise; - create(entity: Partial | any, include?: any): Promise; - update(uuid: string, entity: Partial, include?: any): Promise; - updateWhere(where: any, entity: Partial | any, include?: any): Promise; - delete(uuid: string): Promise; - deleteMany(where: any): Promise; - healthCheck(): Promise; -} diff --git a/src/modules/database/tests/unit/prisma-repository.spec.ts b/src/modules/database/tests/unit/prisma-repository.spec.ts deleted file mode 100644 index eb3bad0..0000000 --- a/src/modules/database/tests/unit/prisma-repository.spec.ts +++ /dev/null @@ -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 { - 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); - prisma = module.get(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, - ); - }); - }); -}); diff --git a/src/modules/health/adapters/primaries/health-server.controller.ts b/src/modules/health/adapters/primaries/health-server.controller.ts deleted file mode 100644 index c0d63c8..0000000 --- a/src/modules/health/adapters/primaries/health-server.controller.ts +++ /dev/null @@ -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 { - const healthCheck = await this.prismaHealthIndicatorUseCase.isHealthy( - 'prisma', - ); - return { - status: - healthCheck['prisma'].status == 'up' - ? ServingStatus.SERVING - : ServingStatus.NOT_SERVING, - }; - } -} diff --git a/src/modules/health/adapters/primaries/health.controller.ts b/src/modules/health/adapters/primaries/health.controller.ts deleted file mode 100644 index 8946060..0000000 --- a/src/modules/health/adapters/primaries/health.controller.ts +++ /dev/null @@ -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; - } - } -} diff --git a/src/modules/health/adapters/secondaries/message-publisher.ts b/src/modules/health/adapters/secondaries/message-publisher.ts deleted file mode 100644 index 98a963b..0000000 --- a/src/modules/health/adapters/secondaries/message-publisher.ts +++ /dev/null @@ -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); - }; -} diff --git a/src/modules/health/domain/usecases/prisma.health-indicator.usecase.ts b/src/modules/health/domain/usecases/prisma.health-indicator.usecase.ts deleted file mode 100644 index e30a617..0000000 --- a/src/modules/health/domain/usecases/prisma.health-indicator.usecase.ts +++ /dev/null @@ -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 => { - try { - await this.repository.healthCheck(); - return this.getStatus(key, true); - } catch (e) { - throw new HealthCheckError('Prisma', { - prisma: e.message, - }); - } - }; -} diff --git a/src/modules/health/health.module.ts b/src/modules/health/health.module.ts deleted file mode 100644 index d84565b..0000000 --- a/src/modules/health/health.module.ts +++ /dev/null @@ -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 {} diff --git a/src/modules/health/tests/unit/message-publisher.spec.ts b/src/modules/health/tests/unit/message-publisher.spec.ts deleted file mode 100644 index eec02ea..0000000 --- a/src/modules/health/tests/unit/message-publisher.spec.ts +++ /dev/null @@ -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); - }); - - 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); - }); -}); diff --git a/src/modules/health/tests/unit/prisma.health-indicator.usecase.spec.ts b/src/modules/health/tests/unit/prisma.health-indicator.usecase.spec.ts deleted file mode 100644 index 3bc9312..0000000 --- a/src/modules/health/tests/unit/prisma.health-indicator.usecase.spec.ts +++ /dev/null @@ -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, - ); - }); - - 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); - }); - }); -}); diff --git a/src/modules/messager/messager.di-tokens.ts b/src/modules/messager/messager.di-tokens.ts new file mode 100644 index 0000000..48e8011 --- /dev/null +++ b/src/modules/messager/messager.di-tokens.ts @@ -0,0 +1 @@ +export const MESSAGE_PUBLISHER = Symbol('MESSAGE_PUBLISHER'); diff --git a/src/modules/messager/messager.module.ts b/src/modules/messager/messager.module.ts new file mode 100644 index 0000000..c588861 --- /dev/null +++ b/src/modules/messager/messager.module.ts @@ -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 => ({ + uri: configService.get('MESSAGE_BROKER_URI'), + exchange: configService.get('MESSAGE_BROKER_EXCHANGE'), + name: 'user', + }), + }), +]; + +const providers: Provider[] = [ + { + provide: MESSAGE_PUBLISHER, + useClass: MessageBrokerPublisher, + }, +]; + +@Module({ + imports, + providers, + exports: [MESSAGE_PUBLISHER], +}) +export class MessagerModule {} diff --git a/src/modules/user/core/application/commands/create-user/create-user.command.ts b/src/modules/user/core/application/commands/create-user/create-user.command.ts new file mode 100644 index 0000000..d7bb7c6 --- /dev/null +++ b/src/modules/user/core/application/commands/create-user/create-user.command.ts @@ -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) { + super(props); + this.firstName = props.firstName; + this.lastName = props.lastName; + this.email = props.email; + this.phone = props.phone; + } +} diff --git a/src/modules/user/core/application/commands/create-user/create-user.service.ts b/src/modules/user/core/application/commands/create-user/create-user.service.ts new file mode 100644 index 0000000..077ca82 --- /dev/null +++ b/src/modules/user/core/application/commands/create-user/create-user.service.ts @@ -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 { + 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; + } + } +} diff --git a/src/modules/user/core/application/commands/update-user/update-user.command.ts b/src/modules/user/core/application/commands/update-user/update-user.command.ts new file mode 100644 index 0000000..98fd5bb --- /dev/null +++ b/src/modules/user/core/application/commands/update-user/update-user.command.ts @@ -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) { + super(props); + this.firstName = props.firstName; + this.lastName = props.lastName; + this.email = props.email; + this.phone = props.phone; + } +} diff --git a/src/modules/user/core/application/commands/update-user/update-user.service.ts b/src/modules/user/core/application/commands/update-user/update-user.service.ts new file mode 100644 index 0000000..78d6241 --- /dev/null +++ b/src/modules/user/core/application/commands/update-user/update-user.service.ts @@ -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 { + 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; + } + } +} diff --git a/src/modules/user/core/application/ports/user.repository.port.ts b/src/modules/user/core/application/ports/user.repository.port.ts new file mode 100644 index 0000000..301f865 --- /dev/null +++ b/src/modules/user/core/application/ports/user.repository.port.ts @@ -0,0 +1,4 @@ +import { RepositoryPort } from '@mobicoop/ddd-library'; +import { UserEntity } from '../../domain/user.entity'; + +export type UserRepositoryPort = RepositoryPort; diff --git a/src/modules/user/core/domain/events/user-created.domain-events.ts b/src/modules/user/core/domain/events/user-created.domain-events.ts new file mode 100644 index 0000000..9d2ba93 --- /dev/null +++ b/src/modules/user/core/domain/events/user-created.domain-events.ts @@ -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) { + super(props); + this.firstName = props.firstName; + this.lastName = props.lastName; + this.email = props.email; + this.phone = props.phone; + } +} diff --git a/src/modules/user/core/domain/events/user-updated.domain-events.ts b/src/modules/user/core/domain/events/user-updated.domain-events.ts new file mode 100644 index 0000000..6911679 --- /dev/null +++ b/src/modules/user/core/domain/events/user-updated.domain-events.ts @@ -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) { + super(props); + this.firstName = props.firstName; + this.lastName = props.lastName; + this.email = props.email; + this.phone = props.phone; + } +} diff --git a/src/modules/user/core/domain/user.entity.ts b/src/modules/user/core/domain/user.entity.ts new file mode 100644 index 0000000..8ec3605 --- /dev/null +++ b/src/modules/user/core/domain/user.entity.ts @@ -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 { + 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 + } +} diff --git a/src/modules/user/core/domain/user.errors.ts b/src/modules/user/core/domain/user.errors.ts new file mode 100644 index 0000000..e3ff588 --- /dev/null +++ b/src/modules/user/core/domain/user.errors.ts @@ -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); + } +} diff --git a/src/modules/user/core/domain/user.types.ts b/src/modules/user/core/domain/user.types.ts new file mode 100644 index 0000000..d2c8782 --- /dev/null +++ b/src/modules/user/core/domain/user.types.ts @@ -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; +} diff --git a/src/modules/database/adapters/secondaries/prisma-service.ts b/src/modules/user/infrastructure/prisma.service.ts similarity index 100% rename from src/modules/database/adapters/secondaries/prisma-service.ts rename to src/modules/user/infrastructure/prisma.service.ts diff --git a/src/modules/user/infrastructure/user.repository.ts b/src/modules/user/infrastructure/user.repository.ts new file mode 100644 index 0000000..537961c --- /dev/null +++ b/src/modules/user/infrastructure/user.repository.ts @@ -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 + 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, + }), + ); + } +} diff --git a/src/modules/user/interface/dtos/grpc-controllers/create-user.grpc.controller.ts b/src/modules/user/interface/dtos/grpc-controllers/create-user.grpc.controller.ts new file mode 100644 index 0000000..e234c5b --- /dev/null +++ b/src/modules/user/interface/dtos/grpc-controllers/create-user.grpc.controller.ts @@ -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 { + 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, + }); + } + } +} diff --git a/src/modules/user/interface/dtos/grpc-controllers/dtos/create-user.request.dto.ts b/src/modules/user/interface/dtos/grpc-controllers/dtos/create-user.request.dto.ts new file mode 100644 index 0000000..9736173 --- /dev/null +++ b/src/modules/user/interface/dtos/grpc-controllers/dtos/create-user.request.dto.ts @@ -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; +} diff --git a/src/modules/user/interface/dtos/grpc-controllers/dtos/update-user.request.dto.ts b/src/modules/user/interface/dtos/grpc-controllers/dtos/update-user.request.dto.ts new file mode 100644 index 0000000..9c9a6d7 --- /dev/null +++ b/src/modules/user/interface/dtos/grpc-controllers/dtos/update-user.request.dto.ts @@ -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; +} diff --git a/src/modules/user/interface/dtos/grpc-controllers/update-user.grpc.controller.ts b/src/modules/user/interface/dtos/grpc-controllers/update-user.grpc.controller.ts new file mode 100644 index 0000000..c5df8cb --- /dev/null +++ b/src/modules/user/interface/dtos/grpc-controllers/update-user.grpc.controller.ts @@ -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 { + 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, + }); + } + } +} diff --git a/src/modules/user/interface/dtos/grpc-controllers/user.proto b/src/modules/user/interface/dtos/grpc-controllers/user.proto new file mode 100644 index 0000000..cde99ea --- /dev/null +++ b/src/modules/user/interface/dtos/grpc-controllers/user.proto @@ -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 {} diff --git a/src/modules/user/interface/dtos/user.paginated.response.dto.ts b/src/modules/user/interface/dtos/user.paginated.response.dto.ts new file mode 100644 index 0000000..96e8cd5 --- /dev/null +++ b/src/modules/user/interface/dtos/user.paginated.response.dto.ts @@ -0,0 +1,6 @@ +import { PaginatedResponseDto } from '@mobicoop/ddd-library'; +import { UserResponseDto } from './user.response.dto'; + +export class UserPaginatedResponseDto extends PaginatedResponseDto { + readonly data: readonly UserResponseDto[]; +} diff --git a/src/modules/user/interface/dtos/user.response.dto.ts b/src/modules/user/interface/dtos/user.response.dto.ts new file mode 100644 index 0000000..13e62ff --- /dev/null +++ b/src/modules/user/interface/dtos/user.response.dto.ts @@ -0,0 +1,8 @@ +import { ResponseBase } from '@mobicoop/ddd-library'; + +export class UserResponseDto extends ResponseBase { + firstName: string; + lastName: string; + email: string; + phone: string; +} diff --git a/src/modules/user/adapters/primaries/user.controller.ts b/src/modules/user/old/adapters/primaries/user.controller.ts similarity index 100% rename from src/modules/user/adapters/primaries/user.controller.ts rename to src/modules/user/old/adapters/primaries/user.controller.ts diff --git a/src/modules/user/adapters/primaries/user.presenter.ts b/src/modules/user/old/adapters/primaries/user.presenter.ts similarity index 100% rename from src/modules/user/adapters/primaries/user.presenter.ts rename to src/modules/user/old/adapters/primaries/user.presenter.ts diff --git a/src/modules/user/adapters/primaries/user.proto b/src/modules/user/old/adapters/primaries/user.proto similarity index 100% rename from src/modules/user/adapters/primaries/user.proto rename to src/modules/user/old/adapters/primaries/user.proto diff --git a/src/modules/user/adapters/secondaries/message-publisher.ts b/src/modules/user/old/adapters/secondaries/message-publisher.ts similarity index 100% rename from src/modules/user/adapters/secondaries/message-publisher.ts rename to src/modules/user/old/adapters/secondaries/message-publisher.ts diff --git a/src/modules/user/adapters/secondaries/users.repository.ts b/src/modules/user/old/adapters/secondaries/users.repository.ts similarity index 100% rename from src/modules/user/adapters/secondaries/users.repository.ts rename to src/modules/user/old/adapters/secondaries/users.repository.ts diff --git a/src/modules/user/commands/create-user.command.ts b/src/modules/user/old/commands/create-user.command.ts similarity index 100% rename from src/modules/user/commands/create-user.command.ts rename to src/modules/user/old/commands/create-user.command.ts diff --git a/src/modules/user/commands/delete-user.command.ts b/src/modules/user/old/commands/delete-user.command.ts similarity index 100% rename from src/modules/user/commands/delete-user.command.ts rename to src/modules/user/old/commands/delete-user.command.ts diff --git a/src/modules/user/commands/update-user.command.ts b/src/modules/user/old/commands/update-user.command.ts similarity index 100% rename from src/modules/user/commands/update-user.command.ts rename to src/modules/user/old/commands/update-user.command.ts diff --git a/src/modules/user/domain/dtos/create-user.request.ts b/src/modules/user/old/domain/dtos/create-user.request.ts similarity index 100% rename from src/modules/user/domain/dtos/create-user.request.ts rename to src/modules/user/old/domain/dtos/create-user.request.ts diff --git a/src/modules/user/domain/dtos/find-all-users.request.ts b/src/modules/user/old/domain/dtos/find-all-users.request.ts similarity index 100% rename from src/modules/user/domain/dtos/find-all-users.request.ts rename to src/modules/user/old/domain/dtos/find-all-users.request.ts diff --git a/src/modules/user/domain/dtos/find-user-by-uuid.request.ts b/src/modules/user/old/domain/dtos/find-user-by-uuid.request.ts similarity index 100% rename from src/modules/user/domain/dtos/find-user-by-uuid.request.ts rename to src/modules/user/old/domain/dtos/find-user-by-uuid.request.ts diff --git a/src/modules/user/domain/dtos/update-user.request.ts b/src/modules/user/old/domain/dtos/update-user.request.ts similarity index 100% rename from src/modules/user/domain/dtos/update-user.request.ts rename to src/modules/user/old/domain/dtos/update-user.request.ts diff --git a/src/modules/user/domain/entities/user.ts b/src/modules/user/old/domain/entities/user.ts similarity index 100% rename from src/modules/user/domain/entities/user.ts rename to src/modules/user/old/domain/entities/user.ts diff --git a/src/modules/user/domain/usecases/create-user.usecase.ts b/src/modules/user/old/domain/usecases/create-user.usecase.ts similarity index 100% rename from src/modules/user/domain/usecases/create-user.usecase.ts rename to src/modules/user/old/domain/usecases/create-user.usecase.ts diff --git a/src/modules/user/domain/usecases/delete-user.usecase.ts b/src/modules/user/old/domain/usecases/delete-user.usecase.ts similarity index 100% rename from src/modules/user/domain/usecases/delete-user.usecase.ts rename to src/modules/user/old/domain/usecases/delete-user.usecase.ts diff --git a/src/modules/user/domain/usecases/find-all-users.usecase.ts b/src/modules/user/old/domain/usecases/find-all-users.usecase.ts similarity index 100% rename from src/modules/user/domain/usecases/find-all-users.usecase.ts rename to src/modules/user/old/domain/usecases/find-all-users.usecase.ts diff --git a/src/modules/user/domain/usecases/find-user-by-uuid.usecase.ts b/src/modules/user/old/domain/usecases/find-user-by-uuid.usecase.ts similarity index 100% rename from src/modules/user/domain/usecases/find-user-by-uuid.usecase.ts rename to src/modules/user/old/domain/usecases/find-user-by-uuid.usecase.ts diff --git a/src/modules/user/domain/usecases/update-user.usecase.ts b/src/modules/user/old/domain/usecases/update-user.usecase.ts similarity index 100% rename from src/modules/user/domain/usecases/update-user.usecase.ts rename to src/modules/user/old/domain/usecases/update-user.usecase.ts diff --git a/src/modules/user/mappers/user.profile.ts b/src/modules/user/old/mappers/user.profile.ts similarity index 100% rename from src/modules/user/mappers/user.profile.ts rename to src/modules/user/old/mappers/user.profile.ts diff --git a/src/modules/user/queries/find-all-users.query.ts b/src/modules/user/old/queries/find-all-users.query.ts similarity index 100% rename from src/modules/user/queries/find-all-users.query.ts rename to src/modules/user/old/queries/find-all-users.query.ts diff --git a/src/modules/user/queries/find-user-by-uuid.query.ts b/src/modules/user/old/queries/find-user-by-uuid.query.ts similarity index 100% rename from src/modules/user/queries/find-user-by-uuid.query.ts rename to src/modules/user/old/queries/find-user-by-uuid.query.ts diff --git a/src/modules/user/tests/unit/find-all-users.usecase.spec.ts b/src/modules/user/old/tests/unit/find-all-users.usecase.spec.ts similarity index 100% rename from src/modules/user/tests/unit/find-all-users.usecase.spec.ts rename to src/modules/user/old/tests/unit/find-all-users.usecase.spec.ts diff --git a/src/modules/user/tests/unit/find-user-by-uuid.usecase.spec.ts b/src/modules/user/old/tests/unit/find-user-by-uuid.usecase.spec.ts similarity index 100% rename from src/modules/user/tests/unit/find-user-by-uuid.usecase.spec.ts rename to src/modules/user/old/tests/unit/find-user-by-uuid.usecase.spec.ts diff --git a/src/modules/user/tests/integration/user.repository.spec.ts b/src/modules/user/tests/integration/user.repository.spec.ts new file mode 100644 index 0000000..405e4e1 --- /dev/null +++ b/src/modules/user/tests/integration/user.repository.spec.ts @@ -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); + userRepository = module.get(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); + }); + }); +}); diff --git a/src/modules/user/tests/integration/users.repository.spec.ts b/src/modules/user/tests/integration/users.repository.spec.ts deleted file mode 100644 index e185654..0000000 --- a/src/modules/user/tests/integration/users.repository.spec.ts +++ /dev/null @@ -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); - usersRepository = module.get(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); - }); - }); -}); diff --git a/src/modules/user/tests/unit/core/create-user.service.spec.ts b/src/modules/user/tests/unit/core/create-user.service.spec.ts new file mode 100644 index 0000000..32bb093 --- /dev/null +++ b/src/modules/user/tests/unit/core/create-user.service.spec.ts @@ -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); + }); + + 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); + }); + }); +}); diff --git a/src/modules/user/tests/unit/core/user.entity.spec.ts b/src/modules/user/tests/unit/core/user.entity.spec.ts new file mode 100644 index 0000000..32aa437 --- /dev/null +++ b/src/modules/user/tests/unit/core/user.entity.spec.ts @@ -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'); + }); +}); diff --git a/src/modules/user/tests/unit/create-user.usecase.spec.ts b/src/modules/user/tests/unit/create-user.usecase.spec.ts deleted file mode 100644 index 5b8db2f..0000000 --- a/src/modules/user/tests/unit/create-user.usecase.spec.ts +++ /dev/null @@ -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); - }); - - 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); - }); - }); -}); diff --git a/src/modules/user/tests/unit/delete-user.usecase.spec.ts b/src/modules/user/tests/unit/delete-user.usecase.spec.ts deleted file mode 100644 index acacc49..0000000 --- a/src/modules/user/tests/unit/delete-user.usecase.spec.ts +++ /dev/null @@ -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); - }); - - 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); - }); - }); -}); diff --git a/src/modules/user/tests/unit/infrastructure/user.repository.spec.ts b/src/modules/user/tests/unit/infrastructure/user.repository.spec.ts new file mode 100644 index 0000000..8f68182 --- /dev/null +++ b/src/modules/user/tests/unit/infrastructure/user.repository.spec.ts @@ -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); + userMapper = module.get(UserMapper); + eventEmitter = module.get(EventEmitter2); + }); + it('should be defined', () => { + expect( + new UserRepository( + prismaService, + userMapper, + eventEmitter, + mockMessagePublisher, + ), + ).toBeDefined(); + }); +}); diff --git a/src/modules/user/tests/unit/interface/create-user.grpc.controller.spec.ts b/src/modules/user/tests/unit/interface/create-user.grpc.controller.spec.ts new file mode 100644 index 0000000..3d7acc0 --- /dev/null +++ b/src/modules/user/tests/unit/interface/create-user.grpc.controller.spec.ts @@ -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, + ); + }); + + 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); + }); +}); diff --git a/src/modules/user/tests/unit/interface/update-user.grpc.controller.spec.ts b/src/modules/user/tests/unit/interface/update-user.grpc.controller.spec.ts new file mode 100644 index 0000000..4986616 --- /dev/null +++ b/src/modules/user/tests/unit/interface/update-user.grpc.controller.spec.ts @@ -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, + ); + }); + + 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); + }); +}); diff --git a/src/modules/user/tests/unit/message-publisher.spec.ts b/src/modules/user/tests/unit/message-publisher.spec.ts deleted file mode 100644 index c1877e3..0000000 --- a/src/modules/user/tests/unit/message-publisher.spec.ts +++ /dev/null @@ -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); - }); - - 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); - }); -}); diff --git a/src/modules/user/tests/unit/update-user.usecase.spec.ts b/src/modules/user/tests/unit/update-user.usecase.spec.ts deleted file mode 100644 index a7a89a7..0000000 --- a/src/modules/user/tests/unit/update-user.usecase.spec.ts +++ /dev/null @@ -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); - }); - - 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); - }); - }); -}); diff --git a/src/modules/user/tests/unit/user.mapper.spec.ts b/src/modules/user/tests/unit/user.mapper.spec.ts new file mode 100644 index 0000000..d4e3217 --- /dev/null +++ b/src/modules/user/tests/unit/user.mapper.spec.ts @@ -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); + }); + + 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'); + }); +}); diff --git a/src/modules/user/user.di-tokens.ts b/src/modules/user/user.di-tokens.ts new file mode 100644 index 0000000..815cfdb --- /dev/null +++ b/src/modules/user/user.di-tokens.ts @@ -0,0 +1,2 @@ +export const USER_MESSAGE_PUBLISHER = Symbol('USER_MESSAGE_PUBLISHER'); +export const USER_REPOSITORY = Symbol('USER_REPOSITORY'); diff --git a/src/modules/user/user.mapper.ts b/src/modules/user/user.mapper.ts new file mode 100644 index 0000000..b8034f4 --- /dev/null +++ b/src/modules/user/user.mapper.ts @@ -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 +{ + 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). + */ +} diff --git a/src/utils/pipes/rpc.validation-pipe.ts b/src/utils/pipes/rpc.validation-pipe.ts deleted file mode 100644 index f2b8c19..0000000 --- a/src/utils/pipes/rpc.validation-pipe.ts +++ /dev/null @@ -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), - }); - }; - } -} diff --git a/src/utils/tests/unit/rpc-validation-pipe.usecase.spec.ts b/src/utils/tests/unit/rpc-validation-pipe.usecase.spec.ts deleted file mode 100644 index b00bf86..0000000 --- a/src/utils/tests/unit/rpc-validation-pipe.usecase.spec.ts +++ /dev/null @@ -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({}, metadata).catch((err) => { - expect(err.message).toEqual('Rpc Exception'); - }); - }); -}); diff --git a/tsconfig.json b/tsconfig.json index adb614c..0e31118 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,6 +16,10 @@ "noImplicitAny": false, "strictBindCallApply": false, "forceConsistentCasingInFileNames": false, - "noFallthroughCasesInSwitch": false + "noFallthroughCasesInSwitch": false, + "paths": { + "@modules/*": ["src/modules/*"], + "@src/*": ["src/*"] + } } }