diff --git a/.github/workflows/development_hgn-rest-dev.yml b/.github/workflows/development_hgn-rest-dev.yml new file mode 100644 index 000000000..05030cf70 --- /dev/null +++ b/.github/workflows/development_hgn-rest-dev.yml @@ -0,0 +1,61 @@ +# Docs for the Azure Web Apps Deploy action: https://github.com/Azure/webapps-deploy +# More GitHub Actions for Azure: https://github.com/Azure/actions + +name: Build and deploy Node.js app to Azure Web App - hgn-rest-dev + +on: + push: + branches: + - development + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js version + uses: actions/setup-node@v3 + with: + node-version: 14 + + - name: npm install, build, and test + run: | + npm install + npm run build --if-present + + - name: Zip artifact for deployment + run: zip release.zip ./* -r + + - name: Upload artifact for deployment job + uses: actions/upload-artifact@v4 + with: + name: node-app + path: release.zip + + deploy: + runs-on: ubuntu-latest + needs: build + environment: + name: 'Production' + url: ${{ steps.deploy-to-webapp.outputs.webapp-url }} + + steps: + - name: Download artifact from build job + uses: actions/download-artifact@v4 + with: + name: node-app + + - name: Unzip artifact for deployment + run: unzip release.zip + + - name: 'Deploy to Azure Web App' + id: deploy-to-webapp + uses: azure/webapps-deploy@v3 + with: + app-name: 'hgn-rest-dev' + slot-name: 'Production' + package: . + publish-profile: ${{ secrets.AZUREAPPSERVICE_PUBLISHPROFILE_37BDFCF15560478F9069CEF1EA105BF0 }} diff --git a/.github/workflows/main_hgn-rest.yml b/.github/workflows/main_hgn-rest.yml index 2282de64e..8b6a4298f 100644 --- a/.github/workflows/main_hgn-rest.yml +++ b/.github/workflows/main_hgn-rest.yml @@ -41,7 +41,7 @@ jobs: environment: name: 'Production' url: ${{ steps.deploy-to-webapp.outputs.webapp-url }} - + steps: - name: Download artifact from build job uses: actions/download-artifact@v4 @@ -50,7 +50,7 @@ jobs: - name: Unzip artifact for deployment run: unzip release.zip - + - name: 'Deploy to Azure Web App' id: deploy-to-webapp uses: azure/webapps-deploy@v3 diff --git a/package-lock.json b/package-lock.json index 3c4e3e99e..9e07004c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -619,9 +619,9 @@ }, "dependencies": { "@babel/helper-plugin-utils": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.7.tgz", - "integrity": "sha512-Rq76wjt7yz9AAc1KnlRKNAi/dMSVWgDRx43FHoJEbcYU6xOWaE2dVPwcdTukJrjxS65GITyfbvEYHvkirZ6uEg==", + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.8.tgz", + "integrity": "sha512-FFWx5142D8h2Mgr/iPVGH5G7w6jDn4jUSpZTyDnQO0Yn7Ks2Kuz6Pci8H6MPCoUJegd/UZQ3tAvfLCxQSnWWwg==", "dev": true } } @@ -691,18 +691,18 @@ } }, "@babel/plugin-syntax-typescript": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.24.7.tgz", - "integrity": "sha512-c/+fVeJBB0FeKsFvwytYiUD+LBvhHjGSI0g446PRGdSVGZLRNArBUno2PETbAly3tpiNAQR5XaZ+JslxkotsbA==", + "version": "7.25.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.25.4.tgz", + "integrity": "sha512-uMOCoHVU52BsSWxPOMVv5qKRdeSlPuImUCB2dlPuBSU+W2/ROE7/Zg8F2Kepbk+8yBa68LlRKxO+xgEVWorsDg==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-plugin-utils": "^7.24.8" }, "dependencies": { "@babel/helper-plugin-utils": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.7.tgz", - "integrity": "sha512-Rq76wjt7yz9AAc1KnlRKNAi/dMSVWgDRx43FHoJEbcYU6xOWaE2dVPwcdTukJrjxS65GITyfbvEYHvkirZ6uEg==", + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.8.tgz", + "integrity": "sha512-FFWx5142D8h2Mgr/iPVGH5G7w6jDn4jUSpZTyDnQO0Yn7Ks2Kuz6Pci8H6MPCoUJegd/UZQ3tAvfLCxQSnWWwg==", "dev": true } } @@ -1477,9 +1477,9 @@ } }, "@types/yargs": { - "version": "17.0.32", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", - "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", "dev": true, "requires": { "@types/yargs-parser": "*" @@ -1593,9 +1593,9 @@ } }, "@types/yargs": { - "version": "17.0.32", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", - "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", "dev": true, "requires": { "@types/yargs-parser": "*" @@ -1710,9 +1710,9 @@ } }, "@types/yargs": { - "version": "17.0.32", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", - "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", "dev": true, "requires": { "@types/yargs-parser": "*" @@ -1825,9 +1825,9 @@ } }, "@types/yargs": { - "version": "17.0.32", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", - "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", "dev": true, "requires": { "@types/yargs-parser": "*" @@ -1911,9 +1911,9 @@ } }, "@types/yargs": { - "version": "17.0.32", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", - "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", "dev": true, "requires": { "@types/yargs-parser": "*" @@ -2023,9 +2023,9 @@ "dev": true }, "@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", "dev": true }, "@jridgewell/trace-mapping": { @@ -2039,9 +2039,9 @@ } }, "@types/yargs": { - "version": "17.0.32", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", - "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", "dev": true, "requires": { "@types/yargs-parser": "*" @@ -2131,9 +2131,9 @@ "dev": true }, "@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", "dev": true }, "@jridgewell/trace-mapping": { @@ -2175,9 +2175,9 @@ } }, "@types/yargs": { - "version": "17.0.32", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", - "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", "dev": true, "requires": { "@types/yargs-parser": "*" @@ -2298,9 +2298,9 @@ "dev": true }, "@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", "dev": true }, "@jridgewell/trace-mapping": { @@ -2314,9 +2314,9 @@ } }, "@types/yargs": { - "version": "17.0.32", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", - "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", "dev": true, "requires": { "@types/yargs-parser": "*" @@ -2857,7 +2857,7 @@ "@types/json5": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", - "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "integrity": "sha1-7ihweulOEdK4J7y+UnC86n8+ce4=", "dev": true }, "@types/methods": { @@ -3081,7 +3081,7 @@ "array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" + "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=" }, "array-includes": { "version": "3.1.6", @@ -4739,7 +4739,7 @@ "bcryptjs": { "version": "2.4.3", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", - "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==" + "integrity": "sha1-mrVie5PmBiH/fNrF2pczAn3x0Ms=" }, "bignumber.js": { "version": "9.0.2", @@ -4797,6 +4797,11 @@ } } }, + "boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" + }, "brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -4869,7 +4874,7 @@ "buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + "integrity": "sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=" }, "buffer-from": { "version": "1.1.2", @@ -4923,6 +4928,33 @@ "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", "dev": true }, + "cheerio": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz", + "integrity": "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==", + "requires": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "htmlparser2": "^8.0.1", + "parse5": "^7.0.0", + "parse5-htmlparser2-tree-adapter": "^7.0.0" + } + }, + "cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "requires": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + } + }, "chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -4945,9 +4977,9 @@ "dev": true }, "cjs-module-lexer": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.3.1.tgz", - "integrity": "sha512-a3KdPAANPbNE4ZUv9h6LckSl9zLsYOP4MBmhIPkRaeyybt+r4UghLvq+xw/YwUcC1gqylCkL4rdVs3Lwupjm4Q==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.1.tgz", + "integrity": "sha512-cuSVIHi9/9E/+821Qjdvngor+xpnlwnuwIyZOaLmHBVdXL+gP+I6QQB9VkO7RI77YIcTV+S1W9AreJ5eN63JBA==", "dev": true }, "clean-stack": { @@ -5036,7 +5068,7 @@ "clone": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", - "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==" + "integrity": "sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18=" }, "clone-deep": { "version": "4.0.1", @@ -5076,7 +5108,7 @@ "color-name": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" }, "colorette": { "version": "2.0.19", @@ -5100,7 +5132,7 @@ "commondir": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", - "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==" + "integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=" }, "component-emitter": { "version": "1.3.1", @@ -5110,7 +5142,7 @@ "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" }, "confusing-browser-globals": { "version": "1.0.11", @@ -5154,7 +5186,7 @@ "cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" + "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=" }, "cookiejar": { "version": "2.1.4", @@ -5226,9 +5258,9 @@ } }, "@types/yargs": { - "version": "17.0.32", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", - "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", "dev": true, "requires": { "@types/yargs-parser": "*" @@ -5321,6 +5353,23 @@ } } }, + "css-select": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", + "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", + "requires": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + } + }, + "css-what": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", + "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==" + }, "damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -5616,7 +5665,7 @@ "ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" }, "electron-to-chromium": { "version": "1.4.81", @@ -5638,7 +5687,7 @@ "encodeurl": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==" + "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=" }, "end-of-stream": { "version": "1.4.4", @@ -5783,12 +5832,12 @@ "escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=" }, "escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==" + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=" }, "eslint": { "version": "8.47.0", @@ -6718,7 +6767,7 @@ "etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==" + "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=" }, "event-target-shim": { "version": "5.0.1", @@ -6881,7 +6930,7 @@ "ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" }, "safe-buffer": { "version": "5.2.1", @@ -6926,7 +6975,7 @@ "fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", "dev": true }, "fast-safe-stringify": { @@ -7000,7 +7049,7 @@ "ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" } } }, @@ -7137,7 +7186,7 @@ "fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==" + "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=" }, "fs-constants": { "version": "1.0.0", @@ -7153,7 +7202,7 @@ "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" }, "fsevents": { "version": "2.3.3", @@ -7460,7 +7509,7 @@ "has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==" + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=" }, "has-property-descriptors": { "version": "1.0.0", @@ -7609,9 +7658,9 @@ } }, "import-local": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.1.0.tgz", - "integrity": "sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", "dev": true, "requires": { "pkg-dir": "^4.2.0", @@ -7666,7 +7715,7 @@ "imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", "dev": true }, "indent-string": { @@ -7678,7 +7727,7 @@ "inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", "requires": { "once": "^1.3.0", "wrappy": "1" @@ -7872,7 +7921,7 @@ "is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==" + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=" }, "is-fullwidth-code-point": { "version": "3.0.0", @@ -7986,13 +8035,13 @@ "isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", "dev": true }, "isobject": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==" + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=" }, "istanbul-lib-coverage": { "version": "3.2.2", @@ -8001,9 +8050,9 @@ "dev": true }, "istanbul-lib-instrument": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.2.tgz", - "integrity": "sha512-1WUsZ9R1lA0HtBSohTkm39WTPlNKSJ5iFk7UwqXkBLoHQT+hfqPsfsTDVuZdKGaBwn7din9bS7SsnoAr943hvw==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", "dev": true, "requires": { "@babel/core": "^7.23.9", @@ -8034,27 +8083,27 @@ } }, "@babel/compat-data": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.24.7.tgz", - "integrity": "sha512-qJzAIcv03PyaWqxRgO4mSU3lihncDT296vnyuE2O8uA4w3UHWI4S3hgeZd1L8W1Bft40w9JxJ2b412iDUFFRhw==", + "version": "7.25.4", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.25.4.tgz", + "integrity": "sha512-+LGRog6RAsCJrrrg/IO6LGmpphNe5DiK30dGjCoxxeGv49B10/3XYGxPsAwrDlMFcFEvdAUavDT8r9k/hSyQqQ==", "dev": true }, "@babel/core": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.7.tgz", - "integrity": "sha512-nykK+LEK86ahTkX/3TgauT0ikKoNCfKHEaZYTUVupJdTLzGNvrblu4u6fa7DhZONAltdf8e662t/abY8idrd/g==", + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.25.2.tgz", + "integrity": "sha512-BBt3opiCOxUr9euZ5/ro/Xv8/V7yJ5bjYMqG/C1YAo8MIKAnumZalCN+msbci3Pigy4lIQfPUpfMM27HMGaYEA==", "dev": true, "requires": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.24.7", - "@babel/generator": "^7.24.7", - "@babel/helper-compilation-targets": "^7.24.7", - "@babel/helper-module-transforms": "^7.24.7", - "@babel/helpers": "^7.24.7", - "@babel/parser": "^7.24.7", - "@babel/template": "^7.24.7", - "@babel/traverse": "^7.24.7", - "@babel/types": "^7.24.7", + "@babel/generator": "^7.25.0", + "@babel/helper-compilation-targets": "^7.25.2", + "@babel/helper-module-transforms": "^7.25.2", + "@babel/helpers": "^7.25.0", + "@babel/parser": "^7.25.0", + "@babel/template": "^7.25.0", + "@babel/traverse": "^7.25.2", + "@babel/types": "^7.25.2", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -8071,26 +8120,26 @@ } }, "@babel/generator": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.24.7.tgz", - "integrity": "sha512-oipXieGC3i45Y1A41t4tAqpnEZWgB/lC6Ehh6+rOviR5XWpTtMmLN+fGjz9vOiNRt0p6RtO6DtD0pdU3vpqdSA==", + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.25.6.tgz", + "integrity": "sha512-VPC82gr1seXOpkjAAKoLhP50vx4vGNlF4msF64dSFq1P8RfB+QAuJWGHPXXPc8QyfVWwwB/TNNU4+ayZmHNbZw==", "dev": true, "requires": { - "@babel/types": "^7.24.7", + "@babel/types": "^7.25.6", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^2.5.1" } }, "@babel/helper-compilation-targets": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.24.7.tgz", - "integrity": "sha512-ctSdRHBi20qWOfy27RUb4Fhp07KSJ3sXcuSvTrXrc4aG8NSYDo1ici3Vhg9bg69y5bj0Mr1lh0aeEgTvc12rMg==", + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.2.tgz", + "integrity": "sha512-U2U5LsSaZ7TAt3cfaymQ8WHh0pxvdHoEk6HVpaexxixjyEquMh0L0YNJNM6CTGKMXV1iksi0iZkGw4AcFkPaaw==", "dev": true, "requires": { - "@babel/compat-data": "^7.24.7", - "@babel/helper-validator-option": "^7.24.7", - "browserslist": "^4.22.2", + "@babel/compat-data": "^7.25.2", + "@babel/helper-validator-option": "^7.24.8", + "browserslist": "^4.23.1", "lru-cache": "^5.1.1", "semver": "^6.3.1" }, @@ -8103,34 +8152,6 @@ } } }, - "@babel/helper-environment-visitor": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.24.7.tgz", - "integrity": "sha512-DoiN84+4Gnd0ncbBOM9AZENV4a5ZiL39HYMyZJGZ/AZEykHYdJw0wW3kdcsh9/Kn+BRXHLkkklZ51ecPKmI1CQ==", - "dev": true, - "requires": { - "@babel/types": "^7.24.7" - } - }, - "@babel/helper-function-name": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.24.7.tgz", - "integrity": "sha512-FyoJTsj/PEUWu1/TYRiXTIHc8lbw+TDYkZuoE43opPS5TrI7MyONBE1oNvfguEXAD9yhQRrVBnXdXzSLQl9XnA==", - "dev": true, - "requires": { - "@babel/template": "^7.24.7", - "@babel/types": "^7.24.7" - } - }, - "@babel/helper-hoist-variables": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.24.7.tgz", - "integrity": "sha512-MJJwhkoGy5c4ehfoRyrJ/owKeMl19U54h27YYftT0o2teQ3FJ3nQUf/I3LlJsX4l3qlw7WRXUmiyajvHXoTubQ==", - "dev": true, - "requires": { - "@babel/types": "^7.24.7" - } - }, "@babel/helper-module-imports": { "version": "7.24.7", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.24.7.tgz", @@ -8142,16 +8163,15 @@ } }, "@babel/helper-module-transforms": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.24.7.tgz", - "integrity": "sha512-1fuJEwIrp+97rM4RWdO+qrRsZlAeL1lQJoPqtCYWv0NL115XM93hIH4CSRln2w52SqvmY5hqdtauB6QFCDiZNQ==", + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.25.2.tgz", + "integrity": "sha512-BjyRAbix6j/wv83ftcVJmBt72QtHI56C7JXZoG2xATiLpmoC7dpd8WnkikExHDVPpi/3qCmO6WY1EaXOluiecQ==", "dev": true, "requires": { - "@babel/helper-environment-visitor": "^7.24.7", "@babel/helper-module-imports": "^7.24.7", "@babel/helper-simple-access": "^7.24.7", - "@babel/helper-split-export-declaration": "^7.24.7", - "@babel/helper-validator-identifier": "^7.24.7" + "@babel/helper-validator-identifier": "^7.24.7", + "@babel/traverse": "^7.25.2" } }, "@babel/helper-simple-access": { @@ -8164,19 +8184,10 @@ "@babel/types": "^7.24.7" } }, - "@babel/helper-split-export-declaration": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.7.tgz", - "integrity": "sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA==", - "dev": true, - "requires": { - "@babel/types": "^7.24.7" - } - }, "@babel/helper-string-parser": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.7.tgz", - "integrity": "sha512-7MbVt6xrwFQbunH2DNQsAP5sTGxfqQtErvBIvIMi6EQnbgUOuVYanvREcmFrOPhoXBrTtjhhP+lW+o5UfK+tDg==", + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.8.tgz", + "integrity": "sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ==", "dev": true }, "@babel/helper-validator-identifier": { @@ -8186,19 +8197,19 @@ "dev": true }, "@babel/helper-validator-option": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.24.7.tgz", - "integrity": "sha512-yy1/KvjhV/ZCL+SM7hBrvnZJ3ZuT9OuZgIJAGpPEToANvc3iM6iDvBnRjtElWibHU6n8/LPR/EjX9EtIEYO3pw==", + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.24.8.tgz", + "integrity": "sha512-xb8t9tD1MHLungh/AIoWYN+gVHaB9kwlu8gffXGSt3FFEIT7RjS+xWbc2vUD1UTZdIpKj/ab3rdqJ7ufngyi2Q==", "dev": true }, "@babel/helpers": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.24.7.tgz", - "integrity": "sha512-NlmJJtvcw72yRJRcnCmGvSi+3jDEg8qFu3z0AFoymmzLx5ERVWyzd9kVXr7Th9/8yIJi2Zc6av4Tqz3wFs8QWg==", + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.25.6.tgz", + "integrity": "sha512-Xg0tn4HcfTijTwfDwYlvVCl43V6h4KyVVX2aEm4qdO/PC6L2YvzLHFdmxhoeSA3eslcE6+ZVXHgWwopXYLNq4Q==", "dev": true, "requires": { - "@babel/template": "^7.24.7", - "@babel/types": "^7.24.7" + "@babel/template": "^7.25.0", + "@babel/types": "^7.25.6" } }, "@babel/highlight": { @@ -8214,47 +8225,47 @@ } }, "@babel/parser": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.7.tgz", - "integrity": "sha512-9uUYRm6OqQrCqQdG1iCBwBPZgN8ciDBro2nIOFaiRz1/BCxaI7CNvQbDHvsArAC7Tw9Hda/B3U+6ui9u4HWXPw==", - "dev": true + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.6.tgz", + "integrity": "sha512-trGdfBdbD0l1ZPmcJ83eNxB9rbEax4ALFTF7fN386TMYbeCQbyme5cOEXQhbGXKebwGaB/J52w1mrklMcbgy6Q==", + "dev": true, + "requires": { + "@babel/types": "^7.25.6" + } }, "@babel/template": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.24.7.tgz", - "integrity": "sha512-jYqfPrU9JTF0PmPy1tLYHW4Mp4KlgxJD9l2nP9fD6yT/ICi554DmrWBAEYpIelzjHf1msDP3PxJIRt/nFNfBig==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.0.tgz", + "integrity": "sha512-aOOgh1/5XzKvg1jvVz7AVrx2piJ2XBi227DHmbY6y+bM9H2FlN+IfecYu4Xl0cNiiVejlsCri89LUsbj8vJD9Q==", "dev": true, "requires": { "@babel/code-frame": "^7.24.7", - "@babel/parser": "^7.24.7", - "@babel/types": "^7.24.7" + "@babel/parser": "^7.25.0", + "@babel/types": "^7.25.0" } }, "@babel/traverse": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.24.7.tgz", - "integrity": "sha512-yb65Ed5S/QAcewNPh0nZczy9JdYXkkAbIsEo+P7BE7yO3txAY30Y/oPa3QkQ5It3xVG2kpKMg9MsdxZaO31uKA==", + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.25.6.tgz", + "integrity": "sha512-9Vrcx5ZW6UwK5tvqsj0nGpp/XzqthkT0dqIc9g1AdtygFToNtTF67XzYS//dm+SAK9cp3B9R4ZO/46p63SCjlQ==", "dev": true, "requires": { "@babel/code-frame": "^7.24.7", - "@babel/generator": "^7.24.7", - "@babel/helper-environment-visitor": "^7.24.7", - "@babel/helper-function-name": "^7.24.7", - "@babel/helper-hoist-variables": "^7.24.7", - "@babel/helper-split-export-declaration": "^7.24.7", - "@babel/parser": "^7.24.7", - "@babel/types": "^7.24.7", + "@babel/generator": "^7.25.6", + "@babel/parser": "^7.25.6", + "@babel/template": "^7.25.0", + "@babel/types": "^7.25.6", "debug": "^4.3.1", "globals": "^11.1.0" } }, "@babel/types": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.7.tgz", - "integrity": "sha512-XEFXSlxiG5td2EJRe8vOmRbaXVgfcBlszKujvVmWIK/UpywWljQCfzAv3RQCGujWQ1RD4YYWEAqDXfuJiy8f5Q==", + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.6.tgz", + "integrity": "sha512-/l42B1qxpG6RdfYf343Uw1vmDjeNhneUXtzhojE7pDgfpEypmRhI6j1kr17XCVv4Cgl9HdAiQY2x0GwKm7rWCw==", "dev": true, "requires": { - "@babel/helper-string-parser": "^7.24.7", + "@babel/helper-string-parser": "^7.24.8", "@babel/helper-validator-identifier": "^7.24.7", "to-fast-properties": "^2.0.0" } @@ -8293,23 +8304,23 @@ }, "dependencies": { "@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", "dev": true } } }, "browserslist": { - "version": "4.23.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.1.tgz", - "integrity": "sha512-TUfofFo/KsK/bWZ9TWQ5O26tsWW4Uhmt8IYklbnUa70udB6P2wA7w7o4PY4muaEPBQaAX+CEnmmIA41NVHtPVw==", + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.3.tgz", + "integrity": "sha512-btwCFJVjI4YWDNfau8RhZ+B1Q/VLoUITrm3RlP6y1tYGWIOa+InuYiRGXUBXo8nA1qKmHMyLB/iVQg5TT4eFoA==", "dev": true, "requires": { - "caniuse-lite": "^1.0.30001629", - "electron-to-chromium": "^1.4.796", - "node-releases": "^2.0.14", - "update-browserslist-db": "^1.0.16" + "caniuse-lite": "^1.0.30001646", + "electron-to-chromium": "^1.5.4", + "node-releases": "^2.0.18", + "update-browserslist-db": "^1.1.0" } }, "convert-source-map": { @@ -8319,9 +8330,9 @@ "dev": true }, "electron-to-chromium": { - "version": "1.4.815", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.815.tgz", - "integrity": "sha512-OvpTT2ItpOXJL7IGcYakRjHCt8L5GrrN/wHCQsRB4PQa1X9fe+X9oen245mIId7s14xvArCGSTIq644yPUKKLg==", + "version": "1.5.22", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.22.tgz", + "integrity": "sha512-tKYm5YHPU1djz0O+CGJ+oJIvimtsCcwR2Z9w7Skh08lUdyzXY5djods3q+z2JkWdb7tCcmM//eVavSRAiaPRNg==", "dev": true }, "lru-cache": { @@ -8334,15 +8345,15 @@ } }, "node-releases": { - "version": "2.0.14", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", - "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==", + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", + "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==", "dev": true }, "semver": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", - "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "dev": true }, "yallist": { @@ -8380,9 +8391,9 @@ } }, "semver": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", - "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "dev": true }, "supports-color": { @@ -8452,9 +8463,9 @@ } }, "@types/yargs": { - "version": "17.0.32", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", - "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", "dev": true, "requires": { "@types/yargs-parser": "*" @@ -8633,9 +8644,9 @@ } }, "@types/yargs": { - "version": "17.0.32", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", - "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", "dev": true, "requires": { "@types/yargs-parser": "*" @@ -8777,9 +8788,9 @@ } }, "@types/yargs": { - "version": "17.0.32", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", - "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", "dev": true, "requires": { "@types/yargs-parser": "*" @@ -8973,9 +8984,9 @@ } }, "@types/yargs": { - "version": "17.0.32", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", - "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", "dev": true, "requires": { "@types/yargs-parser": "*" @@ -9092,9 +9103,9 @@ } }, "@types/yargs": { - "version": "17.0.32", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", - "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", "dev": true, "requires": { "@types/yargs-parser": "*" @@ -9192,9 +9203,9 @@ } }, "@types/yargs": { - "version": "17.0.32", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", - "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", "dev": true, "requires": { "@types/yargs-parser": "*" @@ -9436,9 +9447,9 @@ } }, "@types/yargs": { - "version": "17.0.32", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", - "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", "dev": true, "requires": { "@types/yargs-parser": "*" @@ -9552,9 +9563,9 @@ } }, "@types/yargs": { - "version": "17.0.32", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", - "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", "dev": true, "requires": { "@types/yargs-parser": "*" @@ -9751,9 +9762,9 @@ } }, "@types/yargs": { - "version": "17.0.32", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", - "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", "dev": true, "requires": { "@types/yargs-parser": "*" @@ -9880,9 +9891,9 @@ } }, "@types/yargs": { - "version": "17.0.32", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", - "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", "dev": true, "requires": { "@types/yargs-parser": "*" @@ -9994,9 +10005,9 @@ } }, "@types/yargs": { - "version": "17.0.32", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", - "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", "dev": true, "requires": { "@types/yargs-parser": "*" @@ -10092,9 +10103,9 @@ "dev": true }, "semver": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", - "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "dev": true }, "supports-color": { @@ -10137,9 +10148,9 @@ } }, "@types/yargs": { - "version": "17.0.32", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", - "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", "dev": true, "requires": { "@types/yargs-parser": "*" @@ -10225,9 +10236,9 @@ } }, "@types/yargs": { - "version": "17.0.32", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", - "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", "dev": true, "requires": { "@types/yargs-parser": "*" @@ -10352,9 +10363,9 @@ } }, "@types/yargs": { - "version": "17.0.32", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", - "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", "dev": true, "requires": { "@types/yargs-parser": "*" @@ -10482,7 +10493,7 @@ "json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=", "dev": true }, "json5": { @@ -10837,7 +10848,7 @@ "lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", - "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==" + "integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168=" }, "lodash.merge": { "version": "4.6.2", @@ -11036,7 +11047,7 @@ "media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==" + "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=" }, "memory-pager": { "version": "1.5.0", @@ -11047,7 +11058,7 @@ "merge-descriptors": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" + "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=" }, "merge-stream": { "version": "2.0.0", @@ -11058,7 +11069,7 @@ "methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==" + "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=" }, "micromatch": { "version": "4.0.5", @@ -11385,7 +11396,7 @@ "natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", "dev": true }, "negotiator": { @@ -11549,10 +11560,18 @@ } } }, + "nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "requires": { + "boolbase": "^1.0.0" + } + }, "object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==" + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" }, "object-inspect": { "version": "1.12.0", @@ -12271,7 +12290,7 @@ "once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", "requires": { "wrappy": "1" } @@ -12361,13 +12380,30 @@ "parse-passwd": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/parse-passwd/-/parse-passwd-1.0.0.tgz", - "integrity": "sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==" + "integrity": "sha1-bVuTSkVpk7I9N/QKOC1vFmao5cY=" }, "parse-srcset": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==" }, + "parse5": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", + "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", + "requires": { + "entities": "^4.4.0" + } + }, + "parse5-htmlparser2-tree-adapter": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.0.0.tgz", + "integrity": "sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==", + "requires": { + "domhandler": "^5.0.2", + "parse5": "^7.0.0" + } + }, "parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -12376,12 +12412,12 @@ "path-exists": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", - "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==" + "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=" }, "path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==" + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" }, "path-key": { "version": "3.1.1", @@ -12397,7 +12433,7 @@ "path-to-regexp": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" + "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=" }, "pend": { "version": "1.2.0", @@ -13150,7 +13186,7 @@ "sparse-bitfield": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", - "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "integrity": "sha1-/0rm5oZWBWuks+eSqzM004JzyhE=", "optional": true, "requires": { "memory-pager": "^1.0.2" @@ -13900,7 +13936,7 @@ "strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=", "dev": true }, "strip-final-newline": { @@ -14091,13 +14127,13 @@ "text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", "dev": true }, "through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=", "dev": true }, "tmp": { @@ -14115,7 +14151,7 @@ "to-fast-properties": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", - "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==" + "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=" }, "to-regex-range": { "version": "5.0.1", @@ -14142,7 +14178,7 @@ "tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + "integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=" }, "tsconfig-paths": { "version": "3.14.2", @@ -14308,12 +14344,12 @@ "unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==" + "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=" }, "update-browserslist-db": { - "version": "1.0.16", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.16.tgz", - "integrity": "sha512-KVbTxlBYlckhF5wgfyZXTWnMn7MMZjMu9XG8bPlliUOP9ThaF4QnhP8qrjrH7DRzHfSk0oQv1wToW+iA5GajEQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz", + "integrity": "sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==", "dev": true, "requires": { "escalade": "^3.1.2", @@ -14321,15 +14357,15 @@ }, "dependencies": { "escalade": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", - "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "dev": true }, "picocolors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", - "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", + "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==", "dev": true } } @@ -14346,17 +14382,17 @@ "url-template": { "version": "2.0.8", "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", - "integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==" + "integrity": "sha1-/FZaPMy/93MMd19WQflVV5FDnyE=" }, "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" }, "utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==" + "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=" }, "uuid": { "version": "3.4.0", @@ -14381,9 +14417,9 @@ "dev": true }, "@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", "dev": true }, "@jridgewell/trace-mapping": { @@ -14420,7 +14456,7 @@ "vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" + "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=" }, "walker": { "version": "1.0.8", @@ -14434,12 +14470,12 @@ "webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=" }, "whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "integrity": "sha1-lmRU6HZUYuN2RNNib2dCzotwll0=", "requires": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" @@ -14557,7 +14593,7 @@ "wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" }, "write-file-atomic": { "version": "4.0.2", diff --git a/package.json b/package.json index 8b88744dc..91e73f39a 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "babel-plugin-module-resolver": "^5.0.0", "bcryptjs": "^2.4.3", "body-parser": "^1.18.3", + "cheerio": "^1.0.0-rc.12", "cors": "^2.8.4", "cron": "^1.8.2", "dotenv": "^5.0.1", diff --git a/requirements/emailController/addNonHgnEmailSubscription.md b/requirements/emailController/addNonHgnEmailSubscription.md new file mode 100644 index 000000000..f5748f142 --- /dev/null +++ b/requirements/emailController/addNonHgnEmailSubscription.md @@ -0,0 +1,23 @@ +# Add Non-HGN Email Subscription Function + +## Negative Cases + +1. ❌ **Returns error 400 if `email` field is missing from the request** + - Ensures that the function checks for the presence of the `email` field in the request body and responds with a `400` status code if it's missing. + +2. ❌ **Returns error 400 if the provided `email` already exists in the subscription list** + - This case checks that the function responds with a `400` status code and a message indicating that the email is already subscribed. + +3. ❌ **Returns error 500 if there is an internal error while checking the subscription list** + - Covers scenarios where there's an issue querying the `EmailSubscriptionList` collection for the provided email (e.g., database connection issues). + +4. ❌ **Returns error 500 if there is an error sending the confirmation email** + - This case handles any issues that occur while calling the `emailSender` function, such as network errors or service unavailability. + +## Positive Cases + +1. ❌ **Returns status 200 when a new email is successfully subscribed** + - Ensures that the function successfully creates a JWT token, constructs the email, and sends the subscription confirmation email to the user. + +2. ❌ **Successfully sends a confirmation email containing the correct link** + - Verifies that the generated JWT token is correctly included in the confirmation link sent to the user in the email body. diff --git a/requirements/emailController/confirmNonHgnEmailSubscription.md b/requirements/emailController/confirmNonHgnEmailSubscription.md new file mode 100644 index 000000000..d5e1367af --- /dev/null +++ b/requirements/emailController/confirmNonHgnEmailSubscription.md @@ -0,0 +1,18 @@ +# Confirm Non-HGN Email Subscription Function Tests + +## Negative Cases +1. ✅ **Returns error 400 if `token` field is missing from the request** + - (Test: `should return 400 if token is not provided`) + +2. ✅ **Returns error 401 if the provided `token` is invalid or expired** + - (Test: `should return 401 if token is invalid`) + +3. ✅ **Returns error 400 if the decoded `token` does not contain a valid `email` field** + - (Test: `should return 400 if email is missing from payload`) + +4. ❌ **Returns error 500 if there is an internal error while saving the new email subscription** + +## Positive Cases +1. ❌ **Returns status 200 when a new email is successfully subscribed** + +2. ❌ **Returns status 200 if the email is already subscribed (duplicate email)** diff --git a/requirements/emailController/removeNonHgnEmailSubscription.md b/requirements/emailController/removeNonHgnEmailSubscription.md new file mode 100644 index 000000000..af793e2a9 --- /dev/null +++ b/requirements/emailController/removeNonHgnEmailSubscription.md @@ -0,0 +1,10 @@ +# Remove Non-HGN Email Subscription Function Tests + +## Negative Cases +1. ✅ **Returns error 400 if `email` field is missing from the request** + - (Test: `should return 400 if email is missing`) + +2. ❌ **Returns error 500 if there is an internal error while deleting the email subscription** + +## Positive Cases +1. ❌ **Returns status 200 when an email is successfully unsubscribed** diff --git a/requirements/emailController/sendEmail.md b/requirements/emailController/sendEmail.md new file mode 100644 index 000000000..7ca9a482c --- /dev/null +++ b/requirements/emailController/sendEmail.md @@ -0,0 +1,10 @@ +# Send Email Function + +## Negative Cases + +1. ❌ **Returns error 400 if `to`, `subject`, or `html` fields are missing from the request** +2. ❌ **Returns error 500 if there is an internal error while sending the email** + +## Positive Cases + +1. ✅ **Returns status 200 when email is successfully sent with `to`, `subject`, and `html` fields provided** diff --git a/requirements/emailController/sendEmailToAll.md b/requirements/emailController/sendEmailToAll.md new file mode 100644 index 000000000..32a09fed6 --- /dev/null +++ b/requirements/emailController/sendEmailToAll.md @@ -0,0 +1,26 @@ +# Send Email to All Function + +## Negative Cases + +1. ❌ **Returns error 400 if `subject` or `html` fields are missing from the request** + - The request should be rejected if either the `subject` or `html` content is not provided in the request body. + +2. ❌ **Returns error 500 if there is an internal error while fetching users** + - This case covers scenarios where there's an error fetching users from the `userProfile` collection (e.g., database connection issues). + +3. ❌ **Returns error 500 if there is an internal error while fetching the subscription list** + - This case covers scenarios where there's an error fetching emails from the `EmailSubcriptionList` collection. + +4. ❌ **Returns error 500 if there is an error sending emails** + - This case handles any issues that occur while calling the `emailSender` function, such as network errors or service unavailability. + +## Positive Cases + +1. ❌ **Returns status 200 when emails are successfully sent to all active users** + - Ensures that the function sends emails correctly to all users meeting the criteria (`isActive` and `EmailSubcriptionList`). + +2. ❌ **Returns status 200 when emails are successfully sent to all users in the subscription list** + - Verifies that the function sends emails to all users in the `EmailSubcriptionList`, including the unsubscribe link in the email body. + +3. ❌ **Combines user and subscription list emails successfully** + - Ensures that the function correctly sends emails to both active users and the subscription list without issues. diff --git a/requirements/emailController/updateEmailSubscription.md b/requirements/emailController/updateEmailSubscription.md new file mode 100644 index 000000000..bcafa5a28 --- /dev/null +++ b/requirements/emailController/updateEmailSubscription.md @@ -0,0 +1,20 @@ +# Update Email Subscriptions Function + +## Negative Cases + +1. ❌ **Returns error 400 if `emailSubscriptions` field is missing from the request** + - This ensures that the function checks for the presence of the `emailSubscriptions` field in the request body and responds with a `400` status code if it's missing. + +2. ❌ **Returns error 400 if `email` field is missing from the requestor object** + - Ensures that the function requires an `email` field within the `requestor` object in the request body and returns `400` if it's absent. + +3. ❌ **Returns error 404 if the user with the provided `email` is not found** + - This checks that the function correctly handles cases where no user exists with the given `email` and responds with a `404` status code. + +4. ✅ **Returns error 500 if there is an internal error while updating the user profile** + - Covers scenarios where there's a database error while updating the user's email subscriptions. + +## Positive Cases + +1. ❌ **Returns status 200 and the updated user when email subscriptions are successfully updated** + - Ensures that the function updates the `emailSubscriptions` field for the user and returns the updated user document along with a `200` status code. diff --git a/requirements/informationController/addInformation.md b/requirements/informationController/addInformation.md new file mode 100644 index 000000000..d62066735 --- /dev/null +++ b/requirements/informationController/addInformation.md @@ -0,0 +1,16 @@ +Check mark: ✅ +Cross Mark: ❌ + +# Add Information + +> ## Positive case +1. ✅ Returns 201 if adding new information successfullyn and no cache. +2. ✅ Returns if adding new information successfully and hascache. + +> ## Negative case +1. ✅ Returns error 500 if if there are no information in the database and any error occurs when finding the infoName. +2. ✅ Returns error 400 if if there are duplicate infoName in the database. +3. ✅ Returns error 400 if if there are issues when saving new informations. +4. ✅ Returns error 400 if if there are errors when saving the new information. + +> ## Edge case \ No newline at end of file diff --git a/requirements/informationController/deleteInformation.md b/requirements/informationController/deleteInformation.md new file mode 100644 index 000000000..fb5c3c867 --- /dev/null +++ b/requirements/informationController/deleteInformation.md @@ -0,0 +1,13 @@ +Check mark: ✅ +Cross Mark: ❌ + +# Delete Information + +> ## Positive case +1. ✅ Returns 200 if deleting informations successfull and no cache. +2. ✅ Returns if deleting informations successfully and has cache. + +> ## Negative case +1. ✅ Returns error 400 if if there is any error when finding the information by information id. + +> ## Edge case \ No newline at end of file diff --git a/requirements/informationController/getInformation.md b/requirements/informationController/getInformation.md new file mode 100644 index 000000000..bd8976a5a --- /dev/null +++ b/requirements/informationController/getInformation.md @@ -0,0 +1,13 @@ +Check mark: ✅ +Cross Mark: ❌ + +# Get Information + +> ## Positive case +1. ✅ Returns 200 if the informations key exists in NodeCache. +2. ✅ Returns 200 if there are information in the database. + +> ## Negative case +1. ✅ Returns error 404 if if there are no information in the database and any error occurs when finding the information. + +> ## Edge case \ No newline at end of file diff --git a/requirements/informationController/updateInformation.md b/requirements/informationController/updateInformation.md new file mode 100644 index 000000000..709bcba54 --- /dev/null +++ b/requirements/informationController/updateInformation.md @@ -0,0 +1,13 @@ +Check mark: ✅ +Cross Mark: ❌ + +# Update Information + +> ## Positive case +1. ✅ Returns 200 if updating informations successfully when no cache. +2. ✅ Returns if updating informations successfully when hascache. + +> ## Negative case +1. ✅ Returns error 400 if if there is any error when finding the information by information id. + +> ## Edge case \ No newline at end of file diff --git a/requirements/popUpEditorController/createPopPopupEditor.md b/requirements/popUpEditorController/createPopPopupEditor.md new file mode 100644 index 000000000..0afcaa740 --- /dev/null +++ b/requirements/popUpEditorController/createPopPopupEditor.md @@ -0,0 +1,14 @@ +Check mark: ✅ +Cross Mark: ❌ + +# createPopPopupEditor Function + +> ### Positive case + +> 1. ✅ Should return 201 and the new pop-up editor on success + +> ### Negative case + +> 1. ✅ Should return 403 if user does not have permission to create a pop-up editor +> 2. ✅ Should return 400 if the request body is missing required fields +> 3. ✅ Should return 500 if there is an error saving the new pop-up editor to the database diff --git a/requirements/popUpEditorController/getAllPopupEditors.md b/requirements/popUpEditorController/getAllPopupEditors.md new file mode 100644 index 000000000..f42f93c1a --- /dev/null +++ b/requirements/popUpEditorController/getAllPopupEditors.md @@ -0,0 +1,10 @@ +Check mark: ✅ +Cross Mark: ❌ + +# getAllPopupEditors Function + +> ## Positive case +> 1. ✅ Should return 200 and all pop-up editors on success + +> ## Negative case +> 1. ✅ Should return 404 if there is an error retrieving the pop-up editors from the database diff --git a/requirements/popUpEditorController/getPopupEditorById.md b/requirements/popUpEditorController/getPopupEditorById.md new file mode 100644 index 000000000..013096ed9 --- /dev/null +++ b/requirements/popUpEditorController/getPopupEditorById.md @@ -0,0 +1,10 @@ +Check mark: ✅ +Cross Mark: ❌ + +# getPopupEditorById Function + +> ## Positive case +> 1. ✅ Should return 200 and the pop-up editor on success + +> ## Negative case +> 1. ✅ Should return 404 if the pop-up editor is not found diff --git a/requirements/popUpEditorController/updatePopupEditor.md b/requirements/popUpEditorController/updatePopupEditor.md new file mode 100644 index 000000000..c4e5f5904 --- /dev/null +++ b/requirements/popUpEditorController/updatePopupEditor.md @@ -0,0 +1,11 @@ +Check mark: ✅ +Cross Mark: ❌ + +# updatePopupEditor Function + +> ## Positive case +> 1. ✅ Should return 200 and the updated pop-up editor on success + + +> ## Negative case +> 1. ✅ Should return 404 if the pop-up editor is not found diff --git a/requirements/reasonSchedulingController/deleteReason.md b/requirements/reasonSchedulingController/deleteReason.md new file mode 100644 index 000000000..8df5ccb16 --- /dev/null +++ b/requirements/reasonSchedulingController/deleteReason.md @@ -0,0 +1,16 @@ +Check mark: ✅ +Cross Mark: ❌ + +# deleteReason + +> ## Positive case +1. ✅ Receives a POST request in the **/api/reason/:userId/** route. +2. ✅ Return 200 if delete reason successfully. + +> ## Negative case +1. ✅ Returns 403 when no permission to delete. +2. ✅ Returns 404 when error in finding user Id. +3. ✅ Returns 404 when error in finding reason. +4. ✅ Returns 500 when error in deleting. + +> ## Edge case \ No newline at end of file diff --git a/requirements/reasonSchedulingController/getAllReasons.md b/requirements/reasonSchedulingController/getAllReasons.md new file mode 100644 index 000000000..58499a41b --- /dev/null +++ b/requirements/reasonSchedulingController/getAllReasons.md @@ -0,0 +1,14 @@ +Check mark: ✅ +Cross Mark: ❌ + +# getAllReasons + +> ## Positive case +1. ✅ Receives a GET request in the **/api/reason/:userId** route. +2. ✅ Return 200 if get schedule reason successfully. + +> ## Negative case +1. ✅ Returns 404 when error in finding user by Id. +2. ✅ Returns 400 when any error in fetching the user + +> ## Edge case \ No newline at end of file diff --git a/requirements/reasonSchedulingController/getSingleReason.md b/requirements/reasonSchedulingController/getSingleReason.md new file mode 100644 index 000000000..bc81fd9d9 --- /dev/null +++ b/requirements/reasonSchedulingController/getSingleReason.md @@ -0,0 +1,15 @@ +Check mark: ✅ +Cross Mark: ❌ + +# getSingleReason + +> ## Positive case +1. ✅ Receives a GET request in the **/api/reason/single/:userId** route. +2. ✅ Return 200 if not found schedule reason and return empty object successfully. +3. ✅ Return 200 if found schedule reason and return reason successfully. + +> ## Negative case +1. ✅ Returns 404 when any error in find user by Id +2. ✅ Returns 400 when any error in fetching the user + +> ## Edge case \ No newline at end of file diff --git a/requirements/reasonSchedulingController/patchReason.md b/requirements/reasonSchedulingController/patchReason.md new file mode 100644 index 000000000..6e84a8ba7 --- /dev/null +++ b/requirements/reasonSchedulingController/patchReason.md @@ -0,0 +1,16 @@ +Check mark: ✅ +Cross Mark: ❌ + +# patchReason + +> ## Positive case +1. ✅ Receives a POST request in the **/api/breason/** route. +2. ✅ Return 200 if updated schedule reason and send blue sqaure email successfully. + +> ## Negative case +1. ✅ Returns 400 for not providing reason. +2. ✅ Returns 404 when error in finding user Id. +3. ✅ Returns 404 when not finding provided reason. +4. ✅ Returns 400 when any error in saving. + +> ## Edge case \ No newline at end of file diff --git a/requirements/reasonSchedulingController/postReason.md b/requirements/reasonSchedulingController/postReason.md new file mode 100644 index 000000000..ac8ea8f2d --- /dev/null +++ b/requirements/reasonSchedulingController/postReason.md @@ -0,0 +1,18 @@ +Check mark: ✅ +Cross Mark: ❌ + +# postReason + +> ## Positive case +1. ✅ Receives a POST request in the **/api/reason/** route. +2. ✅ Return 200 if s dchedule reason and send blue sqaure email successfully. + +> ## Negative case +1. ✅ Returns 400 for warning to choose Sunday. +2. ✅ Returns 400 for warning to choose a funture date. +3. ✅ Returns 400 for not providing reason. +4. ✅ Returns 404 when error in finding user Id. +5. ✅ Returns 403 when duplicate reason to the date. +6. ✅ Returns 400 when any error in saving. + +> ## Edge case \ No newline at end of file diff --git a/requirements/taskController/deleteTask.md b/requirements/taskController/deleteTask.md new file mode 100644 index 000000000..4f986e2a9 --- /dev/null +++ b/requirements/taskController/deleteTask.md @@ -0,0 +1,15 @@ +Check mark: ✅ +Cross Mark: ❌ + +# deleteTask Function + +> ## Positive case +1. ✅ Returns status 200 on successful deletion. + +> ## Negative case +1. ✅ Returns status 400 if either no record is found in Task collection or some error occurs while saving the tasks. + +2. ✅ Returns status 403 if the request.body.requestor does not have `deleteTask` permission. + + +> ## Edge case diff --git a/requirements/taskController/deleteTaskByWBS.md b/requirements/taskController/deleteTaskByWBS.md new file mode 100644 index 000000000..20ef90624 --- /dev/null +++ b/requirements/taskController/deleteTaskByWBS.md @@ -0,0 +1,15 @@ +Check mark: ✅ +Cross Mark: ❌ + +# deleteTaskByWBS Function + +> ## Positive case +1. ✅ Returns status 200 on successful deletion. + +> ## Negative case +1. ✅ Returns status 400 if either no record is found in Task collection or some error occurs while saving the tasks. + +2. ✅ Returns status 403 if the request.body.requestor does not have `deleteTask` permission. + + +> ## Edge case diff --git a/requirements/taskController/fixTasks.md b/requirements/taskController/fixTasks.md new file mode 100644 index 000000000..f6a80846a --- /dev/null +++ b/requirements/taskController/fixTasks.md @@ -0,0 +1,11 @@ +Check mark: ✅ +Cross Mark: ❌ + +# updateAllParents Function + +> ## Positive case +1. ✅ Returns status 200 on without performing an operation. + +> ## Negative case + +> ## Edge case diff --git a/requirements/taskController/getTaskById.md b/requirements/taskController/getTaskById.md new file mode 100644 index 000000000..88e7e1d03 --- /dev/null +++ b/requirements/taskController/getTaskById.md @@ -0,0 +1,14 @@ +Check mark: ✅ +Cross Mark: ❌ + +# getTaskById Function + +> ## Positive case +1. ✅ Returns status 200 on successfully getting a taskById. + +> ## Negative case +1. ✅ Returns status 400 if either req.params.id is missing or is `undefined` or if no task is found in Task collection. + +2. ✅ Returns status 500 if some error occurs. + +> ## Edge case diff --git a/requirements/taskController/getTasks.md b/requirements/taskController/getTasks.md new file mode 100644 index 000000000..8cd6f08e7 --- /dev/null +++ b/requirements/taskController/getTasks.md @@ -0,0 +1,12 @@ +Check mark: ✅ +Cross Mark: ❌ + +# getTasks Function + +> ## Positive case +1. ✅ Returns status 200 on successfully querying the Task collection. + +> ## Negative case +1. ✅ Returns status 404 if any error occurs while querying the Task collection. + +> ## Edge case diff --git a/requirements/taskController/getTasksByUserId.md b/requirements/taskController/getTasksByUserId.md new file mode 100644 index 000000000..7f482ef07 --- /dev/null +++ b/requirements/taskController/getTasksByUserId.md @@ -0,0 +1,12 @@ +Check mark: ✅ +Cross Mark: ❌ + +# getTasksByUserId Function + +> ## Positive case +1. ✅ Returns status 200 on successfully getting the tasks. + +> ## Negative case +1. ✅ Returns status 400 if some error occurs. + +> ## Edge case diff --git a/requirements/taskController/getTasksForTeamsByUser.md b/requirements/taskController/getTasksForTeamsByUser.md new file mode 100644 index 000000000..4eae8defa --- /dev/null +++ b/requirements/taskController/getTasksForTeamsByUser.md @@ -0,0 +1,12 @@ +Check mark: ✅ +Cross Mark: ❌ + +# getTasksForTeamsByUser Function + +> ## Positive case +1. ✅ Returns status 200 on successfully fetching teams data. + +> ## Negative case +1. ✅ Returns status 400 if some error occurs. + +> ## Edge case diff --git a/requirements/taskController/getWBSId.md b/requirements/taskController/getWBSId.md new file mode 100644 index 000000000..8efb2290a --- /dev/null +++ b/requirements/taskController/getWBSId.md @@ -0,0 +1,12 @@ +Check mark: ✅ +Cross Mark: ❌ + +# getWBSId Function + +> ## Positive case +1. ✅ Returns status 200 on successfully querying the WBS collection. + +> ## Negative case +1. ✅ Returns status 404 if any error occurs while querying the WBS collection. + +> ## Edge case diff --git a/requirements/taskController/importTask.md b/requirements/taskController/importTask.md new file mode 100644 index 000000000..8626209aa --- /dev/null +++ b/requirements/taskController/importTask.md @@ -0,0 +1,14 @@ +Check mark: ✅ +Cross Mark: ❌ + +# importTask Function + +> ## Positive case +1. ✅ Returns status 201 on successfully creating and saving the new Task. + +> ## Negative case +1. ✅ Returns status 400 if any error occurs while saving the Task. + +2. ✅ Returns status 403 if request.body.requestor is missing permission for importTask. + +> ## Edge case diff --git a/requirements/taskController/moveTask.md b/requirements/taskController/moveTask.md new file mode 100644 index 000000000..68794e051 --- /dev/null +++ b/requirements/taskController/moveTask.md @@ -0,0 +1,13 @@ +Check mark: ✅ +Cross Mark: ❌ + +# updateNum Function + +> ## Positive case +1. ✅ Returns status 200 on successful updation. + +> ## Negative case +1. ✅ Returns status 400 if either request.body.fromNum request.body.toNum is missing or some error occurs while saving the tasks. + + +> ## Edge case diff --git a/requirements/taskController/postTask.md b/requirements/taskController/postTask.md new file mode 100644 index 000000000..971b9a04a --- /dev/null +++ b/requirements/taskController/postTask.md @@ -0,0 +1,14 @@ +Check mark: ✅ +Cross Mark: ❌ + +# postTask Function + +> ## Positive case +1. ✅ Returns status 201 on successfully posting the new task. + +> ## Negative case +1. ✅ Returns status 400 if either request.body.taskName is missing or request.body.isActive is missing or some error occurs while saving task or Wbs or project. + +2. ✅ Returns status 403 if request.body.requestor is missing permission `postTask`. + +> ## Edge case diff --git a/requirements/taskController/sendReviewReq.md b/requirements/taskController/sendReviewReq.md new file mode 100644 index 000000000..53c03ff77 --- /dev/null +++ b/requirements/taskController/sendReviewReq.md @@ -0,0 +1,12 @@ +Check mark: ✅ +Cross Mark: ❌ + +# sendReviewReq Function + +> ## Positive case +1. ✅ Returns status 200 on successful operation. + +> ## Negative case +1. ✅ Returns status 500 if some error occurs. + +> ## Edge case diff --git a/requirements/taskController/swap.md b/requirements/taskController/swap.md new file mode 100644 index 000000000..93540de92 --- /dev/null +++ b/requirements/taskController/swap.md @@ -0,0 +1,16 @@ +Check mark: ✅ +Cross Mark: ❌ + +# swap Function + +> ## Positive case +1. ✅ Returns status 201 on successfully updating. + +> ## Negative case +1. ✅ Returns status 400 if either request.body.taskId1 is missing or request.body.taskId2 is missing or there is error while executing findById in Task or some error occurs while saving task. + +2. ✅ Returns status 403 if request.body.requestor is missing `swapTask` permission. + +3. ✅ Returns status 404 if some error occurs while executing find operation on Task collection. + +> ## Edge case diff --git a/requirements/taskController/updateAllParents.md b/requirements/taskController/updateAllParents.md new file mode 100644 index 000000000..eccce4d41 --- /dev/null +++ b/requirements/taskController/updateAllParents.md @@ -0,0 +1,12 @@ +Check mark: ✅ +Cross Mark: ❌ + +# updateAllParents Function + +> ## Positive case +1. ✅ Returns status 200 on successful updation. + +> ## Negative case +1. ✅ Returns status 400 if some error occurs. - Not possible to check as per current structure + +> ## Edge case diff --git a/requirements/taskController/updateNum.md b/requirements/taskController/updateNum.md new file mode 100644 index 000000000..4b489014c --- /dev/null +++ b/requirements/taskController/updateNum.md @@ -0,0 +1,16 @@ +Check mark: ✅ +Cross Mark: ❌ + +# updateNum Function + +> ## Positive case +1. ✅ Returns status 200 on successfully updating. + +> ## Negative case +1. ✅ Returns status 400 if either request.body.nums is missing or some error occurs while saving child task. + +2. ✅ Returns status 403 if request.body.requestor is missing `updateNum` permission. + +3. ✅ Returns status 404 if some error occurs while processing the child tasks. + +> ## Edge case diff --git a/requirements/taskController/updateTask.md b/requirements/taskController/updateTask.md new file mode 100644 index 000000000..09e631603 --- /dev/null +++ b/requirements/taskController/updateTask.md @@ -0,0 +1,16 @@ +Check mark: ✅ +Cross Mark: ❌ + +# updateTask Function + +> ## Positive case +1. ✅ Returns status 201 on successfully updating. + +> ## Negative case +1. ✅ Returns status 400 if either request.body.nums is missing or some error occurs while saving child task. + +2. ✅ Returns status 403 if request.body.requestor is missing `updateTask` permission. + +3. ✅ Returns status 404 if some error occurs while executing findOneAndUpdate operation on Task collection. + +> ## Edge case diff --git a/requirements/taskController/updateTaskStatus.md b/requirements/taskController/updateTaskStatus.md new file mode 100644 index 000000000..59bdbdd7c --- /dev/null +++ b/requirements/taskController/updateTaskStatus.md @@ -0,0 +1,12 @@ +Check mark: ✅ +Cross Mark: ❌ + +# updateTaskStatus Function + +> ## Positive case +1. ✅ Returns status 201 on successful update operation. + +> ## Negative case +1. ✅ Returns status 404 if some error occurs. + +> ## Edge case diff --git a/requirements/timeZoneAPIController/getTImeZone.md b/requirements/timeZoneAPIController/getTImeZone.md new file mode 100644 index 000000000..c7ae7801e --- /dev/null +++ b/requirements/timeZoneAPIController/getTImeZone.md @@ -0,0 +1,20 @@ +Check mark: ✅ +Cross Mark: ❌ + +# Get Time Zone + +> ## Positive case + +1. ✅ Returns status code 200 and response data as follows: + i. current location + ii. timezone + +> ## Negative case + +1. ✅ Returns status code 403, if the user is not authorised. +2. ✅ Returns status code 401, if the API key is missing. +3. ✅ Returns status code 400, if the location is missing. +4. ✅ Returns status code 404, if geocodeAPIEndpoint returns no results. +5. ✅ Returns status code 500, if any other error occurs. + +> ## Edge case diff --git a/requirements/timeZoneAPIController/getTimeZoneProfileInitialSetup.md b/requirements/timeZoneAPIController/getTimeZoneProfileInitialSetup.md new file mode 100644 index 000000000..1a0f91265 --- /dev/null +++ b/requirements/timeZoneAPIController/getTimeZoneProfileInitialSetup.md @@ -0,0 +1,20 @@ +Check mark: ✅ +Cross Mark: ❌ + +# Get Time Zone + +> ## Positive case + +1. ✅ Returns status code 200 and response data as follows: + i. current location + ii. timezone + +> ## Negative case + +1. ✅ Returns status code 400, if the token is missing in the request body. +2. ✅ Returns status code 403, if the no document exists in ProfileInitialSetupToken database with requested token. +3. ✅ Returns status code 400, if the location is missing. +4. ✅ Returns status code 404, if geocodeAPIEndpoint returns no results. +5. ✅ Returns status code 500, if any other error occurs. + +> ## Edge case diff --git a/src/controllers/actionItemController.js b/src/controllers/actionItemController.js index 6d5864213..390794b31 100644 --- a/src/controllers/actionItemController.js +++ b/src/controllers/actionItemController.js @@ -1,128 +1,123 @@ -/** - * Unused legacy code. Commented out to avoid confusion. Will delete in the next cycle. - * Commented by: Shengwei Peng - * Date: 2024-03-22 - */ - -// const mongoose = require('mongoose'); -// const notificationhelper = require('../helpers/notificationhelper')(); - -// const actionItemController = function (ActionItem) { -// const getactionItem = function (req, res) { -// const userid = req.params.userId; -// ActionItem.find({ -// assignedTo: userid, -// }, ('-createdDateTime -__v')) -// .populate('createdBy', 'firstName lastName') -// .then((results) => { -// const actionitems = []; - -// results.forEach((element) => { -// const actionitem = {}; - -// actionitem._id = element._id; -// actionitem.description = element.description; -// actionitem.createdBy = `${element.createdBy.firstName} ${element.createdBy.lastName}`; -// actionitem.assignedTo = element.assignedTo; - -// actionitems.push(actionitem); -// }); - -// res.status(200).send(actionitems); -// }) -// .catch((error) => { -// res.status(400).send(error); -// }); -// }; -// const postactionItem = function (req, res) { -// const { requestorId, assignedTo } = req.body.requestor; -// const _actionItem = new ActionItem(); - -// _actionItem.description = req.body.description; -// _actionItem.assignedTo = req.body.assignedTo; -// _actionItem.createdBy = req.body.requestor.requestorId; - -// _actionItem.save() -// .then((result) => { -// notificationhelper.notificationcreated(requestorId, assignedTo, _actionItem.description); - -// const actionitem = {}; - -// actionitem.createdBy = 'You'; -// actionitem.description = _actionItem.description; -// actionitem._id = result._id; -// actionitem.assignedTo = _actionItem.assignedTo; - -// res.status(200).send(actionitem); -// }) -// .catch((error) => { -// res.status(400).send(error); -// }); -// }; - -// const deleteactionItem = async function (req, res) { -// const actionItemId = mongoose.Types.ObjectId(req.params.actionItemId); - -// const _actionItem = await ActionItem.findById(actionItemId) -// .catch((error) => { -// res.status(400).send(error); -// }); - -// if (!_actionItem) { -// res.status(400).send({ -// message: 'No valid records found', -// }); -// return; -// } - -// const { requestorId, assignedTo } = req.body.requestor; - -// notificationhelper.notificationdeleted(requestorId, assignedTo, _actionItem.description); - -// _actionItem.remove() -// .then(() => { -// res.status(200).send({ -// message: 'removed', -// }); -// }) -// .catch((error) => { -// res.status(400).send(error); -// }); -// }; - -// const editactionItem = async function (req, res) { -// const actionItemId = mongoose.Types.ObjectId(req.params.actionItemId); - -// const { requestorId, assignedTo } = req.body.requestor; - -// const _actionItem = await ActionItem.findById(actionItemId) -// .catch((error) => { -// res.status(400).send(error); -// }); - -// if (!_actionItem) { -// res.status(400).send({ -// message: 'No valid records found', -// }); -// return; -// } -// notificationhelper.notificationedited(requestorId, assignedTo, _actionItem.description, req.body.description); - -// _actionItem.description = req.body.description; -// _actionItem.assignedTo = req.body.assignedTo; - -// _actionItem.save() -// .then(res.status(200).send('Saved')) -// .catch((error) => res.status(400).send(error)); -// }; - -// return { -// getactionItem, -// postactionItem, -// deleteactionItem, -// editactionItem, - -// }; -// }; - -// module.exports = actionItemController; +const mongoose = require('mongoose'); +const notificationhelper = require('../helpers/notificationhelper')(); + +const actionItemController = function (ActionItem) { + const getactionItem = function (req, res) { + const userid = req.params.userId; + ActionItem.find({ + assignedTo: userid, + }, ('-createdDateTime -__v')) + .populate('createdBy', 'firstName lastName') + .then((results) => { + const actionitems = []; + + results.forEach((element) => { + const actionitem = {}; + + actionitem._id = element._id; + actionitem.description = element.description; + actionitem.createdBy = `${element.createdBy.firstName} ${element.createdBy.lastName}`; + actionitem.assignedTo = element.assignedTo; + + actionitems.push(actionitem); + }); + + res.status(200).send(actionitems); + }) + .catch((error) => { + res.status(400).send(error); + }); + }; + + const postactionItem = function (req, res) { + const { requestorId, assignedTo } = req.body.requestor; + const _actionItem = new ActionItem(); + + _actionItem.description = req.body.description; + _actionItem.assignedTo = req.body.assignedTo; + _actionItem.createdBy = req.body.requestor.requestorId; + + _actionItem.save() + .then((result) => { + notificationhelper.notificationcreated(requestorId, assignedTo, _actionItem.description); + + const actionitem = {}; + + actionitem.createdBy = 'You'; + actionitem.description = _actionItem.description; + actionitem._id = result._id; + actionitem.assignedTo = _actionItem.assignedTo; + + res.status(200).send(actionitem); + }) + .catch((error) => { + res.status(400).send(error); + }); + }; + + const deleteactionItem = async function (req, res) { + const actionItemId = mongoose.Types.ObjectId(req.params.actionItemId); + + const _actionItem = await ActionItem.findById(actionItemId) + .catch((error) => { + res.status(400).send(error); + }); + + if (!_actionItem) { + res.status(400).send({ + message: 'No valid records found', + }); + return; + } + + const { requestorId, assignedTo } = req.body.requestor; + + notificationhelper.notificationdeleted(requestorId, assignedTo, _actionItem.description); + + _actionItem.remove() + .then(() => { + res.status(200).send({ + message: 'removed', + }); + }) + .catch((error) => { + res.status(400).send(error); + }); + }; + + const editactionItem = async function (req, res) { + const actionItemId = mongoose.Types.ObjectId(req.params.actionItemId); + + const { requestorId, assignedTo } = req.body.requestor; + + const _actionItem = await ActionItem.findById(actionItemId) + .catch((error) => { + res.status(400).send(error); + }); + + if (!_actionItem) { + res.status(400).send({ + message: 'No valid records found', + }); + return; + } + + notificationhelper.notificationedited(requestorId, assignedTo, _actionItem.description, req.body.description); + + _actionItem.description = req.body.description; + _actionItem.assignedTo = req.body.assignedTo; + + _actionItem.save() + .then(res.status(200).send('Saved')) + .catch((error) => res.status(400).send(error)); + }; + + return { + getactionItem, + postactionItem, + deleteactionItem, + editactionItem, + }; +}; + +module.exports = actionItemController; diff --git a/src/controllers/emailController.js b/src/controllers/emailController.js index 13de952e3..15243ef5b 100644 --- a/src/controllers/emailController.js +++ b/src/controllers/emailController.js @@ -1,15 +1,87 @@ // emailController.js const jwt = require('jsonwebtoken'); +const cheerio = require('cheerio'); const emailSender = require('../utilities/emailSender'); +const { hasPermission } = require('../utilities/permissions'); const EmailSubcriptionList = require('../models/emailSubcriptionList'); const userProfile = require('../models/userProfile'); const frontEndUrl = process.env.FRONT_END_URL || 'http://localhost:3000'; const jwtSecret = process.env.JWT_SECRET || 'EmailSecret'; +const handleContentToOC = (htmlContent) => + ` + + + + + + ${htmlContent} + + `; + +const handleContentToNonOC = (htmlContent, email) => + ` + + + + + + ${htmlContent} +

Thank you for subscribing to our email updates!

+

If you would like to unsubscribe, please click here

+ + `; + +function extractImagesAndCreateAttachments(html) { + const $ = cheerio.load(html); + const attachments = []; + + $('img').each((i, img) => { + const src = $(img).attr('src'); + if (src.startsWith('data:image')) { + const base64Data = src.split(',')[1]; + const _cid = `image-${i}`; + attachments.push({ + filename: `image-${i}.png`, + content: Buffer.from(base64Data, 'base64'), + cid: _cid, + }); + $(img).attr('src', `cid:${_cid}`); + } + }); + return { + html: $.html(), + attachments, + }; +} + const sendEmail = async (req, res) => { + const canSendEmail = await hasPermission(req.body.requestor, 'sendEmails'); + if (!canSendEmail) { + res.status(403).send('You are not authorized to send emails.'); + return; + } try { const { to, subject, html } = req.body; + // Validate required fields + if (!subject || !html || !to) { + const missingFields = []; + if (!subject) missingFields.push('Subject'); + if (!html) missingFields.push('HTML content'); + if (!to) missingFields.push('Recipient email'); + console.log('missingFields', missingFields); + return res + .status(400) + .send(`${missingFields.join(' and ')} ${missingFields.length > 1 ? 'are' : 'is'} required`); + } + + // Extract images and create attachments + const { html: processedHtml, attachments } = extractImagesAndCreateAttachments(html); + + // Log recipient for debugging + console.log('Recipient:', to); + await emailSender(to, subject, html) .then(result => { @@ -21,6 +93,7 @@ const sendEmail = async (req, res) => { res.status(500).send('Error sending email'); }); + } catch (error) { console.error('Error sending email:', error); return res.status(500).send('Error sending email'); @@ -28,48 +101,51 @@ const sendEmail = async (req, res) => { }; const sendEmailToAll = async (req, res) => { + const canSendEmailToAll = await hasPermission(req.body.requestor, 'sendEmailToAll'); + if (!canSendEmailToAll) { + res.status(403).send('You are not authorized to send emails to all.'); + return; + } try { const { subject, html } = req.body; + if (!subject || !html) { + return res.status(400).send('Subject and HTML content are required'); + } + + const { html: processedHtml, attachments } = extractImagesAndCreateAttachments(html); + const users = await userProfile.find({ - firstName: 'Haoji', + firstName: '', email: { $ne: null }, isActive: true, emailSubscriptions: true, }); - let to = ''; - const emailContent = ` - - - - + if (users.length === 0) { + return res.status(404).send('No users found'); + } + const recipientEmails = users.map((user) => user.email); + console.log('# sendEmailToAll to', recipientEmails.join(',')); + if (recipientEmails.length === 0) { + throw new Error('No recipients defined'); + } - - ${html} - - `; - users.forEach((user) => { - to += `${user.email},`; - }); - emailSender(to, subject, emailContent); - const emailList = await EmailSubcriptionList.find({ email: { $ne: null } }); - emailList.forEach((emailObject) => { - const { email } = emailObject; - const emailContent = ` - - - - + const emailContentToOCmembers = handleContentToOC(processedHtml); + await Promise.all( + recipientEmails.map((email) => + emailSender(email, subject, emailContentToOCmembers, attachments), + ), + ); + const emailSubscribers = await EmailSubcriptionList.find({ email: { $exists: true, $ne: '' } }); + console.log('# sendEmailToAll emailSubscribers', emailSubscribers.length); + await Promise.all( + emailSubscribers.map(({ email }) => { + const emailContentToNonOCmembers = handleContentToNonOC(processedHtml, email); + return emailSender(email, subject, emailContentToNonOCmembers, attachments); + }), + ); - - ${html} -

Thank you for subscribing to our email updates!

-

If you would like to unsubscribe, please click here

- - `; - emailSender(email, subject, emailContent); - }); return res.status(200).send('Email sent successfully'); } catch (error) { console.error('Error sending email:', error); @@ -107,13 +183,9 @@ const addNonHgnEmailSubscription = async (req, res) => { } const payload = { email }; - const token = jwt.sign( - payload, - jwtSecret, - { - expiresIn: 360, - }, - ); + const token = jwt.sign(payload, jwtSecret, { + expiresIn: 360, + }); const emailContent = ` diff --git a/src/controllers/emailController.spec.js b/src/controllers/emailController.spec.js new file mode 100644 index 000000000..f5327a328 --- /dev/null +++ b/src/controllers/emailController.spec.js @@ -0,0 +1,146 @@ +const { mockReq, mockRes, assertResMock } = require('../test'); +const emailController = require('./emailController'); +const jwt = require('jsonwebtoken'); +const userProfile = require('../models/userProfile'); + + +jest.mock('jsonwebtoken'); +jest.mock('../models/userProfile'); +jest.mock('../utilities/emailSender'); + + + + +const makeSut = () => { + const { + sendEmail, + sendEmailToAll, + updateEmailSubscriptions, + addNonHgnEmailSubscription, + removeNonHgnEmailSubscription, + confirmNonHgnEmailSubscription, + } = emailController; + return { + sendEmail, + sendEmailToAll, + updateEmailSubscriptions, + addNonHgnEmailSubscription, + removeNonHgnEmailSubscription, + confirmNonHgnEmailSubscription, + }; +}; +describe('emailController Controller Unit tests', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('sendEmail function', () => { + test('should send email successfully', async () => { + const { sendEmail } = makeSut(); + const mockReq = { + body: { + to: 'recipient@example.com', + subject: 'Test Subject', + html: '

Test Body

', + }, + }; + const response = await sendEmail(mockReq, mockRes); + assertResMock(200, 'Email sent successfully', response, mockRes); + }); +}); + + describe('updateEmailSubscriptions function', () => { + test('should handle error when updating email subscriptions', async () => { + const { updateEmailSubscriptions } = makeSut(); + + + userProfile.findOneAndUpdate = jest.fn(); + + userProfile.findOneAndUpdate.mockRejectedValue(new Error('Update failed')); + + const mockReq = { + body: { + emailSubscriptions: ['subscription1', 'subscription2'], + requestor: { + email: 'test@example.com', + }, + }, + }; + + const response = await updateEmailSubscriptions(mockReq, mockRes); + + assertResMock(500, 'Error updating email subscriptions', response, mockRes); + }); + }); + + + describe('confirmNonHgnEmailSubscription function', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + beforeAll(() => { + jwt.verify = jest.fn(); + }); + + test('should return 400 if token is not provided', async () => { + const { confirmNonHgnEmailSubscription } = makeSut(); + + const mockReq = { body: {} }; + const response = await confirmNonHgnEmailSubscription(mockReq, mockRes); + + assertResMock(400, 'Invalid token', response, mockRes); + }); + + test('should return 401 if token is invalid', async () => { + const { confirmNonHgnEmailSubscription } = makeSut(); + const mockReq = { body: { token: 'invalidToken' } }; + + jwt.verify.mockImplementation(() => { + throw new Error('Token is not valid'); + }); + + await confirmNonHgnEmailSubscription(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(401); + expect(mockRes.json).toHaveBeenCalledWith({ + errors: [ + { msg: 'Token is not valid' }, + ], + }); + }); + + + test('should return 400 if email is missing from payload', async () => { + const { confirmNonHgnEmailSubscription } = makeSut(); + const mockReq = { body: { token: 'validToken' } }; + + // Mocking jwt.verify to return a payload without email + jwt.verify.mockReturnValue({}); + + const response = await confirmNonHgnEmailSubscription(mockReq, mockRes); + + assertResMock(400, 'Invalid token', response, mockRes); + }); + + + + + + }); + describe('removeNonHgnEmailSubscription function', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('should return 400 if email is missing', async () => { + const { removeNonHgnEmailSubscription } = makeSut(); + const mockReq = { body: {} }; + + const response = await removeNonHgnEmailSubscription(mockReq, mockRes); + + assertResMock(400, 'Email is required', response, mockRes); + }); + }); + + }); diff --git a/src/controllers/informationController.js b/src/controllers/informationController.js index 792620995..03a23ba57 100644 --- a/src/controllers/informationController.js +++ b/src/controllers/informationController.js @@ -1,17 +1,17 @@ -const mongoose = require('mongoose'); +// const mongoose = require('mongoose'); // const userProfile = require('../models/userProfile'); // const hasPermission = require('../utilities/permissions'); const escapeRegex = require('../utilities/escapeRegex'); -const cache = require('../utilities/nodeCache')(); +const cacheClosure = require('../utilities/nodeCache'); const informationController = function (Information) { + const cache = cacheClosure(); const getInformations = function (req, res) { // return all informations if cache is available if (cache.hasCache('informations')) { res.status(200).send(cache.getCache('informations')); return; } - Information.find({}, 'infoName infoContent visibility') .then((results) => { // cache results diff --git a/src/controllers/informationController.spec.js b/src/controllers/informationController.spec.js new file mode 100644 index 000000000..e69dd2a32 --- /dev/null +++ b/src/controllers/informationController.spec.js @@ -0,0 +1,392 @@ +/* eslint-disable no-unused-vars */ +// const mongoose = require('mongoose'); +const mongoose = require('mongoose'); + +jest.mock('../utilities/nodeCache'); +const cache = require('../utilities/nodeCache'); +const Information = require('../models/information'); +const escapeRegex = require('../utilities/escapeRegex'); +const informationController = require('./informationController'); +const { mockReq, mockRes, assertResMock } = require('../test'); + +/* eslint-disable no-unused-vars */ +/* eslint-disable prefer-promise-reject-errors */ + +const makeSut = () => { + const { addInformation, getInformations, updateInformation, deleteInformation } = + informationController(Information); + + return { + addInformation, + getInformations, + updateInformation, + deleteInformation, + }; +}; +// Define flushPromises function)); +const flushPromises = () => new Promise(setImmediate); + +const makeMockCache = (method, value) => { + const cacheObject = { + getCache: jest.fn(), + removeCache: jest.fn(), + hasCache: jest.fn(), + setCache: jest.fn(), + }; + + const mockCache = jest.spyOn(cacheObject, method).mockImplementationOnce(() => value); + + cache.mockImplementationOnce(() => cacheObject); + + return { mockCache, cacheObject }; +}; + +describe('informationController module', () => { + beforeEach(() => {}); + afterEach(() => { + jest.clearAllMocks(); + }); + describe('addInformation function', () => { + test('Ensure addInformation returns 500 if any error when finding any information', async () => { + const { addInformation } = makeSut(); + const newMockReq = { + ...mockReq.body, + body: { + infoName: 'some infoName', + }, + }; + jest + .spyOn(Information, 'find') + .mockImplementationOnce(() => Promise.reject(new Error('Error when finding'))); + const response = addInformation(newMockReq, mockRes); + await flushPromises(); + assertResMock(500, { error: new Error('Error when finding') }, response, mockRes); + }); + test('Ensure addInformation returns 400 if duplicate info Name', async () => { + const { addInformation } = makeSut(); + const data = [{ infoName: 'test Info' }]; + const findSpy = jest + .spyOn(Information, 'find') + .mockImplementationOnce(() => Promise.resolve(data)); + const newMockReq = { + body: { + ...mockReq.body, + infoName: 'test Info', + }, + }; + const response = addInformation(newMockReq, mockRes); + await flushPromises(); + expect(findSpy).toHaveBeenCalledWith({ + infoName: { $regex: escapeRegex(newMockReq.body.infoName), $options: 'i' }, + }); + assertResMock( + 400, + { + error: `Info Name must be unique. Another infoName with name ${newMockReq.body.infoName} already exists. Please note that info names are case insensitive`, + }, + response, + mockRes, + ); + }); + test('Ensure addInformations returns 400 if any error when saving new Information', async () => { + const { addInformation } = makeSut(); + const newMockReq = { + body: { + ...mockReq.body, + infoName: 'some Info', + }, + }; + const findSpy = jest + .spyOn(Information, 'find') + .mockImplementationOnce(() => Promise.resolve(true)); + jest + .spyOn(Information.prototype, 'save') + .mockImplementationOnce(() => Promise.reject(new Error('Error when saving'))); + const response = addInformation(newMockReq, mockRes); + await flushPromises(); + + expect(findSpy).toHaveBeenCalledWith({ + infoName: { $regex: escapeRegex(newMockReq.body.infoName), $options: 'i' }, + }); + assertResMock(400, new Error('Error when saving'), response, mockRes); + }); + + test('Ensure addInformation returns 201 if creating information successfully when no cache', async () => { + const { mockCache: hasCacheMock } = makeMockCache('hasCache', ''); + const { addInformation } = makeSut(); + const data = { + infoName: 'mockAdd', + infoContent: 'mockContent', + visibility: '1', + }; + + const findSpy = jest + .spyOn(Information, 'find') + .mockImplementationOnce(() => Promise.resolve([])); + jest.spyOn(Information.prototype, 'save').mockImplementationOnce(() => Promise.resolve(data)); + const newMockReq = { + body: { + ...mockReq.body, + infoName: 'some addInfo', + infoContent: '1', + visibility: '1', + }, + }; + const response = addInformation(newMockReq, mockRes); + await flushPromises(); + expect(findSpy).toHaveBeenCalledWith({ + infoName: { $regex: escapeRegex(newMockReq.body.infoName), $options: 'i' }, + }); + expect(hasCacheMock).toHaveBeenCalledWith('informations'); + assertResMock(201, data, response, mockRes); + }); + test('Ensure addInformation returns 201 if creating information successfully', async () => { + const { mockCache: hasCacheMock, cacheObject } = makeMockCache('hasCache', '[{_id: 1}]'); + const removeCacheMock = jest + .spyOn(cacheObject, 'removeCache') + .mockImplementationOnce(() => null); + const { addInformation } = makeSut(); + const data = [ + { + infoName: 'mockAdd', + infoContent: 'mockContent', + visibility: '1', + }, + ]; + + const findSpy = jest + .spyOn(Information, 'find') + .mockImplementationOnce(() => Promise.resolve([])); + jest.spyOn(Information.prototype, 'save').mockImplementationOnce(() => Promise.resolve(data)); + const newMockReq = { + body: { + ...mockReq.body, + infoName: 'some addInfo', + infoContent: '1', + visibility: '1', + }, + }; + addInformation(newMockReq, mockRes); + await flushPromises(); + expect(findSpy).toHaveBeenCalledWith({ + infoName: { $regex: escapeRegex(newMockReq.body.infoName), $options: 'i' }, + }); + expect(hasCacheMock).toHaveBeenCalledWith('informations'); + expect(removeCacheMock).toHaveBeenCalledWith('informations'); + }); + }); + describe('getInformations function', () => { + test('Ensure getInformations returns 200 if when informations key in cache', async () => { + const data = [ + { + _id: 1, + infoName: 'infoName', + infoContent: 'infoContent', + visibility: '1', + }, + ]; + const { mockCache: hasCacheMock, cacheObject } = makeMockCache('hasCache', data); + const getCacheMock = jest.spyOn(cacheObject, 'getCache').mockImplementationOnce(() => data); + const { getInformations } = makeSut(); + + const response = getInformations(mockReq, mockRes); + await flushPromises(); + expect(hasCacheMock).toHaveBeenCalledWith('informations'); + expect(getCacheMock).toHaveBeenCalledWith('informations'); + assertResMock(200, data, response, mockRes); + }); + test('Ensure getInformations returns 404 if any error when no informations key and catch error in finding', async () => { + const { mockCache: hasCacheMock } = makeMockCache('hasCache', ''); + const findSpy = jest + .spyOn(Information, 'find') + .mockImplementationOnce(() => Promise.reject(new Error('Error when finding information'))); + const { getInformations } = makeSut(); + + const response = getInformations(mockReq, mockRes); + await flushPromises(); + expect(hasCacheMock).toHaveBeenCalledWith('informations'); + expect(findSpy).toHaveBeenCalledWith({}, 'infoName infoContent visibility'); + assertResMock(404, new Error('Error when finding information'), response, mockRes); + }); + + test('Ensure getInformations returns 200 when no informations key and no duplicated information', async () => { + const data = [ + { + infoName: 'mockAdd', + infoContent: 'mockContent', + visibility: '1', + }, + ]; + const { mockCache: hasCacheMock, cacheObject } = makeMockCache('hasCache', ''); + const findSpy = jest + .spyOn(Information, 'find') + .mockImplementationOnce(() => Promise.resolve(data)); + const setCacheMock = jest.spyOn(cacheObject, 'setCache').mockImplementationOnce(() => data); + + const { getInformations } = makeSut(); + const newMockReq = { + body: { + ...mockReq.body, + infoName: 'some getInfo', + infoContent: '1', + visibility: '1', + }, + }; + const response = getInformations(newMockReq, mockRes); + await flushPromises(); + expect(hasCacheMock).toHaveBeenCalledWith('informations'); + expect(findSpy).toHaveBeenCalledWith({}, 'infoName infoContent visibility'); + expect(setCacheMock).toHaveBeenCalledWith('informations', data); + assertResMock(200, data, response, mockRes); + }); + }); + describe('deleteInformation function', () => { + test('Ensure deleteInformation returns 400 if any error when finding and delete information', async () => { + const errorMsg = 'Error when finding and deleting information by Id'; + const { deleteInformation } = makeSut(); + jest + .spyOn(Information, 'findOneAndDelete') + .mockImplementationOnce(() => Promise.reject(new Error(errorMsg))); + const response = deleteInformation(mockReq, mockRes); + await flushPromises(); + + assertResMock(400, new Error(errorMsg), response, mockRes); + }); + test('Ensure deleteInformation returns 200 if delete information successfully no cache', async () => { + const { mockCache: hasCacheMock } = makeMockCache('hasCache', ''); + const deletedData = { + id: '601acda376045c7879d13a77', + infoName: 'deletedInfo', + infoContent: 'deleted', + visibility: '1', + }; + const { deleteInformation } = makeSut(); + const newMockReq = { + ...mockReq.body, + params: { + ...mockReq.params, + id: '601acda376045c7879d13a77', + }, + }; + const findOneDeleteSpy = jest + .spyOn(Information, 'findOneAndDelete') + .mockImplementationOnce(() => Promise.resolve(deletedData)); + const response = deleteInformation(newMockReq, mockRes); + await flushPromises(); + expect(findOneDeleteSpy).toHaveBeenCalledWith({ _id: deletedData.id }); + expect(hasCacheMock).toHaveBeenCalledWith('informations'); + assertResMock(200, deletedData, response, mockRes); + }); + test('Ensure deleteInformation returns if delete information successfully and has cache', async () => { + const { mockCache: hasCacheMock, cacheObject } = makeMockCache('hasCache', '[{_id:123}]'); + const removeCacheMock = jest + .spyOn(cacheObject, 'removeCache') + .mockImplementationOnce(() => null); + const deletedData = { + id: '601acda376045c7879d13a77', + infoName: 'deletedInfo', + infoContent: 'deleted', + visibility: '1', + }; + const { deleteInformation } = makeSut(); + const newMockReq = { + ...mockReq.body, + params: { + ...mockReq.params, + id: '601acda376045c7879d13a77', + infoName: 'deletedInfo', + infoContent: 'deleted', + visibility: '1', + }, + }; + const findOneDeleteSpy = jest + .spyOn(Information, 'findOneAndDelete') + .mockImplementationOnce(() => Promise.resolve(deletedData)); + deleteInformation(newMockReq, mockRes); + await flushPromises(); + expect(findOneDeleteSpy).toHaveBeenCalledWith({ _id: deletedData.id }); + expect(hasCacheMock).toHaveBeenCalledWith('informations'); + expect(removeCacheMock).toHaveBeenCalledWith('informations'); + }); + }); + describe('updateInformation function', () => { + test('Ensure updateInformation returns 400 if any error when finding and update information', async () => { + const errorMsg = 'Error when finding and updating information by Id'; + const { updateInformation } = makeSut(); + jest + .spyOn(Information, 'findOneAndUpdate') + .mockImplementationOnce(() => Promise.reject(new Error(errorMsg))); + const response = updateInformation(mockReq, mockRes); + await flushPromises(); + + assertResMock(400, new Error(errorMsg), response, mockRes); + }); + test('Ensure updateInformation returns 200 if finding and update information successfuly when nocache', async () => { + const { mockCache: hasCacheMock } = makeMockCache('hasCache', ''); + const data = { + id: '601acda376045c7879d13a77', + infoName: 'updatedInfo', + infoContent: 'updated', + visibility: '1', + }; + const newMockReq = { + body: { + id: '601acda376045c7879d13a77', + infoName: 'oldInfo', + infoContent: 'old', + visibility: '0', + }, + params: { + ...mockReq.params, + id: '601acda376045c7879d13a77', + }, + }; + const findOneUpdateSpy = jest + .spyOn(Information, 'findOneAndUpdate') + .mockImplementationOnce(() => Promise.resolve(data)); + const { updateInformation } = makeSut(); + const response = updateInformation(newMockReq, mockRes); + await flushPromises(); + expect(findOneUpdateSpy).toHaveBeenCalledWith({ _id: data.id }, newMockReq.body, { + new: true, + }); + expect(hasCacheMock).toHaveBeenCalledWith('informations'); + assertResMock(200, data, response, mockRes); + }); + test('Ensure updateInformation returns if finding and update information successfuly when hascache', async () => { + const { mockCache: hasCacheMock, cacheObject } = makeMockCache('hasCache', '[{_id:123}]'); + const removeCacheMock = jest + .spyOn(cacheObject, 'removeCache') + .mockImplementationOnce(() => null); + const data = { + id: '601acda376045c7879d13a77', + infoName: 'updatedInfo', + infoContent: 'updated', + visibility: '1', + }; + const newMockReq = { + body: { + id: '601acda376045c7879d13a77', + infoName: 'oldInfo', + infoContent: 'old', + visibility: '0', + }, + params: { + ...mockReq.params, + id: '601acda376045c7879d13a77', + }, + }; + const findOneUpdateSpy = jest + .spyOn(Information, 'findOneAndUpdate') + .mockImplementationOnce(() => Promise.resolve(data)); + const { updateInformation } = makeSut(); + updateInformation(newMockReq, mockRes); + await flushPromises(); + expect(findOneUpdateSpy).toHaveBeenCalledWith({ _id: data.id }, newMockReq.body, { + new: true, + }); + expect(hasCacheMock).toHaveBeenCalledWith('informations'); + expect(removeCacheMock).toHaveBeenCalledWith('informations'); + }); + }); +}); diff --git a/src/controllers/jobsController.js b/src/controllers/jobsController.js new file mode 100644 index 000000000..65cfeafdc --- /dev/null +++ b/src/controllers/jobsController.js @@ -0,0 +1,131 @@ +const Job = require('../models/jobs'); // Import the Job model + +// Controller to fetch all jobs with pagination, search, and filtering +const getJobs = async (req, res) => { + const { page = 1, limit = 18, search = '', category = '' } = req.query; + + try { + // Validate query parameters + const pageNumber = Math.max(1, parseInt(page, 10)); // Ensure page is at least 1 + const limitNumber = Math.max(1, parseInt(limit, 10)); // Ensure limit is at least 1 + + // Build query object + const query = {}; + if (search) query.title = { $regex: search, $options: 'i' }; // Case-insensitive search + if (category) query.category = category; + + // Fetch total count for pagination metadata + const totalJobs = await Job.countDocuments(query); + + // Fetch paginated results + const jobs = await Job.find(query) + .skip((pageNumber - 1) * limitNumber) + .limit(limitNumber); + + // Prepare response + res.json({ + jobs, + pagination: { + totalJobs, + totalPages: Math.ceil(totalJobs / limitNumber), + currentPage: pageNumber, + limit: limitNumber, + hasNextPage: pageNumber < Math.ceil(totalJobs / limitNumber), + hasPreviousPage: pageNumber > 1, + }, + }); + } catch (error) { + res.status(500).json({ error: 'Failed to fetch jobs', details: error.message }); + } +}; + +// Controller to fetch job details by ID +const getJobById = async (req, res) => { + const { id } = req.params; + + try { + const job = await Job.findById(id); + if (!job) { + return res.status(404).json({ error: 'Job not found' }); + } + res.json(job); + } catch (error) { + res.status(500).json({ error: 'Failed to fetch job', details: error.message }); + } +}; + +// Controller to create a new job +const createJob = async (req, res) => { + const { title, category, description, imageUrl, location, applyLink, jobDetailsLink } = req.body; + + try { + const newJob = new Job({ + title, + category, + description, + imageUrl, + location, + applyLink, + jobDetailsLink, + }); + + const savedJob = await newJob.save(); + res.status(201).json(savedJob); + } catch (error) { + res.status(500).json({ error: 'Failed to create job', details: error.message }); + } +}; + +// Controller to update an existing job by ID +const updateJob = async (req, res) => { + const { id } = req.params; + + try { + const updatedJob = await Job.findByIdAndUpdate(id, req.body, { new: true }); + if (!updatedJob) { + return res.status(404).json({ error: 'Job not found' }); + } + res.json(updatedJob); + } catch (error) { + res.status(500).json({ error: 'Failed to update job', details: error.message }); + } +}; + +// Controller to delete a job by ID +const deleteJob = async (req, res) => { + const { id } = req.params; + + try { + const deletedJob = await Job.findByIdAndDelete(id); + if (!deletedJob) { + return res.status(404).json({ error: 'Job not found' }); + } + res.json({ message: 'Job deleted successfully' }); + } catch (error) { + res.status(500).json({ error: 'Failed to delete job', details: error.message }); + } +}; + +const getCategories = async (req, res) => { + try { + const categories = await Job.distinct('category', {}); + + // Sort categories alphabetically + categories.sort((a, b) => a.localeCompare(b)); + + res.status(200).json({ categories }); + } catch (error) { + console.error('Error fetching categories:', error); + res.status(500).json({ message: 'Failed to fetch categories' }); + } +}; + +// Export controllers as a plain object +module.exports = { + getJobs, + getJobById, + createJob, + updateJob, + deleteJob, + getCategories, +}; diff --git a/src/controllers/logincontroller.js b/src/controllers/logincontroller.js index 3ba0203aa..794d00d70 100644 --- a/src/controllers/logincontroller.js +++ b/src/controllers/logincontroller.js @@ -63,7 +63,7 @@ const logincontroller = function () { res.status(200).send({ token }); } else { - res.status(403).send({ + res.status(404).send({ message: 'Invalid password.', }); } diff --git a/src/controllers/logincontroller.spec.js b/src/controllers/logincontroller.spec.js index 595bfe77b..995be69de 100644 --- a/src/controllers/logincontroller.spec.js +++ b/src/controllers/logincontroller.spec.js @@ -110,7 +110,7 @@ describe('logincontroller module', () => { expect(findOneSpy).toHaveBeenCalledWith({ email: mockReqModified.body.email }); assertResMock( - 403, + 404, { message: 'Invalid password.', }, diff --git a/src/controllers/ownerMessageController.js b/src/controllers/ownerMessageController.js index 1b2c30205..3f74cb112 100644 --- a/src/controllers/ownerMessageController.js +++ b/src/controllers/ownerMessageController.js @@ -1,8 +1,11 @@ +const helper = require('../utilities/permissions'); + const ownerMessageController = function (OwnerMessage) { const getOwnerMessage = async function (req, res) { try { const results = await OwnerMessage.find({}); - if (results.length === 0) { // first time initialization + if (results.length === 0) { + // first time initialization const ownerMessage = new OwnerMessage(); await ownerMessage.save(); res.status(200).send({ ownerMessage }); @@ -15,7 +18,9 @@ const ownerMessageController = function (OwnerMessage) { }; const updateOwnerMessage = async function (req, res) { - if (req.body.requestor.role !== 'Owner') { + if ( + !(await helper.hasPermission(req.body.requestor, 'editHeaderMessage')) + ) { res.status(403).send('You are not authorized to create messages!'); } const { isStandard, newMessage } = req.body; @@ -40,7 +45,9 @@ const ownerMessageController = function (OwnerMessage) { }; const deleteOwnerMessage = async function (req, res) { - if (req.body.requestor.role !== 'Owner') { + if ( + !(await helper.hasPermission(req.body.requestor, 'editHeaderMessage')) + ) { res.status(403).send('You are not authorized to delete messages!'); } try { diff --git a/src/controllers/popupEditorController.spec.js b/src/controllers/popupEditorController.spec.js new file mode 100644 index 000000000..e70b05553 --- /dev/null +++ b/src/controllers/popupEditorController.spec.js @@ -0,0 +1,163 @@ +const PopUpEditor = require('../models/popupEditor'); +const { mockReq, mockRes, assertResMock } = require('../test'); + +jest.mock('../utilities/permissions'); + +const helper = require('../utilities/permissions'); +const popupEditorController = require('./popupEditorController'); + +const flushPromises = () => new Promise(setImmediate); + +const mockHasPermission = (value) => + jest.spyOn(helper, 'hasPermission').mockImplementationOnce(() => Promise.resolve(value)); + +const makeSut = () => { + const { getAllPopupEditors, getPopupEditorById, createPopupEditor, updatePopupEditor } = + popupEditorController(PopUpEditor); + return { getAllPopupEditors, getPopupEditorById, createPopupEditor, updatePopupEditor }; +}; + +describe('popupEditorController Controller Unit tests', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe(`getAllPopupEditors function`, () => { + test(`Should return 200 and popup editors on success`, async () => { + const { getAllPopupEditors } = makeSut(); + const mockPopupEditors = [{ popupName: 'popup', popupContent: 'content' }]; + jest.spyOn(PopUpEditor, 'find').mockResolvedValue(mockPopupEditors); + const response = await getAllPopupEditors(mockReq, mockRes); + assertResMock(200, mockPopupEditors, response, mockRes); + }); + + test(`Should return 404 on error`, async () => { + const { getAllPopupEditors } = makeSut(); + const error = new Error('Test Error'); + + jest.spyOn(PopUpEditor, 'find').mockRejectedValue(error); + const response = await getAllPopupEditors(mockReq, mockRes); + await flushPromises(); + + assertResMock(404, error, response, mockRes); + }); + }); + + describe(`getPopupEditorById function`, () => { + test(`Should return 200 and popup editor on success`, async () => { + const { getPopupEditorById } = makeSut(); + const mockPopupEditor = { popupName: 'popup', popupContent: 'content' }; + jest.spyOn(PopUpEditor, 'findById').mockResolvedValue(mockPopupEditor); + const response = await getPopupEditorById(mockReq, mockRes); + assertResMock(200, mockPopupEditor, response, mockRes); + }); + + test(`Should return 404 on error`, async () => { + const { getPopupEditorById } = makeSut(); + const error = new Error('Test Error'); + + jest.spyOn(PopUpEditor, 'findById').mockRejectedValue(error); + const response = await getPopupEditorById(mockReq, mockRes); + await flushPromises(); + + assertResMock(404, error, response, mockRes); + }); + }); + + describe(`createPopupEditor function`, () => { + test(`Should return 403 if user is not authorized`, async () => { + const { createPopupEditor } = makeSut(); + mockHasPermission(false); + const response = await createPopupEditor(mockReq, mockRes); + assertResMock( + 403, + { error: 'You are not authorized to create new popup' }, + response, + mockRes, + ); + }); + + test(`Should return 400 if popupName or popupContent is missing`, async () => { + const { createPopupEditor } = makeSut(); + mockHasPermission(true); + const response = await createPopupEditor(mockReq, mockRes); + assertResMock( + 400, + { error: 'popupName , popupContent are mandatory fields' }, + response, + mockRes, + ); + }); + + test(`Should return 201 and popup editor on success`, async () => { + const { createPopupEditor } = makeSut(); + mockHasPermission(true); + mockReq.body = { popupName: 'popup', popupContent: 'content' }; + const mockPopupEditor = { save: jest.fn().mockResolvedValue(mockReq.body) }; + jest.spyOn(PopUpEditor.prototype, 'save').mockImplementationOnce(mockPopupEditor.save); + const response = await createPopupEditor(mockReq, mockRes); + expect(mockPopupEditor.save).toHaveBeenCalled(); + assertResMock(201, mockReq.body, response, mockRes); + }); + + test(`Should return 500 on error`, async () => { + const { createPopupEditor } = makeSut(); + mockHasPermission(true); + const error = new Error('Test Error'); + + jest.spyOn(PopUpEditor.prototype, 'save').mockRejectedValue(error); + const response = await createPopupEditor(mockReq, mockRes); + await flushPromises(); + + assertResMock(500, { error }, response, mockRes); + }); + }); + describe(`updatePopupEditor function`, () => { + test(`Should return 403 if user is not authorized`, async () => { + const { updatePopupEditor } = makeSut(); + mockHasPermission(false); + const response = await updatePopupEditor(mockReq, mockRes); + assertResMock( + 403, + { error: 'You are not authorized to create new popup' }, + response, + mockRes, + ); + }); + + test(`Should return 400 if popupContent is missing`, async () => { + const { updatePopupEditor } = makeSut(); + mockReq.body = {}; + mockHasPermission(true); + const response = await updatePopupEditor(mockReq, mockRes); + assertResMock(400, { error: 'popupContent is mandatory field' }, response, mockRes); + }); + + test(`Should return 201 and popup editor on success`, async () => { + const { updatePopupEditor } = makeSut(); + mockHasPermission(true); + mockReq.body = { popupContent: 'content' }; + const mockPopupEditor = { save: jest.fn().mockResolvedValue(mockReq.body) }; + jest.spyOn(PopUpEditor, 'findById').mockImplementationOnce((mockReq, callback) => callback(null, mockPopupEditor)); + jest.spyOn(PopUpEditor.prototype, 'save').mockImplementationOnce(mockPopupEditor.save); + const response = await updatePopupEditor(mockReq, mockRes); + expect(mockPopupEditor.save).toHaveBeenCalled(); + assertResMock(201, mockReq.body, response, mockRes); + }); + + test('Should return 500 on popupEditor save error', async () => { + const { updatePopupEditor } = makeSut(); + mockHasPermission(true); + const err = new Error('Test Error'); + mockReq.body = { popupContent: 'content' }; + const mockPopupEditor = { save: jest.fn().mockRejectedValue(err)}; + jest + .spyOn(PopUpEditor, 'findById') + .mockImplementation((mockReq, callback) => callback(null, mockPopupEditor)); + jest.spyOn(PopUpEditor.prototype, 'save').mockImplementationOnce(mockPopupEditor.save); + const response = await updatePopupEditor(mockReq, mockRes); + await flushPromises(); + assertResMock(500, {err}, response, mockRes); + }); + }); +}); diff --git a/src/controllers/profileInitialSetupController.js b/src/controllers/profileInitialSetupController.js index fcf24ce1a..e56e7e406 100644 --- a/src/controllers/profileInitialSetupController.js +++ b/src/controllers/profileInitialSetupController.js @@ -172,13 +172,22 @@ const profileInitialSetupController = function ( const link = `${baseUrl}/ProfileInitialSetup/${savedToken.token}`; await session.commitTransaction(); - const acknowledgment = await sendEmailWithAcknowledgment( - email, - 'NEEDED: Complete your One Community profile setup', - sendLinkMessage(link), - ); - - return res.status(200).send(acknowledgment); + // Send response immediately without waiting for email acknowledgment + res.status(200).send({ message: 'Token created successfully, email is being sent.' }); + + // Asynchronously send the email acknowledgment + setImmediate(async () => { + try { + await sendEmailWithAcknowledgment( + email, + 'NEEDED: Complete your One Community profile setup', + sendLinkMessage(link), + ); + } catch (emailError) { + // Log email sending failure + LOGGER.logException(emailError, 'sendEmailWithAcknowledgment', JSON.stringify({ email, link }), null); + } + }); } catch (error) { await session.abortTransaction(); LOGGER.logException(error, 'getSetupToken', JSON.stringify(req.body), null); @@ -523,30 +532,26 @@ const profileInitialSetupController = function ( */ const getSetupInvitation = (req, res) => { const { role } = req.body.requestor; - if (role === 'Administrator' || role === 'Owner') { - try { - ProfileInitialSetupToken.find({ isSetupCompleted: false }) - .sort({ createdDate: -1 }) - .exec((err, result) => { - // Handle the result - if (err) { - LOGGER.logException(err); - return res - .status(500) - .send( - 'Internal Error: Please retry. If the problem persists, please contact the administrator', - ); - } - return res.status(200).send(result); - }); - } catch (error) { - LOGGER.logException(error); - return res - .status(500) - .send( - 'Internal Error: Please retry. If the problem persists, please contact the administrator', - ); - } + + const { permissions } = req.body.requestor; + let user_permissions = ['getUserProfiles','postUserProfile','putUserProfile','changeUserStatus'] + if ((role === 'Administrator') || (role === 'Owner') || (role === 'Manager') || (role === 'Mentor') || user_permissions.some(e=>permissions.frontPermissions.includes(e))) { + try{ + ProfileInitialSetupToken + .find({ isSetupCompleted: false }) + .sort({ createdDate: -1 }) + .exec((err, result) => { + // Handle the result + if (err) { + LOGGER.logException(err); + return res.status(500).send('Internal Error: Please retry. If the problem persists, please contact the administrator'); + } + return res.status(200).send(result); + }); + } catch (error) { + LOGGER.logException(error); + return res.status(500).send('Internal Error: Please retry. If the problem persists, please contact the administrator'); + } } else { return res.status(403).send('You are not authorized to get setup history.'); } diff --git a/src/controllers/projectController.js b/src/controllers/projectController.js index 72522ba80..3e48b3f42 100644 --- a/src/controllers/projectController.js +++ b/src/controllers/projectController.js @@ -291,12 +291,21 @@ const projectController = function (Project) { res.status(400).send('Invalid request'); return; } - const getId = await hasPermission(req.body.requestor, 'getProjectMembers'); + const getProjMembers = await hasPermission(req.body.requestor, 'getProjectMembers'); + + // If a user has permission to post, edit, or suggest tasks, they also have the ability to assign resources to those tasks. + // Therefore, the _id field must be included when retrieving the user profile for project members (resources). + const postTask = await hasPermission(req.body.requestor, 'postTask'); + const updateTask = await hasPermission(req.body.requestor, 'updateTask'); + const suggestTask = await hasPermission(req.body.requestor, 'suggestTask'); + + const canGetId = (getProjMembers || postTask || updateTask || suggestTask); + userProfile .find( { projects: projectId }, - { firstName: 1, lastName: 1, isActive: 1, profilePic: 1, _id: getId }, + { firstName: 1, lastName: 1, isActive: 1, profilePic: 1, _id: canGetId }, ) .sort({ firstName: 1, lastName: 1 }) .then((results) => { diff --git a/src/controllers/reasonSchedulingController.spec.js b/src/controllers/reasonSchedulingController.spec.js new file mode 100644 index 000000000..e58c77466 --- /dev/null +++ b/src/controllers/reasonSchedulingController.spec.js @@ -0,0 +1,626 @@ +const moment = require('moment-timezone'); +const { mockReq, mockRes, mockUser } = require('../test'); +const UserModel = require('../models/userProfile'); + +jest.mock('../utilities/emailSender'); +const emailSender = require('../utilities/emailSender') + +const { + postReason, + getAllReasons, + getSingleReason, + patchReason, + deleteReason, +} = require('./reasonSchedulingController'); + +// assertResMock +const ReasonModel = require('../models/reason'); + +const flushPromises = () => new Promise(setImmediate); + +function mockDay(dayIdx, past = false) { + const date = moment().tz('America/Los_Angeles').startOf('day'); + while (date.day() !== dayIdx) { + date.add(past ? -1 : 1, 'days'); + } + return date; +} + +describe('reasonScheduling Controller', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockRes.json = jest.fn(); + mockReq.body = { + ...mockReq.body, + ...mockUser(), + reasonData: { + date: mockDay(0), + message: 'some reason', + }, + currentDate: moment.tz('America/Los_Angeles').startOf('day'), + }; + mockReq.params = { + ...mockReq.params, + ...mockUser(), + }; + }); + afterEach(() => { + jest.clearAllMocks(); + }); + describe('postReason method', () => { + test('Ensure postReason returns 400 for warning to choose Sunday', async () => { + mockReq.body.reasonData.date = mockDay(1, true); + await postReason(mockReq, mockRes); + await flushPromises(); + + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: + "You must choose the Sunday YOU'LL RETURN as your date. This is so your reason ends up as a note on that blue square.", + errorCode: 0, + }), + ); + }); + test('Ensure postReason returns 400 for warning to choose a future date', async () => { + mockReq.body.reasonData.date = mockDay(0, true); + await postReason(mockReq, mockRes); + await flushPromises(); + + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'You should select a date that is yet to come', + errorCode: 7, + }), + ); + }); + test('Ensure postReason returns 400 for not providing reason', async () => { + mockReq.body.reasonData.message = null; + await postReason(mockReq, mockRes); + await flushPromises(); + + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'You must provide a reason.', + errorCode: 6, + }), + ); + }); + test('Ensure postReason returns 404 when error in finding user Id', async () => { + const mockFindUser = jest.spyOn(UserModel, 'findById').mockImplementationOnce(() => + Promise.resolve(null)); + + await postReason(mockReq, mockRes); + await flushPromises(); + + expect(mockRes.status).toHaveBeenCalledWith(404); + expect(mockFindUser).toHaveBeenCalledWith(mockReq.body.userId); + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'User not found', + errorCode: 2, + }), + ); + }); + test('Ensure postReason returns 403 when duplicate reason to the date', async () => { + const mockFindUser = jest + .spyOn(UserModel, 'findById') + .mockImplementationOnce(() => mockUser()); + jest.spyOn(UserModel, 'findOneAndUpdate').mockResolvedValueOnce({ + _id: mockReq.body.userId, + timeOffFrom: mockReq.body.currentDate, + timeOffTill: mockReq.body.reasonData.date, + }); + const mockReason = { + reason: 'Some Reason', + userId: mockReq.body.userId, + date: moment.tz('America/Los_Angeles').startOf('day').toISOString(), + }; + const mockFoundReason = jest.spyOn(ReasonModel, 'findOne').mockResolvedValue(mockReason); + + await postReason(mockReq, mockRes); + await flushPromises(); + + expect(mockRes.status).toHaveBeenCalledWith(403); + expect(mockFindUser).toHaveBeenCalledWith(mockReq.body.userId); + expect(mockFoundReason).toHaveBeenCalledWith({ + date: moment + .tz(mockReq.body.reasonData.date, 'America/Los_Angeles') + .startOf('day') + .toISOString(), + userId: mockReq.body.userId, + }); + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'The reason must be unique to the date', + errorCode: 3, + }), + ); + }); + test('Ensure postReason returns 400 when any error in saving.', async () => { + const mockFindUser = jest + .spyOn(UserModel, 'findById') + .mockImplementationOnce(() => mockUser()); + jest.spyOn(UserModel, 'findOneAndUpdate').mockResolvedValueOnce({ + _id: mockReq.body.userId, + timeOffFrom: mockReq.body.currentDate, + timeOffTill: mockReq.body.reasonData.date, + }); + mockRes.sendStatus = jest.fn().mockReturnThis(); + const newReason = { + reason: mockReq.body.reasonData.message, + date: moment + .tz(mockReq.body.reasonData.date, 'America/Los_Angeles') + .startOf('day') + .toISOString(), + userId: mockReq.body.userId, + }; + const mockFoundReason = jest.spyOn(ReasonModel, 'findOne').mockResolvedValue(); + const mockSave = jest.spyOn(ReasonModel.prototype, 'save').mockRejectedValue(newReason); + emailSender.mockImplementation(() => { + throw new Error('Failed to send email'); + }); + + await postReason(mockReq, mockRes); + await flushPromises(); + emailSender.mockRejectedValue(new Error('Failed')); + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockFindUser).toHaveBeenCalledWith(mockReq.body.userId); + expect(mockFoundReason).toHaveBeenCalledWith({ + date: moment + .tz(mockReq.body.reasonData.date, 'America/Los_Angeles') + .startOf('day') + .toISOString(), + userId: mockReq.body.userId, + }); + expect(mockSave).toHaveBeenCalledWith(); + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + errMessage: 'Something went wrong', + }), + ); + }); + test('Ensure postReason returns 200 if schedule reason and send blue sqaure email successfully', async () => { + const mockFindUser = jest + .spyOn(UserModel, 'findById') + .mockImplementationOnce(() => mockUser()); + jest.spyOn(UserModel, 'findOneAndUpdate').mockResolvedValueOnce({ + _id: mockReq.body.userId, + timeOffFrom: mockReq.body.currentDate, + timeOffTill: mockReq.body.reasonData.date, + }); + mockRes.sendStatus = jest.fn().mockReturnThis(); + const newReason = { + reason: mockReq.body.reasonData.message, + date: moment + .tz(mockReq.body.reasonData.date, 'America/Los_Angeles') + .startOf('day') + .toISOString(), + userId: mockReq.body.userId, + }; + const mockFoundReason = jest.spyOn(ReasonModel, 'findOne').mockResolvedValue(); + const mockSave = jest.spyOn(ReasonModel.prototype, 'save').mockResolvedValue(newReason); + emailSender.mockImplementation(() => { + Promise.resolve(); + }); + await postReason(mockReq, mockRes); + await flushPromises(); + expect(mockRes.sendStatus).toHaveBeenCalledWith(200); + expect(mockFindUser).toHaveBeenCalledWith(mockReq.body.userId); + expect(mockFoundReason).toHaveBeenCalledWith({ + date: moment + .tz(mockReq.body.reasonData.date, 'America/Los_Angeles') + .startOf('day') + .toISOString(), + userId: mockReq.body.userId, + }); + expect(mockSave).toHaveBeenCalledWith(); + }); + }); + describe('getAllReason method', () => { + test('Ensure get AllReason returns 404 when error in finding user Id', async () => { + const mockFindUser = jest.spyOn(UserModel, 'findById').mockImplementationOnce(() => null); + + await getAllReasons(mockReq, mockRes); + await flushPromises(); + + expect(mockRes.status).toHaveBeenCalledWith(404); + expect(mockFindUser).toHaveBeenCalledWith(mockReq.params.userId); + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'User not found', + }), + ); + }); + test('Ensure get AllReason returns 400 when any error in fetching the user', async () => { + const mockFindUser = jest + .spyOn(UserModel, 'findById') + .mockImplementationOnce(() => mockUser()); + const mockFoundReason = jest.spyOn(ReasonModel, 'find').mockRejectedValueOnce(null); + await getAllReasons(mockReq, mockRes); + await flushPromises(); + + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockFindUser).toHaveBeenCalledWith(mockReq.params.userId); + expect(mockFoundReason).toHaveBeenCalledWith({ + userId: mockReq.params.userId, + }); + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + errMessage: 'Something went wrong while fetching the user', + }), + ); + }); + test('Ensure get AllReason returns 200 when get schedule reason successfully', async () => { + const mockFindUser = jest + .spyOn(UserModel, 'findById') + .mockImplementationOnce(() => mockUser()); + const reasons = { + reason: 'Some Reason', + userId: mockReq.params.userId, + date: moment.tz('America/Los_Angeles').startOf('day').toISOString(), + isSet: true, + }; + const mockFoundReason = jest.spyOn(ReasonModel, 'find').mockResolvedValue(reasons); + await getAllReasons(mockReq, mockRes); + await flushPromises(); + + expect(mockRes.status).toHaveBeenCalledWith(200); + expect(mockFindUser).toHaveBeenCalledWith(mockReq.params.userId); + expect(mockFoundReason).toHaveBeenCalledWith({ + userId: mockReq.params.userId, + }); + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + reasons, + }), + ); + }); + }); + describe('getSingleReason method', () => { + test('Ensure getSingleReason return 400 when any error in fetching the user', async () => { + await getSingleReason(mockReq, mockRes); + await flushPromises(); + + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Something went wrong while fetching single reason', + }), + ); + }); + test('Ensure getSingleReason return 404 when any error in find user by Id', async () => { + mockReq.query = { + queryData: mockDay(0), + }; + const mockFindUser = jest.spyOn(UserModel, 'findById').mockImplementationOnce(() => null); + + await getSingleReason(mockReq, mockRes); + await flushPromises(); + + expect(mockRes.status).toHaveBeenCalledWith(404); + expect(mockFindUser).toHaveBeenCalledWith(mockReq.params.userId); + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'User not found', + errorCode: 2, + }), + ); + }); + test('Ensure getSingleReason return 200 if not found schedule reason and return empty object successfully.', async () => { + const mockFindUser = jest + .spyOn(UserModel, 'findById') + .mockImplementationOnce(() => mockUser()); + + mockReq.query = { + queryDate: mockDay(0), + }; + const mockFoundReason = jest.spyOn(ReasonModel, 'findOne').mockResolvedValueOnce(); + + await getSingleReason(mockReq, mockRes); + await flushPromises(); + + expect(mockRes.status).toHaveBeenCalledWith(200); + expect(mockFindUser).toHaveBeenCalledWith(mockReq.params.userId); + expect(mockFoundReason).toHaveBeenCalledWith({ + date: moment + .tz(mockReq.query.queryDate, 'America/Los_Angeles') + .startOf('day') + .toISOString(), + userId: mockReq.params.userId, + }); + expect(mockRes.json).toHaveBeenCalledWith({ + reason: '', + date: '', + userId: '', + isSet: false, + }); + }); + test('Ensure getSingleReason return 200 if found schedule reason and return reason successfully.', async () => { + const mockFindUser = jest + .spyOn(UserModel, 'findById') + .mockImplementationOnce(() => mockUser()); + + mockReq.query = { + queryDate: mockDay(0), + }; + const singleReason = { + reason: 'Some Reason', + userId: mockReq.params.userId, + date: moment.tz('America/Los_Angeles').startOf('day').toISOString(), + isSet: true, + }; + const mockFoundReason = jest.spyOn(ReasonModel, 'findOne').mockResolvedValue(singleReason); + + await getSingleReason(mockReq, mockRes); + await flushPromises(); + + expect(mockRes.status).toHaveBeenCalledWith(200); + expect(mockFindUser).toHaveBeenCalledWith(mockReq.params.userId); + expect(mockFoundReason).toHaveBeenCalledWith({ + date: moment + .tz(mockReq.query.queryDate, 'America/Los_Angeles') + .startOf('day') + .toISOString(), + userId: mockReq.params.userId, + }); + expect(mockRes.json).toHaveBeenCalledWith(singleReason); + }); + }); + describe('patchReason method', () => { + test('Ensure patchReason returns 400 for not providing reason', async () => { + mockReq.body.reasonData.message = null; + await patchReason(mockReq, mockRes); + await flushPromises(); + + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'You must provide a reason.', + errorCode: 6, + }), + ); + }); + test('Ensure patchReason returns 404 when error in finding user Id', async () => { + const mockFindUser = jest.spyOn(UserModel, 'findById').mockImplementationOnce(() => null); + + await patchReason(mockReq, mockRes); + await flushPromises(); + + expect(mockRes.status).toHaveBeenCalledWith(404); + expect(mockFindUser).toHaveBeenCalledWith(mockReq.params.userId); + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'User not found', + errorCode: 2, + }), + ); + }); + test('Ensure patchReason returns 404 when error in finding reason', async () => { + const mockFindUser = jest + .spyOn(UserModel, 'findById') + .mockImplementationOnce(() => mockUser()); + const mockFoundReason = jest.spyOn(ReasonModel, 'findOne').mockResolvedValueOnce(); + await patchReason(mockReq, mockRes); + await flushPromises(); + + expect(mockRes.status).toHaveBeenCalledWith(404); + expect(mockFindUser).toHaveBeenCalledWith(mockReq.params.userId); + expect(mockFoundReason).toHaveBeenCalledWith({ + date: moment + .tz(mockReq.body.reasonData.date, 'America/Los_Angeles') + .startOf('day') + .toISOString(), + userId: mockReq.params.userId, + }); + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Reason not found', + errorCode: 4, + }), + ); + }); + test('Ensure patchReason returns 400 when any error in saving.', async () => { + const mockFindUser = jest + .spyOn(UserModel, 'findById') + .mockImplementationOnce(() => mockUser()); + const oldReason = { + reason: 'old message', + date: moment + .tz(mockReq.body.reasonData.date, 'America/Los_Angeles') + .startOf('day') + .toISOString(), + userId: mockReq.params.userId, + save: jest.fn().mockRejectedValueOnce(), + }; + emailSender.mockImplementation(() => { + throw new Error('Failed to send email'); + }); + const mockFoundReason = jest.spyOn(ReasonModel, 'findOne').mockResolvedValueOnce(oldReason); + await patchReason(mockReq, mockRes); + await flushPromises(); + emailSender.mockRejectedValue(new Error('Failed')); + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockFindUser).toHaveBeenCalledWith(mockReq.params.userId); + expect(mockFoundReason).toHaveBeenCalledWith({ + date: moment + .tz(mockReq.body.reasonData.date, 'America/Los_Angeles') + .startOf('day') + .toISOString(), + userId: mockReq.params.userId, + }); + expect(oldReason.save).toHaveBeenCalledWith(); + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'something went wrong while patching the reason', + }), + ); + }); + test('Ensure patchReason returns 200 when any error in saving.', async () => { + const mockFindUser = jest + .spyOn(UserModel, 'findById') + .mockImplementationOnce(() => mockUser()); + const oldReason = { + reason: mockReq.body.reasonData.message, + date: moment + .tz(mockReq.body.reasonData.date, 'America/Los_Angeles') + .startOf('day') + .toISOString(), + userId: mockReq.params.userId, + save: jest.fn().mockResolvedValueOnce(), + }; + const mockFoundReason = jest.spyOn(ReasonModel, 'findOne').mockResolvedValueOnce(oldReason); + emailSender.mockImplementation(() => { + Promise.resolve(); + }); + await patchReason(mockReq, mockRes); + await flushPromises(); + + expect(mockRes.status).toHaveBeenCalledWith(200); + expect(mockFindUser).toHaveBeenCalledWith(mockReq.params.userId); + expect(mockFoundReason).toHaveBeenCalledWith({ + date: moment + .tz(mockReq.body.reasonData.date, 'America/Los_Angeles') + .startOf('day') + .toISOString(), + userId: mockReq.params.userId, + }); + expect(oldReason.save).toHaveBeenCalledWith(); + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Reason Updated!', + }), + ); + }); + }); + describe('deleteReason method', () => { + test('Ensure deleteReason return 403 when no permission to delete', async () => { + const newMockReq = { + ...mockReq, + body: { + ...mockReq.body, + ...mockReq.requestor, + requestor: { + role: 'Volunteer', + }, + }, + }; + await deleteReason(newMockReq, mockRes); + await flushPromises(); + + expect(mockRes.status).toHaveBeenCalledWith(403); + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'You must be an Owner or Administrator to schedule a reason for a Blue Square', + + errorCode: 1, + }), + ); + }); + test('Ensure deleteReason return 404 when not finding user by ID', async () => { + const mockFindUser = jest.spyOn(UserModel, 'findById').mockImplementationOnce(() => null); + await deleteReason(mockReq, mockRes); + await flushPromises(); + + expect(mockRes.status).toHaveBeenCalledWith(404); + expect(mockFindUser).toHaveBeenCalledWith(mockReq.params.userId); + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'User not found', + errorCode: 2, + }), + ); + }); + test('Ensure deleteReason returns 404 when error in finding reason', async () => { + const mockFindUser = jest + .spyOn(UserModel, 'findById') + .mockImplementationOnce(() => mockUser()); + + const mockFoundReason = jest.spyOn(ReasonModel, 'findOne').mockResolvedValueOnce(); + await deleteReason(mockReq, mockRes); + await flushPromises(); + + expect(mockRes.status).toHaveBeenCalledWith(404); + expect(mockFindUser).toHaveBeenCalledWith(mockReq.params.userId); + expect(mockFoundReason).toHaveBeenCalledWith({ + date: moment + .tz(mockReq.body.reasonData.date, 'America/Los_Angeles') + .startOf('day') + .toISOString(), + }); + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Reason not found', + errorCode: 4, + }), + ); + }); + test('Ensure deleteReason returns 500 when error in removing reason', async () => { + const mockFindUser = jest + .spyOn(UserModel, 'findById') + .mockImplementationOnce(() => mockUser()); + const foundReason = { + reason: mockReq.body.reasonData.message, + date: moment + .tz(mockReq.body.reasonData.date, 'America/Los_Angeles') + .startOf('day') + .toISOString(), + userId: mockReq.params.userId, + remove: jest.fn((cb) => cb(true)), + }; + const mockFoundReason = jest.spyOn(ReasonModel, 'findOne').mockReturnValueOnce(foundReason); + + await deleteReason(mockReq, mockRes); + await flushPromises(); + + expect(mockRes.status).toHaveBeenCalledWith(500); + expect(mockFindUser).toHaveBeenCalledWith(mockReq.params.userId); + expect(mockFoundReason).toHaveBeenCalledWith({ + date: moment + .tz(mockReq.body.reasonData.date, 'America/Los_Angeles') + .startOf('day') + .toISOString(), + }); + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Error while deleting document', + errorCode: 5, + }), + ); + }); + test('Ensure deleteReason returns 200 if delete reason successfully.', async () => { + const mockFindUser = jest + .spyOn(UserModel, 'findById') + .mockImplementationOnce(() => mockUser()); + const foundReason = { + reason: mockReq.body.reasonData.message, + date: moment + .tz(mockReq.body.reasonData.date, 'America/Los_Angeles') + .startOf('day') + .toISOString(), + userId: mockReq.params.userId, + remove: jest.fn((cb) => cb(false)), + }; + const mockFoundReason = jest.spyOn(ReasonModel, 'findOne').mockReturnValueOnce(foundReason); + + await deleteReason(mockReq, mockRes); + await flushPromises(); + + expect(mockRes.status).toHaveBeenCalledWith(200); + expect(mockFindUser).toHaveBeenCalledWith(mockReq.params.userId); + expect(mockFoundReason).toHaveBeenCalledWith({ + date: moment + .tz(mockReq.body.reasonData.date, 'America/Los_Angeles') + .startOf('day') + .toISOString(), + }); + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Document deleted', + }), + ); + }); + }); +}); diff --git a/src/controllers/taskController.js b/src/controllers/taskController.js index 1e019ffa1..b4d71a5dd 100644 --- a/src/controllers/taskController.js +++ b/src/controllers/taskController.js @@ -683,7 +683,10 @@ const taskController = function (Task) { }; const updateTask = async (req, res) => { - if (!(await hasPermission(req.body.requestor, 'updateTask'))) { + if ( + !(await hasPermission(req.body.requestor, 'updateTask')) && + !(await hasPermission(req.body.requestor, 'removeUserFromTask')) + ) { res.status(403).send({ error: 'You are not authorized to update Task.' }); return; } diff --git a/src/controllers/taskController.spec.js b/src/controllers/taskController.spec.js new file mode 100644 index 000000000..7d1196df7 --- /dev/null +++ b/src/controllers/taskController.spec.js @@ -0,0 +1,1555 @@ +const mongoose = require('mongoose'); + +// Utility to aid in testing +jest.mock('../utilities/permissions', () => ({ + hasPermission: jest.fn(), +})); + +jest.mock('../utilities/emailSender', () => jest.fn()); + +const taskHelperMethods = { + getTasksForTeams: jest.fn(), + getTasksForSingleUser: jest.fn(), +}; +jest.mock('../helpers/taskHelper', () => () => ({ ...taskHelperMethods })); + +const flushPromises = () => new Promise(setImmediate); +const { mockReq, mockRes, assertResMock } = require('../test'); +const { hasPermission } = require('../utilities/permissions'); +const emailSender = require('../utilities/emailSender'); + +// controller to test +const taskController = require('./taskController'); + +// MongoDB Model imports +const Task = require('../models/task'); +const Project = require('../models/project'); +const UserProfile = require('../models/userProfile'); +const WBS = require('../models/wbs'); +const FollowUp = require('../models/followUp'); + +const makeSut = () => { + const { + getTasks, + getWBSId, + importTask, + postTask, + updateNum, + moveTask, + deleteTask, + deleteTaskByWBS, + updateTask, + swap, + getTaskById, + fixTasks, + updateAllParents, + getTasksByUserId, + sendReviewReq, + getTasksForTeamsByUser, + updateTaskStatus, + } = taskController(Task); + + return { + getTasks, + getWBSId, + importTask, + postTask, + updateNum, + moveTask, + deleteTask, + deleteTaskByWBS, + updateTask, + swap, + getTaskById, + fixTasks, + updateAllParents, + getTasksByUserId, + sendReviewReq, + getTasksForTeamsByUser, + updateTaskStatus, + }; +}; + +describe('Unit Tests for taskController.js', () => { + afterAll(() => { + jest.resetAllMocks(); + }); + + describe('getTasks function', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('Returns 200 on successfully querying the document', async () => { + const { getTasks } = makeSut(); + const mockData = 'some random data'; + + const taskFindSpy = jest.spyOn(Task, 'find').mockResolvedValueOnce(mockData); + + const response = await getTasks(mockReq, mockRes); + await flushPromises(); + + assertResMock(200, mockData, response, mockRes); + expect(taskFindSpy).toHaveBeenCalled(); + expect(taskFindSpy).toHaveBeenCalledTimes(1); + }); + + test('Returns 200 on successfully querying the document', async () => { + const { getTasks } = makeSut(); + const error = 'some random error'; + + const taskFindSpy = jest.spyOn(Task, 'find').mockRejectedValueOnce(error); + + const response = await getTasks(mockReq, mockRes); + await flushPromises(); + + assertResMock(404, error, response, mockRes); + expect(taskFindSpy).toHaveBeenCalled(); + expect(taskFindSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('getWBSId function', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('Returns 200 on successfully querying the document', async () => { + const { getWBSId } = makeSut(); + const mockData = 'some random data'; + + const wbsFindByIdSpy = jest.spyOn(WBS, 'findById').mockResolvedValueOnce(mockData); + + const response = await getWBSId(mockReq, mockRes); + await flushPromises(); + + assertResMock(200, mockData, response, mockRes); + expect(wbsFindByIdSpy).toHaveBeenCalled(); + expect(wbsFindByIdSpy).toHaveBeenCalledTimes(1); + }); + + test('Returns 200 on successfully querying the document', async () => { + const { getWBSId } = makeSut(); + const error = 'some random error'; + + const wbsFindByIdSpy = jest.spyOn(WBS, 'findById').mockRejectedValueOnce(error); + + const response = await getWBSId(mockReq, mockRes); + await flushPromises(); + + assertResMock(404, error, response, mockRes); + expect(wbsFindByIdSpy).toHaveBeenCalled(); + expect(wbsFindByIdSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('importTasks function()', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('Return 403 if `importTask` permission is missing', async () => { + const { importTask } = makeSut(); + hasPermission.mockResolvedValueOnce(false); + + const error = { error: 'You are not authorized to create new Task.' }; + + const response = await importTask(mockReq, mockRes); + await flushPromises(); + + assertResMock(403, error, response, mockRes); + }); + + test('Return 201 on successful import operation', async () => { + const { importTask } = makeSut(); + hasPermission.mockResolvedValueOnce(true); + + mockReq.params.wbs = 'wbs123'; + mockReq.body.list = [ + { + _id: 'mongoDB-Id', + num: '1', + level: 1, + parentId1: null, + parentId2: null, + parentId3: null, + mother: null, + resources: ['parth|userId123|parthProfilePic', 'test|test123|testProfilePic'], + }, + ]; + + const saveMock = jest + .fn() + .mockImplementation(() => Promise.resolve({ _id: '1', wbsId: 'wbs123' })); + const TaskConstructorSpy = jest.spyOn(Task.prototype, 'save').mockImplementation(saveMock); + + const data = 'done'; + + const response = await importTask(mockReq, mockRes); + await flushPromises(); + + assertResMock(201, data, response, mockRes); + expect(TaskConstructorSpy).toBeCalled(); + }); + + test('Return 400 on encountering any error while saving task', async () => { + const { importTask } = makeSut(); + hasPermission.mockResolvedValueOnce(true); + + mockReq.params.wbs = 'wbs123'; + mockReq.body.list = [ + { + _id: 'mongoDB-Id', + num: '1', + level: 1, + parentId1: null, + parentId2: null, + parentId3: null, + mother: null, + resources: ['parth|userId123|parthProfilePic', 'test|test123|testProfilePic'], + }, + ]; + + const error = new Error('error while saving'); + + const saveMock = jest.fn().mockImplementation(() => Promise.reject(error)); + const TaskConstructorSpy = jest.spyOn(Task.prototype, 'save').mockImplementation(saveMock); + + const response = await importTask(mockReq, mockRes); + await flushPromises(); + + assertResMock(400, error, response, mockRes); + expect(TaskConstructorSpy).toBeCalled(); + }); + }); + + describe('postTask function()', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('Return 403 if `postTask` permission is missing', async () => { + const { postTask } = makeSut(); + hasPermission.mockResolvedValueOnce(false); + + const error = { error: 'You are not authorized to create new Task.' }; + + const response = await postTask(mockReq, mockRes); + await flushPromises(); + + assertResMock(403, error, response, mockRes); + }); + + test.each([ + [ + { taskName: undefined, isActive: true }, + 'Task Name, Active status, Task Number are mandatory fields', + ], + [ + { taskName: 'some task name', isActive: undefined }, + 'Task Name, Active status, Task Number are mandatory fields', + ], + [ + { taskName: undefined, isActive: undefined }, + 'Task Name, Active status, Task Number are mandatory fields', + ], + ])('Return 400 if any required field is missing', async (body, expectedError) => { + const { postTask } = makeSut(); + hasPermission.mockResolvedValueOnce(true); + + // Set the request body based on the current test case + mockReq.body.taskName = body.taskName; + mockReq.body.isActive = body.isActive; + + const error = { error: expectedError }; + + const response = await postTask(mockReq, mockRes); + await flushPromises(); + + assertResMock(400, error, response, mockRes); + }); + + test('Return 201 on successfully saving a new Task', async () => { + const { postTask } = makeSut(); + hasPermission.mockResolvedValueOnce(true); + + const newTask = { + taskName: 'Sample Task', + wbsId: new mongoose.Types.ObjectId(), + num: '1', + level: 1, + position: 1, + childrenQty: 0, + isActive: true, + }; + + // Mock the current datetime + const currentDate = Date.now(); + + // Mock Task model + const mockTask = { + save: jest.fn().mockResolvedValue({ + _id: new mongoose.Types.ObjectId(), + wbsId: new mongoose.Types.ObjectId(), + createdDatetime: currentDate, + modifiedDatetime: currentDate, + }), + }; + const taskSaveSpy = jest.spyOn(Task.prototype, 'save').mockResolvedValue(mockTask); + + // Mock WBS model + const mockWBS = { + _id: new mongoose.Types.ObjectId(), + projectId: 'projectId', + modifiedDatetime: Date.now(), + save: jest.fn().mockResolvedValue({ + _id: new mongoose.Types.ObjectId(), + projectId: 'projectId', + modifiedDatetime: Date.now(), + }), + }; + const wbsFindByIdSpy = jest.spyOn(WBS, 'findById').mockResolvedValue(mockWBS); + + // Mock Project model + const mockProjectObj = { + save: jest.fn().mockResolvedValue({ + _id: new mongoose.Types.ObjectId(), + modifiedDatetime: currentDate, + }), + modifiedDatetime: currentDate, + }; + const projectFindByIdSpy = jest.spyOn(Project, 'findById').mockResolvedValue(mockProjectObj); + + // add the necessary request params + mockReq.params = { + ...mockReq.params, + id: new mongoose.Types.ObjectId(), + }; + + // add the necessary body parameters + mockReq.body = { + ...mockReq.body, + ...newTask, + }; + + const response = await postTask(mockReq, mockRes); + await flushPromises(); + + assertResMock(201, expect.anything(), response, mockRes); + expect(taskSaveSpy).toBeCalled(); + expect(wbsFindByIdSpy).toBeCalled(); + expect(projectFindByIdSpy).toBeCalled(); + }); + + test('Return 400 on encountering any error during Promise.all', async () => { + const { postTask } = makeSut(); + hasPermission.mockResolvedValueOnce(true); + + const newTask = { + taskName: 'Sample Task', + wbsId: new mongoose.Types.ObjectId(), + num: '1', + level: 1, + position: 1, + childrenQty: 0, + isActive: true, + }; + + // Mock the current datetime + const currentDate = Date.now(); + + // Mock the Task model + const mockTaskError = new Error('Failed to save task'); + + // Use jest.fn() to mock the save method to reject with an error + const taskSaveMock = jest.fn().mockRejectedValue(mockTaskError); + + // Spy on the Task prototype's save method + const taskSaveSpy = jest.spyOn(Task.prototype, 'save').mockImplementation(taskSaveMock); + + // Mock WBS model + const mockWBS = { + _id: new mongoose.Types.ObjectId(), + projectId: 'projectId', + modifiedDatetime: Date.now(), + save: jest.fn().mockResolvedValue({ + _id: new mongoose.Types.ObjectId(), + projectId: 'projectId', + modifiedDatetime: Date.now(), + }), + }; + // Mock `WBS.findById` to return `mockWBS` + const wbsFindByIdSpy = jest.spyOn(WBS, 'findById').mockResolvedValue(mockWBS); + + // Mock Project model + const mockProjectObj = { + save: jest.fn().mockResolvedValueOnce({ + _id: new mongoose.Types.ObjectId(), + modifiedDatetime: currentDate, + }), + modifiedDatetime: currentDate, + }; + const projectFindByIdSpy = jest + .spyOn(Project, 'findById') + .mockResolvedValueOnce(mockProjectObj); + + // add the necessary request params + mockReq.params = { + ...mockReq.params, + id: new mongoose.Types.ObjectId(), + }; + + // add the necessary body parameters + mockReq.body = { + ...mockReq.body, + ...newTask, + }; + + const response = await postTask(mockReq, mockRes); + await flushPromises(); + + assertResMock(400, mockTaskError, response, mockRes); + expect(taskSaveSpy).toBeCalled(); + expect(wbsFindByIdSpy).toBeCalled(); + expect(projectFindByIdSpy).toBeCalled(); + }); + }); + + describe('updateNum function()', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('Return 403 if `updateNum` permission is missing', async () => { + const { updateNum } = makeSut(); + hasPermission.mockResolvedValueOnce(false); + + const error = { error: 'You are not authorized to create new projects.' }; + + const response = await updateNum(mockReq, mockRes); + await flushPromises(); + + assertResMock(403, error, response, mockRes); + }); + + test('Return 400 if `nums` is missing from the request body', async () => { + const { updateNum } = makeSut(); + hasPermission.mockResolvedValueOnce(true); + + const error = { error: 'Num is a mandatory fields' }; + mockReq.body.nums = null; + + const response = await updateNum(mockReq, mockRes); + await flushPromises(); + + assertResMock(400, error, response, mockRes); + }); + + test('Return 200 on successful update - nums is empty array', async () => { + const { updateNum } = makeSut(); + hasPermission.mockResolvedValueOnce(true); + + mockReq.body.nums = []; + + const response = await updateNum(mockReq, mockRes); + await flushPromises(); + + assertResMock(200, true, response, mockRes); + }); + + test('Return 200 on successful update - nums is not an empty array', async () => { + const { updateNum } = makeSut(); + hasPermission.mockResolvedValueOnce(true); + + mockReq.body.nums = [ + { + id: 'sample-id', + num: 'sample-num', + }, + ]; + + const mockDataForTaskFindByIdSpy = { + num: 0, + save: jest.fn().mockResolvedValue({}), + }; + + const taskFindByIdSpy = jest.spyOn(Task, 'findById').mockImplementation((id, callback) => { + callback(null, mockDataForTaskFindByIdSpy); + }); + + const mockDataForTaskFindSpy = []; + const taskFindSpy = jest.spyOn(Task, 'find').mockResolvedValueOnce(mockDataForTaskFindSpy); + + const response = await updateNum(mockReq, mockRes); + await flushPromises(); + + assertResMock(200, true, response, mockRes); + expect(taskFindSpy).toBeCalled(); + expect(taskFindByIdSpy).toBeCalled(); + }); + + test('Return 404 if error occurs on Task.find()', async () => { + const { updateNum } = makeSut(); + hasPermission.mockResolvedValueOnce(true); + + mockReq.body.nums = [ + { + id: 'sample-id', + num: 'sample-num', + }, + ]; + + const mockDataForTaskFindByIdSpy = { + num: 0, + save: jest.fn().mockResolvedValue({}), + }; + + const taskFindByIdSpy = jest.spyOn(Task, 'findById').mockImplementation((id, callback) => { + callback(null, mockDataForTaskFindByIdSpy); + }); + + const mockError = new Error({ error: 'some error occurred' }); + const taskFindSpy = jest.spyOn(Task, 'find').mockRejectedValueOnce(mockError); + + const response = await updateNum(mockReq, mockRes); + await flushPromises(); + + assertResMock(404, mockError, response, mockRes); + expect(taskFindSpy).toBeCalled(); + expect(taskFindByIdSpy).toBeCalled(); + }); + + test('Return 400 if error occurs while saving a Task within Task.findById()', async () => { + const { updateNum } = makeSut(); + hasPermission.mockResolvedValueOnce(true); + + mockReq.body.nums = [ + { + id: 'sample-id', + num: 'sample-num', + }, + ]; + + const mockError = new Error({ error: 'some error occurred' }); + + const mockDataForTaskFindByIdSpy = { + num: 0, + save: jest.fn().mockRejectedValueOnce(mockError), + }; + + const taskFindByIdSpy = jest.spyOn(Task, 'findById').mockImplementation((id, callback) => { + callback(null, mockDataForTaskFindByIdSpy); + }); + + const mockDataForTaskFindSpy = []; + const taskFindSpy = jest.spyOn(Task, 'find').mockResolvedValueOnce(mockDataForTaskFindSpy); + + const response = await updateNum(mockReq, mockRes); + await flushPromises(); + + assertResMock(400, mockError, response, mockRes); + expect(taskFindSpy).toBeCalled(); + expect(taskFindByIdSpy).toBeCalled(); + }); + }); + + describe('moveTask function()', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('Return 400 if either `fromNum` or `toNum` is missing in request body', async () => { + const { moveTask } = makeSut(); + + const error = { error: 'wbsId, fromNum, toNum are mandatory fields' }; + mockReq.body.fromNum = null; + + const response = await moveTask(mockReq, mockRes); + await flushPromises(); + + assertResMock(400, error, response, mockRes); + }); + + test('Return 200 on successful exeecution', async () => { + const { moveTask } = makeSut(); + + const requestData = { + body: { + fromNum: '1.0', + toNum: '2.0', + }, + }; + + mockReq.body = { + ...mockReq.body, + ...requestData.body, + }; + + const taskFindSpy = jest.spyOn(Task, 'find').mockResolvedValue([ + { num: '1.0', save: jest.fn().mockResolvedValue({}) }, + { num: '1.1', save: jest.fn().mockResolvedValue({}) }, + ]); + + mockReq.params.wbsId = 'someWbsId'; + + const response = await moveTask(mockReq, mockRes); + await flushPromises(); + + assertResMock(200, 'Success!', response, mockRes); + expect(taskFindSpy).toBeCalled(); + }); + + test('Return 400 on some error', async () => { + const { moveTask } = makeSut(); + + const requestData = { + body: { + fromNum: '1.0', + toNum: '2.0', + }, + }; + + mockReq.body = { + ...mockReq.body, + ...requestData.body, + }; + + const error = new Error({ error: 'some error' }); + const taskFindSpy = jest.spyOn(Task, 'find').mockResolvedValue([ + { num: '1.0', save: jest.fn().mockResolvedValue({}) }, + { num: '1.1', save: jest.fn().mockRejectedValueOnce(error) }, + ]); + + mockReq.params.wbsId = 'someWbsId'; + + const response = await moveTask(mockReq, mockRes); + await flushPromises(); + + assertResMock(400, error, response, mockRes); + expect(taskFindSpy).toBeCalled(); + }); + }); + + describe('deleteTask function()', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('Return 403 if `deleteTask` permission is missing', async () => { + const { deleteTask } = makeSut(); + hasPermission.mockResolvedValueOnce(false); + + const error = { error: 'You are not authorized to deleteTasks.' }; + + const response = await deleteTask(mockReq, mockRes); + await flushPromises(); + + assertResMock(403, error, response, mockRes); + }); + + test('Return 400 if no Task found', async () => { + const { deleteTask } = makeSut(); + + const error = { error: 'No valid records found' }; + hasPermission.mockResolvedValueOnce(true); + + mockReq.params = { + ...mockReq.params, + taskId: 456, + mother: 'null', + }; + + const taskFindSpy = jest.spyOn(Task, 'find').mockResolvedValue([]); + const followUpFindOneAndDeleteSpy = jest + .spyOn(FollowUp, 'findOneAndDelete') + .mockResolvedValue(true); + + const response = await deleteTask(mockReq, mockRes); + await flushPromises(); + + assertResMock(400, error, response, mockRes); + expect(taskFindSpy).toHaveBeenCalled(); + expect(followUpFindOneAndDeleteSpy).toHaveBeenCalled(); + }); + + test('Return 200 on successfully deleting task', async () => { + const { deleteTask } = makeSut(); + + const message = { message: 'Task successfully deleted' }; + hasPermission.mockResolvedValueOnce(true); + + mockReq.params = { + ...mockReq.params, + taskId: 456, + mother: 'null', + }; + + const taskFindSpy = jest.spyOn(Task, 'find').mockResolvedValue([ + { + remove: jest.fn().mockImplementation(() => Promise.resolve(1)), + }, + ]); + const followUpFindOneAndDeleteSpy = jest + .spyOn(FollowUp, 'findOneAndDelete') + .mockResolvedValue(true); + + const response = await deleteTask(mockReq, mockRes); + await flushPromises(); + + assertResMock(200, message, response, mockRes); + expect(taskFindSpy).toHaveBeenCalled(); + expect(followUpFindOneAndDeleteSpy).toHaveBeenCalled(); + }); + }); + + describe('deleteTaskByWBS function()', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('Return 403 if `deleteTask` permission is missing', async () => { + const { deleteTaskByWBS } = makeSut(); + hasPermission.mockResolvedValueOnce(false); + + const error = { error: 'You are not authorized to deleteTasks.' }; + + const response = await deleteTaskByWBS(mockReq, mockRes); + await flushPromises(); + + assertResMock(403, error, response, mockRes); + }); + + test('Return 400 if no Task found', async () => { + const { deleteTaskByWBS } = makeSut(); + + const error = { error: 'No valid records found' }; + hasPermission.mockResolvedValueOnce(true); + + mockReq.params = { + ...mockReq.params, + wbsId: 456, + }; + + const taskFindSpy = jest.spyOn(Task, 'find').mockImplementation((query, callback) => { + callback(null, []); + return { + catch: jest.fn(), + }; + }); + + const response = await deleteTaskByWBS(mockReq, mockRes); + await flushPromises(); + + assertResMock(400, error, response, mockRes); + expect(taskFindSpy).toHaveBeenCalled(); + }); + + test('Return 400 if Task.find fails', async () => { + const { deleteTaskByWBS } = makeSut(); + + const expectedError = new Error('Database error'); + hasPermission.mockResolvedValueOnce(true); + + mockReq.params = { + ...mockReq.params, + wbsId: 456, + }; + + const taskFindSpy = jest.spyOn(Task, 'find').mockImplementation((query, callback) => { + callback(expectedError, null); + return { + catch: jest.fn((catchCallback) => { + catchCallback(expectedError); + return Promise.resolve(); + }), + }; + }); + + const response = await deleteTaskByWBS(mockReq, mockRes); + await flushPromises(); + + assertResMock(400, expectedError, response, mockRes); + expect(taskFindSpy).toHaveBeenCalled(); + }); + + test('Return 200 on successfully deleting task', async () => { + const { deleteTaskByWBS } = makeSut(); + + const message = { message: ' Tasks were successfully deleted' }; + hasPermission.mockResolvedValueOnce(true); + + mockReq.params = { + ...mockReq.params, + wbsId: 456, + }; + + const taskFindSpy = jest.spyOn(Task, 'find').mockImplementation((query, callback) => { + callback(null, [ + { + remove: jest.fn().mockImplementation(() => Promise.resolve(1)), + }, + ]); + return { + catch: jest.fn(), + }; + }); + + const followUpFindOneAndDeleteSpy = jest + .spyOn(FollowUp, 'findOneAndDelete') + .mockResolvedValue(true); + + const response = await deleteTaskByWBS(mockReq, mockRes); + await flushPromises(); + + assertResMock(200, message, response, mockRes); + expect(taskFindSpy).toHaveBeenCalled(); + expect(followUpFindOneAndDeleteSpy).toHaveBeenCalled(); + }); + }); + + describe('updateTask function()', () => { + const mockedTask = { + wbs: 111, + }; + const mockedWBS = { + projectId: 111, + modifiedDatetime: new Date(), + save: jest.fn(), + }; + const mockedProject = { + projectId: 111, + modifiedDatetime: new Date(), + save: jest.fn(), + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('Return 403 if `updateTask` permission is missing', async () => { + const { updateTask } = makeSut(); + hasPermission.mockResolvedValueOnce(false); + + const error = { error: 'You are not authorized to update Task.' }; + + const response = await updateTask(mockReq, mockRes); + await flushPromises(); + + assertResMock(403, error, response, mockRes); + }); + + test('Return 200 on successful update', async () => { + const { updateTask } = makeSut(); + + hasPermission.mockResolvedValueOnce(true); + + mockReq.params = { + ...mockReq.params, + taskId: 456, + }; + + const taskFindByIdSpy = jest.spyOn(Task, 'findById').mockResolvedValue(mockedTask); + const taskFindOneAndUpdateSpy = jest + .spyOn(Task, 'findOneAndUpdate') + .mockResolvedValueOnce(true); + const wbsFindByIdSpy = jest.spyOn(WBS, 'findById').mockResolvedValue(mockedWBS); + const projectFindByIdSpy = jest.spyOn(Project, 'findById').mockResolvedValue(mockedProject); + + const response = await updateTask(mockReq, mockRes); + await flushPromises(); + + // assertResMock(201, null, response, mockRes); + expect(mockRes.status).toBeCalledWith(201); + expect(response).toBeUndefined(); + expect(taskFindByIdSpy).toHaveBeenCalled(); + expect(taskFindOneAndUpdateSpy).toHaveBeenCalled(); + expect(wbsFindByIdSpy).toHaveBeenCalled(); + expect(projectFindByIdSpy).toHaveBeenCalled(); + }); + + test('Return 404 on encountering error', async () => { + const { updateTask } = makeSut(); + + const error = { error: 'No valid records found' }; + hasPermission.mockResolvedValueOnce(true); + + mockReq.params = { + ...mockReq.params, + taskId: 456, + }; + + const taskFindByIdSpy = jest.spyOn(Task, 'findById').mockResolvedValue(mockedTask); + const taskFindOneAndUpdateSpy = jest + .spyOn(Task, 'findOneAndUpdate') + .mockRejectedValueOnce(error); + const wbsFindByIdSpy = jest.spyOn(WBS, 'findById').mockResolvedValue(mockedWBS); + const projectFindByIdSpy = jest.spyOn(Project, 'findById').mockResolvedValue(mockedProject); + + const response = await updateTask(mockReq, mockRes); + await flushPromises(); + + assertResMock(404, error, response, mockRes); + expect(taskFindByIdSpy).toHaveBeenCalled(); + expect(taskFindOneAndUpdateSpy).toHaveBeenCalled(); + expect(wbsFindByIdSpy).toHaveBeenCalled(); + expect(projectFindByIdSpy).toHaveBeenCalled(); + }); + }); + + describe('swap function()', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('Return 403 if `swapTask` permission is missing', async () => { + const { swap } = makeSut(); + hasPermission.mockResolvedValueOnce(false); + + const error = { error: 'You are not authorized to create new projects.' }; + + const response = await swap(mockReq, mockRes); + await flushPromises(); + + assertResMock(403, error, response, mockRes); + }); + + test('Return 400 if `taskId1` is missing', async () => { + const { swap } = makeSut(); + hasPermission.mockResolvedValueOnce(true); + + mockReq.body.taskId1 = null; + mockReq.body.taskId2 = 'some-value'; + + const error = { error: 'taskId1 and taskId2 are mandatory fields' }; + + const response = await swap(mockReq, mockRes); + await flushPromises(); + + assertResMock(400, error, response, mockRes); + }); + + test('Return 400 if `taskId2` is missing', async () => { + const { swap } = makeSut(); + hasPermission.mockResolvedValueOnce(true); + + mockReq.body.taskId1 = 'some-value'; + mockReq.body.taskId2 = null; + + const error = { error: 'taskId1 and taskId2 are mandatory fields' }; + + const response = await swap(mockReq, mockRes); + await flushPromises(); + + assertResMock(400, error, response, mockRes); + }); + + test('Return 400 if `taskId1` and `taskId2` are missing', async () => { + const { swap } = makeSut(); + hasPermission.mockResolvedValueOnce(true); + + mockReq.body.taskId1 = null; + mockReq.body.taskId2 = null; + + const error = { error: 'taskId1 and taskId2 are mandatory fields' }; + + const response = await swap(mockReq, mockRes); + await flushPromises(); + + assertResMock(400, error, response, mockRes); + }); + + test('Return 400 if no task exists with the id same as `taskId1`', async () => { + const { swap } = makeSut(); + hasPermission.mockResolvedValueOnce(true); + + mockReq.body.taskId1 = 'invalid-taskId1'; + mockReq.body.taskId2 = 'some value'; + + const error = 'No valid records found'; + + const taskFindByIdSpy = jest.spyOn(Task, 'findById').mockImplementation((id, callback) => { + if (id === 'invalid-taskId1') { + callback(null, null); // the first null shows no error | second null show no task1 + } else if (id === 'invalid-taskId2') { + callback(null, 'some task2 exists'); + } + }); + + const response = await swap(mockReq, mockRes); + await flushPromises(); + + assertResMock(400, error, response, mockRes); + expect(taskFindByIdSpy).toHaveBeenCalled(); + }); + + test('Return 400 if no task exists with the id same as `taskId2`', async () => { + const { swap } = makeSut(); + hasPermission.mockResolvedValueOnce(true); + + mockReq.body.taskId1 = 'valid-taskId1'; + mockReq.body.taskId2 = 'invalid-taskId2'; + + const error = 'No valid records found'; + + const taskFindByIdSpy = jest.spyOn(Task, 'findById').mockImplementation((id, callback) => { + if (id === 'valid-taskId1') { + callback(null, { _id: 'valid-taskId1', name: 'Task 1' }); + } + + if (id === 'invalid-taskId2') { + callback(null, null); // the first null shows no error | second null show no task2 found + } + }); + + const response = await swap(mockReq, mockRes); + await flushPromises(); + + assertResMock(400, error, response, mockRes); + expect(taskFindByIdSpy).toHaveBeenCalledTimes(2); + expect(taskFindByIdSpy).toHaveBeenNthCalledWith(1, 'valid-taskId1', expect.any(Function)); + expect(taskFindByIdSpy).toHaveBeenNthCalledWith(2, 'invalid-taskId2', expect.any(Function)); + }); + + test('Return 400 if some error occurs while saving task1', async () => { + const { swap } = makeSut(); + hasPermission.mockResolvedValueOnce(true); + + mockReq.body.taskId1 = 'valid-taskId1'; + mockReq.body.taskId2 = 'valid-taskId2'; + + const error = 'some error'; + + const validTask1 = { + _id: 'valid-taskId1', + name: 'Task 1', + num: 1, + parentId: 'pId', + save: jest.fn().mockRejectedValue(error), + }; + + const validTask2 = { + _id: 'valid-taskId2', + name: 'Task 2', + num: 2, + parentId: 'pId', + save: jest.fn().mockResolvedValue('sadasd'), + }; + + const taskFindByIdSpy = jest.spyOn(Task, 'findById').mockImplementation((id, callback) => { + if (id === 'valid-taskId1') { + callback(null, validTask1); + } + if (id === 'valid-taskId2') { + callback(null, validTask2); + } + }); + + const taskFindSpy = jest.spyOn(Task, 'find').mockResolvedValueOnce('works fine'); + + const response = await swap(mockReq, mockRes); + await flushPromises(); + + assertResMock(400, error, response, mockRes); + expect(taskFindByIdSpy).toHaveBeenCalled(); + expect(taskFindSpy).toHaveBeenCalled(); + }); + + test('Return 400 if some error occurs while saving task2', async () => { + const { swap } = makeSut(); + hasPermission.mockResolvedValueOnce(true); + + mockReq.body.taskId1 = 'valid-taskId1'; + mockReq.body.taskId2 = 'valid-taskId2'; + + const error = 'some error'; + + const validTask1 = { + _id: 'valid-taskId1', + name: 'Task 1', + num: 1, + parentId: 'pId', + save: jest.fn().mockResolvedValue(), + }; + + const validTask2 = { + _id: 'valid-taskId2', + name: 'Task 2', + num: 2, + parentId: 'pId', + save: jest.fn().mockRejectedValue(error), + }; + + const taskFindByIdSpy = jest.spyOn(Task, 'findById').mockImplementation((id, callback) => { + if (id === 'valid-taskId1') { + callback(null, validTask1); + } + if (id === 'valid-taskId2') { + callback(null, validTask2); + } + }); + + const taskFindSpy = jest.spyOn(Task, 'find').mockResolvedValueOnce('works fine'); + + const response = await swap(mockReq, mockRes); + await flushPromises(); + + assertResMock(400, error, response, mockRes); + expect(taskFindByIdSpy).toHaveBeenCalled(); + expect(taskFindSpy).toHaveBeenCalled(); + }); + + test('Return 404 if some error occurs while saving task.find', async () => { + const { swap } = makeSut(); + hasPermission.mockResolvedValueOnce(true); + + mockReq.body.taskId1 = 'valid-taskId1'; + mockReq.body.taskId2 = 'valid-taskId2'; + + const error = 'some error'; + + const validTask1 = { + _id: 'valid-taskId1', + name: 'Task 1', + num: 1, + parentId: 'pId', + save: jest.fn().mockResolvedValue(), + }; + + const validTask2 = { + _id: 'valid-taskId2', + name: 'Task 2', + num: 2, + parentId: 'pId', + save: jest.fn().mockResolvedValue(), + }; + + const taskFindByIdSpy = jest.spyOn(Task, 'findById').mockImplementation((id, callback) => { + if (id === 'valid-taskId1') { + callback(null, validTask1); + } + if (id === 'valid-taskId2') { + callback(null, validTask2); + } + }); + + const taskFindSpy = jest.spyOn(Task, 'find').mockRejectedValueOnce(error); + + const response = await swap(mockReq, mockRes); + await flushPromises(); + + assertResMock(404, error, response, mockRes); + expect(taskFindByIdSpy).toHaveBeenCalled(); + expect(taskFindSpy).toHaveBeenCalled(); + }); + + test('Return 200 if swapped correctly', async () => { + const { swap } = makeSut(); + hasPermission.mockResolvedValueOnce(true); + + mockReq.body.taskId1 = 'valid-taskId1'; + mockReq.body.taskId2 = 'valid-taskId2'; + + const message = 'no error'; + + const validTask1 = { + _id: 'valid-taskId1', + name: 'Task 1', + num: 1, + parentId: 'pId', + save: jest.fn().mockResolvedValue(), + }; + + const validTask2 = { + _id: 'valid-taskId2', + name: 'Task 2', + num: 2, + parentId: 'pId', + save: jest.fn().mockResolvedValue(), + }; + + const taskFindByIdSpy = jest.spyOn(Task, 'findById').mockImplementation((id, callback) => { + if (id === 'valid-taskId1') { + callback(null, validTask1); + } + if (id === 'valid-taskId2') { + callback(null, validTask2); + } + }); + + const taskFindSpy = jest.spyOn(Task, 'find').mockResolvedValueOnce(message); + + const response = await swap(mockReq, mockRes); + await flushPromises(); + + assertResMock(200, message, response, mockRes); + expect(taskFindByIdSpy).toHaveBeenCalled(); + expect(taskFindSpy).toHaveBeenCalled(); + }); + }); + + describe('getTaskById function()', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('Returns 400 if the taskId is missing from the params', async () => { + const { getTaskById } = makeSut(); + + mockReq.params.id = null; + + const error = { error: 'Task ID is missing' }; + + const response = await getTaskById(mockReq, mockRes); + await flushPromises(); + + assertResMock(400, error, response, mockRes); + }); + + test('Returns 400 if the taskId is missing from the params', async () => { + const { getTaskById } = makeSut(); + + mockReq.params.id = 'someTaskId'; + + const error = { error: 'This is not a valid task' }; + + const taskFindByIdSpy = jest.spyOn(Task, 'findById').mockResolvedValueOnce(null); + + const response = await getTaskById(mockReq, mockRes); + await flushPromises(); + + assertResMock(400, error, response, mockRes); + expect(taskFindByIdSpy).toHaveBeenCalled(); + }); + + test('Returns 500 if some error occurs at Task.findById', async () => { + const { getTaskById } = makeSut(); + + mockReq.params.id = 'someTaskId'; + + const error = new Error('some error occurred'); + + const taskFindByIdSpy = jest.spyOn(Task, 'findById').mockRejectedValueOnce(error); + + const response = await getTaskById(mockReq, mockRes); + await flushPromises(); + + assertResMock( + 500, + { error: 'Internal Server Error', details: error.message }, + response, + mockRes, + ); + expect(taskFindByIdSpy).toHaveBeenCalled(); + }); + + test('Returns 200 if some error occurs at Task.findById', async () => { + const { getTaskById } = makeSut(); + + mockReq.params.id = 'someTaskId'; + + const mockTask = { + resources: [], + }; + + const taskFindByIdSpy = jest.spyOn(Task, 'findById').mockResolvedValueOnce(mockTask); + + const response = await getTaskById(mockReq, mockRes); + await flushPromises(); + + assertResMock(200, mockTask, response, mockRes); + expect(taskFindByIdSpy).toHaveBeenCalled(); + }); + }); + + describe('fixTasks function()', () => { + test('Returns 200 without performing any action', async () => { + const { fixTasks } = makeSut(); + + const response = fixTasks(mockReq, mockRes); + + await flushPromises(); + + assertResMock(200, 'done', response, mockRes); + }); + }); + + describe('updateAllParents function()', () => { + test('Returns 200 Task.Find() on successful operation', async () => { + const { updateAllParents } = makeSut(); + + const mockTasks = []; + + const taskFind = jest.spyOn(Task, 'find').mockResolvedValueOnce(mockTasks); + const response = updateAllParents(mockReq, mockRes); + + await flushPromises(); + + assertResMock(200, 'done', response, mockRes); + expect(taskFind).toHaveBeenCalled(); + }); + + test('Returns 400 on some error', async () => { + const { updateAllParents } = makeSut(); + + const error = new Error('some error'); + + const taskFind = jest.spyOn(Task, 'find').mockImplementationOnce(() => { + throw error; + }); + const response = await updateAllParents(mockReq, mockRes); + await flushPromises(); + + assertResMock(400, error, response, mockRes); + expect(taskFind).toHaveBeenCalled(); + }); + }); + + describe('getTasksByUserId function()', () => { + test('Returns 200 and tasks when aggregation is successful', async () => { + const { getTasksByUserId } = makeSut(); + + mockReq.params.userId = '507f1f77bcf86cd799439011'; + + const mockTasks = [ + { _id: 'task1', taskName: 'Task 1', wbsName: 'WBS 1', projectName: 'Project 1' }, + { _id: 'task2', taskName: 'Task 2', wbsName: 'WBS 2', projectName: 'Project 2' }, + ]; + + // Mock the Task.aggregate method + const mockAggregate = { + match: jest.fn().mockReturnThis(), + lookup: jest.fn().mockReturnThis(), + unwind: jest.fn().mockReturnThis(), + addFields: jest.fn().mockReturnThis(), + project: jest.fn().mockReturnThis(), + }; + + mockAggregate.project.mockResolvedValue(mockTasks); + + const taskAggregate = jest.spyOn(Task, 'aggregate').mockReturnValue(mockAggregate); + + const response = await getTasksByUserId(mockReq, mockRes); + + assertResMock(200, mockTasks, response, mockRes); + expect(taskAggregate).toHaveBeenCalled(); + }); + + test('Returns 400 when error occurs', async () => { + const { getTasksByUserId } = makeSut(); + + mockReq.params.userId = '507f1f77bcf86cd799439011'; + + const mockError = new Error('some error'); + + // Mock the Task.aggregate method + const mockAggregate = { + match: jest.fn().mockReturnThis(), + lookup: jest.fn().mockReturnThis(), + unwind: jest.fn().mockReturnThis(), + addFields: jest.fn().mockReturnThis(), + project: jest.fn().mockReturnThis(), + }; + + mockAggregate.project.mockRejectedValueOnce(mockError); + + const taskAggregate = jest.spyOn(Task, 'aggregate').mockReturnValue(mockAggregate); + + const response = await getTasksByUserId(mockReq, mockRes); + + assertResMock(400, mockError, response, mockRes); + expect(taskAggregate).toHaveBeenCalled(); + }); + }); + + describe('sendReviewReq function()', () => { + test('Returns 200 on success', async () => { + const { sendReviewReq } = makeSut(); + + mockReq.body = { + ...mockReq.body, + myUserId: 'id', + name: 'name', + taskName: 'task', + }; + + const userProfileFindByIdSpy = jest.spyOn(UserProfile, 'findById').mockResolvedValueOnce([]); + const userProfileFindSpy = jest.spyOn(UserProfile, 'find').mockResolvedValueOnce([]); + + const response = await sendReviewReq(mockReq, mockRes); + + assertResMock(200, 'Success', response, mockRes); + expect(emailSender).toHaveBeenCalledWith( + [], + expect.any(String), + expect.any(String), + null, + null, + ); + expect(userProfileFindByIdSpy).toHaveBeenCalled(); + expect(userProfileFindSpy).toHaveBeenCalled(); + }); + + test('Returns 400 on error', async () => { + const { sendReviewReq } = makeSut(); + + mockReq.body = { + ...mockReq.body, + myUserId: 'id', + name: 'name', + taskName: 'task', + }; + + const mockError = new Error('some error'); + + emailSender.mockImplementation(() => mockError); + + const userProfileFindByIdSpy = jest.spyOn(UserProfile, 'findById').mockResolvedValueOnce([]); + const userProfileFindSpy = jest.spyOn(UserProfile, 'find').mockResolvedValueOnce([]); + + const response = await sendReviewReq(mockReq, mockRes); + + assertResMock(400, mockError, response, mockRes); + + expect(emailSender).toHaveBeenCalledWith( + [], + expect.any(String), + expect.any(String), + null, + null, + ); + expect(userProfileFindByIdSpy).toHaveBeenCalled(); + expect(userProfileFindSpy).toHaveBeenCalled(); + }); + }); + + describe('getTasksForTeamsByUser function()', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('Returns 200 on success - getTasksForTeams', async () => { + mockReq.params.userId = 1234; + const mockData = ['mockData']; + + taskHelperMethods.getTasksForTeams.mockResolvedValueOnce(mockData); + + const { getTasksForTeamsByUser } = makeSut(); + + const response = await getTasksForTeamsByUser(mockReq, mockRes); + await flushPromises(); + + assertResMock(200, mockData, response, mockRes); + }); + + test('Returns 200 on success - getTasksForTeamsByUser', async () => { + mockReq.params.userId = 1234; + const mockData = ['mockData']; + + const execMock = { + exec: jest.fn().mockResolvedValueOnce(mockData), + }; + + taskHelperMethods.getTasksForTeams.mockResolvedValueOnce([]); + taskHelperMethods.getTasksForSingleUser.mockImplementation(() => execMock); + + const { getTasksForTeamsByUser } = makeSut(); + + const response = await getTasksForTeamsByUser(mockReq, mockRes); + await flushPromises(); + + assertResMock(200, mockData, response, mockRes); + }); + + test('Returns 400 on error', async () => { + mockReq.params.userId = 1234; + const mockError = new Error('error'); + + taskHelperMethods.getTasksForTeams.mockRejectedValueOnce(mockError); + + const { getTasksForTeamsByUser } = makeSut(); + + const response = await getTasksForTeamsByUser(mockReq, mockRes); + await flushPromises(); + + assertResMock(400, { error: mockError }, response, mockRes); + }); + }); + + describe('updateTaskStatus function()', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const mockedTask = { + wbs: 111, + }; + const mockedWBS = { + projectId: 111, + modifiedDatetime: new Date(), + save: jest.fn(), + }; + const mockedProject = { + projectId: 111, + modifiedDatetime: new Date(), + save: jest.fn(), + }; + + test('Returns 200 on success - updateTaskStatus', async () => { + const { updateTaskStatus } = makeSut(); + + mockReq.params = { + ...mockReq.params, + taskId: 456, + }; + + const taskFindByIdSpy = jest.spyOn(Task, 'findById').mockResolvedValue(mockedTask); + const taskFindOneAndUpdateSpy = jest + .spyOn(Task, 'findOneAndUpdate') + .mockResolvedValueOnce(true); + const wbsFindByIdSpy = jest.spyOn(WBS, 'findById').mockResolvedValue(mockedWBS); + const projectFindByIdSpy = jest.spyOn(Project, 'findById').mockResolvedValue(mockedProject); + + const response = await updateTaskStatus(mockReq, mockRes); + await flushPromises(); + + // assertResMock(201, null, response, mockRes); + expect(mockRes.status).toBeCalledWith(201); + expect(response).toBeUndefined(); + expect(taskFindByIdSpy).toHaveBeenCalled(); + expect(taskFindOneAndUpdateSpy).toHaveBeenCalled(); + expect(wbsFindByIdSpy).toHaveBeenCalled(); + expect(projectFindByIdSpy).toHaveBeenCalled(); + }); + + test('Returns 400 on error', async () => { + const { updateTaskStatus } = makeSut(); + const error = new Error('some error'); + + mockReq.params = { + ...mockReq.params, + taskId: 456, + }; + + const taskFindByIdSpy = jest.spyOn(Task, 'findById').mockResolvedValue(mockedTask); + const taskFindOneAndUpdateSpy = jest + .spyOn(Task, 'findOneAndUpdate') + .mockRejectedValueOnce(error); + const wbsFindByIdSpy = jest.spyOn(WBS, 'findById').mockResolvedValue(mockedWBS); + const projectFindByIdSpy = jest.spyOn(Project, 'findById').mockResolvedValue(mockedProject); + + const response = await updateTaskStatus(mockReq, mockRes); + await flushPromises(); + + assertResMock(404, error, response, mockRes); + + expect(taskFindByIdSpy).toHaveBeenCalled(); + expect(taskFindOneAndUpdateSpy).toHaveBeenCalled(); + expect(wbsFindByIdSpy).toHaveBeenCalled(); + expect(projectFindByIdSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/controllers/teamController.js b/src/controllers/teamController.js index 41f515e99..42d9d8d25 100644 --- a/src/controllers/teamController.js +++ b/src/controllers/teamController.js @@ -6,8 +6,66 @@ const Logger = require('../startup/logger'); const teamcontroller = function (Team) { const getAllTeams = function (req, res) { - Team.find({}) - .sort({ teamName: 1 }) + Team.aggregate([ + { + $unwind: '$members', + }, + { + $lookup: { + from: 'userProfiles', + localField: 'members.userId', + foreignField: '_id', + as: 'userProfile', + }, + }, + { + $unwind: '$userProfile', + }, + { + $match: { + isActive: true, + } + }, + { + $group: { + _id: { + teamId: '$_id', + teamCode: '$userProfile.teamCode', + }, + count: { $sum: 1 }, + teamName: { $first: '$teamName' }, + members: { + $push: { + _id: '$userProfile._id', + name: '$userProfile.name', + email: '$userProfile.email', + teamCode: '$userProfile.teamCode', + addDateTime: '$members.addDateTime', + }, + }, + createdDatetime: { $first: '$createdDatetime' }, + modifiedDatetime: { $first: '$modifiedDatetime' }, + isActive: { $first: '$isActive' }, + }, + }, + { + $sort: { count: -1 }, // Sort by the most frequent teamCode + }, + { + $group: { + _id: '$_id.teamId', + teamCode: { $first: '$_id.teamCode' }, // Get the most frequent teamCode + teamName: { $first: '$teamName' }, + members: { $first: '$members' }, + createdDatetime: { $first: '$createdDatetime' }, + modifiedDatetime: { $first: '$modifiedDatetime' }, + isActive: { $first: '$isActive' }, + }, + }, + { + $sort: { teamName: 1 }, // Sort teams by name + }, + ]) .then((results) => res.status(200).send(results)) .catch((error) => { Logger.logException(error); @@ -110,14 +168,15 @@ const teamcontroller = function (Team) { return; } - const canEditTeamCode = - req.body.requestor.role === 'Owner' || - req.body.requestor.permissions?.frontPermissions.includes('editTeamCode'); + // Removed the permission check as the permission check if done in earlier + // const canEditTeamCode = + // req.body.requestor.role === 'Owner' || + // req.body.requestor.permissions?.frontPermissions.includes('editTeamCode'); - if (!canEditTeamCode) { - res.status(403).send('You are not authorized to edit team code.'); - return; - } + // if (!canEditTeamCode) { + // res.status(403).send('You are not authorized to edit team code.'); + // return; + // } record.teamName = req.body.teamName; record.isActive = req.body.isActive; @@ -222,59 +281,62 @@ const teamcontroller = function (Team) { }, }, ]) - .then((result) => res.status(200).send(result)) + .then((result) => { + res.status(200).send(result) + }) .catch((error) => { Logger.logException(error, null, `TeamId: ${teamId} Request:${req.body}`); - res.status(500).send(error); + return res.status(500).send(error); }); }; const updateTeamVisibility = async (req, res) => { - console.log("==============> 9 "); + console.log('==============> 9 '); const { visibility, teamId, userId } = req.body; - + try { Team.findById(teamId, (error, team) => { if (error || team === null) { res.status(400).send('No valid records found'); return; } - - const memberIndex = team.members.findIndex(member => member.userId.toString() === userId); + + const memberIndex = team.members.findIndex((member) => member.userId.toString() === userId); if (memberIndex === -1) { res.status(400).send('Member not found in the team.'); return; } - + team.members[memberIndex].visible = visibility; team.modifiedDatetime = Date.now(); - - team.save() - .then(updatedTeam => { - // Additional operations after team.save() + + team + .save() + .then((updatedTeam) => { + // Additional operations after team.save() const assignlist = []; const unassignlist = []; - team.members.forEach(member => { + team.members.forEach((member) => { if (member.userId.toString() === userId) { // Current user, no need to process further return; } - + if (visibility) { assignlist.push(member.userId); } else { - console.log("Visiblity set to false so removing it"); + console.log('Visiblity set to false so removing it'); unassignlist.push(member.userId); } }); - + const addTeamToUserProfile = userProfile .updateMany({ _id: { $in: assignlist } }, { $addToSet: { teams: teamId } }) .exec(); const removeTeamFromUserProfile = userProfile .updateMany({ _id: { $in: unassignlist } }, { $pull: { teams: teamId } }) .exec(); - + Promise.all([addTeamToUserProfile, removeTeamFromUserProfile]) .then(() => { res.status(200).send({ result: 'Done' }); @@ -283,18 +345,17 @@ const teamcontroller = function (Team) { res.status(500).send({ error }); }); }) - .catch(errors => { + .catch((errors) => { console.error('Error saving team:', errors); res.status(400).send(errors); }); - }); } catch (error) { - res.status(500).send(`Error updating team visibility: ${ error.message}`); + res.status(500).send(`Error updating team visibility: ${error.message}`); } }; - /** + /** * Leaner version of the teamcontroller.getAllTeams * Remove redundant data: members, isActive, createdDatetime, modifiedDatetime. */ @@ -308,7 +369,48 @@ const teamcontroller = function (Team) { res.status(500).send('Fetch team code failed.'); }); }; - + + const getAllTeamMembers = async function (req,res) { + try{ + const teamIds = req.body; + const cacheKey='teamMembersCache' + if(cache.hasCache(cacheKey)){ + let data=cache.getCache('teamMembersCache') + return res.status(200).send(data); + } + if (!Array.isArray(teamIds) || teamIds.length === 0 || !teamIds.every(team => mongoose.Types.ObjectId.isValid(team._id))) { + return res.status(400).send({ error: 'Invalid request: teamIds must be a non-empty array of valid ObjectId strings.' }); + } + let data = await Team.aggregate([ + { + $match: { _id: { $in: teamIds.map(team => mongoose.Types.ObjectId(team._id)) } } + }, + { $unwind: '$members' }, + { + $lookup: { + from: 'userProfiles', + localField: 'members.userId', + foreignField: '_id', + as: 'userProfile', + }, + }, + { $unwind: { path: '$userProfile', preserveNullAndEmptyArrays: true } }, + { + $group: { + _id: '$_id', // Group by team ID + teamName: { $first: '$teamName' }, // Use $first to keep the team name + createdDatetime: { $first: '$createdDatetime' }, + members: { $push: '$members' }, // Rebuild the members array + }, + }, + ]) + cache.setCache(cacheKey,data) + res.status(200).send(data); + }catch(error){ + console.log(error) + res.status(500).send({'message':"Fetching team members failed"}); + } + } return { getAllTeams, getAllTeamCode, @@ -319,6 +421,7 @@ const teamcontroller = function (Team) { assignTeamToUsers, getTeamMembership, updateTeamVisibility, + getAllTeamMembers }; }; diff --git a/src/controllers/timeZoneAPIController.js b/src/controllers/timeZoneAPIController.js index 07c9c0b17..2023f312a 100644 --- a/src/controllers/timeZoneAPIController.js +++ b/src/controllers/timeZoneAPIController.js @@ -1,11 +1,11 @@ // eslint-disable-next-line import/no-extraneous-dependencies const fetch = require('node-fetch'); +const dotenv = require('dotenv'); + +dotenv.config(); const ProfileInitialSetupToken = require('../models/profileInitialSetupToken'); const { hasPermission } = require('../utilities/permissions'); -const premiumKey = process.env.TIMEZONE_PREMIUM_KEY; -const commonKey = process.env.TIMEZONE_COMMON_KEY; - const performTimeZoneRequest = async (req, res, apiKey) => { const { location } = req.params; @@ -17,7 +17,6 @@ const performTimeZoneRequest = async (req, res, apiKey) => { try { const geocodeAPIEndpoint = 'https://api.opencagedata.com/geocode/v1/json'; const url = `${geocodeAPIEndpoint}?key=${apiKey}&q=${location}&pretty=1&limit=1`; - const response = await fetch(url); const data = await response.json(); @@ -53,16 +52,17 @@ const performTimeZoneRequest = async (req, res, apiKey) => { const timeZoneAPIController = function () { const getTimeZone = async (req, res) => { + const premiumKey = process.env.TIMEZONE_PREMIUM_KEY; + const commonKey = process.env.TIMEZONE_COMMON_KEY; const { requestor } = req.body; - if (!requestor.role) { res.status(403).send('Unauthorized Request'); return; } - const userAPIKey = (await hasPermission(requestor, 'getTimeZoneAPIKey')) ? premiumKey : commonKey; + if (!userAPIKey) { res.status(401).send('API Key Missing'); return; @@ -72,6 +72,7 @@ const timeZoneAPIController = function () { }; const getTimeZoneProfileInitialSetup = async (req, res) => { + const commonKey = process.env.TIMEZONE_COMMON_KEY; const { token } = req.body; if (!token) { res.status(400).send('Missing token'); diff --git a/src/controllers/timeZoneAPIController.spec.js b/src/controllers/timeZoneAPIController.spec.js new file mode 100644 index 000000000..2f3607cc6 --- /dev/null +++ b/src/controllers/timeZoneAPIController.spec.js @@ -0,0 +1,316 @@ +jest.mock('../utilities/permissions', () => ({ + hasPermission: jest.fn(), // Mocking the hasPermission function +})); + +jest.mock('node-fetch'); +// eslint-disable-next-line import/no-extraneous-dependencies +const fetch = require('node-fetch'); + +const originalPremiumKey = process.env.TIMEZONE_PREMIUM_KEY; +process.env.TIMEZONE_PREMIUM_KEY = 'mockPremiumKey'; + +const originalCommonKey = process.env.TIMEZONE_COMMON_KEY; +delete process.env.TIMEZONE_COMMON_KEY; + +const successfulFetchRequestWithResults = jest.fn(() => + Promise.resolve({ + json: () => + Promise.resolve({ + status: { + code: 200, + message: 'Request Processed Successfully', + }, + results: [ + { + annotations: { + timezone: { + name: 'timeZone - Fiji', + }, + }, + geometry: { + lat: 1, + lng: 1, + }, + components: { + country: 'U.S.', + city: 'Paris', + }, + }, + ], + }), + }), +); + +const successfulFetchRequestWithNoResults = jest.fn(() => + Promise.resolve({ + json: () => + Promise.resolve({ + status: { + code: 200, + message: 'Request Processed Successfully', + }, + results: [], + }), + }), +); + +const unsuccessfulFetchRequestInternalServerError = jest.fn(() => + Promise.resolve({ + json: () => + Promise.resolve({ + status: { + code: null, + message: 'Internal Server Error', + }, + results: [], + }), + }), +); + +const { hasPermission } = require('../utilities/permissions'); +const timeZoneAPIController = require('./timeZoneAPIController'); +const ProfileInitialSetupToken = require('../models/profileInitialSetupToken'); +const { mockReq, mockRes, assertResMock } = require('../test'); + +const flushPromises = () => new Promise(setImmediate); +const makeSut = () => { + const { getTimeZone, getTimeZoneProfileInitialSetup } = timeZoneAPIController(); + return { getTimeZone, getTimeZoneProfileInitialSetup }; +}; + +describe('timeZoneAPIController Unit Tests', () => { + afterAll(() => { + // Reseting TIMEZONE_PREMIUM_KEY and TIMEZONE_COMMON_KEY environment variables to their original values + if (originalPremiumKey) { + process.env.TIMEZONE_PREMIUM_KEY = originalPremiumKey; + } else { + delete process.env.TIMEZONE_PREMIUM_KEY; + } + + if (originalCommonKey) { + process.env.TIMEZONE_COMMON_KEY = originalCommonKey; + } else { + delete process.env.TIMEZONE_COMMON_KEY; + } + }); + + describe('getTimeZone() function', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + beforeEach(() => { + hasPermission.mockResolvedValue(true); + }); + test('Returns 403, as requestor.role is missing in request body', async () => { + const { getTimeZone } = makeSut(); + + // setting request.role to `Null` + mockReq.body.requestor.role = null; + + const response = await getTimeZone(mockReq, mockRes); + + assertResMock(403, 'Unauthorized Request', response, mockRes); + }); + + test('Returns 401, as API is missing', async () => { + delete process.env.TIMEZONE_COMMON_KEY; + + const { getTimeZone } = makeSut(); + mockReq.body.requestor.role = 'Volunteer'; + + hasPermission.mockResolvedValue(false); + + const response = await getTimeZone(mockReq, mockRes); + await flushPromises(); + + expect(hasPermission).toBeCalledTimes(1); + assertResMock(401, 'API Key Missing', response, mockRes); + }); + + test('Returns 400, when `location` is missing in req.params', async () => { + const { getTimeZone } = makeSut(); + mockReq.body.requestor.role = 'Volunteer'; + + const response = await getTimeZone(mockReq, mockRes); + await flushPromises(); + + expect(hasPermission).toBeCalledTimes(1); + assertResMock(400, 'Missing location', response, mockRes); + }); + + test('Returns 500, when status.code !== 200 and status code is missing', async () => { + const { getTimeZone } = makeSut(); + mockReq.body.requestor.role = 'Volunteer'; + mockReq.params.location = 'New Jersey'; + + fetch.mockImplementation(unsuccessfulFetchRequestInternalServerError); + + const response = await getTimeZone(mockReq, mockRes); + await flushPromises(); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(hasPermission).toBeCalledTimes(1); + assertResMock(500, 'opencage error- Internal Server Error', response, mockRes); + }); + + test('Returns 404, when status.code == 200 and data.results is empty', async () => { + const { getTimeZone } = makeSut(); + mockReq.body.requestor.role = 'Volunteer'; + mockReq.params.location = 'New Jersey'; + + fetch.mockImplementation(successfulFetchRequestWithNoResults); + + const response = await getTimeZone(mockReq, mockRes); + await flushPromises(); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(hasPermission).toBeCalledTimes(1); + assertResMock(404, 'No results found', response, mockRes); + }); + + test('Returns 200, when status.code == 200 and data.results is not empty', async () => { + const { getTimeZone } = makeSut(); + mockReq.body.requestor.role = 'Volunteer'; + mockReq.params.location = 'New Jersey'; + + fetch.mockImplementation(successfulFetchRequestWithResults); + const timezone = 'timeZone - Fiji'; // mocking the timezone data to be returned by `successfulFetchRequestWithResults` + const currentLocation = { + // mocking the currentLocation data to be returned by `successfulFetchRequestWithResults` + userProvided: mockReq.params.location, + coords: { + lat: 1, + lng: 1, + }, + country: 'U.S.', + city: 'Paris', + }; + + const response = await getTimeZone(mockReq, mockRes); + await flushPromises(); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(hasPermission).toBeCalledTimes(1); + assertResMock(200, { timezone, currentLocation }, response, mockRes); + }); + }); + + describe('getTimeZoneProfileInitialSetup() function', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + beforeEach(() => { + hasPermission.mockResolvedValue(true); + }); + + test('Returns status code 400 if token is missing in request.body', async () => { + mockReq.body.token = null; + + const { getTimeZoneProfileInitialSetup } = makeSut(); + + const response = await getTimeZoneProfileInitialSetup(mockReq, mockRes); + await flushPromises(); + + assertResMock(400, 'Missing token', response, mockRes); + }); + + test('Returns status code 403 if token is missing in request.body', async () => { + mockReq.body.token = 'random_token_value'; + + const { getTimeZoneProfileInitialSetup } = makeSut(); + const profileInitialSetupTokenFindOneSpy = jest + .spyOn(ProfileInitialSetupToken, 'findOne') + .mockReturnValue(null); + + const response = await getTimeZoneProfileInitialSetup(mockReq, mockRes); + await flushPromises(); + + expect(profileInitialSetupTokenFindOneSpy).toBeCalledTimes(1); + assertResMock(403, 'Unauthorized Request', response, mockRes); + }); + + test('Returns 500, when status.code !== 200 and status code is missing', async () => { + const { getTimeZoneProfileInitialSetup } = makeSut(); + mockReq.body.requestor.role = 'Volunteer'; + mockReq.params.location = 'New Jersey'; + + const profileInitialSetupTokenFindOneSpy = jest + .spyOn(ProfileInitialSetupToken, 'findOne') + .mockReturnValue('token'); + fetch.mockImplementation(unsuccessfulFetchRequestInternalServerError); + + const response = await getTimeZoneProfileInitialSetup(mockReq, mockRes); + await flushPromises(); + + expect(profileInitialSetupTokenFindOneSpy).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledTimes(1); + assertResMock(500, 'opencage error- Internal Server Error', response, mockRes); + }); + + test('Returns 404, when status.code == 200 and data.results is empty', async () => { + const { getTimeZoneProfileInitialSetup } = makeSut(); + mockReq.body.requestor.role = 'Volunteer'; + mockReq.params.location = 'New Jersey'; + + const profileInitialSetupTokenFindOneSpy = jest + .spyOn(ProfileInitialSetupToken, 'findOne') + .mockReturnValue('token'); + fetch.mockImplementation(successfulFetchRequestWithNoResults); + + const response = await getTimeZoneProfileInitialSetup(mockReq, mockRes); + await flushPromises(); + + expect(profileInitialSetupTokenFindOneSpy).toBeCalledTimes(1); + expect(fetch).toHaveBeenCalledTimes(1); + assertResMock(404, 'No results found', response, mockRes); + }); + + test('Returns 200, when status.code == 200 and data.results is not empty', async () => { + const { getTimeZoneProfileInitialSetup } = makeSut(); + mockReq.body.requestor.role = 'Volunteer'; + mockReq.params.location = 'New Jersey'; + + const profileInitialSetupTokenFindOneSpy = jest + .spyOn(ProfileInitialSetupToken, 'findOne') + .mockReturnValue('token'); + fetch.mockImplementation(successfulFetchRequestWithResults); + + const timezone = 'timeZone - Fiji'; // mocking the timezone data to be returned by `successfulFetchRequestWithResults` + const currentLocation = { + // mocking the currentLocation data to be returned by `successfulFetchRequestWithResults` + userProvided: mockReq.params.location, + coords: { + lat: 1, + lng: 1, + }, + country: 'U.S.', + city: 'Paris', + }; + + const response = await getTimeZoneProfileInitialSetup(mockReq, mockRes); + await flushPromises(); + + expect(profileInitialSetupTokenFindOneSpy).toBeCalledTimes(1); + expect(fetch).toHaveBeenCalledTimes(1); + assertResMock(200, { timezone, currentLocation }, response, mockRes); + }); + + test('Returns 400, when `location` is missing in req.params', async () => { + const { getTimeZoneProfileInitialSetup } = makeSut(); + mockReq.body.requestor.role = 'Volunteer'; + mockReq.params.location = null; + + const profileInitialSetupTokenFindOneSpy = jest + .spyOn(ProfileInitialSetupToken, 'findOne') + .mockReturnValue('token'); + + const response = await getTimeZoneProfileInitialSetup(mockReq, mockRes); + await flushPromises(); + + expect(profileInitialSetupTokenFindOneSpy).toBeCalledTimes(1); + assertResMock(400, 'Missing location', response, mockRes); + }); + }); +}); diff --git a/src/controllers/userProfileController.js b/src/controllers/userProfileController.js index 83a21a4da..6a23dd34b 100644 --- a/src/controllers/userProfileController.js +++ b/src/controllers/userProfileController.js @@ -1493,26 +1493,7 @@ const userProfileController = function (UserProfile, Project) { res.status(200).send({ refreshToken: currentRefreshToken }); }; - // Search for user by first name - // const getUserBySingleName = (req, res) => { - // const pattern = new RegExp(`^${ req.params.singleName}`, 'i'); - - // // Searches for first or last name - // UserProfile.find({ - // $or: [ - // { firstName: { $regex: pattern } }, - // { lastName: { $regex: pattern } }, - // ], - // }) - // .select('firstName lastName') - // .then((users) => { - // if (users.length === 0) { - // return res.status(404).send({ error: 'Users Not Found' }); - // } - // res.status(200).send(users); - // }) - // .catch((error) => res.status(500).send(error)); - // }; + const getUserBySingleName = (req, res) => { const pattern = new RegExp(`^${req.params.singleName}`, 'i'); @@ -1557,13 +1538,7 @@ const userProfileController = function (UserProfile, Project) { .catch((error) => res.status(500).send(error)); }; - // function escapeRegExp(string) { - // return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - // } - /** - * Authorizes user to be able to add Weekly Report Recipients - * - */ + const authorizeUser = async (req, res) => { try { let authorizedUser; diff --git a/src/cronjobs/userProfileJobs.js b/src/cronjobs/userProfileJobs.js index f0f69e146..e7a8662a6 100644 --- a/src/cronjobs/userProfileJobs.js +++ b/src/cronjobs/userProfileJobs.js @@ -19,6 +19,16 @@ const userProfileJobs = () => { } await userhelper.awardNewBadges(); await userhelper.reActivateUser(); + }, + null, + false, + 'America/Los_Angeles', + ); + + // Job to run every day, 1 minute past midnight to deactivate the user + const dailyUserDeactivateJobs = new CronJob( + '1 0 * * *', // Every day, 1 minute past midnight + async () => { await userhelper.deActivateUser(); }, null, @@ -27,5 +37,6 @@ const userProfileJobs = () => { ); allUserProfileJobs.start(); + dailyUserDeactivateJobs.start(); }; module.exports = userProfileJobs; diff --git a/src/models/jobs.js b/src/models/jobs.js new file mode 100644 index 000000000..b1f98a34a --- /dev/null +++ b/src/models/jobs.js @@ -0,0 +1,17 @@ +const mongoose = require('mongoose'); + +const { Schema } = mongoose; + +const jobSchema = new Schema({ + title: { type: String, required: true }, // Job title + category: { type: String, required: true }, // General category (e.g., Engineering, Marketing) + description: { type: String, required: true }, // Detailed job description + imageUrl: { type: String, required: true }, // URL of the job-related image + location: { type: String, required: true }, // Job location (optional for remote jobs) + applyLink: { type: String, required: true }, // URL for the application form + featured: { type: Boolean, default: false }, // Whether the job should be featured prominently + datePosted: { type: Date, default: Date.now }, // Date the job was posted + jobDetailsLink: { type: String, required: true }, // Specific job details URL +}); + +module.exports = mongoose.model('Job', jobSchema); diff --git a/src/routes/informationRouter.test.js b/src/routes/informationRouter.test.js new file mode 100644 index 000000000..12a600723 --- /dev/null +++ b/src/routes/informationRouter.test.js @@ -0,0 +1,145 @@ +const request = require('supertest'); +const { jwtPayload } = require('../test'); +const cache = require('../utilities/nodeCache')(); +const { app } = require('../app'); +const { + mockReq, + createUser, + mongoHelper: { dbConnect, dbDisconnect, dbClearCollections, dbClearAll }, +} = require('../test'); + +const agent = request.agent(app); + +describe('information routes', () => { + let user; + let token; + let reqBody = { + ...mockReq.body, + }; + beforeAll(async () => { + await dbConnect(); + user = await createUser(); + token = jwtPayload(user); + reqBody = { + ...reqBody, + infoName: 'some infoName', + infoContent: 'some infoContent', + visibility: '1', + }; + }); + beforeEach(async () => { + await dbClearCollections('informations'); + }); + + afterAll(async () => { + await dbClearAll(); + await dbDisconnect(); + }); + describe('informationRoutes', () => { + it('should return 401 if authorization header is not present', async () => { + await agent.post('/api/informations').send(reqBody).expect(401); + await agent.get('/api/informations/randomID').send(reqBody).expect(401); + }); + }); + describe('Post Information route', () => { + it('Should return 201 if the information is successfully added', async () => { + const response = await agent + .post('/api/informations') + .send(reqBody) + .set('Authorization', token) + .expect(201); + + expect(response.body).toEqual({ + _id: expect.anything(), + __v: expect.anything(), + infoName: reqBody.infoName, + infoContent: reqBody.infoContent, + visibility: reqBody.visibility, + }); + }); + }); + describe('Get Information route', () => { + it('Should return 201 if the information is successfully added', async () => { + const informations = [ + { + _id: '6605f860f948db61dab6f27m', + infoName: 'get info', + infoContent: 'get infoConten', + visibility: '1', + }, + ]; + cache.setCache('informations', JSON.stringify(informations)); + const response = await agent + .get('/api/informations') + .send(reqBody) + .set('Authorization', token) + .expect(200); + expect(response.body).toEqual({}); + }); + }); + describe('Delete Information route', () => { + it('Should return 400 if the route does not exist', async () => { + await agent + .delete('/api/informations/random123') + .send(reqBody) + .set('Authorization', token) + .expect(400); + }); + // thrown: "Exceeded timeout of 5000 ms for a test. + // Add a timeout value to this test to increase the timeout, if this is a long-running test. See https://jestjs.io/docs/api#testname-fn-timeout." + // it('Should return 200 if deleting successfully', async () => { + // const _info = new Information(); + // _info.infoName = reqBody.infoName; + // _info.infoContent = reqBody.infoContent; + // _info.visibility = reqBody.visibility; + // const info = await _info.save(); + // const response = await agent + // .delete(`/api/informations/${info._id}`) + // .set('Authorization', token) + // .send(reqBody) + // .expect(200); + + // expect(response.body).toEqual( + // { + // _id: expect.anything(), + // __v: expect.anything(), + // infoName: info.infoName, + // infoContent: info.infoContent, + // visibility: info.visibility, + // }); + // }); + }); + describe('Update Information route', () => { + it('Should return 400 if the route does not exist', async () => { + await agent + .put('/api/informations/random123') + .send(reqBody) + .set('Authorization', token) + .expect(400); + }); + // thrown: "Exceeded timeout of 5000 ms for a test. + // Add a timeout value to this test to increase the timeout, if this is a long-running test. See https://jestjs.io/docs/api#testname-fn-timeout." + // it('Should return 200 if udapted successfully', async () => { + // const _info = new Information(); + // _info.infoName = reqBody.infoName; + // _info.infoContent = reqBody.infoContent; + // _info.visibility = reqBody.visibility; + // const info = await _info.save(); + + // const response = await agent + // .put(`/api/informations/${info.id}`) + // .send(reqBody) + // .set('Authorization', token) + // .expect(200); + // expect(response.body).toEqual( + // { + // _id: expect.anything(), + // __v: expect.anything(), + // infoName: info.infoName, + // infoContent: info.infoContent, + // visibility: info.visibility, + // }); + + // }); + }); +}); diff --git a/src/routes/jobsRouter.js b/src/routes/jobsRouter.js new file mode 100644 index 000000000..5b66735a6 --- /dev/null +++ b/src/routes/jobsRouter.js @@ -0,0 +1,14 @@ +const express = require('express'); +const jobsController = require('../controllers/jobsController'); // Adjust the path if needed + +const router = express.Router(); + +// Define routes +router.get('/', jobsController.getJobs); +router.get('/categories', jobsController.getCategories); +router.get('/:id', jobsController.getJobById); +router.post('/', jobsController.createJob); +router.put('/:id', jobsController.updateJob); +router.delete('/:id', jobsController.deleteJob); + +module.exports = router; diff --git a/src/routes/reasonRouter.test.js b/src/routes/reasonRouter.test.js new file mode 100644 index 000000000..a1f1ab6dc --- /dev/null +++ b/src/routes/reasonRouter.test.js @@ -0,0 +1,338 @@ +const request = require('supertest'); +const moment = require('moment-timezone'); +const { jwtPayload } = require('../test'); +const cache = require('../utilities/nodeCache')(); +const { app } = require('../app'); +const { + mockReq, + mockUser, + createUser, + createTestPermissions, + mongoHelper: { dbConnect, dbDisconnect, dbClearCollections, dbClearAll }, +} = require('../test'); +// const Reason = require('../models/reason'); + +function mockDay(dayIdx, past = false) { + const date = moment().tz('America/Los_Angeles').startOf('day'); + while (date.day() !== dayIdx) { + date.add(past ? -1 : 1, 'days'); + } + return date; +} +const agent = request.agent(app); +describe('reason routers', () => { + let adminUser; + let adminToken; + let reqBody = { + body: { + ...mockReq.body, + ...mockUser(), + }, + }; + beforeAll(async () => { + await dbConnect(); + await createTestPermissions(); + adminUser = await createUser(); + adminToken = jwtPayload(adminUser); + }); + beforeEach(async () => { + await dbClearCollections('reason'); + await dbClearCollections('userProfiles'); + cache.setCache('allusers', '[]'); + reqBody = { + body: { + ...mockReq.body, + ...mockUser(), + reasonData: { + date: mockDay(0), + message: 'some reason', + }, + currentDate: moment.tz('America/Los_Angeles').startOf('day'), + }, + }; + }); + afterAll(async () => { + await dbClearAll(); + await dbDisconnect(); + }); + describe('reasonRouters', () => { + it('should return 401 if authorization header is not present', async () => { + await agent.post('/api/reason/').send(reqBody.body).expect(401); + await agent.get('/api/reason/randomId').send(reqBody.body).expect(401); + await agent.get('/api/reason/single/randomId').send(reqBody.body).expect(401); + await agent.patch('/api/reason/randomId/').send(reqBody.body).expect(401); + await agent.delete('/api/reason/randomId').send(reqBody.body).expect(401); + }); + }); + describe('Post reason route', () => { + it('Should return 400 if user did not choose SUNDAY', async () => { + reqBody.body.reasonData.date = mockDay(1, true); + const response = await agent + .post('/api/reason/') + .send(reqBody.body) + .set('Authorization', adminToken) + .expect(400); + expect(response.body).toEqual({ + message: + "You must choose the Sunday YOU'LL RETURN as your date. This is so your reason ends up as a note on that blue square.", + errorCode: 0, + }); + }); + it('Should return 400 if warning to choose a future date', async () => { + reqBody.body.reasonData.date = mockDay(0, true); + const response = await agent + .post('/api/reason/') + .send(reqBody.body) + .set('Authorization', adminToken) + .expect(400); + expect(response.body).toEqual({ + message: 'You should select a date that is yet to come', + errorCode: 7, + }); + }); + it('Should return 400 if not providing reason', async () => { + reqBody.body.reasonData.message = null; + const response = await agent + .post('/api/reason/') + .send(reqBody.body) + .set('Authorization', adminToken) + .expect(400); + expect(response.body).toEqual({ + message: 'You must provide a reason.', + errorCode: 6, + }); + }); + it('Should return 404 if error in finding user Id', async () => { + reqBody.body.userId = null; + const response = await agent + .post('/api/reason/') + .send(reqBody.body) + .set('Authorization', adminToken) + .expect(404); + expect(response.body).toEqual({ + message: 'User not found', + errorCode: 2, + }); + }); + it('Should return 403 if duplicate resonse', async () => { + // const userProfile = new userPro + let response = await agent + .post('/api/userProfile') + .send(reqBody.body) + .set('Authorization', adminToken) + .expect(200); + + expect(response.body).toBeTruthy(); + response = await agent + .get('/api/userProfile') + .send(reqBody.body) + .set('Authorization', adminToken) + .expect(200); + const userId = response.body[0]._id; + reqBody.body.userId = userId; + response = await agent + .post('/api/reason/') + .send(reqBody.body) + .set('Authorization', adminToken) + .expect(200); + expect(response.body).toBeTruthy(); + response = await agent + .post('/api/reason/') + .send(reqBody.body) + .set('Authorization', adminToken) + .expect(403); + }); + it('Should return 200 if post successfully', async () => { + let response = await agent + .post('/api/userProfile') + .send(reqBody.body) + .set('Authorization', adminToken) + .expect(200); + + expect(response.body).toBeTruthy(); + response = await agent + .get('/api/userProfile') + .send(reqBody.body) + .set('Authorization', adminToken) + .expect(200); + const userId = response.body[0]._id; + reqBody.body.userId = userId; + response = await agent + .post('/api/reason/') + .send(reqBody.body) + .set('Authorization', adminToken) + .expect(200); + }); + }); + describe('Get AllReason route', () => { + it('Should return 400 if route does not exist', async () => { + const response = await agent + .get(`/api/reason/random123`) + .send(reqBody.body) + .set('Authorization', adminToken) + .expect(400); + expect(response.body).toEqual({ + errMessage: 'Something went wrong while fetching the user', + }); + }); + it('Should return 200 if get all reasons', async () => { + let response = await agent + .post('/api/userProfile') + .send(reqBody.body) + .set('Authorization', adminToken) + .expect(200); + + expect(response.body).toBeTruthy(); + response = await agent + .get('/api/userProfile') + .send(reqBody.body) + .set('Authorization', adminToken) + .expect(200); + const userId = response.body[0]._id; + reqBody.body.userId = userId; + response = await agent + .get(`/api/reason/${userId}`) + .send(reqBody.body) + .set('Authorization', adminToken) + .expect(200); + }); + }); + describe('Get Single Reason route', () => { + it('Should return 400 if route does not exist', async () => { + reqBody.query = { + queryDate: mockDay(1, true), + }; + const response = await agent + .get(`/api/reason/single/5a7e21f00317bc1538def4b9`) + .set('Authorization', adminToken) + .expect(404); + expect(response.body).toEqual({ + message: 'User not found', + errorCode: 2, + }); + }); + it('Should return 200 if get all reasons', async () => { + let response = await agent + .post('/api/userProfile') + .send(reqBody.body) + .set('Authorization', adminToken) + .expect(200); + + expect(response.body).toBeTruthy(); + response = await agent.get('/api/userProfile').set('Authorization', adminToken).expect(200); + const userId = response.body[0]._id; + reqBody.body.userId = userId; + reqBody.query = { + queryDate: mockDay(1, true), + }; + response = await agent + .get(`/api/reason/single/${userId}`) + .set('Authorization', adminToken) + .expect(200); + }); + }); + describe('Patch reason route', () => { + it('Should return 404 if error in finding user Id', async () => { + reqBody.body.userId = null; + const response = await agent + .patch('/api/reason/5a7e21f00317bc1538def4b9/') + .send(reqBody.body) + .set('Authorization', adminToken) + .expect(404); + expect(response.body).toEqual({ + message: 'User not found', + errorCode: 2, + }); + }); + it('Should return 404 if duplicate reasons', async () => { + let response = await agent + .post('/api/userProfile') + .send(reqBody.body) + .set('Authorization', adminToken) + .expect(200); + + expect(response.body).toBeTruthy(); + response = await agent.get('/api/userProfile').set('Authorization', adminToken).expect(200); + const userId = response.body[0]._id; + reqBody.body.userId = userId; + response = await agent + .patch(`/api/reason/${userId}/`) + .send(reqBody.body) + .set('Authorization', adminToken) + .expect(404); + expect(response.body).toEqual({ + message: 'Reason not found', + errorCode: 4, + }); + }); + it('Should return 200 if patch successfully', async () => { + let response = await agent + .post('/api/userProfile') + .send(reqBody.body) + .set('Authorization', adminToken) + .expect(200); + + expect(response.body).toBeTruthy(); + response = await agent.get('/api/userProfile').set('Authorization', adminToken).expect(200); + const userId = response.body[0]._id; + reqBody.body.userId = userId; + response = await agent + .post(`/api/reason/`) + .send(reqBody.body) + .set('Authorization', adminToken) + .expect(200); + expect(response.body).toBeTruthy(); + response = await agent + .patch(`/api/reason/${userId}/`) + .send(reqBody.body) + .set('Authorization', adminToken) + .expect(200); + expect(response.body).toEqual({ + message: 'Reason Updated!', + }); + }); + }); + describe('Delete reason route', () => { + it('Should return 404 if route does not exist', async () => { + const response = await agent + .delete(`/api/reason/5a7e21f00317bc1538def4b9`) + .send(reqBody.body) + .set('Authorization', adminToken) + .expect(404); + expect(response.body).toEqual({ + message: 'User not found', + errorCode: 2, + }); + }); + it('Should return 200 if deleting successfully', async () => { + let response = await agent + .post('/api/userProfile') + .send(reqBody.body) + .set('Authorization', adminToken) + .expect(200); + + expect(response.body).toBeTruthy(); + response = await agent + .get('/api/userProfile') + .send(reqBody.body) + .set('Authorization', adminToken) + .expect(200); + const userId = response.body[0]._id; + reqBody.body.userId = userId; + response = await agent + .post(`/api/reason/`) + .send(reqBody.body) + .set('Authorization', adminToken) + .expect(200); + expect(response.body).toBeTruthy(); + response = await agent + .delete(`/api/reason/${userId}`) + .send(reqBody.body) + .set('Authorization', adminToken) + .expect(200); + expect(response.body).toEqual({ + message: 'Document deleted', + }); + }); + }); +}); diff --git a/src/routes/teamRouter.js b/src/routes/teamRouter.js index 1bf8cfc44..3fbd8abb5 100644 --- a/src/routes/teamRouter.js +++ b/src/routes/teamRouter.js @@ -11,6 +11,10 @@ const router = function (team) { .post(controller.postTeam) .put(controller.updateTeamVisibility); + teamRouter + .route("/team/reports") + .post(controller.getAllTeamMembers); + teamRouter .route('/team/:teamId') .get(controller.getTeamById) diff --git a/src/routes/timeZoneAPIRoutes.test.js b/src/routes/timeZoneAPIRoutes.test.js new file mode 100644 index 000000000..975e56ac2 --- /dev/null +++ b/src/routes/timeZoneAPIRoutes.test.js @@ -0,0 +1,208 @@ +const request = require('supertest'); +const { jwtPayload } = require('../test'); + +const originalPremiumKey = process.env.TIMEZONE_PREMIUM_KEY; + +const { app } = require('../app'); +const { + mockReq, + mongoHelper: { dbConnect, dbDisconnect }, + createTestPermissions, + createUser, + mockUser, +} = require('../test'); + +const UserProfile = require('../models/userProfile'); +const ProfileInitialSetupToken = require('../models/profileInitialSetupToken'); + +const agent = request.agent(app); + +describe('timeZoneAPI routes', () => { + let adminUser; + let adminToken; + let volunteerUser; + let volunteerToken; + + const reqBody = {}; + const incorrectLocationParams = 'r'; + const locationParamsThatResultsInNoMatch = 'someReallyRandomLocation'; + const correctLocationParams = 'Berlin,+Germany'; + + beforeAll(async () => { + await dbConnect(); + await createTestPermissions(); + + reqBody.body = { + // This is the user we want to create + ...mockReq.body, + }; + adminUser = await createUser(); // This is the admin requestor user + adminToken = jwtPayload(adminUser); + + volunteerUser = mockUser(); // This is the admin requestor user + volunteerUser.email = 'volunteer@onecommunity.com'; + volunteerUser.role = 'Volunteer'; + volunteerUser = new UserProfile(volunteerUser); + volunteerUser = await volunteerUser.save(); + volunteerToken = jwtPayload(volunteerUser); + }); + + afterAll(async () => { + await dbDisconnect(); + + if (originalPremiumKey) { + process.env.TIMEZONE_PREMIUM_KEY = originalPremiumKey; + } else { + delete process.env.TIMEZONE_PREMIUM_KEY; + } + }); + + describe('API routes', () => { + it("should return 404 if route doesn't exist", async () => { + await agent + .post('/api/timezonesss') + .send(reqBody.body) + .set('Authorization', adminToken) + .expect(404); + }); + }); + + describe('getTimeZone - request parameter `location` based tests', () => { + test('401 when `API key` is missing', async () => { + const location = 'Berlin,+Germany'; + delete process.env.TIMEZONE_PREMIUM_KEY; + + const response = await agent + .get(`/api/timezone/${location}`) + .set('Authorization', adminToken) + .send(reqBody.body) + .expect(401); + + expect(response.error.text).toBe('API Key Missing'); + }); + + test('400 when `location` is incorrect', async () => { + const response = await agent + .get(`/api/timezone/${incorrectLocationParams}`) // Make sure this is the intended test + .set('Authorization', volunteerToken) + .send(reqBody.body) + .expect(400); + + expect(response.error.text).toBeTruthy(); + }); + + test('200 when `location` is correctly formatted', async () => { + const response = await agent + .get(`/api/timezone/${correctLocationParams}`) // Make sure this is the intended test + .set('Authorization', volunteerToken) + .send(reqBody.body) + .expect(200); + + expect(response).toBeTruthy(); + expect(response._body.timezone).toBeTruthy(); + expect(response._body.currentLocation).toBeTruthy(); + expect(response._body.currentLocation.userProvided).toBe(correctLocationParams); + }); + + test('404 when results.length === 0', async () => { + const response = await agent + .get(`/api/timezone/${locationParamsThatResultsInNoMatch}`) // Make sure this is the intended test + .set('Authorization', volunteerToken) + .send(reqBody.body) + .expect(404); + + expect(response).toBeTruthy(); + }); + }); + + describe('getTimeZoneProfileInitialSetup - token is missing in body or in ProfileInitialSetupToken', () => { + test('401 when `token` is missing in request body', async () => { + const location = 'Berlin,+Germany'; + + const response = await agent + .post(`/api/timezone/${location}`) + .set('Authorization', adminToken) + .send(reqBody.body) + .expect(400); + + expect(response.error.text).toBe('Missing token'); + }); + + test('403 when ProfileInitialSetupToken does not contains `req.body.token`', async () => { + const location = 'Berlin,+Germany'; + reqBody.body = { + ...reqBody, + token: 'randomToken', + }; + + const response = await agent + .post(`/api/timezone/${location}`) + .set('Authorization', adminToken) + .send(reqBody.body) + .expect(403); + + expect(response.error.text).toBe('Unauthorized Request'); + }); + }); + + describe('getTimeZoneProfileInitialSetup - token is present in ProfileInitialSetupToken', () => { + const tokenData = 'randomToken'; + + beforeAll(async () => { + const expirationDate = new Date().setDate(new Date().getDate() + 10); + + let data = { + token: tokenData, + email: 'randomEmail', + weeklyCommittedHours: 5, + expiration: expirationDate, + createdDate: new Date(), + isCancelled: false, + isSetupCompleted: true, + }; + + data = new ProfileInitialSetupToken(data); + + // eslint-disable-next-line no-unused-vars + data = await data.save(); + + reqBody.body = { + ...reqBody, + token: tokenData, + }; + }); + + test('400 when `location` is incorrect', async () => { + const response = await agent + .get(`/api/timezone/${incorrectLocationParams}`) // Make sure this is the intended test + .set('Authorization', volunteerToken) + .send(reqBody.body) + .expect(400); + + expect(response.error.text).toBeTruthy(); + }); + + test('200 when `location` is correctly formatted', async () => { + const response = await agent + .get(`/api/timezone/${correctLocationParams}`) // Make sure this is the intended test + .set('Authorization', volunteerToken) + .send(reqBody.body) + .expect(200); + + expect(response).toBeTruthy(); + expect(response._body.timezone).toBeTruthy(); + expect(response._body.currentLocation).toBeTruthy(); + expect(response._body.currentLocation.userProvided).toBe(correctLocationParams); + }); + + test('404 when results.length === 0', async () => { + const response = await agent + .get(`/api/timezone/${locationParamsThatResultsInNoMatch}`) // Make sure this is the intended test + .set('Authorization', volunteerToken) + .send(reqBody.body) + .expect(404); + + expect(response).toBeTruthy(); + }); + }); +}); diff --git a/src/startup/db.js b/src/startup/db.js index c3c61807c..719c17f94 100644 --- a/src/startup/db.js +++ b/src/startup/db.js @@ -33,7 +33,7 @@ const afterConnect = async () => { module.exports = function () { const uri = `mongodb://${process.env.user}:${encodeURIComponent(process.env.password)}@${process.env.cluster}/${process.env.dbName}?ssl=true&replicaSet=${process.env.replicaSetName}&authSource=admin`; - + mongoose.connect(uri, { useNewUrlParser: true, useUnifiedTopology: true, diff --git a/src/startup/middleware.js b/src/startup/middleware.js index eef3bd71b..400f5af6c 100644 --- a/src/startup/middleware.js +++ b/src/startup/middleware.js @@ -10,9 +10,8 @@ module.exports = function (app) { } if ( - (req.originalUrl === '/api/login' - || req.originalUrl === '/api/forgotpassword') - && req.method === 'POST' + (req.originalUrl === '/api/login' || req.originalUrl === '/api/forgotpassword') && + req.method === 'POST' ) { next(); return; @@ -21,12 +20,26 @@ module.exports = function (app) { next(); return; } - if (((req.originalUrl === '/api/ProfileInitialSetup' || req.originalUrl === '/api/validateToken' || req.originalUrl === '/api/getTimeZoneAPIKeyByToken') && req.method === 'POST') || (req.originalUrl === '/api/getTotalCountryCount' && req.method === 'GET') || (req.originalUrl.includes('/api/timezone') && req.method === 'POST') + if ( + ((req.originalUrl === '/api/ProfileInitialSetup' || + req.originalUrl === '/api/validateToken' || + req.originalUrl === '/api/getTimeZoneAPIKeyByToken') && + req.method === 'POST') || + (req.originalUrl === '/api/getTotalCountryCount' && req.method === 'GET') || + (req.originalUrl.includes('/api/timezone') && req.method === 'POST') ) { next(); return; } - if (req.originalUrl === '/api/add-non-hgn-email-subscription' || req.originalUrl === '/api/confirm-non-hgn-email-subscription' || req.originalUrl === '/api/remove-non-hgn-email-subscription' && req.method === 'POST') { + if ( + req.originalUrl === '/api/add-non-hgn-email-subscription' || + req.originalUrl === '/api/confirm-non-hgn-email-subscription' || + (req.originalUrl === '/api/remove-non-hgn-email-subscription' && req.method === 'POST') + ) { + next(); + return; + } + if (req.originalUrl.startsWith('/api/jobs') && req.method === 'GET') { next(); return; } @@ -44,13 +57,12 @@ module.exports = function (app) { res.status(401).send('Invalid token'); return; } - if ( - !payload - || !payload.expiryTimestamp - || !payload.userid - || !payload.role - || moment().isAfter(payload.expiryTimestamp) + !payload || + !payload.expiryTimestamp || + !payload.userid || + !payload.role || + moment().isAfter(payload.expiryTimestamp) ) { res.status(401).send('Unauthorized request'); return; diff --git a/src/startup/routes.js b/src/startup/routes.js index 82a4155a8..b307ac4f4 100644 --- a/src/startup/routes.js +++ b/src/startup/routes.js @@ -4,7 +4,6 @@ const project = require('../models/project'); const information = require('../models/information'); const team = require('../models/team'); // const actionItem = require('../models/actionItem'); -const notification = require('../models/notification'); const wbs = require('../models/wbs'); const task = require('../models/task'); const popup = require('../models/popupEditor'); @@ -55,6 +54,7 @@ const timeEntryRouter = require('../routes/timeentryRouter')(timeEntry); const projectRouter = require('../routes/projectRouter')(project); const informationRouter = require('../routes/informationRouter')(information); const teamRouter = require('../routes/teamRouter')(team); +const jobsRouter = require('../routes/jobsRouter'); // const actionItemRouter = require('../routes/actionItemRouter')(actionItem); const notificationRouter = require('../routes/notificationRouter')(); const loginRouter = require('../routes/loginRouter')(); @@ -162,6 +162,7 @@ module.exports = function (app) { app.use('/api', timeOffRequestRouter); app.use('/api', followUpRouter); app.use('/api', blueSquareEmailAssignmentRouter); + app.use('/api/jobs', jobsRouter) // bm dashboard app.use('/api/bm', bmLoginRouter); app.use('/api/bm', bmMaterialsRouter); diff --git a/src/test/createTestPermissions.js b/src/test/createTestPermissions.js index 58623ea3f..e0f9eddf1 100644 --- a/src/test/createTestPermissions.js +++ b/src/test/createTestPermissions.js @@ -51,7 +51,9 @@ const permissionsRoles = [ 'changeUserStatus', 'updatePassword', 'deleteUserProfile', - 'infringementAuthorizer', + 'addInfringements', + 'editInfringements', + 'deleteInfringements', // WBS 'postWbs', 'deleteWbs', @@ -111,7 +113,9 @@ const permissionsRoles = [ 'getUserProfiles', 'getProjectMembers', 'putUserProfile', - 'infringementAuthorizer', + 'addInfringements', + 'editInfringements', + 'deleteInfringements', 'getReporteesLimitRoles', 'updateTask', 'putTeam', @@ -139,7 +143,9 @@ const permissionsRoles = [ 'getUserProfiles', 'getProjectMembers', 'putUserProfile', - 'infringementAuthorizer', + 'addInfringements', + 'editInfringements', + 'deleteInfringements', 'getReporteesLimitRoles', 'getAllInvInProjectWBS', 'postInvInProjectWBS', @@ -194,6 +200,8 @@ const permissionsRoles = [ 'editTimeEntryToggleTangible', 'deleteTimeEntry', 'postTimeEntry', + 'sendEmails', + 'sendEmailToAll', 'updatePassword', 'getUserProfiles', 'getProjectMembers', @@ -202,7 +210,9 @@ const permissionsRoles = [ 'putUserProfileImportantInfo', 'updateSummaryRequirements', 'deleteUserProfile', - 'infringementAuthorizer', + 'addInfringements', + 'editInfringements', + 'deleteInfringements', 'postWbs', 'deleteWbs', 'getAllInvInProjectWBS', diff --git a/src/utilities/createInitialPermissions.js b/src/utilities/createInitialPermissions.js index 43dfec2a0..062679cdd 100644 --- a/src/utilities/createInitialPermissions.js +++ b/src/utilities/createInitialPermissions.js @@ -52,7 +52,9 @@ const permissionsRoles = [ 'changeUserRehireableStatus', 'updatePassword', 'deleteUserProfile', - 'infringementAuthorizer', + 'addInfringements', + 'editInfringements', + 'deleteInfringements', 'manageAdminLinks', 'manageTimeOffRequests', 'changeUserRehireableStatus', @@ -149,7 +151,6 @@ const permissionsRoles = [ { roleName: 'Mentor', permissions: [ - 'updateTask', 'suggestTask', 'putReviewStatus', 'getReporteesLimitRoles', @@ -212,6 +213,8 @@ const permissionsRoles = [ 'editTimeEntryToggleTangible', 'deleteTimeEntry', 'postTimeEntry', + 'sendEmails', + 'sendEmailToAll', 'updatePassword', 'getUserProfiles', 'getProjectMembers', @@ -220,7 +223,9 @@ const permissionsRoles = [ 'putUserProfileImportantInfo', 'updateSummaryRequirements', 'deleteUserProfile', - 'infringementAuthorizer', + 'addInfringements', + 'editInfringements', + 'deleteInfringements', 'postWbs', 'deleteWbs', 'getAllInvInProjectWBS', @@ -251,7 +256,10 @@ const permissionsRoles = [ 'seeUsersInDashboard', 'changeUserRehireableStatus', - 'manageAdminLinks', + + 'removeUserFromTask', + + 'editHeaderMessage', ], }, ]; diff --git a/src/utilities/emailSender.js b/src/utilities/emailSender.js index 19834abf4..b0fb40112 100644 --- a/src/utilities/emailSender.js +++ b/src/utilities/emailSender.js @@ -2,112 +2,113 @@ const nodemailer = require('nodemailer'); const { google } = require('googleapis'); const logger = require('../startup/logger'); -const closure = () => { - const queue = []; - - const CLIENT_EMAIL = process.env.REACT_APP_EMAIL; - const CLIENT_ID = process.env.REACT_APP_EMAIL_CLIENT_ID; - const CLIENT_SECRET = process.env.REACT_APP_EMAIL_CLIENT_SECRET; - const REDIRECT_URI = process.env.REACT_APP_EMAIL_CLIENT_REDIRECT_URI; - const REFRESH_TOKEN = process.env.REACT_APP_EMAIL_REFRESH_TOKEN; - // Create the email envelope (transport) - const transporter = nodemailer.createTransport({ - service: 'gmail', - auth: { - type: 'OAuth2', - user: CLIENT_EMAIL, - clientId: CLIENT_ID, - clientSecret: CLIENT_SECRET, - }, - }); +const config = { + email: process.env.REACT_APP_EMAIL, + clientId: process.env.REACT_APP_EMAIL_CLIENT_ID, + clientSecret: process.env.REACT_APP_EMAIL_CLIENT_SECRET, + redirectUri: process.env.REACT_APP_EMAIL_CLIENT_REDIRECT_URI, + refreshToken: process.env.REACT_APP_EMAIL_REFRESH_TOKEN, + batchSize: 50, + concurrency: 3, + rateLimitDelay: 1000, +}; - const OAuth2Client = new google.auth.OAuth2(CLIENT_ID, CLIENT_SECRET, REDIRECT_URI); +const OAuth2Client = new google.auth.OAuth2( + config.clientId, + config.clientSecret, + config.redirectUri, +); +OAuth2Client.setCredentials({ refresh_token: config.refreshToken }); - OAuth2Client.setCredentials({ refresh_token: REFRESH_TOKEN }); +// Create the email envelope (transport) +const transporter = nodemailer.createTransport({ + service: 'gmail', + auth: { + type: 'OAuth2', + user: config.email, + clientId: config.clientId, + clientSecret: config.clientSecret, + }, +}); - setInterval(async () => { - const nextItem = queue.shift(); +const sendEmail = async (mailOptions) => { + try { + const { token } = await OAuth2Client.getAccessToken(); + mailOptions.auth = { + user: config.email, + refreshToken: config.refreshToken, + accessToken: token, + }; + const result = await transporter.sendMail(mailOptions); + if (process.env.NODE_ENV === 'local') { + logger.logInfo(`Email sent: ${JSON.stringify(result)}`); + } + return result; + } catch (error) { + logger.logException(error, `Error sending email: ${mailOptions.to}`); + throw error; + } +}; - if (!nextItem) return; +const queue = []; +let isProcessing = false; - const { recipient, subject, message, cc, bcc, replyTo, acknowledgingReceipt, resolve, reject} = nextItem; +const processQueue = async () => { + if (isProcessing || queue.length === 0) return; - try { - // Generate the accessToken on the fly - const res = await OAuth2Client.getAccessToken(); - const ACCESSTOKEN = res.token; + isProcessing = true; + console.log('Processing email queue...'); - const mailOptions = { - from: CLIENT_EMAIL, - to: recipient, - cc, - bcc, - subject, - html: message, - replyTo, - auth: { - user: CLIENT_EMAIL, - refreshToken: REFRESH_TOKEN, - accessToken: ACCESSTOKEN, - }, - }; + const processBatch = async () => { + if (queue.length === 0) { + isProcessing = false; + return; + } - const result = await transporter.sendMail(mailOptions); - if (typeof acknowledgingReceipt === 'function') { - acknowledgingReceipt(null, result); - } - // Prevent logging email in production - // Why? - // 1. Could create a security risk - // 2. Could create heavy loads on the server if emails are sent to many people - // 3. Contain limited useful info: - // result format : {"accepted":["emailAddr"],"rejected":[],"envelopeTime":209,"messageTime":566,"messageSize":317,"response":"250 2.0.0 OK 17***69 p11-2***322qvd.85 - gsmtp","envelope":{"from":"emailAddr", "to":"emailAddr"}} - if (process.env.NODE_ENV === 'local') { - logger.logInfo(`Email sent: ${JSON.stringify(result)}`); - } - resolve(result); + const batch = queue.shift(); + try { + console.log('Sending email...'); + await sendEmail(batch); } catch (error) { - if (typeof acknowledgingReceipt === 'function') { - acknowledgingReceipt(error, null); - } - logger.logException( - error, - `Error sending email: from ${CLIENT_EMAIL} to ${recipient} subject ${subject}`, - `Extra Data: cc ${cc} bcc ${bcc}`, - ); - reject(error); + logger.logException(error, 'Failed to send email batch'); } - }, process.env.MAIL_QUEUE_INTERVAL || 1000); - const emailSender = function ( - recipient, - subject, - message, - cc = null, - bcc = null, - replyTo = null, - acknowledgingReceipt = null, - ) { - return new Promise((resolve, reject) => { - if (process.env.sendEmail) { - queue.push({ - recipient, - subject, - message, - cc, - bcc, - replyTo, - acknowledgingReceipt, - resolve, - reject, - }); - } else { - resolve('Email sending is disabled'); - } - }); + setTimeout(processBatch, config.rateLimitDelay); }; - return emailSender; + const concurrentProcesses = Array(config.concurrency).fill().map(processBatch); + + try { + await Promise.all(concurrentProcesses); + } finally { + isProcessing = false; + } +}; + +const emailSender = ( + recipients, + subject, + message, + attachments = null, + cc = null, + replyTo = null, +) => { + if (!process.env.sendEmail) return; + const recipientsArray = Array.isArray(recipients) ? recipients : [recipients]; + for (let i = 0; i < recipients.length; i += config.batchSize) { + const batchRecipients = recipientsArray.slice(i, i + config.batchSize); + queue.push({ + from: config.email, + bcc: batchRecipients.join(','), + subject, + html: message, + attachments, + cc, + replyTo, + }); + } + console.log('Emails queued:', queue.length); + setImmediate(processQueue); }; -module.exports = closure(); +module.exports = emailSender; diff --git a/src/utilities/permissions.js b/src/utilities/permissions.js index 2299e8812..75aee3b59 100644 --- a/src/utilities/permissions.js +++ b/src/utilities/permissions.js @@ -64,10 +64,10 @@ const canRequestorUpdateUser = async (requestorId, targetUserId) => { // Find out a list of protected email account ids and allowed email id allowedEmailAccountIds = query .filter(({ email }) => ALLOWED_EMAIL_ACCOUNT.includes(email)) - .map(({ _id }) => _id); + .map(({ _id }) => _id.toString()); protectedEmailAccountIds = query .filter(({ email }) => PROTECTED_EMAIL_ACCOUNT.includes(email)) - .map(({ _id }) => _id); + .map(({ _id }) => _id.toString()); serverCache.setCache('protectedEmailAccountIds', protectedEmailAccountIds); serverCache.setCache('allowedEmailAccountIds', allowedEmailAccountIds);